chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,732 @@
|
||||
(serve-advanced-autoscaling)=
|
||||
|
||||
# Advanced Ray Serve Autoscaling
|
||||
|
||||
This guide goes over more advanced autoscaling parameters in [autoscaling_config](../api/doc/ray.serve.config.AutoscalingConfig.rst) and an advanced model composition example.
|
||||
|
||||
|
||||
(serve-autoscaling-config-parameters)=
|
||||
## Autoscaling config parameters
|
||||
|
||||
In this section, we go into more detail about Serve autoscaling concepts as well as how to set your autoscaling config.
|
||||
|
||||
### [Required] Define the steady state of your system
|
||||
|
||||
To define what the steady state of your deployments should be, set values for `target_ongoing_requests` and `max_ongoing_requests`.
|
||||
|
||||
#### **[`target_ongoing_requests`](../api/doc/ray.serve.config.AutoscalingConfig.rst) [default=2]**
|
||||
:::{note}
|
||||
The default for `target_ongoing_requests` changed from 1.0 to 2.0 in Ray 2.32.0. You can continue to set it manually to override the default.
|
||||
:::
|
||||
|
||||
Serve scales the number of replicas for a deployment up or down based on the average number of ongoing requests per replica. Specifically, Serve compares the *actual* number of ongoing requests per replica with the target value you set in the autoscaling config and makes upscale or downscale decisions from that. Set the target value with `target_ongoing_requests`, and Serve attempts to ensure that each replica has roughly that number of requests being processed and waiting in the queue.
|
||||
|
||||
Always load test your workloads. For example, if the use case is latency sensitive, you can lower the `target_ongoing_requests` number to maintain high performance. Benchmark your application code and set this number based on an end-to-end latency objective.
|
||||
|
||||
:::{note}
|
||||
As an example, suppose you have two replicas of a synchronous deployment that has 100ms latency, serving a traffic load of 30 QPS. Then Serve assigns requests to replicas faster than the replicas can finish processing them; more and more requests queue up at the replica (these requests are "ongoing requests") as time progresses, and then the average number of ongoing requests at each replica steadily increases. Latency also increases because new requests have to wait for old requests to finish processing. If you set `target_ongoing_requests = 1`, Serve detects a higher than desired number of ongoing requests per replica, and adds more replicas. At 3 replicas, your system would be able to process 30 QPS with 1 ongoing request per replica on average.
|
||||
:::
|
||||
|
||||
#### **`max_ongoing_requests` [default=5]**
|
||||
:::{note}
|
||||
The default for `max_ongoing_requests` changed from 100 to 5 in Ray 2.32.0. You can continue to set it manually to override the default.
|
||||
:::
|
||||
|
||||
There is also a maximum queue limit that proxies respect when assigning requests to replicas. Define the limit with `max_ongoing_requests`. Set `max_ongoing_requests` to ~20 to 50% higher than `target_ongoing_requests`.
|
||||
|
||||
- Setting it too low can throttle throughput. Instead of being forwarded to replicas for concurrent execution, requests will tend to queue up at the proxy, waiting for replicas to finish processing existing requests.
|
||||
|
||||
:::{note}
|
||||
`max_ongoing_requests` should be tuned higher especially for lightweight requests, else the overall throughput will be impacted.
|
||||
:::
|
||||
|
||||
- Setting it too high can lead to imbalanced routing. Concretely, this can lead to very high tail latencies during upscale, because when the autoscaler is scaling a deployment up due to a traffic spike, most or all of the requests might be assigned to the existing replicas before the new replicas are started.
|
||||
|
||||
|
||||
### [Required] Define upper and lower autoscaling limits
|
||||
|
||||
To use autoscaling, you need to define the minimum and maximum number of resources allowed for your system.
|
||||
|
||||
* **[`min_replicas`](../api/doc/ray.serve.config.AutoscalingConfig.rst) [default=1]**: This is the minimum number of replicas for the deployment. If you want to ensure your system can deal with a certain level of traffic at all times, set `min_replicas` to a positive number. On the other hand, if you anticipate periods of no traffic and want to scale to zero to save cost, set `min_replicas = 0`. Note that setting `min_replicas = 0` causes higher tail latencies; when you start sending traffic, the deployment scales up, and there will be a cold start time as Serve waits for replicas to be started to serve the request.
|
||||
|
||||
* **[`max_replicas`](../api/doc/ray.serve.config.AutoscalingConfig.rst) [default=1]**: This is the maximum number of replicas for the deployment. This should be greater than `min_replicas`. Ray Serve Autoscaling relies on the Ray Autoscaler to scale up more nodes when the currently available cluster resources (CPUs, GPUs, etc.) are not enough to support more replicas.
|
||||
|
||||
* **[`initial_replicas`](../api/doc/ray.serve.config.AutoscalingConfig.rst)**: This is the number of replicas that are started initially for the deployment. This defaults to the value for `min_replicas`.
|
||||
|
||||
|
||||
### [Optional] Define how the system reacts to changing traffic
|
||||
|
||||
Given a steady stream of traffic and appropriately configured `min_replicas` and `max_replicas`, the steady state of your system is essentially fixed for a chosen configuration value for `target_ongoing_requests`. Before reaching steady state, however, your system is reacting to traffic shifts. How you want your system to react to changes in traffic determines how you want to set the remaining autoscaling configurations.
|
||||
|
||||
* **[`upscale_delay_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst) [default=30s]**: This defines how long Serve waits before scaling up the number of replicas in your deployment. In other words, this parameter controls the frequency of upscale decisions. If the replicas are *consistently* serving more requests than desired for an `upscale_delay_s` number of seconds, then Serve scales up the number of replicas based on aggregated ongoing requests metrics. For example, if your service is likely to experience bursts of traffic, you can lower `upscale_delay_s` so that your application can react quickly to increases in traffic.
|
||||
|
||||
Ray Serve allows you to use different delays for different downscaling scenarios, providing more granular control over when replicas are removed. This is particularly useful when you want different behavior for scaling down to zero versus scaling down to a non-zero number of replicas.
|
||||
|
||||
* **[`downscale_delay_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst) [default=600s]**: This defines how long Serve waits before scaling down the number of replicas in your deployment. If the replicas are *consistently* serving fewer requests than desired for a `downscale_delay_s` number of seconds, Serve scales down the number of replicas based on aggregated ongoing requests metrics. This delay applies to all downscaling decisions except for the optional 1→0 transition (see below). For example, if your application initializes slowly, you can increase `downscale_delay_s` to make downscaling happen more infrequently and avoid reinitialization costs when the application needs to upscale again.
|
||||
|
||||
* **[`downscale_to_zero_delay_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst) [Optional]**: This defines how long Serve waits before scaling from one replica down to zero (only applies when `min_replicas = 0`). If not specified, the 1→0 transition uses the `downscale_delay_s` value. This is useful when you want more conservative scale-to-zero behavior. For example, you might set `downscale_delay_s = 300` for regular downscaling but `downscale_to_zero_delay_s = 1800` to wait 30 minutes before scaling to zero, avoiding cold starts for brief periods of inactivity.
|
||||
|
||||
* **[`upscale_smoothing_factor`](../api/doc/ray.serve.config.AutoscalingConfig.rst) [default_value=1.0] (DEPRECATED)**: This parameter is renamed to `upscaling_factor`. `upscale_smoothing_factor` will be removed in a future release.
|
||||
|
||||
* **[`downscale_smoothing_factor`](../api/doc/ray.serve.config.AutoscalingConfig.rst) [default_value=1.0] (DEPRECATED)**: This parameter is renamed to `downscaling_factor`. `downscale_smoothing_factor` will be removed in a future release.
|
||||
|
||||
* **[`upscaling_factor`](../api/doc/ray.serve.config.AutoscalingConfig.rst) [default_value=1.0]**: The multiplicative factor to amplify or moderate each upscaling decision. For example, when the application has high traffic volume in a short period of time, you can increase `upscaling_factor` to scale up the resource quickly. This parameter is like a "gain" factor to amplify the response of the autoscaling algorithm.
|
||||
|
||||
* **[`downscaling_factor`](../api/doc/ray.serve.config.AutoscalingConfig.rst) [default_value=1.0]**: The multiplicative factor to amplify or moderate each downscaling decision. For example, if you want your application to be less sensitive to drops in traffic and scale down more conservatively, you can decrease `downscaling_factor` to slow down the pace of downscaling.
|
||||
|
||||
* **[`metrics_interval_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst) [default_value=10]**: In future this deployment level config will be removed in favor of cross-application level global config.
|
||||
|
||||
This controls how often each replica and handle sends reports on current ongoing requests to the autoscaler. ::{note} If metrics are reported infrequently, Ray Serve can take longer to notice a change in autoscaling metrics, so scaling can start later even if your delays are short. For example, if you set `upscale_delay_s = 3` but metrics are pushed every 10 seconds, Ray Serve might not see a change until the next push, so scaling up can be limited to about once every 10 seconds. ::
|
||||
|
||||
* **[`look_back_period_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst) [default_value=30]**: This is the window over which the average number of ongoing requests per replica is calculated.
|
||||
|
||||
* **[`aggregation_function`](../api/doc/ray.serve.config.AutoscalingConfig.rst) [default_value="mean"]**: This controls how metrics are aggregated over the `look_back_period_s` time window. The aggregation function determines how Ray Serve combines multiple metric measurements into a single value for autoscaling decisions. Supported values:
|
||||
- `"mean"` (default): Uses time-weighted average of metrics. This provides smooth scaling behavior that responds to sustained traffic patterns.
|
||||
- `"max"`: Uses the maximum metric value observed. This makes autoscaling more sensitive to spikes, scaling up quickly when any replica experiences high load.
|
||||
- `"min"`: Uses the minimum metric value observed. This results in more conservative scaling behavior.
|
||||
|
||||
For most workloads, the default `"mean"` aggregation provides the best balance. Use `"max"` if you need to react quickly to traffic spikes, or `"min"` if you prefer conservative scaling that avoids rapid fluctuations.
|
||||
|
||||
### How autoscaling metrics work
|
||||
|
||||
Understanding how metrics flow through the autoscaling system helps you configure the parameters effectively. The metrics pipeline involves several stages, each with its own timing parameters:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ Metrics Pipeline Overview │
|
||||
├──────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Replicas/Handles Controller Autoscaling Policy │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Record │ Push │ Receive │ Decide │ Policy │ │
|
||||
│ │ Metrics │────────────>│ Metrics │──────────>│ Runs │ │
|
||||
│ │ (10s) │ (10s) │ │ (0.1s) │ │ │
|
||||
│ └──────────┘ │ Aggregate│ └──────────┘ │
|
||||
│ │ (30s) │ │
|
||||
│ └──────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Stage 1: Metric recording
|
||||
|
||||
Replicas and deployment handles continuously record autoscaling metrics:
|
||||
- **What**: Number of ongoing requests (queued + running)
|
||||
- **Frequency**: Every 10s (configurable via [`metrics_interval_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst))
|
||||
- **Storage**: Metrics are stored locally as a timeseries
|
||||
|
||||
#### Stage 2: Metric pushing
|
||||
|
||||
Periodically, replicas and handles push their metrics to the controller:
|
||||
- **Frequency**: Every 10s (configurable via `RAY_SERVE_REPLICA_AUTOSCALING_METRIC_PUSH_INTERVAL_S` and `RAY_SERVE_HANDLE_AUTOSCALING_METRIC_PUSH_INTERVAL_S`)
|
||||
- **Data sent**: Both raw timeseries data and pre-aggregated metrics
|
||||
- **Raw timeseries**: Data points are clipped to the [`look_back_period_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst) window before sending (only recent measurements within the window are sent)
|
||||
- **Pre-aggregated metrics**: A simple average computed over the [`look_back_period_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst) window at the replica/handle
|
||||
- **Controller usage**: The controller decides which data to use based on the `RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER` setting (see Stage 3 below)
|
||||
|
||||
#### Stage 3: Metric aggregation
|
||||
|
||||
The controller aggregates metrics to compute total ongoing requests across all replicas. Ray Serve supports two aggregation modes (controlled by `RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER`):
|
||||
|
||||
**Simple mode (default - `RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER=0`):**
|
||||
- **Input**: Pre-aggregated simple averages from replicas/handles (already clipped to [`look_back_period_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst))
|
||||
- **Method**: Sums the pre-aggregated values from all sources. Each component computes a simple average (arithmetic mean) before sending.
|
||||
- **Output**: Single value representing total ongoing requests
|
||||
- **Characteristics**: Lightweight and works well for most workloads. However, because it uses simple averages rather than time-weighted averages, it can be less accurate when replicas have different metric reporting intervals or when metrics arrive at different times.
|
||||
|
||||
**Aggregate mode (experimental - `RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER=1`):**
|
||||
- **Input**: Raw timeseries data from replicas/handles (already clipped to [`look_back_period_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst))
|
||||
- **Method**: Time-weighted aggregation using the [`aggregation_function`](../api/doc/ray.serve.config.AutoscalingConfig.rst) (mean, max, or min). Uses an instantaneous merge approach that treats metrics as right-continuous step functions.
|
||||
- **Output**: Single value representing total ongoing requests
|
||||
- **Characteristics**: Provides more mathematically accurate aggregation, especially when replicas report metrics at different intervals or you need precise time-weighted averages. The trade-off is increased controller overhead.
|
||||
|
||||
:::{note}
|
||||
The [`aggregation_function`](../api/doc/ray.serve.config.AutoscalingConfig.rst) parameter only applies in aggregate mode. In simple mode, the aggregation is always a sum of the pre-computed simple averages.
|
||||
:::
|
||||
|
||||
:::{note}
|
||||
The long-term plan is to deprecate simple mode in favor of aggregate mode. Aggregate mode provides more accurate metrics aggregation and will become the default in a future release. Consider testing aggregate mode(`RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER=1`) in your deployments to prepare for this transition.
|
||||
:::
|
||||
|
||||
#### Stage 4: Policy execution
|
||||
|
||||
The autoscaling policy runs frequently to make scaling decisions, see [Custom policy for deployment](#custom-policy-for-deployment) for details on implementing custom scaling logic:
|
||||
- **Frequency**: Every 0.1s (configurable via `RAY_SERVE_CONTROL_LOOP_INTERVAL_S`)
|
||||
- **Input**: [`AutoscalingContext`](../api/doc/ray.serve.config.AutoscalingContext.rst)
|
||||
- **Output**: Tuple of `(target_replicas, updated_policy_state)`
|
||||
|
||||
#### Timing parameter interactions
|
||||
|
||||
The timing parameters interact in important ways:
|
||||
|
||||
**Recording vs pushing intervals:**
|
||||
- Push interval ≥ Recording interval
|
||||
- Recording interval (10s) determines granularity of data
|
||||
- Push interval (10s) determines how fresh the controller's data is
|
||||
- With default values: Each push contains 1 data points (10s ÷ 10s)
|
||||
|
||||
**Push interval vs look-back period:**
|
||||
- [`look_back_period_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst) (30s) should be > push interval (10s)
|
||||
- If look-back is too short, you won't have enough data for stable decisions
|
||||
- If look-back is too long, autoscaling becomes less responsive
|
||||
|
||||
**Push interval vs control loop:**
|
||||
- Control loop (0.1s) runs much faster than metrics arrive (10s)
|
||||
- Most control loop iterations reuse existing metrics
|
||||
- New scaling decisions only happen when fresh metrics arrive
|
||||
|
||||
**Push interval vs upscale/downscale delays:**
|
||||
- Delays control when Ray Serve applies a scale up or scale down.
|
||||
- The metrics push interval controls how quickly Ray Serve receives fresh metrics.
|
||||
- If the push interval < delay, Ray Serve can use multiple metric updates before it scales.
|
||||
- Example: push every 10s with `upscale_delay_s = 20` means up to 2 new metric updates before scaling
|
||||
|
||||
**Recommendation:** Keep default values unless you have specific needs. If you need faster autoscaling, decrease push intervals first, then adjust delays.
|
||||
|
||||
### Environment variables
|
||||
|
||||
Several environment variables control autoscaling behavior at a lower level. These variables affect metrics collection and the control loop timing:
|
||||
|
||||
#### Control loop and timeout settings
|
||||
|
||||
* **`RAY_SERVE_CONTROL_LOOP_INTERVAL_S`** (default: 0.1s): How often the Ray Serve controller runs the autoscaling control loop. Your autoscaling policy function executes at this frequency. The default value of 0.1s means policies run approximately 10 times per second.
|
||||
|
||||
* **`RAY_SERVE_RECORD_AUTOSCALING_STATS_TIMEOUT_S`** (default: 10.0s): Maximum time allowed for the `record_autoscaling_stats()` method to complete in custom metrics collection. If this timeout is exceeded, the metrics collection fails and a warning is logged.
|
||||
|
||||
* **`RAY_SERVE_MIN_HANDLE_METRICS_TIMEOUT_S`** (default: 10.0s): Minimum timeout for handle metrics collection. The system uses the maximum of this value and `2 * `[`metrics_interval_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst) to determine when to drop stale handle metrics.
|
||||
|
||||
#### Advanced feature flags
|
||||
|
||||
* **`RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER`** (default: false): Enables an experimental metrics aggregation mode where the controller aggregates raw timeseries data instead of using pre-aggregated metrics. This mode provides more accurate time-weighted averages but may increase controller overhead. See Stage 3 in "How autoscaling metrics work" for details.
|
||||
|
||||
|
||||
## Model composition example
|
||||
|
||||
Determining the autoscaling configuration for a multi-model application requires understanding each deployment's scaling requirements. Every deployment has a different latency and differing levels of concurrency. As a result, finding the right autoscaling config for a model-composition application requires experimentation.
|
||||
|
||||
This example is a simple application with three deployments composed together to build some intuition about multi-model autoscaling. Assume these deployments:
|
||||
* `HeavyLoad`: A mock 200ms workload with high CPU usage.
|
||||
* `LightLoad`: A mock 100ms workload with high CPU usage.
|
||||
* `Driver`: A driver deployment that fans out to the `HeavyLoad` and `LightLoad` deployments and aggregates the two outputs.
|
||||
|
||||
|
||||
### Attempt 1: One `Driver` replica
|
||||
|
||||
First consider the following deployment configurations. Because the driver deployment has low CPU usage and is only asynchronously making calls to the downstream deployments, allocating one fixed `Driver` replica is reasonable.
|
||||
|
||||
::::{tab-set}
|
||||
|
||||
:::{tab-item} Driver
|
||||
|
||||
```yaml
|
||||
- name: Driver
|
||||
num_replicas: 1
|
||||
max_ongoing_requests: 200
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
:::{tab-item} HeavyLoad
|
||||
|
||||
```yaml
|
||||
- name: HeavyLoad
|
||||
max_ongoing_requests: 3
|
||||
autoscaling_config:
|
||||
target_ongoing_requests: 1
|
||||
min_replicas: 0
|
||||
initial_replicas: 0
|
||||
max_replicas: 200
|
||||
upscale_delay_s: 3
|
||||
downscale_delay_s: 60
|
||||
upscaling_factor: 0.3
|
||||
downscaling_factor: 0.3
|
||||
metrics_interval_s: 2
|
||||
look_back_period_s: 10
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
:::{tab-item} LightLoad
|
||||
|
||||
```yaml
|
||||
- name: LightLoad
|
||||
max_ongoing_requests: 3
|
||||
autoscaling_config:
|
||||
target_ongoing_requests: 1
|
||||
min_replicas: 0
|
||||
initial_replicas: 0
|
||||
max_replicas: 200
|
||||
upscale_delay_s: 3
|
||||
downscale_delay_s: 60
|
||||
upscaling_factor: 0.3
|
||||
downscaling_factor: 0.3
|
||||
metrics_interval_s: 2
|
||||
look_back_period_s: 10
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
:::{tab-item} Application Code
|
||||
|
||||
```{literalinclude} ../doc_code/autoscale_model_comp_example.py
|
||||
:language: python
|
||||
:start-after: __serve_example_begin__
|
||||
:end-before: __serve_example_end__
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
::::
|
||||
|
||||
|
||||
Running the same Locust load test from the [Resnet workload](resnet-autoscaling-example) generates the following results:
|
||||
|
||||
| | |
|
||||
| ----------------------- | ---------------------- |
|
||||
| HeavyLoad and LightLoad Number Replicas | <img src="https://raw.githubusercontent.com/ray-project/images/master/docs/serve/autoscaling-guide/model_composition_replicas.png" alt="comp" width="600" /> |
|
||||
|
||||
|
||||
As you might expect, the number of autoscaled `LightLoad` replicas is roughly half that of autoscaled `HeavyLoad` replicas. Although the same number of requests per second are sent to both deployments, `LightLoad` replicas can process twice as many requests per second as `HeavyLoad` replicas can, so the deployment should need half as many replicas to handle the same traffic load.
|
||||
|
||||
Unfortunately, the service latency rises to from 230 to 400 ms when the number of Locust users increases to 100.
|
||||
|
||||
| P50 Latency | QPS |
|
||||
| ------- | --- |
|
||||
|  |  |
|
||||
|
||||
Note that the number of `HeavyLoad` replicas should roughly match the number of Locust users to adequately serve the Locust traffic. However, when the number of Locust users increased to 100, the `HeavyLoad` deployment struggled to reach 100 replicas, and instead only reached 65 replicas. The per-deployment latencies reveal the root cause. While `HeavyLoad` and `LightLoad` latencies stayed steady at 200ms and 100ms, `Driver` latencies rose from 230 to 400 ms. This suggests that the high Locust workload may be overwhelming the `Driver` replica and impacting its asynchronous event loop's performance.
|
||||
|
||||
|
||||
### Attempt 2: Autoscale `Driver`
|
||||
|
||||
For this attempt, set an autoscaling configuration for `Driver` as well, with the setting `target_ongoing_requests = 20`. Now the deployment configurations are as follows:
|
||||
|
||||
::::{tab-set}
|
||||
|
||||
:::{tab-item} Driver
|
||||
|
||||
```yaml
|
||||
- name: Driver
|
||||
max_ongoing_requests: 200
|
||||
autoscaling_config:
|
||||
target_ongoing_requests: 20
|
||||
min_replicas: 1
|
||||
initial_replicas: 1
|
||||
max_replicas: 10
|
||||
upscale_delay_s: 3
|
||||
downscale_delay_s: 60
|
||||
upscaling_factor: 0.3
|
||||
downscaling_factor: 0.3
|
||||
metrics_interval_s: 2
|
||||
look_back_period_s: 10
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
:::{tab-item} HeavyLoad
|
||||
|
||||
```yaml
|
||||
- name: HeavyLoad
|
||||
max_ongoing_requests: 3
|
||||
autoscaling_config:
|
||||
target_ongoing_requests: 1
|
||||
min_replicas: 0
|
||||
initial_replicas: 0
|
||||
max_replicas: 200
|
||||
upscale_delay_s: 3
|
||||
downscale_delay_s: 60
|
||||
upscaling_factor: 0.3
|
||||
downscaling_factor: 0.3
|
||||
metrics_interval_s: 2
|
||||
look_back_period_s: 10
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
:::{tab-item} LightLoad
|
||||
|
||||
```yaml
|
||||
- name: LightLoad
|
||||
max_ongoing_requests: 3
|
||||
autoscaling_config:
|
||||
target_ongoing_requests: 1
|
||||
min_replicas: 0
|
||||
initial_replicas: 0
|
||||
max_replicas: 200
|
||||
upscale_delay_s: 3
|
||||
downscale_delay_s: 60
|
||||
upscaling_factor: 0.3
|
||||
downscaling_factor: 0.3
|
||||
metrics_interval_s: 2
|
||||
look_back_period_s: 10
|
||||
```
|
||||
|
||||
:::
|
||||
::::
|
||||
|
||||
Running the same Locust load test again generates the following results:
|
||||
|
||||
| | |
|
||||
| ------------------------------------ | ------------------- |
|
||||
| HeavyLoad and LightLoad Number Replicas | <img src="https://raw.githubusercontent.com/ray-project/images/master/docs/serve/autoscaling-guide/model_composition_improved_replicas.png" alt="heavy" width="600"/> |
|
||||
| Driver Number Replicas | <img src="https://raw.githubusercontent.com/ray-project/images/master/docs/serve/autoscaling-guide/model_composition_improved_driver_replicas.png" alt="driver" width="600"/> |
|
||||
|
||||
With up to 6 `Driver` deployments to receive and distribute the incoming requests, the `HeavyLoad` deployment successfully scales up to 90+ replicas, and `LightLoad` up to 47 replicas. This configuration helps the application latency stay consistent as the traffic load increases.
|
||||
|
||||
| Improved P50 Latency | Improved RPS |
|
||||
| ---------------- | ------------ |
|
||||
|  |  |
|
||||
|
||||
|
||||
## Troubleshooting guide
|
||||
|
||||
|
||||
### Unstable number of autoscaled replicas
|
||||
|
||||
If the number of replicas in your deployment keeps oscillating even though the traffic is relatively stable, try the following:
|
||||
|
||||
* Set a smaller `upscaling_factor` and `downscaling_factor`. Setting both values smaller than one helps the autoscaler make more conservative upscale and downscale decisions. It effectively smooths out the replicas graph, and there will be less "sharp edges".
|
||||
|
||||
* Set a `look_back_period_s` value that matches the rest of the autoscaling config. For longer upscale and downscale delay values, a longer look back period can likely help stabilize the replica graph, but for shorter upscale and downscale delay values, a shorter look back period may be more appropriate. For instance, the following replica graphs show how a deployment with `upscale_delay_s = 3` works with a longer vs shorter look back period.
|
||||
|
||||
| `look_back_period_s = 30` | `look_back_period_s = 3` |
|
||||
| ------------------------------------------------ | ----------------------------------------------- |
|
||||
|  |  |
|
||||
|
||||
|
||||
### High spikes in latency during bursts of traffic
|
||||
|
||||
If you expect your application to receive bursty traffic, and at the same time want the deployments to scale down in periods of inactivity, you are likely concerned about how quickly the deployment can scale up and respond to bursts of traffic. While an increase in latency initially during a burst in traffic may be unavoidable, you can try the following to improve latency during bursts of traffic.
|
||||
|
||||
* Set a lower `upscale_delay_s`. The autoscaler always waits `upscale_delay_s` seconds before making a decision to upscale, so lowering this delay allows the autoscaler to react more quickly to changes, especially bursts, of traffic.
|
||||
|
||||
* Set a larger `upscaling_factor`. If `upscaling_factor > 1`, then the autoscaler scales up more aggressively than normal. This setting can allow your deployment to be more sensitive to bursts of traffic.
|
||||
|
||||
* Lower the [`metrics_interval_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst). Always set [`metrics_interval_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst) to be less than or equal to `upscale_delay_s`, otherwise upscaling is delayed because the autoscaler doesn't receive fresh information often enough.
|
||||
|
||||
* Set a lower `max_ongoing_requests`. If `max_ongoing_requests` is too high relative to `target_ongoing_requests`, then when traffic increases, Serve might assign most or all of the requests to the existing replicas before the new replicas are started. This setting can lead to very high latencies during upscale.
|
||||
|
||||
|
||||
### Deployments scaling down too quickly
|
||||
|
||||
You may observe that deployments are scaling down too quickly. Instead, you may want the downscaling to be much more conservative to maximize the availability of your service.
|
||||
|
||||
* Set a longer `downscale_delay_s`. The autoscaler always waits `downscale_delay_s` seconds before making a decision to downscale, so by increasing this number, your system has a longer "grace period" after traffic drops before the autoscaler starts to remove replicas.
|
||||
|
||||
* Set a smaller `downscaling_factor`. If `downscaling_factor < 1`, then the autoscaler removes *less replicas* than what it thinks it should remove to achieve the target number of ongoing requests. In other words, the autoscaler makes more conservative downscaling decisions.
|
||||
|
||||
| `downscaling_factor = 1` | `downscaling_factor = 0.5` |
|
||||
| ------------------------------------------------ | ----------------------------------------------- |
|
||||
|  |  |
|
||||
|
||||
|
||||
(serve-custom-autoscaling-policies)=
|
||||
## Custom autoscaling policies
|
||||
|
||||
:::{warning}
|
||||
Custom autoscaling policies are experimental and may change in future releases.
|
||||
:::
|
||||
|
||||
Ray Serve’s built-in, request-driven autoscaling works well for most apps. Use **custom autoscaling policies** when you need more control—e.g., scaling on external metrics (CloudWatch, Prometheus), anticipating predictable traffic (scheduled batch jobs), or applying business logic that goes beyond queue thresholds.
|
||||
|
||||
Custom policies let you implement scaling logic based on any metrics or rules you choose.
|
||||
|
||||
### Custom policy for deployment
|
||||
|
||||
A custom autoscaling policy is a user-provided Python function that takes an [`AutoscalingContext`](../api/doc/ray.serve.config.AutoscalingContext.rst) and returns a tuple `(target_replicas, policy_state)` for a single Deployment.
|
||||
|
||||
An `AutoscalingContext` object provides the following information to the custom autoscaling policy:
|
||||
* **Current state:** Current replica count and deployment metadata.
|
||||
* **Built-in metrics:** Total requests, queued requests, per-replica counts.
|
||||
* **Custom metrics:** Values your deployment reports via `record_autoscaling_stats()`. (See below.)
|
||||
* **Capacity bounds:** `min` / `max` replica limits adjusted for current cluster capacity.
|
||||
* **Policy state:** A `dict` you can use to persist arbitrary state across control-loop iterations.
|
||||
* **Timing:** Timestamps of the last scale actions and “now”.
|
||||
|
||||
The following example showcases a policy that scales up during business hours and evening batch processing, and scales down during off-peak hours:
|
||||
|
||||
`autoscaling_policy.py` file:
|
||||
```{literalinclude} ../doc_code/autoscaling_policy.py
|
||||
:language: python
|
||||
:start-after: __begin_scheduled_batch_processing_policy__
|
||||
:end-before: __end_scheduled_batch_processing_policy__
|
||||
```
|
||||
|
||||
`main.py` file:
|
||||
```{literalinclude} ../doc_code/scheduled_batch_processing.py
|
||||
:language: python
|
||||
:start-after: __serve_example_begin__
|
||||
:end-before: __serve_example_end__
|
||||
```
|
||||
|
||||
Policies are defined **per deployment**. If you don’t provide one, Ray Serve falls back to its built-in request-based policy.
|
||||
|
||||
The policy function is invoked by the Ray Serve controller every `RAY_SERVE_CONTROL_LOOP_INTERVAL_S` seconds (default **0.1s**), so your logic runs against near-real-time state.
|
||||
|
||||
Your policy can return an `int` or a `float` for `target_replicas`. If it returns a float, Ray Serve converts it to an integer replica count by rounding up to the next greatest integer.
|
||||
|
||||
:::{warning}
|
||||
Keep policy functions **fast and lightweight**. Slow logic can block the Serve controller and degrade cluster responsiveness.
|
||||
:::
|
||||
|
||||
|
||||
### Applying standard autoscaling parameters to custom policies
|
||||
|
||||
Ray Serve automatically applies the following standard autoscaling parameters from your [`AutoscalingConfig`](../api/doc/ray.serve.config.AutoscalingConfig.rst) to custom policies:
|
||||
- `upscale_delay_s`, `downscale_delay_s`, `downscale_to_zero_delay_s`
|
||||
- `upscaling_factor`, `downscaling_factor`
|
||||
- `min_replicas`, `max_replicas`
|
||||
|
||||
The following example shows a custom autoscaling policy with standard autoscaling parameters applied.
|
||||
|
||||
```{literalinclude} ../doc_code/autoscaling_policy.py
|
||||
:language: python
|
||||
:start-after: __begin_apply_autoscaling_config_example__
|
||||
:end-before: __end_apply_autoscaling_config_example__
|
||||
```
|
||||
|
||||
```{literalinclude} ../doc_code/autoscaling_policy.py
|
||||
:language: python
|
||||
:start-after: __begin_apply_autoscaling_config_usage__
|
||||
:end-before: __end_apply_autoscaling_config_usage__
|
||||
```
|
||||
|
||||
::::{note}
|
||||
Your policy function should return the "raw" desired number of replicas. Ray Serve applies the `autoscaling_config` settings (delays, factors, and bounds) on top of your decision.
|
||||
|
||||
Your policy can return an `int` or a `float` "raw desired" replica count. Ray Serve returns an integer decision number.
|
||||
::::
|
||||
|
||||
|
||||
### Custom metrics
|
||||
|
||||
You can make richer decisions by emitting your own metrics from the deployment. Implement `record_autoscaling_stats()` to return a `dict[str, float]`. Ray Serve will surface these values in the [`AutoscalingContext`](../api/doc/ray.serve.config.AutoscalingContext.rst).
|
||||
|
||||
This example demonstrates how deployments can provide their own metrics (CPU usage, memory usage) and how autoscaling policies can use these metrics to make scaling decisions:
|
||||
|
||||
`autoscaling_policy.py` file:
|
||||
```{literalinclude} ../doc_code/autoscaling_policy.py
|
||||
:language: python
|
||||
:start-after: __begin_custom_metrics_autoscaling_policy__
|
||||
:end-before: __end_custom_metrics_autoscaling_policy__
|
||||
```
|
||||
|
||||
`main.py` file:
|
||||
```{literalinclude} ../doc_code/custom_metrics_autoscaling.py
|
||||
:language: python
|
||||
:start-after: __serve_example_begin__
|
||||
:end-before: __serve_example_end__
|
||||
```
|
||||
|
||||
:::{note}
|
||||
The `record_autoscaling_stats()` method can be either synchronous or asynchronous. It must complete within the timeout specified by `RAY_SERVE_RECORD_AUTOSCALING_STATS_TIMEOUT_S` (default 10 seconds).
|
||||
:::
|
||||
|
||||
In your policy, access custom metrics via:
|
||||
|
||||
* **`ctx.raw_metrics[metric_name]`** — A mapping of replica IDs to lists of raw metric values. The number of data points stored for each replica depends on the [`look_back_period_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst) (the sliding window size) and [`metrics_interval_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst) (the metric recording interval).
|
||||
* **`ctx.aggregated_metrics[metric_name]`** — A time-weighted average computed from the raw metric values for each replica.
|
||||
|
||||
|
||||
### Class-based policies
|
||||
|
||||
When your policy needs long-running setup — such as polling an external metrics service, maintaining a persistent connection, or running background computation — you can define it as a **class** instead of a plain function. Pass the class reference through `policy_function` and supply constructor arguments through `policy_kwargs`.
|
||||
|
||||
Ray Serve instantiates the class once on the controller when the deployment starts. `__init__` runs one-time setup, and `__call__` runs on every autoscaling tick with the current [`AutoscalingContext`](../api/doc/ray.serve.config.AutoscalingContext.rst).
|
||||
|
||||
The following example shows a policy that reads a target replica count from a JSON file in a background loop. In production you could replace the file read with an HTTP call, a message-queue consumer, or any other async IO operation:
|
||||
|
||||
`class_based_autoscaling_policy.py` file:
|
||||
```{literalinclude} ../doc_code/class_based_autoscaling_policy.py
|
||||
:language: python
|
||||
:start-after: __begin_class_based_autoscaling_policy__
|
||||
:end-before: __end_class_based_autoscaling_policy__
|
||||
```
|
||||
|
||||
`main.py` file:
|
||||
```{literalinclude} ../doc_code/class_based_autoscaling.py
|
||||
:language: python
|
||||
:start-after: __serve_example_begin__
|
||||
:end-before: __serve_example_end__
|
||||
```
|
||||
|
||||
:::{note}
|
||||
The instance lives only on the Serve controller and is never serialized after creation, so it's safe to hold non-picklable state such as `asyncio.Task` objects, open connections, or thread pools. `policy_kwargs` values must be JSON-serializable because they travel through the deployment config.
|
||||
:::
|
||||
|
||||
:::{tip}
|
||||
If you're using `@task_consumer` deployments for asynchronous inference, Ray Serve provides a built-in `AsyncInferenceAutoscalingPolicy` that scales based on message queue length. See [Asynchronous Inference: Autoscaling](serve-async-inference-autoscaling) for setup and configuration.
|
||||
:::
|
||||
|
||||
|
||||
### Application level autoscaling
|
||||
|
||||
By default, each deployment in Ray Serve autoscales independently. When you have multiple deployments that need to scale in a coordinated way—such as deployments that share backend resources, have dependencies on each other, or need load-aware routing—you can define an **application-level autoscaling policy**. This policy makes scaling decisions for all deployments within an application simultaneously.
|
||||
|
||||
#### Define an application level policy
|
||||
|
||||
An application-level autoscaling policy is a function that takes a `dict[DeploymentID, AutoscalingContext]` objects (one per deployment) and returns a tuple of `(decisions, policy_state)`. Each context contains metrics and bounds for one deployment, and the policy returns target replica counts for all deployments.
|
||||
|
||||
The `policy_state` returned from an application-level policy must be a `Dict[DeploymentID, Dict]`— a dictionary mapping each deployment ID to its own state dictionary. Serve stores this per-deployment state and on the next control-loop iteration, injects each deployment's state back into that deployment's `AutoscalingContext.policy_state`. The per deployment number replicas returned from the policy can be an `int` or a `float`. If it returns a float, Ray Serve converts it to an integer replica count by rounding up to the next greatest integer.
|
||||
|
||||
Serve itself does not interpret the contents of `policy_state`. All the keys in each deployment's state dictionary are user-controlled except for internal keys that are used when default parameters are applied to custom autoscaling policies. The following example shows a policy that scales deployments based on their relative load, ensuring that downstream deployments have enough capacity for upstream traffic:
|
||||
|
||||
`autoscaling_policy.py` file:
|
||||
```{literalinclude} ../doc_code/autoscaling_policy.py
|
||||
:language: python
|
||||
:start-after: __begin_application_level_autoscaling_policy__
|
||||
:end-before: __end_application_level_autoscaling_policy__
|
||||
```
|
||||
The following example shows a stateful application-level policy that persists state between control-loop iterations:
|
||||
|
||||
`autoscaling_policy.py` file:
|
||||
```{literalinclude} ../doc_code/autoscaling_policy.py
|
||||
:language: python
|
||||
:start-after: __begin_stateful_application_level_policy__
|
||||
:end-before: __end_stateful_application_level_policy__
|
||||
```
|
||||
|
||||
#### Configure application level autoscaling
|
||||
|
||||
To use an application-level policy, you need to define your deployments:
|
||||
|
||||
`main.py` file:
|
||||
```{literalinclude} ../doc_code/application_level_autoscaling.py
|
||||
:language: python
|
||||
:start-after: __serve_example_begin__
|
||||
:end-before: __serve_example_end__
|
||||
```
|
||||
|
||||
Then specify the application-level policy in your application config:
|
||||
|
||||
`serve.yaml` file:
|
||||
```{literalinclude} ../doc_code/application_level_autoscaling.yaml
|
||||
:language: yaml
|
||||
:emphasize-lines: 4-5
|
||||
```
|
||||
|
||||
:::{note}
|
||||
Programmatic configuration of application-level autoscaling policies through `serve.run()` will be supported in a future release.
|
||||
:::
|
||||
|
||||
:::{note}
|
||||
When you specify both a deployment-level policy and an application-level policy, the application-level policy takes precedence. Ray Serve logs a warning if you configure both.
|
||||
:::
|
||||
|
||||
|
||||
#### Applying standard autoscaling parameters to application-level policies
|
||||
Ray Serve automatically applies standard autoscaling parameters (delays, factors, and min/max bounds) to application-level policies on a per-deployment basis. These parameters include:
|
||||
- `upscale_delay_s`, `downscale_delay_s`, `downscale_to_zero_delay_s`
|
||||
- `upscaling_factor`, `downscaling_factor`
|
||||
- `min_replicas`, `max_replicas`
|
||||
|
||||
The YAML configuration file shows the default parameters applied to the application level policy.
|
||||
```{literalinclude} ../doc_code/application_level_autoscaling_with_defaults.yaml
|
||||
:language: yaml
|
||||
```
|
||||
Your application level policy can return per deployment desired replicas as `int` or `float` values. Ray Serve applies the autoscaling config parameters per deployment and returns integer decisions.
|
||||
:::{warning}
|
||||
### Gotchas and limitations
|
||||
|
||||
When you provide a custom policy, Ray Serve can fully support it as long as it's simple, self-contained Python code that relies only on the standard library. Once the policy becomes more complex, such as depending on other custom modules or packages, you need to bundle those modules into the Docker image or environment. This is because Ray Serve uses `cloudpickle` to serialize custom policies and it doesn't vendor transitive dependencies—if your policy inherits from a superclass in another module or imports custom packages, those must exist in the target environment. Additionally, environment parity matters: differences in Python version, `cloudpickle` version, or library versions can affect deserialization.
|
||||
|
||||
#### Alternatives for complex policies
|
||||
|
||||
When your custom autoscaling policy has complex dependencies or you want better control over versioning and deployment, you have several alternatives:
|
||||
|
||||
- **Contribute to Ray Serve**: If your policy is general-purpose and might benefit others, consider contributing it to Ray Serve as a built-in policy by opening a feature request or pull request on the [Ray GitHub repository](https://github.com/ray-project/ray/issues). The recommended location for the implementation is `python/ray/serve/autoscaling_policy.py`.
|
||||
- **Ensure dependencies in your environment**: Make sure that the external dependencies are installed in your Docker image or environment.
|
||||
:::
|
||||
|
||||
|
||||
(serve-external-scale-api)=
|
||||
|
||||
### External scaling API
|
||||
|
||||
:::{warning}
|
||||
This API is in alpha and may change before becoming stable.
|
||||
:::
|
||||
|
||||
The external scaling API provides programmatic control over the number of replicas for any deployment in your Ray Serve application. Unlike Ray Serve's built-in autoscaling, which scales based on queue depth and ongoing requests, this API allows you to scale based on any external criteria you define.
|
||||
|
||||
#### Example: Predictive scaling
|
||||
|
||||
This example shows how to implement predictive scaling based on historical patterns or forecasts. You can preemptively scale up before anticipated traffic spikes by running an external script that adjusts replica counts based on time of day.
|
||||
|
||||
##### Define the deployment
|
||||
|
||||
The following example creates a simple text processing deployment that you can scale externally. Save this code to a file named `external_scaler_predictive.py`:
|
||||
|
||||
```{literalinclude} ../doc_code/external_scaler_predictive.py
|
||||
:language: python
|
||||
:start-after: __serve_example_begin__
|
||||
:end-before: __serve_example_end__
|
||||
```
|
||||
|
||||
##### Configure external scaling
|
||||
|
||||
Before using the external scaling API, enable it in your application configuration by setting `external_scaler_enabled: true`. Save this configuration to a file named `external_scaler_config.yaml`:
|
||||
|
||||
```{literalinclude} ../doc_code/external_scaler_config.yaml
|
||||
:language: yaml
|
||||
:start-after: __external_scaler_config_begin__
|
||||
:end-before: __external_scaler_config_end__
|
||||
```
|
||||
|
||||
:::{warning}
|
||||
External scaling and built-in autoscaling are mutually exclusive. You can't use both for the same application. If you set `external_scaler_enabled: true`, you **must not** configure `autoscaling_config` on any deployment in that application. Attempting to use both results in an error.
|
||||
:::
|
||||
|
||||
##### Implement the scaling logic
|
||||
|
||||
The following script implements predictive scaling based on time of day and historical traffic patterns. Save this script to a file named `external_scaler_predictive_client.py`:
|
||||
|
||||
```{literalinclude} ../doc_code/external_scaler_predictive_client.py
|
||||
:language: python
|
||||
:start-after: __client_script_begin__
|
||||
:end-before: __client_script_end__
|
||||
```
|
||||
|
||||
The script uses the external scaling API endpoint to scale deployments:
|
||||
- **API endpoint**: `POST http://localhost:8265/api/v1/applications/{application_name}/deployments/{deployment_name}/scale`
|
||||
- **Request body**: `{"target_num_replicas": <number>}` (must conform to the [`ScaleDeploymentRequest`](../api/doc/ray.serve.schema.ScaleDeploymentRequest.rst) schema)
|
||||
|
||||
The scaling client continuously adjusts the number of replicas based on the time of day:
|
||||
- Business hours (9 AM - 5 PM): 10 replicas
|
||||
- Off-peak hours: 3 replicas
|
||||
|
||||
##### Run the example
|
||||
|
||||
Follow these steps to run the complete example:
|
||||
|
||||
1. Start the Ray Serve application with the configuration:
|
||||
|
||||
```bash
|
||||
serve run external_scaler_config.yaml
|
||||
```
|
||||
|
||||
2. Run the predictive scaling client in a separate terminal:
|
||||
|
||||
```bash
|
||||
python external_scaler_predictive_client.py
|
||||
```
|
||||
|
||||
The client adjusts replica counts automatically based on the time of day. You can monitor the scaling behavior in the Ray dashboard or by checking the application logs.
|
||||
|
||||
#### Important considerations
|
||||
|
||||
Understanding how the external scaler interacts with your deployments helps you build reliable scaling logic:
|
||||
|
||||
- **Idempotent API calls**: The scaling API is idempotent. You can safely call it multiple times with the same `target_num_replicas` value without side effects. This makes it safe to run your scaling logic on a schedule or in response to repeated metric updates.
|
||||
|
||||
- **Interaction with serve deploy**: When you upgrade your service with `serve deploy`, the number of replicas you set through the external scaler API stays intact. This behavior matches what you'd expect from Ray Serve's built-in autoscaler—deployment updates don't reset replica counts.
|
||||
|
||||
- **Query current replica count**: You can get the current number of replicas for any deployment by querying the GET `/applications` API:
|
||||
|
||||
```bash
|
||||
curl -X GET http://localhost:8265/api/serve/applications/ \
|
||||
```
|
||||
|
||||
The response follows the [`ServeInstanceDetails`](../api/doc/ray.serve.schema.ServeInstanceDetails.rst) schema, which includes an `applications` field containing a dictionary with application names as keys. Each application includes detailed information about all its deployments, including current replica counts. Use this information to make informed scaling decisions. For example, you might scale up gradually by adding a percentage of existing replicas rather than jumping to a fixed number.
|
||||
|
||||
- **Initial replica count**: When you deploy an application for the first time, Ray Serve creates the number of replicas specified in the `num_replicas` field of your deployment configuration. The external scaler can then adjust this count dynamically based on your scaling logic.
|
||||
@@ -0,0 +1,128 @@
|
||||
(serve-app-builder-guide)=
|
||||
# Pass Arguments to Applications
|
||||
|
||||
This section describes how to pass arguments to your applications using an application builder function.
|
||||
|
||||
## Defining an application builder
|
||||
|
||||
When writing an application, there are often parameters that you want to be able to easily change in development or production. For example, you might have a path to trained model weights and want to test out a newly trained model. In Ray Serve, these parameters are typically passed to the constructor of your deployments using `.bind()`. This pattern allows you to configure deployments using ordinary Python code, but it requires modifying the code whenever one of the parameters needs to change.
|
||||
|
||||
To pass arguments without changing the code, define an "application builder" function that takes an arguments dictionary (or [Pydantic object](typed-app-builders)) and returns the built application to be run.
|
||||
|
||||
```{literalinclude} ../doc_code/app_builder.py
|
||||
:start-after: __begin_untyped_builder__
|
||||
:end-before: __end_untyped_builder__
|
||||
:language: python
|
||||
```
|
||||
|
||||
You can use this application builder function as the import path in the `serve run` CLI command or the config file (as shown below). To avoid writing code to handle type conversions and missing arguments, use a [Pydantic object](typed-app-builders) instead.
|
||||
|
||||
### Passing arguments via `serve run`
|
||||
|
||||
Pass arguments to the application builder from `serve run` using the following syntax:
|
||||
|
||||
```bash
|
||||
$ serve run hello:app_builder key1=val1 key2=val2
|
||||
```
|
||||
|
||||
The arguments are passed to the application builder as a dictionary, in this case `{"key1": "val1", "key2": "val2"}`. For example, to pass a new message to the `HelloWorld` app defined above (with the code saved in `hello.py`):
|
||||
|
||||
```bash
|
||||
% serve run hello:app_builder message="Hello from CLI"
|
||||
2023-05-16 10:47:31,641 INFO scripts.py:404 -- Running import path: 'hello:app_builder'.
|
||||
2023-05-16 10:47:33,344 INFO worker.py:1615 -- Started a local Ray instance. View the dashboard at http://127.0.0.1:8265
|
||||
(ServeController pid=56826) INFO 2023-05-16 10:47:35,115 controller 56826 deployment_state.py:1244 - Deploying new version of deployment default_HelloWorld.
|
||||
(ServeController pid=56826) INFO 2023-05-16 10:47:35,141 controller 56826 deployment_state.py:1483 - Adding 1 replica to deployment default_HelloWorld.
|
||||
(ProxyActor pid=56828) INFO: Started server process [56828]
|
||||
(ServeReplica:default_HelloWorld pid=56830) Message: Hello from CLI
|
||||
2023-05-16 10:47:36,131 SUCC scripts.py:424 -- Deployed Serve app successfully.
|
||||
```
|
||||
|
||||
Notice that the "Hello from CLI" message is printed from within the deployment constructor.
|
||||
|
||||
### Passing arguments via config file
|
||||
|
||||
Pass arguments to the application builder in the config file's `args` field:
|
||||
|
||||
```yaml
|
||||
applications:
|
||||
- name: MyApp
|
||||
import_path: hello:app_builder
|
||||
args:
|
||||
message: "Hello from config"
|
||||
```
|
||||
|
||||
For example, to pass a new message to the `HelloWorld` app defined above (with the code saved in `hello.py` and the config saved in `config.yaml`):
|
||||
|
||||
```bash
|
||||
% serve run config.yaml
|
||||
2023-05-16 10:49:25,247 INFO scripts.py:351 -- Running config file: 'config.yaml'.
|
||||
2023-05-16 10:49:26,949 INFO worker.py:1615 -- Started a local Ray instance. View the dashboard at http://127.0.0.1:8265
|
||||
2023-05-16 10:49:28,678 SUCC scripts.py:419 -- Submitted deploy config successfully.
|
||||
(ServeController pid=57109) INFO 2023-05-16 10:49:28,676 controller 57109 controller.py:559 - Building application 'MyApp'.
|
||||
(ProxyActor pid=57111) INFO: Started server process [57111]
|
||||
(ServeController pid=57109) INFO 2023-05-16 10:49:28,940 controller 57109 application_state.py:202 - Built application 'MyApp' successfully.
|
||||
(ServeController pid=57109) INFO 2023-05-16 10:49:28,942 controller 57109 deployment_state.py:1244 - Deploying new version of deployment MyApp_HelloWorld.
|
||||
(ServeController pid=57109) INFO 2023-05-16 10:49:29,016 controller 57109 deployment_state.py:1483 - Adding 1 replica to deployment MyApp_HelloWorld.
|
||||
(ServeReplica:MyApp_HelloWorld pid=57113) Message: Hello from config
|
||||
```
|
||||
|
||||
Notice that the "Hello from config" message is printed from within the deployment constructor.
|
||||
|
||||
(typed-app-builders)=
|
||||
### Typing arguments with Pydantic
|
||||
|
||||
:::{warning}
|
||||
**Pydantic v1 Deprecation Notice:** Pydantic v1 is deprecated and Ray will drop support for it in version 2.56. If you're using Pydantic v1, upgrade to Pydantic v2 by running `pip install -U pydantic`. See [GitHub issue #58876](https://github.com/ray-project/ray/issues/58876) for more details.
|
||||
:::
|
||||
|
||||
To avoid writing logic to parse and validate the arguments by hand, define a [Pydantic model](https://pydantic-docs.helpmanual.io/usage/models/) as the single input parameter's type to your application builder function (the parameter must be type annotated). Arguments are passed the same way, but the resulting dictionary is used to construct the Pydantic model using `model.parse_obj(args_dict)`.
|
||||
|
||||
```{literalinclude} ../doc_code/app_builder.py
|
||||
:start-after: __begin_typed_builder__
|
||||
:end-before: __end_typed_builder__
|
||||
:language: python
|
||||
```
|
||||
|
||||
```bash
|
||||
% serve run hello:typed_app_builder message="Hello from CLI"
|
||||
2023-05-16 10:47:31,641 INFO scripts.py:404 -- Running import path: 'hello:typed_app_builder'.
|
||||
2023-05-16 10:47:33,344 INFO worker.py:1615 -- Started a local Ray instance. View the dashboard at http://127.0.0.1:8265
|
||||
(ServeController pid=56826) INFO 2023-05-16 10:47:35,115 controller 56826 deployment_state.py:1244 - Deploying new version of deployment default_HelloWorld.
|
||||
(ServeController pid=56826) INFO 2023-05-16 10:47:35,141 controller 56826 deployment_state.py:1483 - Adding 1 replica to deployment default_HelloWorld.
|
||||
(ProxyActor pid=56828) INFO: Started server process [56828]
|
||||
(ServeReplica:default_HelloWorld pid=56830) Message: Hello from CLI
|
||||
2023-05-16 10:47:36,131 SUCC scripts.py:424 -- Deployed Serve app successfully.
|
||||
```
|
||||
|
||||
## Common patterns
|
||||
|
||||
### Multiple parametrized applications using the same builder
|
||||
|
||||
You can use application builders to run multiple applications with the same code but different parameters. For example, multiple applications may share preprocessing and HTTP handling logic but use many different trained model weights. The same application builder `import_path` can take different arguments to define multiple applications as follows:
|
||||
|
||||
```yaml
|
||||
applications:
|
||||
- name: Model1
|
||||
import_path: my_module:my_model_code
|
||||
args:
|
||||
model_uri: s3://my_bucket/model_1
|
||||
- name: Model2
|
||||
import_path: my_module:my_model_code
|
||||
args:
|
||||
model_uri: s3://my_bucket/model_2
|
||||
- name: Model3
|
||||
import_path: my_module:my_model_code
|
||||
args:
|
||||
model_uri: s3://my_bucket/model_3
|
||||
```
|
||||
|
||||
### Configuring multiple composed deployments
|
||||
|
||||
You can use the arguments passed to an application builder to configure multiple deployments in a single application. For example a model composition application might take weights to two different models as follows:
|
||||
|
||||
```{literalinclude} ../doc_code/app_builder.py
|
||||
:start-after: __begin_composed_builder__
|
||||
:end-before: __end_composed_builder__
|
||||
:language: python
|
||||
```
|
||||
@@ -0,0 +1,456 @@
|
||||
(serve-asyncio-best-practices)=
|
||||
|
||||
# Asyncio and concurrency best practices in Ray Serve
|
||||
|
||||
The code that runs inside of each replica in a Ray Serve deployment runs on an asyncio event loop. Asyncio enables efficient I/O bound concurrency but requires following a few best practices for optimal performance.
|
||||
|
||||
This guide explains:
|
||||
|
||||
- When to use `async def` versus `def` in Ray Serve.
|
||||
- How Ray Serve executes your code (loops, threads, and the router).
|
||||
- How `max_ongoing_requests` interacts with asyncio concurrency.
|
||||
- How to think about Python's GIL, native code, and true parallelism.
|
||||
|
||||
The examples assume the following imports unless stated otherwise:
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __imports_begin__
|
||||
:end-before: __imports_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
## How to choose between `async def` and `def`
|
||||
|
||||
Use this decision table as a starting point:
|
||||
|
||||
| Workload type | Recommended handler | Reason |
|
||||
| --- | --- | --- |
|
||||
| I/O-bound (databases, HTTP calls, queues) | `async def` | Lets the event loop handle many requests while each waits on I/O. |
|
||||
| CPU-bound (model inference, heavy numeric compute) | `def` or `async def` with offload | Async alone doesn't make CPU work faster. You need more replicas, threads, or native parallelism. |
|
||||
| Streaming responses | `async def` generator | Integrates with backpressure and non-blocking iteration. |
|
||||
| FastAPI ingress (`@serve.ingress`) | `def` or `async def` | FastAPI runs `def` endpoints in a threadpool, so they don't block the loop. |
|
||||
|
||||
## How Ray Serve executes your code
|
||||
|
||||
At a high level, requests go through a router to a replica actor that runs your code:
|
||||
|
||||
```text
|
||||
Client
|
||||
↓
|
||||
Serve router (asyncio loop A)
|
||||
↓
|
||||
Replica actor
|
||||
├─ System / control loop
|
||||
└─ User code loop (your handlers)
|
||||
└─ Optional threadpool for sync methods
|
||||
```
|
||||
|
||||
The following are the key ideas to consider when deciding to use `async def` or `def`:
|
||||
|
||||
- Serve uses asyncio event loops for routing and for running replicas.
|
||||
- By default, user code runs on a separate event loop from the replica's main/control loop, so blocking user code doesn't interfere with health checks and autoscaling.
|
||||
- Depending on the value of `RAY_SERVE_RUN_SYNC_IN_THREADPOOL`, `def` handlers may run directly on the user event loop (blocking) or in a threadpool (non-blocking for the loop).
|
||||
|
||||
### Pure Serve deployments (no FastAPI ingress)
|
||||
|
||||
For a simple deployment:
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __echo_async_begin__
|
||||
:end-before: __echo_async_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
- `async def __call__` runs directly on the replica's user event loop.
|
||||
- While this handler awaits `asyncio.sleep`, the loop is free to start handling other requests.
|
||||
|
||||
For a synchronous deployment:
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __blocking_echo_begin__
|
||||
:end-before: __blocking_echo_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
How this method executes depends on configuration:
|
||||
|
||||
- With `RAY_SERVE_RUN_SYNC_IN_THREADPOOL=0` (current default), `__call__` runs directly on the user event loop and blocks it for 1 second.
|
||||
- With `RAY_SERVE_RUN_SYNC_IN_THREADPOOL=1`, Serve offloads `__call__` to a threadpool so the event loop stays responsive.
|
||||
|
||||
### FastAPI ingress (`@serve.ingress`)
|
||||
|
||||
When you use FastAPI ingress, FastAPI controls how endpoints run:
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __fastapi_deployment_begin__
|
||||
:end-before: __fastapi_deployment_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Important differences:
|
||||
|
||||
- FastAPI always dispatches `def` endpoints to a threadpool.
|
||||
- In pure Serve, `def` methods run on the event loop unless you opt into threadpool behavior.
|
||||
|
||||
## Threadpool sizing and overrides
|
||||
|
||||
Serve sets a default threadpool size for user code that mirrors Python's `ThreadPoolExecutor` defaults while respecting `ray_actor_options["num_cpus"]`.
|
||||
|
||||
In most cases, the default is fine. If you need to tune it, you can override the default executor inside your deployment:
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __threadpool_override_begin__
|
||||
:end-before: __threadpool_override_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Guidance for choosing a size:
|
||||
|
||||
- Default is fine in most cases.
|
||||
- For I/O-blocking code, consider a threadpool larger than `num_cpus`.
|
||||
- For GIL-releasing compute (NumPy/Pandas/SciPy, etc.), keep the threadpool at or below `num_cpus`.
|
||||
|
||||
## Blocking versus non-blocking in practice
|
||||
|
||||
Blocking code keeps the event loop from processing other work. Non-blocking code yields control back to the loop when it's waiting on something.
|
||||
|
||||
### Blocking I/O versus asynchronous I/O
|
||||
|
||||
Blocking I/O example:
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __blocking_http_begin__
|
||||
:end-before: __blocking_http_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Even though the method is `async def`, `requests.get` blocks the loop. No other requests can run on this replica during the request call. Blocking in `async def` is still blocking.
|
||||
|
||||
If the blocking call hangs and you've configured a request timeout, Ray Serve cancels the request when that timeout expires, but that cancellation is best-effort. Python only delivers `asyncio` cancellation when the task cooperates and yields control back to the event loop. A synchronous call such as `requests.get` doesn't do that, so one hung request can stall the replica's event loop and prevent later requests from running on that replica.
|
||||
|
||||
Non-blocking equivalent with async HTTP client:
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __async_http_begin__
|
||||
:end-before: __async_http_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Non-blocking equivalent using a threadpool:
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __threaded_http_begin__
|
||||
:end-before: __threaded_http_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
## Concurrency doesn't equal parallelism in Python
|
||||
|
||||
It's common to expect `async` code to "use all the cores" or make CPU-heavy code faster. asyncio doesn't do that.
|
||||
|
||||
### Concurrency: Handling many waiting operations
|
||||
|
||||
Asyncio gives you **concurrency** for I/O-bound workloads:
|
||||
|
||||
- While one request waits on the database, another can wait on an HTTP call.
|
||||
- Handlers yield back to the event loop at each `await`.
|
||||
|
||||
This is ideal for high-throughput APIs that mostly wait on external systems.
|
||||
|
||||
### Parallelism: Using multiple CPU cores
|
||||
|
||||
True CPU parallelism usually comes from:
|
||||
|
||||
- Multiple processes (for example, multiple Serve replicas).
|
||||
- Native code that releases the GIL and runs across cores.
|
||||
|
||||
Python's GIL means that pure Python bytecode runs one thread at a time in a process, even if you use a threadpool.
|
||||
|
||||
### Using GIL-releasing native code
|
||||
|
||||
Many numeric and ML libraries release the GIL while doing heavy work in native code:
|
||||
|
||||
- NumPy, many linear algebra routines.
|
||||
- PyTorch and some other deep learning frameworks.
|
||||
- Some image-processing or compression libraries.
|
||||
|
||||
In these cases, you can still get useful parallelism from threads inside a single replica process:
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __numpy_deployment_begin__
|
||||
:end-before: __numpy_deployment_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
However:
|
||||
|
||||
- GIL-releasing behavior is library-specific and sometimes operation-specific.
|
||||
- Some libraries use their own internal threadpools; combining them with your own threadpools can oversubscribe CPUs.
|
||||
- You should verify that your model stack is thread-safe before relying on this form of parallelism.
|
||||
|
||||
For predictable CPU scaling, it's usually simpler to increase the number of replicas.
|
||||
|
||||
### Summary
|
||||
|
||||
- `async def` improves **concurrency** for I/O-bound code.
|
||||
- CPU-bound code doesn't become faster merely because it's `async`.
|
||||
- Parallel CPU scaling comes mostly from **more processes** (replicas or tasks) and, in some cases, native code that releases the GIL.
|
||||
|
||||
## How `max_ongoing_requests` and replica concurrency work
|
||||
|
||||
Each deployment has a `max_ongoing_requests` configuration that controls how many in-flight requests a replica handles at once.
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __max_ongoing_requests_begin__
|
||||
:end-before: __max_ongoing_requests_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- Ray Serve uses an internal semaphore to limit concurrent in-flight requests per replica to `max_ongoing_requests`.
|
||||
- Requests beyond that limit queue in the router or handle until capacity becomes available, or they fail with backpressure depending on configuration.
|
||||
|
||||
How useful `max_ongoing_requests` is depends on how your handler behaves.
|
||||
|
||||
### `async` handlers and `max_ongoing_requests`
|
||||
|
||||
With an `async def` handler that spends most of its time awaiting I/O, `max_ongoing_requests` directly controls concurrency:
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __async_io_bound_begin__
|
||||
:end-before: __async_io_bound_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
- Up to 100 requests can be in-flight per replica.
|
||||
- While one request is waiting, the event loop can work on others.
|
||||
|
||||
### Blocking `def` handlers and `max_ongoing_requests`
|
||||
|
||||
With a blocking `def` handler that runs on the event loop (threadpool disabled), `max_ongoing_requests` doesn't give you the concurrency you expect:
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __blocking_cpu_begin__
|
||||
:end-before: __blocking_cpu_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
In this case:
|
||||
|
||||
- The event loop can only run one handler at a time.
|
||||
- Even though `max_ongoing_requests=100`, the replica effectively processes requests serially.
|
||||
|
||||
If you enable the sync-in-threadpool behavior (see the next section), each in-flight request can run in a thread:
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __cpu_with_threadpool_begin__
|
||||
:end-before: __cpu_with_threadpool_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Now:
|
||||
|
||||
- Up to `max_ongoing_requests` calls can be running at once.
|
||||
- Real throughput depends on:
|
||||
- How many threads the threadpool uses.
|
||||
- Whether your workload is CPU-bound or GIL-releasing.
|
||||
- Underlying native libraries and system resources.
|
||||
|
||||
For heavily CPU-bound workloads, it's usually better to:
|
||||
|
||||
- Keep `max_ongoing_requests` modest (to avoid queueing too many heavy tasks), and
|
||||
- Scale **replicas** (`num_replicas`) rather than pushing a single replica's concurrency too high.
|
||||
|
||||
## Environment flags and sync-in-threadpool warning
|
||||
|
||||
Ray Serve exposes several environment variables that control how user code interacts with event loops and threads.
|
||||
|
||||
### `RAY_SERVE_RUN_SYNC_IN_THREADPOOL`
|
||||
|
||||
By default (`RAY_SERVE_RUN_SYNC_IN_THREADPOOL=0`), which means synchronous methods in a deployment run directly on the user event loop. To help you migrate to a safer model, Serve emits a warning like:
|
||||
|
||||
> `RAY_SERVE_RUN_SYNC_IN_THREADPOOL_WARNING`: Calling sync method '...' directly on the asyncio loop. In a future version, sync methods will be run in a threadpool by default...
|
||||
|
||||
This warning means:
|
||||
|
||||
- You have a `def` method that is currently running on the event loop.
|
||||
- In a future version, that method runs in a threadpool instead.
|
||||
|
||||
You can opt in to the future behavior now by setting:
|
||||
|
||||
```bash
|
||||
export RAY_SERVE_RUN_SYNC_IN_THREADPOOL=1
|
||||
```
|
||||
|
||||
When this flag is `1`:
|
||||
|
||||
- Serve runs synchronous methods in a threadpool.
|
||||
- The event loop is free to keep serving other requests while sync methods run.
|
||||
|
||||
Before enabling this in production, make sure:
|
||||
|
||||
- Your handler code and any shared state are thread-safe.
|
||||
- Your model objects can safely be used from multiple threads, or you protect them with locks.
|
||||
|
||||
### `RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD`
|
||||
|
||||
By default, Serve runs user code in a separate event loop from the replica's main/control loop:
|
||||
|
||||
```bash
|
||||
export RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD=1 # default
|
||||
```
|
||||
|
||||
This isolation:
|
||||
|
||||
- Protects system tasks (health checks, controller communication) from being blocked by user code.
|
||||
- Adds some amount of overhead to cross-loop communication, resulting in higher latency in request. For throughput-optimized configurations, see [High throughput optimization](serve-high-throughput).
|
||||
|
||||
You can disable this behavior:
|
||||
|
||||
```bash
|
||||
export RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD=0
|
||||
```
|
||||
|
||||
Only advanced users should change this. When user code and system tasks share a loop, any blocking operation in user code can interfere with replica health and control-plane operations.
|
||||
|
||||
### `RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP`
|
||||
|
||||
Serve's request router is also run on its own event loop by default:
|
||||
|
||||
```bash
|
||||
export RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP=1 # default
|
||||
```
|
||||
|
||||
This ensures:
|
||||
|
||||
- The router can continue routing and load balancing requests even if some replicas are running slow user code.
|
||||
|
||||
Disabling this:
|
||||
|
||||
```bash
|
||||
export RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP=0
|
||||
```
|
||||
|
||||
makes the router share an event loop with other work. This can reduce overhead in advanced, highly optimized scenarios, but makes the system more sensitive to blocking operations. See [High throughput optimization](serve-high-throughput).
|
||||
|
||||
For most production deployments, you should keep the defaults (`1`) for both separate-loop flags.
|
||||
|
||||
## Batching and streaming semantics
|
||||
|
||||
Batching and streaming both rely on the event loop to stay responsive. They don't change where your code runs: batched handlers and streaming handlers still run on the same user event loop as any other handler. This means that if you add batching or streaming on top of blocking code, you can make event loop blocking effects much worse.
|
||||
|
||||
### Batching
|
||||
|
||||
When you enable batching, Serve groups multiple incoming requests together and passes them to your handler as a list. The handler still runs on the user event loop, but each call now processes many requests at once instead of just one. If that batched work is blocking, it blocks the event loop for all of those requests at the same time.
|
||||
|
||||
The following example shows a batched deployment:
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __batched_model_begin__
|
||||
:end-before: __batched_model_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
The batch handler runs on the user event loop:
|
||||
|
||||
- If `_run_model` is CPU-heavy and runs inline, it blocks the loop for the duration of the batch.
|
||||
- You can offload the batch computation:
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __batched_model_offload_begin__
|
||||
:end-before: __batched_model_offload_end__
|
||||
:language: python
|
||||
:emphasize-lines: 9-16
|
||||
```
|
||||
|
||||
This keeps the event loop responsive while the model runs in a thread.
|
||||
|
||||
#### `max_concurrent_batches` and event loop yielding
|
||||
|
||||
The `@serve.batch` decorator accepts a `max_concurrent_batches` argument that controls how many batches can be processed concurrently. However, this argument only works effectively if your batch handler yields control back to the event loop during processing.
|
||||
|
||||
If your batch handler blocks the event loop (for example, by doing heavy CPU work without awaiting or offloading), `max_concurrent_batches` won't provide the concurrency you expect. The event loop can only start processing a new batch when the current batch yields control.
|
||||
|
||||
To get the benefit of `max_concurrent_batches`:
|
||||
|
||||
- Use `async def` for your batch handler and `await` I/O operations or offloaded CPU work.
|
||||
- Offload CPU-heavy batch processing to a threadpool with `asyncio.to_thread()` or `loop.run_in_executor()`.
|
||||
- Avoid blocking operations that prevent the event loop from scheduling other batches.
|
||||
|
||||
In the offloaded batch example above, the handler yields to the event loop when awaiting the threadpool executor, which allows multiple batches to be in flight simultaneously (up to the `max_concurrent_batches` limit).
|
||||
|
||||
### Streaming
|
||||
|
||||
Streaming is different from a regular response because the client starts receiving data while your handler is still running. Serve calls your handler once, gets back a generator or async generator, and then repeatedly asks it for the next chunk. That generator code still runs on the user event loop (or in a worker thread if you offload it).
|
||||
|
||||
Streaming is especially sensitive to blocking:
|
||||
|
||||
- If you block between chunks, you delay the next piece of data to the client.
|
||||
- While the generator is blocked on the event loop, other requests on that loop can't make progress.
|
||||
- The system also cannot react quickly to slow clients (backpressure) or cancellation.
|
||||
|
||||
Bad streaming example:
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __blocking_stream_begin__
|
||||
:end-before: __blocking_stream_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Better streaming example:
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __async_stream_begin__
|
||||
:end-before: __async_stream_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
In streaming scenarios:
|
||||
|
||||
- Prefer `async def` generators that use `await` between yields.
|
||||
- Avoid long CPU-bound loops between yields; offload them if needed.
|
||||
|
||||
## Offloading patterns: I/O, CPU
|
||||
|
||||
This section summarizes common offloading patterns you can use inside `async` handlers.
|
||||
|
||||
### Blocking I/O in `async def`
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __offload_io_begin__
|
||||
:end-before: __offload_io_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
### CPU-heavy code in `async def`
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __offload_cpu_begin__
|
||||
:end-before: __offload_cpu_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
### (Advance) Using Ray tasks or remote actors for true parallelism
|
||||
|
||||
:::{note}
|
||||
While you can spawn Ray tasks from Ray Serve deployments, this approach isn't recommended because it lacks tooling for observability and debugging.
|
||||
:::
|
||||
|
||||
```{literalinclude} ../doc_code/asyncio_best_practices.py
|
||||
:start-after: __ray_parallel_begin__
|
||||
:end-before: __ray_parallel_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
This pattern:
|
||||
|
||||
- Uses multiple Ray workers and processes.
|
||||
- Bypasses the GIL limitation of a single Python process.
|
||||
|
||||
## Summary
|
||||
|
||||
- Use `async def` for I/O-bound and streaming work so the event loop can stay responsive.
|
||||
- Use `max_ongoing_requests` to bound concurrency per replica, but remember that blocking `def` handlers can still serialize work if they run on the event loop.
|
||||
- Consider enabling `RAY_SERVE_RUN_SYNC_IN_THREADPOOL` once your code is thread-safe, and be aware of the sync-in-threadpool warning.
|
||||
- For CPU-heavy workloads, scale replicas or GIL-releasing native code for real parallelism.
|
||||
@@ -0,0 +1,227 @@
|
||||
(custom-request-router-guide)=
|
||||
# Use Custom Algorithm for Request Routing
|
||||
|
||||
:::{warning}
|
||||
This API is in alpha and may change before becoming stable.
|
||||
:::
|
||||
|
||||
Different Ray serve applications demand different logics for load balancing. For example, in serving LLMs you might want to have a different policy than balancing number of requests across replicas: e.g. balancing ongoing input tokens, balancing kv-cache utilization, etc. [`RequestRouter`](../api/doc/ray.serve.request_router.RequestRouter.rst) is an abstraction in Ray Serve that allows extension and customization of load-balancing logic for each deployment.
|
||||
|
||||
This guide shows how to use [`RequestRouter`](../api/doc/ray.serve.request_router.RequestRouter.rst) API to achieve custom load balancing across replicas of a given deployment. It will cover the following:
|
||||
- Define a simple uniform request router for load balancing
|
||||
- Deploy an app with the uniform request router
|
||||
- Utility mixins for request routing
|
||||
- Define a complex throughput-aware request router
|
||||
- Deploy an app with the throughput-aware request router
|
||||
- Experimental: Use the round-robin request router
|
||||
- Experimental: Use the consistent-hash request router for session stickiness
|
||||
- Experimental: Define a centralized capacity queue request router
|
||||
|
||||
|
||||
(simple-uniform-request-router)=
|
||||
## Define simple uniform request router
|
||||
Create a file `custom_request_router.py` with the following code:
|
||||
|
||||
```{literalinclude} ../doc_code/custom_request_router.py
|
||||
:start-after: __begin_define_uniform_request_router__
|
||||
:end-before: __end_define_uniform_request_router__
|
||||
:language: python
|
||||
```
|
||||
This code defines a simple uniform request router that routes requests a random replica to distribute the load evenly regardless of the queue length of each replica or the body of the request. The router is defined as a class that inherits from [`RequestRouter`](../api/doc/ray.serve.request_router.RequestRouter.rst). It implements the [`choose_replicas`](../api/doc/ray.serve.request_router.RequestRouter.choose_replicas.rst) method, which returns the random replica for all incoming requests. The returned type is a list of lists of replicas, where each inner list represents a rank of replicas. The first rank is the most preferred and the last rank is the least preferred. The request will be attempted to be routed to the replica with the shortest request queue in each set of the rank in order until a replica is able to process the request. If none of the replicas are able to process the request, [`choose_replicas`](../api/doc/ray.serve.request_router.RequestRouter.choose_replicas.rst) will be called again with a backoff delay until a replica is able to process the request.
|
||||
|
||||
|
||||
:::{note}
|
||||
This request router also implements [`on_request_routed`](../api/doc/ray.serve.request_router.RequestRouter.on_request_routed.rst) which can help you update the state of the request router after a request is routed.
|
||||
:::
|
||||
|
||||
(deploy-app-with-uniform-request-router)=
|
||||
## Deploy an app with the uniform request router
|
||||
To use a custom request router, you need to pass the `request_router_class` argument to the [`deployment`](../api/doc/ray.serve.deployment_decorator.rst) decorator. Also note that the `request_router_class` can be passed as the already imported class or as the string of import path to the class. Let's deploy a simple app that uses the uniform request router like this:
|
||||
|
||||
```{literalinclude} ../doc_code/custom_request_router_app.py
|
||||
:start-after: __begin_deploy_app_with_uniform_request_router__
|
||||
:end-before: __end_deploy_app_with_uniform_request_router__
|
||||
:language: python
|
||||
```
|
||||
|
||||
As the request is routed, both "UniformRequestRouter routing request" and "on_request_routed callback is called!!" messages will be printed to the console. The response will also be randomly routed to one of the replicas. You can test this by sending more requests and seeing the distribution of the replicas are roughly equal.
|
||||
|
||||
:::{note}
|
||||
Currently, the only way to configure the request router is to pass it as an argument to the deployment decorator. This means that you cannot change the request router for an existing deployment handle with running router. If you have a particular usecase where you need to reconfigure a request router on the deployment handle, please open a feature request on the [Ray GitHub repository](https://github.com/ray-project/ray/issues)
|
||||
:::
|
||||
|
||||
(utility-mixin)=
|
||||
## Utility mixins for request routing
|
||||
Ray Serve provides utility mixins that can be used to extend the functionality of the request router. These mixins can be used to implement common routing policies such as locality-aware routing, multiplexed model support, and FIFO request routing.
|
||||
|
||||
- [`FIFOMixin`](../api/doc/ray.serve.request_router.FIFOMixin.rst): This mixin implements first in first out (FIFO) request routing. The default behavior for the request router is OOO (out of order) which routes requests to the exact replica which got assigned by the request passed to [`choose_replicas`](../api/doc/ray.serve.request_router.RequestRouter.choose_replicas.rst). This mixin is useful for the routing algorithm that can work independently of the request content, so the requests can be routed as soon as possible in the order they were received. By including this mixin, in your custom request router, the request matching algorithm will be updated to route requests FIFO. There are no additional flags needs to be configured and no additional helper methods provided by this mixin.
|
||||
- [`LocalityMixin`](../api/doc/ray.serve.request_router.LocalityMixin.rst): This mixin implements locality-aware request routing. It updates the internal states when between replica updates to track the location between replicas in the same node, same zone, and everything else. It offers helpers [`apply_locality_routing`](../api/doc/ray.serve.request_router.LocalityMixin.apply_locality_routing.rst) and [`rank_replicas_via_locality`](../api/doc/ray.serve.request_router.LocalityMixin.rank_replicas_via_locality.rst) to route and ranks replicas based on their locality to the request, which can be useful for reducing latency and improving performance.
|
||||
- [`MultiplexMixin`](../api/doc/ray.serve.request_router.MultiplexMixin.rst): When you use model-multiplexing you need to route requests based on knowing which replica has already a hot version of the model. It updates the internal states when between replica updates to track the model loaded on each replica, and size of the model cache on each replica. It offers helpers [`apply_multiplex_routing`](../api/doc/ray.serve.request_router.MultiplexMixin.apply_multiplex_routing.rst) and [`rank_replicas_via_multiplex`](../api/doc/ray.serve.request_router.MultiplexMixin.rank_replicas_via_multiplex.rst) to route and ranks replicas based on their multiplexed model id of the request.
|
||||
|
||||
|
||||
(throughput-aware-request-router)=
|
||||
## Define a complex throughput-aware request router
|
||||
A fully featured request router can be more complex and should take into account the multiplexed model, locality, the request queue length on each replica, and using custom statistics like throughput to decide which replica to route the request to. The following class defines a throughput-aware request router that routes requests to the replica with these factors in mind. Add the following code into the `custom_request_router.py` file:
|
||||
|
||||
```{literalinclude} ../doc_code/custom_request_router.py
|
||||
:start-after: __begin_define_throughput_aware_request_router__
|
||||
:end-before: __end_define_throughput_aware_request_router__
|
||||
:language: python
|
||||
```
|
||||
|
||||
This request router inherits from [`RequestRouter`](../api/doc/ray.serve.request_router.RequestRouter.rst), as well as [`FIFOMixin`](../api/doc/ray.serve.request_router.FIFOMixin.rst) for FIFO request routing, [`LocalityMixin`](../api/doc/ray.serve.request_router.LocalityMixin.rst) for locality-aware request routing, and [`MultiplexMixin`](../api/doc/ray.serve.request_router.MultiplexMixin.rst) for multiplexed model support. It implements [`choose_replicas`](../api/doc/ray.serve.request_router.RequestRouter.choose_replicas.rst) to take the highest ranked replicas from [`rank_replicas_via_multiplex`](../api/doc/ray.serve.request_router.MultiplexMixin.rank_replicas_via_multiplex.rst) and [`rank_replicas_via_locality`](../api/doc/ray.serve.request_router.LocalityMixin.rank_replicas_via_locality.rst) and uses the [`select_available_replicas`](../api/doc/ray.serve.request_router.RequestRouter.select_available_replicas.rst) helper to filter out replicas that have reached their maximum request queue length. Finally, it takes the replicas with the minimum throughput and returns the top one.
|
||||
|
||||
(deploy-app-with-throughput-aware-request-router)=
|
||||
## Deploy an app with the throughput-aware request router
|
||||
To use the throughput-aware request router, you can deploy an app like this:
|
||||
|
||||
```{literalinclude} ../doc_code/custom_request_router_app.py
|
||||
:start-after: __begin_deploy_app_with_throughput_aware_request_router__
|
||||
:end-before: __end_deploy_app_with_throughput_aware_request_router__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Similar to the uniform request router, the custom request router can be defined in the `request_router_class` argument of the [`deployment`](../api/doc/ray.serve.deployment_decorator.rst) decorator. The Serve controller pulls statistics from the replica of each deployment by calling record_routing_stats. The `request_routing_stats_period_s` and `request_routing_stats_timeout_s` arguments control the frequency and timeout time of the serve controller pulling information from each replica in its background thread. You can customize the emission of these statistics by overriding `record_routing_stats` in the definition of the deployment class. The custom request router can then get the updated routing stats by looking up the `routing_stats` attribute of the running replicas and use it in the routing policy.
|
||||
|
||||
|
||||
(round-robin-request-router)=
|
||||
## Experimental: Use the round-robin request router
|
||||
|
||||
`RoundRobinRouter` cycles through replicas in round-robin fashion, starting at an arbitrary replica so routers spun up together don't synchronize on the same first replica. If the chosen replica is at capacity, the router falls back to the next replica in order and wraps around the candidate list. Each router instance keeps its own cursor, so with multiple routers (for example, one per Ray Serve proxy) the fan-out is round-robin per router and approximately uniform across replicas in aggregate. The router mixes in [`FIFOMixin`](../api/doc/ray.serve.request_router.FIFOMixin.rst) so queued requests are routed in arrival order.
|
||||
|
||||
### When to use
|
||||
Use the round-robin router when you want a predictable, even distribution across replicas and don't need load-aware or locality-aware decisions. It fits stateless workloads with roughly uniform per-request latency. Unlike the default power-of-two-choices router, the round-robin router doesn't compare replicas by their number of ongoing requests. It only skips a replica once it reaches `max_ongoing_requests`. Therefore, requests can pile up behind a slow replica before it falls back to the next one.
|
||||
|
||||
### Example
|
||||
Reference the router by import path through [`RequestRouterConfig`](../api/doc/ray.serve.config.RequestRouterConfig.rst):
|
||||
|
||||
```{literalinclude} ../doc_code/custom_request_router_app.py
|
||||
:start-after: __begin_deploy_app_with_round_robin_router__
|
||||
:end-before: __end_deploy_app_with_round_robin_router__
|
||||
:language: python
|
||||
```
|
||||
|
||||
|
||||
(consistent-hash-request-router)=
|
||||
## Experimental: Use the consistent-hash request router for session stickiness
|
||||
|
||||
`ConsistentHashRouter` pins each session to a specific replica using a consistent-hash ring with virtual nodes. The routing key is the session ID read from the HTTP header named by `RAY_SERVE_SESSION_ID_HEADER_KEY` (default `x-session-id`), so the same session ID consistently maps to the same replica. Requests without that header fall back to a per-request internal ID and spread uniformly across replicas. If the chosen replica rejects (for example, due to backpressure from the number of ongoing request), the router walks up to `num_fallback_replicas` clockwise successors on the ring. If those are also at capacity, the router sleeps with exponential backoff and retries the same primary and fallbacks until one accepts. When the replica set changes, the ring is rebuilt and only keys owned by added or removed replicas are reshuffled.
|
||||
|
||||
Two parameters are configurable through [`request_router_kwargs`](../api/doc/ray.serve.config.RequestRouterConfig.rst):
|
||||
|
||||
* `num_virtual_nodes` (default `100`): vnodes per replica on the ring. Higher values spread sessions more evenly across replicas.
|
||||
* `num_fallback_replicas` (default `2`): clockwise successors tried after the primary rejects. Set to `0` for strict affinity with no fallback.
|
||||
|
||||
### When to use
|
||||
Use the consistent-hash router when requests carry session-scoped state worth keeping warm on a single replica, for example in-memory caches keyed by user or KV-cache reuse for LLM chat sessions. Skip it for stateless workloads, since affinity sacrifices queue-aware balancing and a hot session can saturate its assigned replica while others are idle. The router does not combine with queue-depth, locality, or multiplexed-model signals, since mixing them in would break determinism and therefore break affinity.
|
||||
|
||||
### Example
|
||||
Configure the router via [`RequestRouterConfig`](../api/doc/ray.serve.config.RequestRouterConfig.rst) and pass tuning parameters through `request_router_kwargs`:
|
||||
|
||||
```{literalinclude} ../doc_code/custom_request_router_app.py
|
||||
:start-after: __begin_deploy_app_with_consistent_hash_router__
|
||||
:end-before: __end_deploy_app_with_consistent_hash_router__
|
||||
:language: python
|
||||
```
|
||||
|
||||
If your clients send the session identifier under a different header (for example, `x-correlation-id`), point Ray Serve at it before the cluster starts:
|
||||
|
||||
```bash
|
||||
export RAY_SERVE_SESSION_ID_HEADER_KEY=x-correlation-id
|
||||
```
|
||||
|
||||
|
||||
(capacity-queue-request-router)=
|
||||
## Experimental: Define a centralized capacity queue request router
|
||||
|
||||
In the previous examples, the routing decisions are based on the locally visible state of the target replicas from the perspective of the router replica. This view is **eventually consistent** not strongly because the serve controller frequently broadcasts the replica information to the router. Under high concurrency with multiple routers, this information can drift from reality and can cause several routers to simultaneously pick the same replica, causing transient load imbalance or triggering rejections and retries. For some applications this can result in lower throughput. A **centralized** approach avoids this: a single actor tracks per-replica in-flight counts, and every router acquires a *capacity token* before forwarding a request. This way, each token guarantees the target replica has room, eliminating the rejection protocol entirely.
|
||||
|
||||
This example demonstrates how we can implement such routing policy. The example has three pieces:
|
||||
|
||||
1. An importable **`CapacityQueue`** actor that tracks per-replica capacity and hands out tokens using a least-loaded selection strategy.
|
||||
2. An importable **`CapacityQueueRouter`** custom request router that acquires a token before routing and releases it when the request completes. In a real application, we can have multiple replicas of `CapacityQueueRouter` each one keeping tracking their own view of state of replicas. The centralized `CapacityQueue` actor is meant to keep their local information synchronized with reality.
|
||||
3. A **deployment** that ties them together using a [deployment actor](../api/doc/ray.serve.config.DeploymentActorConfig.rst) for the queue and a [`RequestRouterConfig`](../api/doc/ray.serve.config.RequestRouterConfig.rst) for the router.
|
||||
|
||||
(deploy-app-with-capacity-queue-router)=
|
||||
### Deploy an app with the capacity queue router
|
||||
|
||||
The deployment wires the pieces together: a `DeploymentActorConfig` for the capacity queue and a `RequestRouterConfig` pointing at the custom router:
|
||||
|
||||
```{literalinclude} ../doc_code/capacity_queue_request_router_app.py
|
||||
:start-after: __begin_deploy_app_with_capacity_queue_router__
|
||||
:end-before: __end_deploy_app_with_capacity_queue_router__
|
||||
:language: python
|
||||
```
|
||||
|
||||
When the app starts:
|
||||
|
||||
1. The Serve controller creates the `CapacityQueue` deployment actor **before** any replicas start. `CapacityQueue` subscribes to replica updates via long poll.
|
||||
2. As the controller starts replicas, it sends deployment-target updates. The queue's long-poll callback automatically registers each replica with its `max_ongoing_requests` capacity and unregisters replicas that are removed during scale-down or crash recovery.
|
||||
3. The `CapacityQueueRouter` running in each proxy discovers the singleton `CapacityQueue` deployment actor, acquires a token for every incoming request, and routes to the replica identified by the token.
|
||||
4. When the request completes, `CapacityQueueRouter.on_request_completed` fires and the token is released back to the queue.
|
||||
|
||||
Because the queue is a deployment actor, the controller handles its lifecycle automatically — health checks, cleanup on app deletion, and versioning during rolling updates.
|
||||
|
||||
### Fault tolerance
|
||||
|
||||
The `CapacityQueueRouter` handles failures gracefully:
|
||||
|
||||
- **Queue unavailable** — if the queue actor is dead, not yet discovered, or errors, the router retries with exponential backoff and falls back to power-of-two-choices after `MAX_FAULT_RETRIES` consecutive failures. Requests never raise exceptions due to queue issues.
|
||||
- **Capacity exhausted** — when all replicas are at capacity, the router backs off and retries until capacity frees up.
|
||||
- **Queue restart** — a restarted queue has no knowledge of pre-crash in-flight counts and may temporarily over-provision. This self-heals: replicas reject excess requests, and the router does not release rejected tokens intentionally, ratcheting up `in_flight` on the queue until it matches reality. `token_ttl_s` (if configured) auto-reclaims any remaining leaked tokens.
|
||||
- **Replica death** — the controller sends a long-poll update, the queue unregisters the dead replica, and tokens are only issued for live replicas.
|
||||
|
||||
### Usage
|
||||
The centralized capacity queue request router could bring performance benefits particularly in a constrained supply deployment, i.e. `max_ongoing_request=1` or `2`.
|
||||
|
||||
### Benchmark
|
||||
|
||||
#### Benchmark Setup
|
||||
- Deployment topology: Client -> `ParentDeployment` -> `ChildDeployment`. Request router selection is applied to both deployments, controlling how parent replicas are selected by the HTTP proxy and how child replicas are selected by parent's `DeploymentHandle`.
|
||||
- Scale: small (8 replicas), medium (32 replicas), large (128 replicas), xlarge (512 replicas).
|
||||
- Workload: Replica processing latency is drawn from an exponential distribution with mean 1s and capped at 10s.
|
||||
- `max_ongoing_request` is set to `2`.
|
||||
- Load generation: Applies closed-loop load generation where the load consistently keeps replicas saturated at `max_ongoing_request` concurrency.
|
||||
- Warmup: 10s; metrics within the warmup window are discarded entirely.
|
||||
|
||||
#### Benchmark Metrics
|
||||
- Throughput: Requests per second, i.e. `num_requests / duration`.
|
||||
- Utilization: Measures what fraction of a replica's total processing capacity was consumed by actual work during the experiment. Concretely, `sum(replica_processing_latency_s) / (duration_s * max_ongoing_requests)`. For GPU deployments, utilization serves as an assessment proxy for GPU utilization.
|
||||
- Latency: Measures the client-side end-to-end latency, covering the full round-trip -- client -> `ParentDeployment` -> `ChildDeployment` -> `ParentDeployment` -> client.
|
||||
|
||||
#### Normal Situation
|
||||
Under normal (success) situations, `CapacityQueueRouter` yields higher throughput and utilization and lower latency.
|
||||
|
||||
```{image} ../images/capacity-queue-router-normal.png
|
||||
:align: center
|
||||
:width: 800px
|
||||
```
|
||||
|
||||
#### Fault Situation
|
||||
A fault is simulated by killing the `CapacityQueue` router, and upon recovery, `CapacityQueue` converges towards its pre-fault performance.
|
||||
|
||||
```{image} ../images/capacity-queue-router-fault.png
|
||||
:align: center
|
||||
:width: 800px
|
||||
```
|
||||
|
||||
:::{note}
|
||||
If you experience the following error when the `CapacityQueue` actor experiences faults and routing decisions fall back to the power-of-two-choices router, set `RAY_SERVE_QUEUE_LENGTH_RESPONSE_DEADLINE_S` to a higher value.
|
||||
|
||||
> Failed to get queue length from Replica(id='...', deployment='ParentDeployment', app='...') within 0.1s.
|
||||
|
||||
:::
|
||||
|
||||
:::{warning}
|
||||
## Gotchas and limitations
|
||||
|
||||
When you provide a custom router, Ray Serve can fully support it as long as it's simple, self-contained Python code that relies only on the standard library. Once the router becomes more complex, such as depending on other custom modules or packages, you need to ensure those modules are bundled into the Docker image or environment. This is because Ray Serve uses `cloudpickle` to serialize custom routers and it doesn't vendor transitive dependencies—if your router inherits from a superclass in another module or imports custom packages, those must exist in the target environment. Additionally, environment parity matters: differences in Python version, `cloudpickle` version, or library versions can affect deserialization.
|
||||
|
||||
### Alternatives for complex routers
|
||||
|
||||
When your custom request router has complex dependencies or you want better control over versioning and deployment, you have several alternatives:
|
||||
|
||||
- **Use built-in routers**: Consider using the routers shipped with Ray Serve—these are well-tested, production-ready, and guaranteed to work across different environments.
|
||||
- **Contribute to Ray Serve**: If your router is general-purpose and might benefit others, consider contributing it to Ray Serve as a built-in router by opening a feature request or pull request on the [Ray GitHub repository](https://github.com/ray-project/ray/issues). The recommended location for the implementation is `python/ray/serve/_private/request_router/`.
|
||||
- **Ensure dependencies in your environment**: Make sure that the external dependencies are installed in your Docker image or environment.
|
||||
:::
|
||||
@@ -0,0 +1,89 @@
|
||||
(serve-in-production-deploying)=
|
||||
|
||||
# Deploy on VM
|
||||
|
||||
You can deploy your Serve application to production on a Ray cluster using the Ray Serve CLI. `serve deploy` takes in a config file path and it deploys that file to a Ray cluster over HTTP. This could either be a local, single-node cluster as in this example or a remote, multi-node cluster started with the [Ray Cluster Launcher](cloud-vm-index).
|
||||
|
||||
This section should help you:
|
||||
|
||||
- understand how to deploy a Ray Serve config file using the CLI.
|
||||
- understand how to update your application using the CLI.
|
||||
- understand how to deploy to a remote cluster started with the [Ray Cluster Launcher](cloud-vm-index).
|
||||
|
||||
Start by deploying this [config](production-config-yaml) for the Text ML Application [example](serve-in-production-example):
|
||||
|
||||
```console
|
||||
$ ls
|
||||
text_ml.py
|
||||
serve_config.yaml
|
||||
|
||||
$ ray start --head
|
||||
...
|
||||
|
||||
$ serve deploy serve_config.yaml
|
||||
2022-06-20 17:26:31,106 SUCC scripts.py:139 --
|
||||
Sent deploy request successfully!
|
||||
* Use `serve status` to check deployments' statuses.
|
||||
* Use `serve config` to see the running app's config.
|
||||
```
|
||||
|
||||
`ray start --head` starts a long-lived Ray cluster locally. `serve deploy serve_config.yaml` deploys the `serve_config.yaml` file to this local cluster. To stop Ray cluster, run the CLI command `ray stop`.
|
||||
|
||||
The message `Sent deploy request successfully!` means:
|
||||
* The Ray cluster has received your config file successfully.
|
||||
* It will start a new Serve application if one hasn't already started.
|
||||
* The Serve application will deploy the deployments from your deployment graph, updated with the configurations from your config file.
|
||||
|
||||
It does **not** mean that your Serve application, including your deployments, has already started running successfully. This happens asynchronously as the Ray cluster attempts to update itself to match the settings from your config file. See [Inspect an application](serve-in-production-inspecting) for how to get the current status.
|
||||
|
||||
(serve-in-production-remote-cluster)=
|
||||
|
||||
## Using a remote cluster
|
||||
|
||||
By default, `serve deploy` deploys to a cluster running locally. However, you should also use `serve deploy` whenever you want to deploy your Serve application to a remote cluster. `serve deploy` takes in an optional `--address/-a` argument where you can specify your remote Ray cluster's dashboard address. This address should be of the form:
|
||||
|
||||
```
|
||||
[RAY_CLUSTER_URI]:[DASHBOARD_PORT]
|
||||
```
|
||||
|
||||
As an example, the address for the local cluster started by `ray start --head` is `http://127.0.0.1:8265`. We can explicitly deploy to this address using the command
|
||||
|
||||
```console
|
||||
$ serve deploy config_file.yaml -a http://127.0.0.1:8265
|
||||
```
|
||||
|
||||
The Ray Dashboard's default port is 8265. To set it to a different value, use the `--dashboard-port` argument when running `ray start`.
|
||||
|
||||
:::{note}
|
||||
When running on a remote cluster, you need to ensure that the import path is accessible. See [Handle Dependencies](serve-handling-dependencies) for how to add a runtime environment.
|
||||
:::
|
||||
|
||||
:::{tip}
|
||||
By default, all the Serve CLI commands assume that you're working with a local cluster. All Serve CLI commands, except `serve start` and `serve run` use the Ray Dashboard address associated with a local cluster started by `ray start --head`. However, if the `RAY_DASHBOARD_ADDRESS` environment variable is set, these Serve CLI commands will default to that value instead.
|
||||
|
||||
Similarly, `serve start` and `serve run`, use the Ray head node address associated with a local cluster by default. If the `RAY_ADDRESS` environment variable is set, they will use that value instead.
|
||||
|
||||
You can check `RAY_DASHBOARD_ADDRESS`'s value by running:
|
||||
|
||||
```console
|
||||
$ echo $RAY_DASHBOARD_ADDRESS
|
||||
```
|
||||
|
||||
You can set this variable by running the CLI command:
|
||||
|
||||
```console
|
||||
$ export RAY_DASHBOARD_ADDRESS=[YOUR VALUE]
|
||||
```
|
||||
|
||||
You can unset this variable by running the CLI command:
|
||||
|
||||
```console
|
||||
$ unset RAY_DASHBOARD_ADDRESS
|
||||
```
|
||||
|
||||
Check for this variable in your environment to make sure you're using your desired Ray Dashboard address.
|
||||
:::
|
||||
|
||||
To inspect the status of the Serve application in production, see [Inspect an application](serve-in-production-inspecting).
|
||||
|
||||
Make heavyweight code updates (like `runtime_env` changes) by starting a new Ray Cluster, updating your Serve config file, and deploying the file with `serve deploy` to the new cluster. Once the new deployment is finished, switch your traffic to the new cluster.
|
||||
@@ -0,0 +1,102 @@
|
||||
(serve-deployment-scoped-actors)=
|
||||
# Use deployment-scoped actors
|
||||
|
||||
:::{warning}
|
||||
Deployment-scoped actors are experimental and may change between Ray minor versions.
|
||||
:::
|
||||
|
||||
A deployment-scoped actor is a Ray actor that the Serve controller starts and manages for one deployment. All replicas in that deployment share the actor. The actor outlives individual replica restarts, and Ray Serve cleans it up when you delete the application or call `serve.shutdown()`.
|
||||
|
||||
Ray Serve already uses this pattern in the [custom request router guide](custom-request-router). The experimental centralized capacity queue router uses a deployment-scoped actor to keep shared routing state in one place while the controller manages its lifecycle.
|
||||
|
||||
## Decide whether to use deployment-scoped actors
|
||||
|
||||
Deployment-scoped actors work best when you need controller-managed shared state or coordination for one deployment, such as:
|
||||
|
||||
- A shared counter, cache, prefix tree, or rate limiter that every replica should see.
|
||||
- A coordinator that should survive replica restarts and rolling updates.
|
||||
- A helper actor that should inherit the deployment's environment and be cleaned up with the deployment.
|
||||
- Centralized control-plane logic, such as a queue or router helper, where one actor prevents each replica from keeping a conflicting local view.
|
||||
|
||||
Deployment-scoped actors aren't a good fit when:
|
||||
|
||||
- The state belongs to one replica. Keep it inside the replica instead.
|
||||
- The state must stay durable across cluster loss. Use an external database or cache instead.
|
||||
- The actor would sit on the hot path for high-volume data-plane work and become a serialization bottleneck.
|
||||
- Multiple deployments or non-Serve jobs need to share the same service. Use a dedicated Ray actor or an external service instead.
|
||||
|
||||
## Define a deployment-scoped actor
|
||||
|
||||
The following example defines a shared counter actor and attaches it to a deployment with `DeploymentActorConfig`:
|
||||
|
||||
```{literalinclude} ../doc_code/deployment_scoped_actors.py
|
||||
:start-after: __begin_define_deployment_scoped_actor__
|
||||
:end-before: __end_define_deployment_scoped_actor__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Every replica of `SharedCounterDeployment` reads and updates the same `SharedCounter` actor. `serve.get_deployment_actor()` only works inside a running Serve replica, so call it from the deployment's methods or constructor, not from driver code.
|
||||
|
||||
## Run the example
|
||||
|
||||
The example file is runnable as written:
|
||||
|
||||
```{literalinclude} ../doc_code/deployment_scoped_actors.py
|
||||
:start-after: __begin_run_deployment_scoped_actor_example__
|
||||
:end-before: __end_run_deployment_scoped_actor_example__
|
||||
:language: python
|
||||
```
|
||||
|
||||
When you run the script, the two requests return increasing values because they hit the same shared actor state.
|
||||
|
||||
## Handle actor recreation
|
||||
|
||||
Ray Serve health-checks deployment-scoped actors and may recreate them after failures. A handle cached before recreation can become stale. The safest pattern is to call `serve.get_deployment_actor()` when you need the actor. If you want to cache the handle, refresh it on `RayActorError` and retry the lookup until the new actor is registered:
|
||||
|
||||
```{literalinclude} ../doc_code/deployment_scoped_actors.py
|
||||
:start-after: __begin_cached_handle_refresh__
|
||||
:end-before: __end_cached_handle_refresh__
|
||||
:language: python
|
||||
```
|
||||
|
||||
This pattern comes from the existing Serve recovery tests. It matters most when the actor manages long-lived coordination state and you want to avoid a lookup on every request.
|
||||
|
||||
## Understand rollout behavior
|
||||
|
||||
Changes to `deployment_actors` are heavyweight deployment changes. If you change actor configuration, such as the actor class, actor name, `init_args`, `init_kwargs`, or actor options, Ray Serve rolls out a new deployment version and restarts replicas in that deployment.
|
||||
|
||||
That rollout has a few important properties:
|
||||
|
||||
- If you redeploy with the same deployment-actor config, Ray Serve keeps the existing actor.
|
||||
- If you change the deployment-actor config, Ray Serve creates a new actor for the new version and the old actor state doesn't carry over.
|
||||
- During an incremental rollout, Ray Serve can keep both old and new deployment-scoped actors alive at the same time.
|
||||
- Ray Serve starts the new version's deployment-scoped actors before it starts any replicas for the new version.
|
||||
- Ray Serve doesn't stop the old version's deployment-scoped actors until every old-version replica has drained and stopped.
|
||||
|
||||
This ordering matters because `serve.get_deployment_actor()` resolves the actor for the replica's own code version. In other words, old replicas keep talking to old actors while new replicas talk to new actors during the rollout window.
|
||||
|
||||
## Understand health and status behavior
|
||||
|
||||
Ray Serve health-checks deployment-scoped actors, and deployment health depends on them.
|
||||
|
||||
- If a deployment-scoped actor can't start after retries, the deployment moves to `DEPLOY_FAILED`.
|
||||
- If one actor fails to start in a deployment with multiple deployment-scoped actors, Ray Serve doesn't partially succeed. The deployment still fails.
|
||||
- If a deployment actor dies and Ray Serve is still managing it, the controller recreates it and the deployment can recover to healthy.
|
||||
- If the replacement actor is blocked in its constructor or otherwise can't become ready, the deployment stays `UNHEALTHY` until the actor becomes healthy again.
|
||||
- A replica health check can also depend on a deployment-scoped actor. If the replica's `check_health` method calls the actor and that call fails repeatedly, Ray Serve can restart the replica even if the actor itself still exists.
|
||||
|
||||
## Understand lifecycle and limits
|
||||
|
||||
- The actor is scoped to one deployment in one application. Different apps can reuse the same logical actor name without colliding.
|
||||
- Ray Serve shares the actor across all replicas of that deployment, including autoscaled replicas.
|
||||
- Ray Serve cleans up the actor when you delete the application or shut Serve down.
|
||||
- When you delete an application, Ray Serve keeps deployment-scoped actors alive until in-flight requests finish and the old replicas stop draining.
|
||||
- The actor can survive a Serve controller restart because the controller rediscovers it.
|
||||
- If the actor constructor keeps failing, the deployment fails to deploy.
|
||||
- `actor_options` on the deployment actor let you reserve different resources or override parts of the deployment `runtime_env`.
|
||||
|
||||
:::{note}
|
||||
Deployment-scoped actors follow Ray actor concurrency semantics. If the actor uses threaded execution, Ray defaults `max_concurrency` to `1`. If the actor is an async actor, Ray defaults `max_concurrency` to `1000`. If those defaults don't fit your workload, set `max_concurrency` explicitly in `actor_options`.
|
||||
:::
|
||||
|
||||
Even though the actor can survive replica restarts and controller restarts, it isn't a substitute for durable external storage.
|
||||
@@ -0,0 +1,128 @@
|
||||
(serve-dev-workflow)=
|
||||
|
||||
# Development Workflow
|
||||
|
||||
This page describes the recommended workflow for developing Ray Serve applications. If you're ready to go to production, jump to the [Production Guide](serve-in-production) section.
|
||||
|
||||
## Local Development using `serve.run`
|
||||
|
||||
You can use `serve.run` in a Python script to run and test your application locally, using a handle to send requests programmatically rather than over HTTP.
|
||||
|
||||
Benefits:
|
||||
|
||||
- Self-contained Python is convenient for writing local integration tests.
|
||||
- No need to deploy to a cloud provider or manage infrastructure.
|
||||
|
||||
Drawbacks:
|
||||
|
||||
- Doesn't test HTTP endpoints.
|
||||
- Can't use GPUs if your local machine doesn't have them.
|
||||
|
||||
Let's see a simple example.
|
||||
|
||||
```{literalinclude} ../doc_code/local_dev.py
|
||||
:start-after: __local_dev_start__
|
||||
:end-before: __local_dev_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
We can add the code below to deploy and test Serve locally.
|
||||
|
||||
```{literalinclude} ../doc_code/local_dev.py
|
||||
:start-after: __local_dev_handle_start__
|
||||
:end-before: __local_dev_handle_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
## Local Development with HTTP requests
|
||||
|
||||
You can use the `serve run` CLI command to run and test your application locally using HTTP to send requests (similar to how you might use the `uvicorn` command if you're familiar with [Uvicorn](https://www.uvicorn.org/)).
|
||||
|
||||
Recall our example above:
|
||||
|
||||
```{literalinclude} ../doc_code/local_dev.py
|
||||
:start-after: __local_dev_start__
|
||||
:end-before: __local_dev_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Now run the following command in your terminal:
|
||||
|
||||
```bash
|
||||
serve run local_dev:app
|
||||
# 2022-08-11 11:31:47,692 INFO scripts.py:294 -- Deploying from import path: "local_dev:app".
|
||||
# 2022-08-11 11:31:50,372 INFO worker.py:1481 -- Started a local Ray instance. View the dashboard at http://127.0.0.1:8265.
|
||||
# (ServeController pid=9865) INFO 2022-08-11 11:31:54,039 controller 9865 proxy_state.py:129 - Starting HTTP proxy with name 'SERVE_CONTROLLER_ACTOR:SERVE_PROXY_ACTOR-dff7dc5b97b4a11facaed746f02448224aa0c1fb651988ba7197e949' on node 'dff7dc5b97b4a11facaed746f02448224aa0c1fb651988ba7197e949' listening on '127.0.0.1:8000'
|
||||
# (ServeController pid=9865) INFO 2022-08-11 11:31:55,373 controller 9865 deployment_state.py:1232 - Adding 1 replicas to deployment 'Doubler'.
|
||||
# (ServeController pid=9865) INFO 2022-08-11 11:31:55,389 controller 9865 deployment_state.py:1232 - Adding 1 replicas to deployment 'HelloDeployment'.
|
||||
# (HTTPProxyActor pid=9872) INFO: Started server process [9872]
|
||||
# 2022-08-11 11:31:57,383 SUCC scripts.py:315 -- Deployed successfully.
|
||||
```
|
||||
|
||||
The `serve run` command blocks the terminal and can be canceled with Ctrl-C. Typically, `serve run` should not be run simultaneously from multiple terminals, unless each `serve run` is targeting a separate running Ray cluster.
|
||||
|
||||
Now that Serve is running, we can send HTTP requests to the application. For simplicity, we'll just use the `curl` command to send requests from another terminal.
|
||||
|
||||
```bash
|
||||
curl -X PUT "http://localhost:8000/?name=Ray"
|
||||
# Hello, Ray! Hello, Ray!
|
||||
```
|
||||
|
||||
After you're done testing, you can shut down Ray Serve by interrupting the `serve run` command (e.g., with Ctrl-C):
|
||||
|
||||
```console
|
||||
^C2022-08-11 11:47:19,829 INFO scripts.py:323 -- Got KeyboardInterrupt, shutting down...
|
||||
(ServeController pid=9865) INFO 2022-08-11 11:47:19,926 controller 9865 deployment_state.py:1257 - Removing 1 replicas from deployment 'Doubler'.
|
||||
(ServeController pid=9865) INFO 2022-08-11 11:47:19,929 controller 9865 deployment_state.py:1257 - Removing 1 replicas from deployment 'HelloDeployment'.
|
||||
```
|
||||
|
||||
Note that rerunning `serve run` redeploys all deployments. To prevent redeploying the deployments whose code hasn't changed, you can use `serve deploy`; see the [Production Guide](serve-in-production) for details.
|
||||
|
||||
### Local Testing Mode
|
||||
|
||||
:::{note}
|
||||
This is an experimental feature.
|
||||
:::
|
||||
|
||||
Ray Serve supports a local testing mode that allows you to run your deployments locally in a single process. This mode is useful for unit testing and debugging your application logic without the overhead of a full Ray cluster. To enable this mode, use the `_local_testing_mode` flag in the `serve.run` function:
|
||||
|
||||
```{literalinclude} ../doc_code/local_dev.py
|
||||
:start-after: __local_dev_testing_start__
|
||||
:end-before: __local_dev_testing_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
This mode runs each deployment in a background thread and supports most of the same features as running on a full Ray cluster. Note that some features, such as converting `DeploymentResponses` to `ObjectRefs`, are not supported in local testing mode. If you encounter limitations, consider filing a feature request on GitHub.
|
||||
|
||||
## Testing on a remote cluster
|
||||
|
||||
To test on a remote cluster, use `serve run` again, but this time, pass in an `--address` argument to specify the address of the Ray cluster to connect to. For remote clusters, this address has the form `ray://<head-node-ip-address>:10001`; see [Ray Client](ray-client-ref) for more information.
|
||||
|
||||
When making the transition from your local machine to a remote cluster, you'll need to make sure your cluster has a similar environment to your local machine--files, environment variables, and Python packages, for example.
|
||||
|
||||
Let's see a simple example that just packages the code. Run the following command on your local machine, with your remote cluster head node IP address substituted for `<head-node-ip-address>` in the command:
|
||||
|
||||
```bash
|
||||
serve run --address=ray://<head-node-ip-address>:10001 --working-dir="./project/src" local_dev:app
|
||||
```
|
||||
|
||||
This connects to the remote cluster with the Ray Client, uploads the `working_dir` directory, and runs your Serve application. Here, the local directory specified by `working_dir` must contain `local_dev.py` so that it can be uploaded to the cluster and imported by Ray Serve.
|
||||
|
||||
Once this is up and running, we can send requests to the application:
|
||||
|
||||
```bash
|
||||
curl -X PUT http://<head-node-ip-address>:8000/?name=Ray
|
||||
# Hello, Ray! Hello, Ray!
|
||||
```
|
||||
|
||||
For more complex dependencies, including files outside the working directory, environment variables, and Python packages, you can use {ref}`Runtime Environments<runtime-environments>`. This example uses the --runtime-env-json argument:
|
||||
|
||||
```bash
|
||||
serve run --address=ray://<head-node-ip-address>:10001 --runtime-env-json='{"env_vars": {"MY_ENV_VAR": "my-value"}, "working_dir": "./project/src", "pip": ["requests", "chess"]}' local_dev:app
|
||||
```
|
||||
|
||||
You can also specify the `runtime_env` in a YAML file; see [serve run](#serve-cli) for details.
|
||||
|
||||
## What's Next?
|
||||
|
||||
View details about your Serve application in the [Ray dashboard](dash-serve-view). Once you are ready to deploy to production, see the [Production Guide](serve-in-production).
|
||||
@@ -0,0 +1,124 @@
|
||||
(serve-performance-batching-requests)=
|
||||
# Dynamic Request Batching
|
||||
|
||||
Serve offers a request batching feature that can improve your service throughput without sacrificing latency. This improvement is possible because ML models can utilize efficient vectorized computation to process a batch of requests at a time. Batching is also necessary when your model is expensive to use and you want to maximize the utilization of hardware.
|
||||
|
||||
Machine Learning (ML) frameworks such as Tensorflow, PyTorch, and Scikit-Learn support evaluating multiple samples at the same time. Ray Serve allows you to take advantage of this feature with dynamic request batching. When a request arrives, Serve puts the request in a queue. This queue buffers the requests to form a batch. The deployment picks up the batch and evaluates it. After the evaluation, Ray Serve splits up the resulting batch, and returns each response individually.
|
||||
|
||||
## Enable batching for your deployment
|
||||
You can enable batching by using the {mod}`ray.serve.batch` decorator. The following simple example modifies the `Model` class to accept a batch:
|
||||
```{literalinclude} ../doc_code/batching_guide.py
|
||||
---
|
||||
start-after: __single_sample_begin__
|
||||
end-before: __single_sample_end__
|
||||
---
|
||||
```
|
||||
|
||||
The batching decorators expect you to make the following changes in your method signature:
|
||||
- Declare the method as an async method because the decorator batches in asyncio event loop.
|
||||
- Modify the method to accept a list of its original input types as input. For example, `arg1: int, arg2: str` should be changed to `arg1: List[int], arg2: List[str]`.
|
||||
- Modify the method to return a list. The length of the return list and the input list must be of equal lengths for the decorator to split the output evenly and return a corresponding response back to its respective request.
|
||||
|
||||
```{literalinclude} ../doc_code/batching_guide.py
|
||||
---
|
||||
start-after: __batch_begin__
|
||||
end-before: __batch_end__
|
||||
emphasize-lines: 11-12
|
||||
---
|
||||
```
|
||||
|
||||
You can supply 4 optional parameters to the decorators:
|
||||
- `max_batch_size` controls the size of the batch. The default value is 10.
|
||||
- `batch_wait_timeout_s` controls how long Serve should wait for a batch once the first request arrives. The default value is 0.01 (10 milliseconds).
|
||||
- `max_concurrent_batches` maximum number of batches that can run concurrently. The default value is 1.
|
||||
- `batch_size_fn` optional function to compute the effective batch size. If provided, this function takes a list of items and returns an integer representing the batch size. This is useful for batching based on custom metrics such as total nodes in graphs or total tokens in sequences. If `None` (the default), the batch size is computed as `len(batch)`.
|
||||
|
||||
Once the first request arrives, the batching decorator waits for a full batch (up to `max_batch_size`) until `batch_wait_timeout_s` is reached. If the timeout is reached, Serve sends the batch to the model regardless of the batch size.
|
||||
|
||||
:::{tip}
|
||||
You can reconfigure your `batch_wait_timeout_s` and `max_batch_size` parameters using the `set_batch_wait_timeout_s` and `set_max_batch_size` methods:
|
||||
|
||||
```{literalinclude} ../doc_code/batching_guide.py
|
||||
---
|
||||
start-after: __batch_params_update_begin__
|
||||
end-before: __batch_params_update_end__
|
||||
---
|
||||
```
|
||||
|
||||
Use these methods in the constructor or the `reconfigure` [method](serve-user-config) to control the `@serve.batch` parameters through your Serve configuration file.
|
||||
:::
|
||||
|
||||
## Custom batch size functions
|
||||
|
||||
By default, Ray Serve measures batch size as the number of items in the batch (`len(batch)`). However, in many workloads, the computational cost depends on properties of the items themselves rather than just the count. For example:
|
||||
|
||||
- **Graph Neural Networks (GNNs)**: The cost depends on the total number of nodes across all graphs, not the number of graphs
|
||||
- **Natural Language Processing (NLP)**: Transformer models batch by total token count, not the number of sequences
|
||||
- **Variable-resolution images**: Memory usage depends on total pixels, not the number of images
|
||||
|
||||
Use the `batch_size_fn` parameter to define a custom metric for batch size:
|
||||
|
||||
### Graph Neural Network example
|
||||
|
||||
The following example shows how to batch graph data by total node count:
|
||||
|
||||
```{literalinclude} ../doc_code/batching_guide.py
|
||||
---
|
||||
start-after: __batch_size_fn_begin__
|
||||
end-before: __batch_size_fn_end__
|
||||
emphasize-lines: 20
|
||||
---
|
||||
```
|
||||
|
||||
In this example, `batch_size_fn=lambda graphs: sum(g.num_nodes for g in graphs)` ensures that the batch contains at most 10,000 total nodes, preventing GPU memory overflow regardless of how many individual graphs are in the batch.
|
||||
|
||||
### NLP token batching example
|
||||
|
||||
The following example shows how to batch text sequences by total token count:
|
||||
|
||||
```{literalinclude} ../doc_code/batching_guide.py
|
||||
---
|
||||
start-after: __batch_size_fn_nlp_begin__
|
||||
end-before: __batch_size_fn_nlp_end__
|
||||
emphasize-lines: 12
|
||||
---
|
||||
```
|
||||
|
||||
This pattern ensures that the total number of tokens doesn't exceed the model's context window or memory limits.
|
||||
|
||||
(serve-streaming-batched-requests-guide)=
|
||||
|
||||
## Streaming batched requests
|
||||
|
||||
Use an async generator to stream the outputs from your batched requests. The following example converts the `StreamingResponder` class to accept a batch.
|
||||
|
||||
```{literalinclude} ../doc_code/batching_guide.py
|
||||
---
|
||||
start-after: __single_stream_begin__
|
||||
end-before: __single_stream_end__
|
||||
---
|
||||
```
|
||||
|
||||
Decorate async generator functions with the {mod}`ray.serve.batch` decorator. Similar to non-streaming methods, the function takes in a `List` of inputs and in each iteration it `yield`s an iterable of outputs with the same length as the input batch size.
|
||||
|
||||
```{literalinclude} ../doc_code/batching_guide.py
|
||||
---
|
||||
start-after: __batch_stream_begin__
|
||||
end-before: __batch_stream_end__
|
||||
---
|
||||
```
|
||||
|
||||
Calling the `serve.batch`-decorated function returns an async generator that you can `await` to receive results.
|
||||
|
||||
Some inputs within a batch may generate fewer outputs than others. When a particular input has nothing left to yield, pass a `StopIteration` object into the output iterable. This action terminates the generator that Serve returns when it calls the `serve.batch` function with that input. When `serve.batch`-decorated functions return streaming generators over HTTP, this action allows the end client's connection to terminate once its call is done, instead of waiting until the entire batch is done.
|
||||
|
||||
## Tips for fine-tuning batching parameters
|
||||
|
||||
`max_batch_size` ideally should be a power of 2 (2, 4, 8, 16, ...) because CPUs and GPUs are both optimized for data of these shapes. Large batch sizes incur a high memory cost as well as latency penalty for the first few requests.
|
||||
|
||||
When using `batch_size_fn`, set `max_batch_size` based on your custom metric rather than item count. For example, if batching by total nodes in graphs, set `max_batch_size` to your GPU's maximum node capacity (such as 10,000 nodes) rather than a count of graphs.
|
||||
|
||||
Set `batch_wait_timeout_s` considering the end-to-end latency SLO (Service Level Objective). For example, if your latency target is 150ms, and the model takes 100ms to evaluate the batch, set the `batch_wait_timeout_s` to a value much lower than 150ms - 100ms = 50ms.
|
||||
|
||||
When using batching in a Serve Deployment Graph, the relationship between an upstream node and a downstream node might affect the performance as well. Consider a chain of two models where first model sets `max_batch_size=8` and second model sets `max_batch_size=6`. In this scenario, when the first model finishes a full batch of 8, the second model finishes one batch of 6 and then to fill the next batch, which Serve initially only partially fills with 8 - 6 = 2 requests, leads to incurring latency costs. The batch size of downstream models should ideally be multiples or divisors of the upstream models to ensure the batches work optimally together.
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
(serve-gang-scheduling)=
|
||||
|
||||
# Gang scheduling
|
||||
|
||||
:::{note}
|
||||
Gang scheduling is an **alpha** feature. The API may change in future releases.
|
||||
:::
|
||||
|
||||
Gang scheduling enables you to co-schedule groups of deployment replicas atomically. A **gang** is a set of replicas that are reserved and started together using a single [Ray placement group](ray-placement-group-doc-ref). If the cluster doesn't have enough resources for the entire gang, none of the replicas in that gang are started.
|
||||
|
||||
This is useful for workloads where a partial set of replicas is useless, such as:
|
||||
- **Data parallel attention deployment**: In WideEP deployments, data parallel attention - expert parallelism ranks are required coordinate with each other to perform dispatch-combine collective communication. Any rank failure leads to dispatch-combine collective hangs, and the entire data parallel attention - expert parallelism group needs to go through failover mechanism to re-establish collectives.
|
||||
- **Any workload requiring coordinated startup**, where replicas need to discover each other's identities and establish communication before serving traffic.
|
||||
|
||||
## Getting started
|
||||
|
||||
Configure gang scheduling by passing a `GangSchedulingConfig` to the `@serve.deployment` decorator:
|
||||
|
||||
```{literalinclude} ../doc_code/gang_scheduling.py
|
||||
:start-after: __basic_gang_start__
|
||||
:end-before: __basic_gang_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
This creates 2 gangs of 4 replicas each resulting in a total of 8 replicas. Partial gang isn't allowed, and therefore `num_replicas` must be a multiple of `gang_size`.
|
||||
|
||||
### How resources are reserved within a gang
|
||||
|
||||
The `ray_actor_options` field defines the resource requirements for each replica actor (for example, CPU, GPU, memory). When gang scheduling is enabled **without** `placement_group_bundles`, Ray uses the resources from `ray_actor_options` as the bundle template for each replica's slot in the gang placement group. For example, with `ray_actor_options={"num_cpus": 0.25}` and `gang_size=4`, the gang placement group contains 4 bundles of `{"CPU": 0.25}` each.
|
||||
|
||||
When `placement_group_bundles` is also set, each replica occupies multiple consecutive bundles in the gang placement group instead of a single flat bundle. The replica actor runs in the **first** bundle, so the resources in `ray_actor_options` must fit within that first bundle. The remaining bundles are available for child actors or tasks spawned by the replica. See [Combining with placement group bundles](#combining-with-placement-group-bundles) for details.
|
||||
|
||||
You can also configure gang scheduling via `.options()`:
|
||||
|
||||
```{literalinclude} ../doc_code/gang_scheduling.py
|
||||
:start-after: __options_start__
|
||||
:end-before: __options_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
In production deployments, declarative YAML config files are often used:
|
||||
|
||||
```yaml
|
||||
applications:
|
||||
- name: my_app
|
||||
route_prefix: /
|
||||
import_path: my_module:app
|
||||
deployments:
|
||||
- name: MyModel
|
||||
num_replicas: 8
|
||||
ray_actor_options:
|
||||
num_cpus: 0.25
|
||||
gang_scheduling_config:
|
||||
gang_size: 4
|
||||
```
|
||||
|
||||
### Accessing gang context
|
||||
|
||||
Each replica in a gang has access to a `GangContext` through the replica context. This provides the information replicas need to discover each other and coordinate:
|
||||
|
||||
```{literalinclude} ../doc_code/gang_scheduling.py
|
||||
:start-after: __gang_context_start__
|
||||
:end-before: __gang_context_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Here's the interface of `GangContext`:
|
||||
|
||||
```{eval-rst}
|
||||
.. autoclass:: ray.serve.context.GangContext
|
||||
:members:
|
||||
:no-index:
|
||||
```
|
||||
|
||||
Replicas can use `rank` and `world_size` to set up distributed communication, e.g. initializing NCCL process groups, and `member_replica_ids` to discover and connect to their peers.
|
||||
|
||||
## Placement group strategies
|
||||
|
||||
Gang scheduling supports two placement group strategies that control how replicas within a gang are distributed across nodes:
|
||||
|
||||
### PACK (default)
|
||||
|
||||
Packs all replicas in a gang onto as few nodes as possible. This is best for workloads that benefit from locality, such as data parallel ranks within data parallel attention - expert parallelism deployment for MoE LLMs.
|
||||
|
||||
Although PACK colocates as many replicas per node as possible, it does not guarantee that ranks are contiguous within a node. For example, a gang of 8 replicas split across 2 nodes may be assigned ranks `{0, 3, 5, 7}` on one node and `{1, 2, 4, 6}` on the other, rather than `{0, 1, 2, 3}` and `{4, 5, 6, 7}`. Applications should not assume that consecutive ranks share a node. This is particularly important when a single replica owns multiple bundles, such as a tensor-parallel group. In that case, the bundles belonging to one replica may land on different nodes, splitting collective communication across the network. To ensure that the bundles for a single replica land on the same node, sort the gang placement group's bundle indices by node IP and assign each replica a contiguous slice of the sorted order.
|
||||
|
||||
```{literalinclude} ../doc_code/gang_scheduling.py
|
||||
:start-after: __pack_strategy_start__
|
||||
:end-before: __pack_strategy_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
### SPREAD
|
||||
|
||||
Spreads replicas in a gang across as many distinct nodes as possible. This is useful for fault isolation, where you want to minimize the impact of a single node failure.
|
||||
|
||||
```{literalinclude} ../doc_code/gang_scheduling.py
|
||||
:start-after: __spread_strategy_start__
|
||||
:end-before: __spread_strategy_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Both strategies are **best effort**. PACK tries to colocate but may spread across nodes if a single node lacks capacity. SPREAD tries to distribute but may colocate if there aren't enough nodes.
|
||||
|
||||
### Combining with placement group bundles
|
||||
|
||||
You can combine gang scheduling with `placement_group_bundles` to reserve additional resources per replica within the gang. When both are set, the gang placement group contains the flattened bundles for all replicas in the gang. Each replica occupies `len(placement_group_bundles)` consecutive bundles, with the replica actor running in the first bundle.
|
||||
|
||||
```{literalinclude} ../doc_code/gang_scheduling.py
|
||||
:start-after: __placement_group_bundles_start__
|
||||
:end-before: __placement_group_bundles_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
```{image} images/gang-single-pg.png
|
||||
:alt: Gang scheduling with a single bundle per replica
|
||||
:width: 600px
|
||||
```
|
||||
|
||||
In this example, each gang of 2 replicas creates a single gang placement group with 2 bundles (one `{"CPU": 1, "GPU": 1}` bundle per replica) upon scheduling. Note that `ray_actor_options={"num_cpus": 0}` is set so the replica actor doesn't request resources outside the placement group — all resource reservation is handled through the bundles.
|
||||
|
||||
If each replica needed multiple bundles, for example, one for the replica actor and one for a worker, the gang PG would contain `gang_size * len(placement_group_bundles)` total bundles. Replica 0 would occupy bundle indices 0 and 1, while replica 1 would occupy indices 2 and 3.
|
||||
|
||||
```{literalinclude} ../doc_code/gang_scheduling.py
|
||||
:start-after: __multi_placement_group_bundles_start__
|
||||
:end-before: __multi_placement_group_bundles_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
```{image} images/gang-multi-pg.png
|
||||
:alt: Gang scheduling with multiple bundles per replica
|
||||
:width: 600px
|
||||
```
|
||||
|
||||
You can also use `placement_group_bundle_label_selector` to control which nodes the gang's bundles are placed on. The per-replica label selector is replicated across all replicas in the gang, so every replica is steered to nodes matching the selector. For example, to schedule all gang members on nodes with A100 GPUs:
|
||||
|
||||
```{literalinclude} ../doc_code/gang_scheduling.py
|
||||
:start-after: __label_selector_start__
|
||||
:end-before: __label_selector_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
## Autoscaling
|
||||
|
||||
Gang scheduling works with Ray Serve autoscaling (`num_replicas="auto"`). When autoscaling is enabled, the replica count recommended by the base autoscaling policy is always rounded **up** to the next multiple of `gang_size`, so the deployment never operates below the capacity the base policy requested.
|
||||
|
||||
```{literalinclude} ../doc_code/gang_scheduling.py
|
||||
:start-after: __autoscaling_start__
|
||||
:end-before: __autoscaling_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
When using autoscaling with gang scheduling, `min_replicas`, `max_replicas`, and `initial_replicas` must all be multiples of `gang_size`.
|
||||
|
||||
:::{note}
|
||||
Scale-to-zero (`min_replicas=0`) is not supported with gang scheduling.
|
||||
:::
|
||||
|
||||
In Ray Serve autoscaler, gang quantization is handled automatically by a `GangSchedulingAutoscalingPolicy` wrapper that is injected around the base autoscaling policy.
|
||||
|
||||
**Example**: With `gang_size=4`, if the base autoscaling policy recommends 5 replicas, the `GangSchedulingAutoscalingPolicy` rounds up to 8. If the policy recommends 10 replicas, the gang-aware policy rounds up to 12. Always rounding up makes the output deterministic: the same desired count produces the same replica target regardless of the current replica count, which prevents oscillation between two gang-aligned values.
|
||||
|
||||
## Fault tolerance
|
||||
|
||||
### RESTART_GANG policy
|
||||
|
||||
The `runtime_failure_policy` controls what happens when a replica in a running gang fails a health check. The default policy is `RESTART_GANG`:
|
||||
|
||||
```{literalinclude} ../doc_code/gang_scheduling.py
|
||||
:start-after: __fault_tolerance_start__
|
||||
:end-before: __fault_tolerance_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
When any replica in a gang fails its health check, **all replicas in that gang** are torn down and a fresh gang is created. This ensures gang members always start together and can re-establish coordinated state (for example, NCCL communicators).
|
||||
|
||||
:::{note}
|
||||
`RESTART_REPLICA` (restarting only the failed replica while keeping healthy gang members running) is not yet supported. If you need this behavior, file a [GitHub issue](https://github.com/ray-project/ray/issues).
|
||||
:::
|
||||
|
||||
### Incomplete gang detection
|
||||
|
||||
If a gang member dies while the Serve controller is down, the controller detects the incomplete gang on recovery. It checks each running replica's `gang_context.member_replica_ids` against the set of tracked replicas. If any member is missing, the entire gang is restarted.
|
||||
|
||||
### Controller recovery
|
||||
|
||||
Gang scheduling state survives controller restarts: `GangContext` is persisted in replica metadata and restored when the controller reconnects to existing replicas.
|
||||
|
||||
|
||||
## How gang scheduling works
|
||||
|
||||
This section describes the internal mechanics for users who want to understand the system in depth.
|
||||
|
||||
### Placement group lifecycle
|
||||
|
||||
1. **Reservation**: During each reconciliation loop, the Serve controller's deployment scheduler creates named placement groups for each gang. The bundles in the placement group are constructed by repeating the per-replica resource requirements `gang_size` times, or flattening `placement_group_bundles` across all replicas in the gang.
|
||||
|
||||
2. **Atomic startup**: Once a gang placement group is ready, all replicas in the gang are scheduled together. Each replica is assigned a `rank` (0 to `gang_size - 1`) and receives a `GangContext`. Replicas are scheduled into specific bundle indices within the gang placement group.
|
||||
|
||||
3. **Cleanup**: When a deployment is deleted or a gang is replaced, its placement group is removed. The controller also runs periodic leak detection to clean up orphaned gang placement groups.
|
||||
|
||||
### Scaling
|
||||
|
||||
**Upscaling**: The controller reserves gang placement groups, then starts all replicas in each gang together once the placement group is ready. Gangs that can't be scheduled due to insufficient resources are retried on subsequent reconciliation loops while successfully scheduled gangs proceed. If any replica fails during startup, the entire gang is stopped and retried.
|
||||
|
||||
**Downscaling**: The controller selects **complete gangs** for removal rather than individual replicas, ensuring no gang is left partially running.
|
||||
|
||||
### Rolling updates
|
||||
|
||||
When a deployment config or code changes, the rolling update process replaces **complete gangs** atomically. The rollout size is aligned to `gang_size` multiples, so each update wave stops and starts whole gangs.
|
||||
|
||||
### Migration
|
||||
|
||||
When replicas need to migrate due to node draining, entire gangs are migrated atomically rather than individual replicas, preserving the atomic scheduling guarantees.
|
||||
|
||||
## Constraints and limitations
|
||||
|
||||
- `max_replicas_per_node` cannot be used together with gang scheduling since gang scheduling uses placement groups.
|
||||
@@ -0,0 +1,331 @@
|
||||
(serve-set-up-grpc-service)=
|
||||
# Set Up a gRPC Service
|
||||
|
||||
This section helps you understand how to:
|
||||
- Build a user defined gRPC service and protobuf
|
||||
- Start Serve with gRPC enabled
|
||||
- Deploy gRPC applications
|
||||
- Send gRPC requests to Serve deployments
|
||||
- Check proxy health
|
||||
- Work with gRPC metadata
|
||||
- Use streaming and model composition
|
||||
- Handle errors
|
||||
- Use gRPC context
|
||||
|
||||
|
||||
(custom-serve-grpc-service)=
|
||||
## Define a gRPC service
|
||||
Running a gRPC server starts with defining gRPC services, RPC methods, and protobufs similar to the one below.
|
||||
|
||||
```{literalinclude} ../doc_code/grpc_proxy/user_defined_protos.proto
|
||||
:start-after: __begin_proto__
|
||||
:end-before: __end_proto__
|
||||
:language: proto
|
||||
```
|
||||
|
||||
|
||||
This example creates a file named `user_defined_protos.proto` with two gRPC services: `UserDefinedService` and `ImageClassificationService`. `UserDefinedService` has three RPC methods: `__call__`, `Multiplexing`, and `Streaming`. `ImageClassificationService` has one RPC method: `Predict`. Their corresponding input and output types are also defined specifically for each RPC method.
|
||||
|
||||
Once you define the `.proto` services, use `grpcio-tools` to compile python code for those services. Example command looks like the following:
|
||||
```bash
|
||||
python -m grpc_tools.protoc -I=. --python_out=. --grpc_python_out=. ./user_defined_protos.proto
|
||||
```
|
||||
|
||||
It generates two files: `user_defined_protos_pb2.py` and `user_defined_protos_pb2_grpc.py`.
|
||||
|
||||
For more details on `grpcio-tools` see [https://grpc.io/docs/languages/python/basics/#generating-client-and-server-code](https://grpc.io/docs/languages/python/basics/#generating-client-and-server-code).
|
||||
|
||||
:::{note}
|
||||
Ensure that the generated files are in the same directory as where the Ray cluster is running so that Serve can import them when starting the proxies.
|
||||
:::
|
||||
|
||||
(start-serve-with-grpc-proxy)=
|
||||
## Start Serve with gRPC enabled
|
||||
The [Serve start](https://docs.ray.io/en/releases-2.7.0/serve/api/index.html#serve-start) CLI, [`ray.serve.start`](https://docs.ray.io/en/releases-2.7.0/serve/api/doc/ray.serve.start.html#ray.serve.start) API, and [Serve config files](https://docs.ray.io/en/releases-2.7.0/serve/production-guide/config.html#serve-config-files-serve-build) all support starting Serve with a gRPC proxy. Two options are related to Serve's gRPC proxy: `grpc_port` and `grpc_servicer_functions`. `grpc_port` is the port for gRPC proxies to listen to. It defaults to 9000. `grpc_servicer_functions` is a list of import paths for gRPC `add_servicer_to_server` functions to add to a gRPC proxy. It also serves as the flag to determine whether to start gRPC server. The default is an empty list, meaning no gRPC server is started.
|
||||
|
||||
::::{tab-set}
|
||||
|
||||
:::{tab-item} CLI
|
||||
```bash
|
||||
ray start --head
|
||||
serve start \
|
||||
--grpc-port 9000 \
|
||||
--grpc-servicer-functions user_defined_protos_pb2_grpc.add_UserDefinedServiceServicer_to_server \
|
||||
--grpc-servicer-functions user_defined_protos_pb2_grpc.add_ImageClassificationServiceServicer_to_server
|
||||
|
||||
```
|
||||
:::
|
||||
|
||||
:::{tab-item} Python API
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_start_grpc_proxy__
|
||||
:end-before: __end_start_grpc_proxy__
|
||||
:language: python
|
||||
```
|
||||
:::
|
||||
|
||||
:::{tab-item} Serve config file
|
||||
```yaml
|
||||
# config.yaml
|
||||
grpc_options:
|
||||
port: 9000
|
||||
grpc_servicer_functions:
|
||||
- user_defined_protos_pb2_grpc.add_UserDefinedServiceServicer_to_server
|
||||
- user_defined_protos_pb2_grpc.add_ImageClassificationServiceServicer_to_server
|
||||
|
||||
applications:
|
||||
- name: app1
|
||||
route_prefix: /app1
|
||||
import_path: test_deployment_v2:g
|
||||
runtime_env: {}
|
||||
|
||||
- name: app2
|
||||
route_prefix: /app2
|
||||
import_path: test_deployment_v2:g2
|
||||
runtime_env: {}
|
||||
|
||||
```
|
||||
|
||||
```bash
|
||||
# Start Serve with above config file.
|
||||
serve run config.yaml
|
||||
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
::::
|
||||
|
||||
:::{note}
|
||||
The default max gRPC message size is ~2GB. To adjust it, set `RAY_SERVE_GRPC_MAX_MESSAGE_SIZE` (in bytes) before starting Ray, e.g., `export RAY_SERVE_GRPC_MAX_MESSAGE_SIZE=104857600` for 100MB.
|
||||
:::
|
||||
|
||||
(deploy-serve-grpc-applications)=
|
||||
## Deploy gRPC applications
|
||||
gRPC applications in Serve works similarly to HTTP applications. The only difference is that the input and output of the methods need to match with what's defined in the `.proto` file and that the method of the application needs to be an exact match (case sensitive) with the predefined RPC methods. For example, if we want to deploy `UserDefinedService` with `__call__` method, the method name needs to be `__call__`, the input type needs to be `UserDefinedMessage`, and the output type needs to be `UserDefinedResponse`. Serve passes the protobuf object into the method and expects the protobuf object back from the method.
|
||||
|
||||
Example deployment:
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_grpc_deployment__
|
||||
:end-before: __end_grpc_deployment__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Deploy the application:
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_deploy_grpc_app__
|
||||
:end-before: __end_deploy_grpc_app__
|
||||
:language: python
|
||||
```
|
||||
|
||||
:::{note}
|
||||
`route_prefix` is still a required field as of Ray 2.7.0 due to a shared code path with HTTP. Future releases will make it optional for gRPC.
|
||||
:::
|
||||
|
||||
|
||||
(send-serve-grpc-proxy-request)=
|
||||
## Send gRPC requests to serve deployments
|
||||
Sending a gRPC request to a Serve deployment is similar to sending a gRPC request to any other gRPC server. Create a gRPC channel and stub, then call the RPC method on the stub with the appropriate input. The output is the protobuf object that your Serve application returns.
|
||||
|
||||
Sending a gRPC request:
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_send_grpc_requests__
|
||||
:end-before: __end_send_grpc_requests__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Read more about gRPC clients in Python: [https://grpc.io/docs/languages/python/basics/#client](https://grpc.io/docs/languages/python/basics/#client)
|
||||
|
||||
|
||||
(serve-grpc-proxy-health-checks)=
|
||||
## Check proxy health
|
||||
Similar to HTTP `/-/routes` and `/-/healthz` endpoints, Serve also provides gRPC service method to be used in health check.
|
||||
- `/ray.serve.RayServeAPIService/ListApplications` is used to list all applications deployed in Serve.
|
||||
- `/ray.serve.RayServeAPIService/Healthz` is used to check the health of the proxy. It returns `OK` status and "success" message if the proxy is healthy.
|
||||
|
||||
The service method and protobuf are defined as below:
|
||||
```proto
|
||||
message ListApplicationsRequest {}
|
||||
|
||||
message ListApplicationsResponse {
|
||||
repeated string application_names = 1;
|
||||
}
|
||||
|
||||
message HealthzRequest {}
|
||||
|
||||
message HealthzResponse {
|
||||
string message = 1;
|
||||
}
|
||||
|
||||
service RayServeAPIService {
|
||||
rpc ListApplications(ListApplicationsRequest) returns (ListApplicationsResponse);
|
||||
rpc Healthz(HealthzRequest) returns (HealthzResponse);
|
||||
}
|
||||
```
|
||||
|
||||
You can call the service method with the following code:
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_health_check__
|
||||
:end-before: __end_health_check__
|
||||
:language: python
|
||||
```
|
||||
|
||||
:::{note}
|
||||
Serve provides the `RayServeAPIServiceStub` stub, and `HealthzRequest` and `ListApplicationsRequest` protobufs for you to use. You don't need to generate them from the proto file. They are available for your reference.
|
||||
:::
|
||||
|
||||
(serve-grpc-metadata)=
|
||||
## Work with gRPC metadata
|
||||
Just like HTTP headers, gRPC also supports metadata to pass request related information. You can pass metadata to Serve's gRPC proxy and Serve knows how to parse and use them. Serve also passes trailing metadata back to the client.
|
||||
|
||||
List of Serve accepted metadata keys:
|
||||
- `application`: The name of the Serve application to route to. If not passed and only one application is deployed, serve routes to the only deployed app automatically.
|
||||
- `request_id`: The request ID to track the request.
|
||||
- `multiplexed_model_id`: The model ID to do model multiplexing.
|
||||
|
||||
List of Serve returned trailing metadata keys:
|
||||
- `request_id`: The request ID to track the request.
|
||||
|
||||
Example of using metadata:
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_metadata__
|
||||
:end-before: __end_metadata__
|
||||
:language: python
|
||||
```
|
||||
|
||||
(serve-grpc-proxy-more-examples)=
|
||||
## Use streaming and model composition
|
||||
gRPC proxy supports all four gRPC streaming types:
|
||||
- **Unary-Unary**: Single request, single response (default)
|
||||
- **Server streaming (Unary-Stream)**: Single request, streaming response
|
||||
- **Client streaming (Stream-Unary)**: Streaming request, single response
|
||||
- **Bidirectional streaming (Stream-Stream)**: Streaming request, streaming response
|
||||
|
||||
### Server Streaming
|
||||
The `Streaming` method is deployed with the app named "app1" above. The following code gets a streaming response.
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_streaming__
|
||||
:end-before: __end_streaming__
|
||||
:language: python
|
||||
```
|
||||
|
||||
### Client Streaming
|
||||
Client streaming allows clients to send a stream of requests and receive a single response. Use the `gRPCInputStream` class to iterate over incoming request messages.
|
||||
|
||||
Define a proto file with a client streaming RPC:
|
||||
```proto
|
||||
service UserDefinedService {
|
||||
rpc ClientStreaming(stream UserDefinedMessage) returns (UserDefinedResponse);
|
||||
}
|
||||
```
|
||||
|
||||
Deployment example:
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_client_streaming_deployment__
|
||||
:end-before: __end_client_streaming_deployment__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Client code:
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_client_streaming_client__
|
||||
:end-before: __end_client_streaming_client__
|
||||
:language: python
|
||||
```
|
||||
|
||||
### Bidirectional Streaming
|
||||
Bidirectional streaming allows clients to send and receive streams of messages simultaneously.
|
||||
|
||||
Define a proto file with a bidirectional streaming RPC:
|
||||
```proto
|
||||
service UserDefinedService {
|
||||
rpc BidiStreaming(stream UserDefinedMessage) returns (stream UserDefinedResponse);
|
||||
}
|
||||
```
|
||||
|
||||
Deployment example:
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_bidi_streaming_deployment__
|
||||
:end-before: __end_bidi_streaming_deployment__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Client code:
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_bidi_streaming_client__
|
||||
:end-before: __end_bidi_streaming_client__
|
||||
:language: python
|
||||
```
|
||||
|
||||
### Using gRPC Context with Streaming
|
||||
You can access the gRPC context in streaming methods by adding a `grpc_context` parameter:
|
||||
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_streaming_with_context__
|
||||
:end-before: __end_streaming_with_context__
|
||||
:language: python
|
||||
```
|
||||
|
||||
### Model composition
|
||||
Assuming we have the below deployments. `ImageDownloader` and `DataPreprocessor` are two separate steps to download and process the image before PyTorch can run inference. The `ImageClassifier` deployment initializes the model, calls both `ImageDownloader` and `DataPreprocessor`, and feed into the resnet model to get the classes and probabilities of the given image.
|
||||
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_model_composition_deployment__
|
||||
:end-before: __end_model_composition_deployment__
|
||||
:language: python
|
||||
```
|
||||
|
||||
We can deploy the application with the following code:
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_model_composition_deploy__
|
||||
:end-before: __end_model_composition_deploy__
|
||||
:language: python
|
||||
```
|
||||
|
||||
The client code to call the application looks like the following:
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_model_composition_client__
|
||||
:end-before: __end_model_composition_client__
|
||||
:language: python
|
||||
```
|
||||
|
||||
:::{note}
|
||||
At this point, two applications are running on Serve, "app1" and "app2". If more than one application is running, you need to pass `application` to the metadata so Serve knows which application to route to.
|
||||
:::
|
||||
|
||||
|
||||
(serve-grpc-proxy-error-handling)=
|
||||
## Handle errors
|
||||
Similar to any other gRPC server, request throws a `grpc.RpcError` when the response code is not "OK". Put your request code in a try-except block and handle the error accordingly.
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_error_handle__
|
||||
:end-before: __end_error_handle__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Serve uses the following gRPC error codes:
|
||||
- `NOT_FOUND`: When multiple applications are deployed to Serve and the application is not passed in metadata or passed but no matching application.
|
||||
- `UNAVAILABLE`: Only on the health check methods when the proxy is in draining state. When the health check is throwing `UNAVAILABLE`, it means the health check failed on this node and you should no longer route to this node.
|
||||
- `DEADLINE_EXCEEDED`: The request took longer than the timeout setting and got cancelled.
|
||||
- `INTERNAL`: Other unhandled errors during the request.
|
||||
|
||||
(serve-grpc-proxy-grpc-context)=
|
||||
## Use gRPC context
|
||||
Serve provides a [gRPC context object](https://grpc.github.io/grpc/python/grpc.html#grpc.ServicerContext) to the deployment replica to get information about the request as well as setting response metadata such as code and details. If the handler function is defined with a `grpc_context` argument, Serve will pass a [RayServegRPCContext](../api/doc/ray.serve.grpc_util.RayServegRPCContext.rst) object in for each request. Below is an example of how to set a custom status code, details, and trailing metadata. You can also set a status code before raising an exception, and Serve will preserve that status code in the error response. This is useful for returning meaningful status codes like `RESOURCE_EXHAUSTED` (retryable) or `INVALID_ARGUMENT` (not retryable) instead of the generic `INTERNAL` error.
|
||||
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_grpc_context_define_app__
|
||||
:end-before: __end_grpc_context_define_app__
|
||||
:language: python
|
||||
```
|
||||
|
||||
The client code is defined like the following to get those attributes.
|
||||
```{literalinclude} ../doc_code/grpc_proxy/grpc_guide.py
|
||||
:start-after: __begin_grpc_context_client__
|
||||
:end-before: __end_grpc_context_client__
|
||||
:language: python
|
||||
```
|
||||
|
||||
:::{note}
|
||||
If the handler raises an unhandled exception without setting a status code on the `RayServegRPCContext` object, Serve returns an `INTERNAL` error code with the exception message in the details. However, if you set a status code on the context before raising the exception, Serve preserves that status code in the response. This allows you to return meaningful status codes like `INVALID_ARGUMENT` or `RESOURCE_EXHAUSTED` even when raising exceptions.
|
||||
:::
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
@@ -0,0 +1,45 @@
|
||||
(serve-advanced-guides)=
|
||||
# Advanced Guides
|
||||
|
||||
```{toctree}
|
||||
:hidden:
|
||||
|
||||
app-builder-guide
|
||||
advanced-autoscaling
|
||||
asyncio-best-practices
|
||||
performance
|
||||
dyn-req-batch
|
||||
inplace-updates
|
||||
dev-workflow
|
||||
grpc-guide
|
||||
replica-ranks
|
||||
replica-scheduling
|
||||
gang-scheduling
|
||||
managing-java-deployments
|
||||
deploy-vm
|
||||
multi-app-container
|
||||
custom-request-router
|
||||
deployment-scoped-actors
|
||||
multi-node-gpu-troubleshooting
|
||||
```
|
||||
|
||||
If you’re new to Ray Serve, start with the [Ray Serve Quickstart](serve-getting-started).
|
||||
|
||||
Use these advanced guides for more options and configurations:
|
||||
- [Pass Arguments to Applications](app-builder-guide)
|
||||
- [Advanced Ray Serve Autoscaling](serve-advanced-autoscaling)
|
||||
- [Asyncio and Concurrency best practices in Ray Serve](serve-asyncio-best-practices)
|
||||
- [Performance Tuning](serve-perf-tuning)
|
||||
- [Dynamic Request Batching](serve-performance-batching-requests)
|
||||
- [In-Place Updates for Serve](serve-inplace-updates)
|
||||
- [Development Workflow](serve-dev-workflow)
|
||||
- [gRPC Support](serve-set-up-grpc-service)
|
||||
- [Replica Ranks](serve-replica-ranks)
|
||||
- [Replica Scheduling](serve-replica-scheduling)
|
||||
- [Gang Scheduling](serve-gang-scheduling)
|
||||
- [Ray Serve Dashboard](dash-serve-view)
|
||||
- [Experimental Java API](serve-java-api)
|
||||
- [Run Applications in Different Containers](serve-container-runtime-env-guide)
|
||||
- [Use Custom Algorithm for Request Routing](custom-request-router)
|
||||
- [Use deployment-scoped actors](serve-deployment-scoped-actors)
|
||||
- [Troubleshoot multi-node GPU setups for serving LLMs](multi-node-gpu-troubleshooting)
|
||||
@@ -0,0 +1,115 @@
|
||||
(serve-inplace-updates)=
|
||||
|
||||
# Updating Applications In-Place
|
||||
|
||||
You can update your Serve applications once they're in production by updating the settings in your config file and redeploying it using the `serve deploy` command. In the redeployed config file, you can add new deployment settings or remove old deployment settings. This is because `serve deploy` is **idempotent**, meaning your Serve application's config always matches (or honors) the latest config you deployed successfully – regardless of what config files you deployed before that.
|
||||
|
||||
(serve-in-production-lightweight-update)=
|
||||
|
||||
## Lightweight Config Updates
|
||||
|
||||
Lightweight config updates modify running deployment replicas without tearing them down and restarting them, so there's less downtime as the deployments update. For each deployment, modifying the following values is considered a lightweight config update, and won't tear down the replicas for that deployment:
|
||||
- `num_replicas`
|
||||
- `autoscaling_config`
|
||||
- `user_config`
|
||||
- `max_ongoing_requests`
|
||||
- `graceful_shutdown_timeout_s`
|
||||
- `graceful_shutdown_wait_loop_s`
|
||||
- `health_check_period_s`
|
||||
- `health_check_timeout_s`
|
||||
|
||||
(serve-updating-user-config)=
|
||||
|
||||
## Updating the user config
|
||||
This example uses the text summarization and translation application [from the production guide](production-config-yaml). Both of the individual deployments contain a `reconfigure()` method. This method allows you to issue lightweight updates to the deployments by updating the `user_config`.
|
||||
|
||||
First let's deploy the graph. Make sure to stop any previous Ray cluster using the CLI command `ray stop` for this example:
|
||||
|
||||
```console
|
||||
$ ray start --head
|
||||
$ serve deploy serve_config.yaml
|
||||
```
|
||||
|
||||
Then send a request to the application:
|
||||
```{literalinclude} ../doc_code/production_guide/text_ml.py
|
||||
:language: python
|
||||
:start-after: __start_client__
|
||||
:end-before: __end_client__
|
||||
```
|
||||
|
||||
Change the language that the text is translated into from French to German by changing the `language` attribute in the `Translator` user config:
|
||||
|
||||
```yaml
|
||||
...
|
||||
|
||||
applications:
|
||||
- name: default
|
||||
route_prefix: /
|
||||
import_path: text_ml:app
|
||||
runtime_env:
|
||||
pip:
|
||||
- torch
|
||||
- transformers
|
||||
deployments:
|
||||
- name: Translator
|
||||
num_replicas: 1
|
||||
user_config:
|
||||
language: german
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
Without stopping the Ray cluster, redeploy the app using `serve deploy`:
|
||||
|
||||
```console
|
||||
$ serve deploy serve_config.yaml
|
||||
...
|
||||
```
|
||||
|
||||
We can inspect our deployments with `serve status`. Once the application's `status` returns to `RUNNING`, we can try our request one more time:
|
||||
|
||||
```console
|
||||
$ serve status
|
||||
proxies:
|
||||
cef533a072b0f03bf92a6b98cb4eb9153b7b7c7b7f15954feb2f38ec: HEALTHY
|
||||
applications:
|
||||
default:
|
||||
status: RUNNING
|
||||
message: ''
|
||||
last_deployed_time_s: 1694041157.2211847
|
||||
deployments:
|
||||
Translator:
|
||||
status: HEALTHY
|
||||
replica_states:
|
||||
RUNNING: 1
|
||||
message: ''
|
||||
Summarizer:
|
||||
status: HEALTHY
|
||||
replica_states:
|
||||
RUNNING: 1
|
||||
message: ''
|
||||
```
|
||||
|
||||
The language has updated. Now the returned text is in German instead of French.
|
||||
```{literalinclude} ../doc_code/production_guide/text_ml.py
|
||||
:language: python
|
||||
:start-after: __start_second_client__
|
||||
:end-before: __end_second_client__
|
||||
```
|
||||
|
||||
## Code Updates
|
||||
|
||||
Changing the following values in a deployment's config will trigger redeployment and restart all the deployment's replicas.
|
||||
- `ray_actor_options`
|
||||
- `placement_group_bundles`
|
||||
- `placement_group_strategy`
|
||||
|
||||
Changing the following application-level config values is also considered a code update, and all deployments in the application will be restarted.
|
||||
- `import_path`
|
||||
- `runtime_env`
|
||||
|
||||
:::{warning}
|
||||
Although you can update your Serve application by deploying an entirely new deployment graph using a different `import_path` and a different `runtime_env`, this is NOT recommended in production.
|
||||
|
||||
The best practice for large-scale code updates is to start a new Ray cluster, deploy the updated code to it using `serve deploy`, and then switch traffic from your old cluster to the new one.
|
||||
:::
|
||||
@@ -0,0 +1,142 @@
|
||||
(serve-java-api)=
|
||||
# Experimental Java API
|
||||
|
||||
:::{warning}
|
||||
Java API support is an experimental feature and subject to change.
|
||||
|
||||
The Java API is not currently supported on KubeRay.
|
||||
:::
|
||||
|
||||
Java is a mainstream programming language for production services. Ray Serve offers a native Java API for creating, updating, and managing deployments. You can create Ray Serve deployments using Java and call them via Python, or vice versa.
|
||||
|
||||
This section helps you to:
|
||||
|
||||
- create, query, and update Java deployments
|
||||
- configure Java deployment resources
|
||||
- manage Python deployments using the Java API
|
||||
|
||||
```{contents}
|
||||
```
|
||||
|
||||
## Creating a Deployment
|
||||
|
||||
By specifying the full name of the class as an argument to the `Serve.deployment()` method, as shown in the code below, you can create and deploy a deployment of the class.
|
||||
|
||||
```{literalinclude} ../../../../java/serve/src/test/java/io/ray/serve/docdemo/ManageDeployment.java
|
||||
:start-after: docs-create-start
|
||||
:end-before: docs-create-end
|
||||
:language: java
|
||||
```
|
||||
|
||||
## Accessing a Deployment
|
||||
|
||||
Once a deployment is deployed, you can fetch its instance by name.
|
||||
|
||||
```{literalinclude} ../../../../java/serve/src/test/java/io/ray/serve/docdemo/ManageDeployment.java
|
||||
:start-after: docs-query-start
|
||||
:end-before: docs-query-end
|
||||
:language: java
|
||||
```
|
||||
|
||||
## Updating a Deployment
|
||||
|
||||
You can update a deployment's code and configuration and then redeploy it. The following example updates the `"counter"` deployment's initial value to 2.
|
||||
|
||||
```{literalinclude} ../../../../java/serve/src/test/java/io/ray/serve/docdemo/ManageDeployment.java
|
||||
:start-after: docs-update-start
|
||||
:end-before: docs-update-end
|
||||
:language: java
|
||||
```
|
||||
|
||||
## Configuring a Deployment
|
||||
|
||||
Ray Serve lets you configure your deployments to:
|
||||
|
||||
- scale out by increasing the number of [deployment replicas](serve-architecture-high-level-view)
|
||||
- assign [replica resources](serve-cpus-gpus) such as CPUs and GPUs.
|
||||
|
||||
The next two sections describe how to configure your deployments.
|
||||
|
||||
### Scaling Out
|
||||
|
||||
By specifying the `numReplicas` parameter, you can change the number of deployment replicas:
|
||||
|
||||
```{literalinclude} ../../../../java/serve/src/test/java/io/ray/serve/docdemo/ManageDeployment.java
|
||||
:start-after: docs-scale-start
|
||||
:end-before: docs-scale-end
|
||||
:language: java
|
||||
```
|
||||
|
||||
### Resource Management (CPUs, GPUs)
|
||||
|
||||
Through the `rayActorOptions` parameter, you can reserve resources for each deployment replica, such as one GPU:
|
||||
|
||||
```{literalinclude} ../../../../java/serve/src/test/java/io/ray/serve/docdemo/ManageDeployment.java
|
||||
:start-after: docs-resource-start
|
||||
:end-before: docs-resource-end
|
||||
:language: java
|
||||
```
|
||||
|
||||
## Managing a Python Deployment
|
||||
|
||||
A Python deployment can also be managed and called by the Java API. Suppose you have a Python file `counter.py` in the `/path/to/code/` directory:
|
||||
|
||||
```python
|
||||
from ray import serve
|
||||
|
||||
@serve.deployment
|
||||
class Counter(object):
|
||||
def __init__(self, value):
|
||||
self.value = int(value)
|
||||
|
||||
def increase(self, delta):
|
||||
self.value += int(delta)
|
||||
return str(self.value)
|
||||
|
||||
```
|
||||
|
||||
You can deploy it through the Java API and call it through a `RayServeHandle`:
|
||||
|
||||
```java
|
||||
import io.ray.api.Ray;
|
||||
import io.ray.serve.api.Serve;
|
||||
import io.ray.serve.deployment.Deployment;
|
||||
import io.ray.serve.generated.DeploymentLanguage;
|
||||
import java.io.File;
|
||||
|
||||
public class ManagePythonDeployment {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
System.setProperty(
|
||||
"ray.job.code-search-path",
|
||||
System.getProperty("java.class.path") + File.pathSeparator + "/path/to/code/");
|
||||
|
||||
Serve.start(true, false, null);
|
||||
|
||||
Deployment deployment =
|
||||
Serve.deployment()
|
||||
.setDeploymentLanguage(DeploymentLanguage.PYTHON)
|
||||
.setName("counter")
|
||||
.setDeploymentDef("counter.Counter")
|
||||
.setNumReplicas(1)
|
||||
.setInitArgs(new Object[] {"1"})
|
||||
.create();
|
||||
deployment.deploy(true);
|
||||
|
||||
System.out.println(Ray.get(deployment.getHandle().method("increase").remote("2")));
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
:::{note}
|
||||
Before `Ray.init` or `Serve.start`, you need to specify a directory to find the Python code. For details, please refer to [Cross-Language Programming](cross_language).
|
||||
:::
|
||||
|
||||
## Future Roadmap
|
||||
|
||||
In the future, Ray Serve plans to provide more Java features, such as:
|
||||
- an improved Java API that matches the Python version
|
||||
- HTTP ingress support
|
||||
- bring-your-own Java Spring project as a deployment
|
||||
@@ -0,0 +1,169 @@
|
||||
(serve-container-runtime-env-guide)=
|
||||
# Run Multiple Applications in Different Containers
|
||||
|
||||
This section explains how to run multiple Serve applications on the same cluster in separate containers with different images.
|
||||
|
||||
This feature is experimental and the API is subject to change. If you have additional feature requests or run into issues, please submit them on [Github](https://github.com/ray-project/ray/issues).
|
||||
|
||||
## Install Podman
|
||||
|
||||
The `image_uri` runtime environment feature uses [Podman](https://podman.io/) to start and run containers. Follow the [Podman Installation Instructions](https://podman.io/docs/installation) to install Podman in the environment for all head and worker nodes.
|
||||
|
||||
:::{note}
|
||||
For Ubuntu, the Podman package is available in the official repositories for Ubuntu 20.10 and newer.
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install podman -y
|
||||
```
|
||||
:::
|
||||
|
||||
## Run a Serve application in a container
|
||||
|
||||
This example deploys two applications in separate containers: a Whisper model and a Resnet50 image classification model.
|
||||
|
||||
First, install the required dependencies in the images.
|
||||
|
||||
:::{warning}
|
||||
The Ray version and Python version in the container *must* match those of the host environment exactly. Note that for Python, the versions must match down to the patch number.
|
||||
:::
|
||||
|
||||
Save the following to files named `whisper.Dockerfile` and `resnet.Dockerfile`.
|
||||
|
||||
::::{tab-set}
|
||||
:::{tab-item} whisper.Dockerfile
|
||||
```dockerfile
|
||||
# Use the latest Ray GPU image, `rayproject/ray:latest-py38-gpu`, so the Whisper model can run on GPUs.
|
||||
FROM rayproject/ray:latest-py38-gpu
|
||||
|
||||
# Install the package `faster_whisper`, which is a dependency for the Whisper model.
|
||||
RUN pip install faster_whisper==0.10.0
|
||||
RUN sudo apt-get update && sudo apt-get install curl -y
|
||||
|
||||
# Download the source code for the Whisper application into `whisper_example.py`.
|
||||
RUN curl -O https://raw.githubusercontent.com/ray-project/ray/master/doc/source/serve/doc_code/whisper_example.py
|
||||
|
||||
# Add /home/ray path to PYTHONPATH avoid import module error
|
||||
ENV PYTHONPATH "${PYTHONPATH}:/home/ray"
|
||||
```
|
||||
:::
|
||||
:::{tab-item} resnet.Dockerfile
|
||||
```dockerfile
|
||||
# Use the latest Ray CPU image, `rayproject/ray:latest-py38-cpu`.
|
||||
FROM rayproject/ray:latest-py38-cpu
|
||||
|
||||
# Install the packages `torch` and `torchvision`, which are dependencies for the ResNet model.
|
||||
RUN pip install torch==2.0.1 torchvision==0.15.2
|
||||
RUN sudo apt-get update && sudo apt-get install curl -y
|
||||
|
||||
# Download the source code for the ResNet application into `resnet50_example.py`.
|
||||
RUN curl -O https://raw.githubusercontent.com/ray-project/ray/master/doc/source/serve/doc_code/resnet50_example.py
|
||||
|
||||
# Add /home/ray path to PYTHONPATH avoid import module error
|
||||
ENV PYTHONPATH "${PYTHONPATH}:/home/ray"
|
||||
```
|
||||
:::
|
||||
::::
|
||||
|
||||
Then, build the corresponding images and push it to your choice of container registry. This tutorial uses `alice/whisper_image:latest` and `alice/resnet_image:latest` as placeholder names for the images, but make sure to swap out `alice` for a repo name of your choice.
|
||||
|
||||
::::{tab-set}
|
||||
:::{tab-item} Whisper
|
||||
```bash
|
||||
# Build the image from the Dockerfile using Podman
|
||||
export IMG1=alice/whisper_image:latest
|
||||
podman build -t $IMG1 -f whisper.Dockerfile .
|
||||
# Push to a registry. This step is unnecessary if you are deploying Serve locally.
|
||||
podman push $IMG1
|
||||
```
|
||||
:::
|
||||
:::{tab-item} Resnet
|
||||
```bash
|
||||
# Build the image from the Dockerfile using Podman
|
||||
export IMG2=alice/resnet_image:latest
|
||||
podman build -t $IMG2 -f resnet.Dockerfile .
|
||||
# Push to a registry. This step is unnecessary if you are deploying Serve locally.
|
||||
podman push $IMG2
|
||||
```
|
||||
:::
|
||||
::::
|
||||
|
||||
Finally, you can specify the container image within which you want to run each application in the `image_uri` field of an application's runtime environment specification.
|
||||
|
||||
:::{note}
|
||||
Previously you could access the feature through the `container` field of the runtime environment. That API is now deprecated in favor of `image_uri`.
|
||||
:::
|
||||
|
||||
The following Serve config runs the `whisper` app with the image `IMG1`, and the `resnet` app with the image `IMG2`. `podman images` command can be used to list the names of the images. Concretely, all deployment replicas in the applications start and run in containers with the respective images.
|
||||
|
||||
```yaml
|
||||
applications:
|
||||
- name: whisper
|
||||
import_path: whisper_example:entrypoint
|
||||
route_prefix: /whisper
|
||||
runtime_env:
|
||||
image_uri: {IMG1}
|
||||
- name: resnet
|
||||
import_path: resnet50_example:app
|
||||
route_prefix: /resnet
|
||||
runtime_env:
|
||||
image_uri: {IMG2}
|
||||
```
|
||||
|
||||
### Send queries
|
||||
|
||||
|
||||
|
||||
```python
|
||||
>>> import requests
|
||||
>>> audio_file = "https://storage.googleapis.com/public-lyrebird-test/test_audio_22s.wav"
|
||||
>>> resp = requests.post("http://localhost:8000/whisper", json={"filepath": audio_file}) # doctest: +SKIP
|
||||
>>> resp.json() # doctest: +SKIP
|
||||
{
|
||||
"language": "en",
|
||||
"language_probability": 1,
|
||||
"duration": 21.775,
|
||||
"transcript_text": " Well, think about the time of our ancestors. A ping, a ding, a rustling in the bushes is like, whoo, that means an immediate response. Oh my gosh, what's that thing? Oh my gosh, I have to do it right now. And dude, it's not a tiger, right? Like, but our, our body treats stress as if it's life-threatening because to quote Robert Sapolsky or butcher his quote, he's a Robert Sapolsky is like one of the most incredible stress physiologists of",
|
||||
"whisper_alignments": [
|
||||
[
|
||||
0.0,
|
||||
0.36,
|
||||
" Well,",
|
||||
0.3125
|
||||
],
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
>>> link_to_image = "https://serve-resnet-benchmark-data.s3.us-west-1.amazonaws.com/000000000019.jpeg"
|
||||
>>> resp = requests.post("http://localhost:8000/resnet", json={"uri": link_to_image}) # doctest: +SKIP
|
||||
>>> resp.text # doctest: +SKIP
|
||||
ox
|
||||
```
|
||||
|
||||
## Advanced
|
||||
|
||||
### Compatibility with other runtime environment fields
|
||||
|
||||
Currently, use of the `image_uri` field is only supported with `config` and `env_vars`. If you have a use case for pairing `image_uri` with another runtime environment feature, submit a feature request on [Github](https://github.com/ray-project/ray/issues).
|
||||
|
||||
### Environment variables
|
||||
|
||||
The following environment variables will be set for the process in your container, in order of highest to lowest priority:
|
||||
1. Environment variables specified in `runtime_env["env_vars"]`.
|
||||
2. All environment variables that start with the prefix `RAY_` (including the two special variables `RAY_RAYLET_PID` and `RAY_JOB_ID`) are inherited by the container at runtime.
|
||||
3. Any environment variables set in the docker image.
|
||||
|
||||
### Running the Ray cluster in a Docker container
|
||||
|
||||
If raylet is running inside a container, then that container needs the necessary permissions to start a new container. To setup correct permissions, you need to start the container that runs the raylet with the flag `--privileged`.
|
||||
|
||||
### Troubleshooting
|
||||
* **Permission denied: '/tmp/ray/session_2023-11-28_15-27-22_167972_6026/ports_by_node.json.lock'**
|
||||
* This error likely occurs because the user running inside the Podman container is different from the host user that started the Ray cluster. The folder `/tmp/ray`, which is volume mounted into the podman container, is owned by the host user that started Ray. The container, on the other hand, is started with the flag `--userns=keep-id`, meaning the host user is mapped into the container as itself. Therefore, permissions issues should only occur if the user inside the container is different from the host user. For instance, if the user on host is `root`, and you're using a container whose base image is a standard Ray image, then by default the container starts with user `ray(1000)`, who won't be able to access the mounted `/tmp/ray` volume.
|
||||
* **ERRO[0000] 'overlay' is not supported over overlayfs: backing file system is unsupported for this graph driver**
|
||||
* This error should only occur when you're running the Ray cluster inside a container. If you see this error when starting the replica actor, try volume mounting `/var/lib/containers` in the container that runs raylet. That is, add `-v /var/lib/containers:/var/lib/containers` to the command that starts the Docker container.
|
||||
* **cannot clone: Operation not permitted; Error: cannot re-exec process**
|
||||
* This error should only occur when you're running the Ray cluster inside a container. This error implies that you don't have the permissions to use Podman to start a container. You need to start the container that runs raylet, with privileged permissions by adding `--privileged`.
|
||||
* **Very slow or hanging container startup**
|
||||
* This is typically caused by using the default podman storage driver (`vfs`) with large container images. Podman runs in rootless mode, so its startup sequence involves modifying permissions of files in the container. The default storage driver is very slow to do this. Try configuring podman to use the `overlay` storage driver instead. You may need to also configure the `mount_program` to point to `/usr/bin/fuse-overlayfs` (or your appropriate local path).
|
||||
@@ -0,0 +1,252 @@
|
||||
(serve-multi-node-gpu-troubleshooting)=
|
||||
|
||||
# Troubleshoot multi-node GPU serving on KubeRay
|
||||
|
||||
This guide helps you diagnose and resolve common issues when deploying multi-node GPU workloads on KubeRay, particularly for large language model (LLM) serving with vLLM.
|
||||
|
||||
## Debugging strategy
|
||||
|
||||
When encountering issues with multi-node GPU serving, use this systematic approach to isolate the problem:
|
||||
|
||||
1. **Test on different platforms** Compare behavior between:
|
||||
- Single node without KubeRay
|
||||
- Standalone vLLM server on KubeRay
|
||||
- Ray Serve LLM deployment on KubeRay
|
||||
|
||||
2. **Vary hardware configurations** Test with different GPU types—for example, A100s vs H100s—to identify hardware-specific issues
|
||||
|
||||
3. **Use minimal reproducers** Create simplified test cases that isolate specific components (NCCL, model loading, etc.)
|
||||
|
||||
## Common issues and solutions
|
||||
|
||||
### 1. Head pod scheduled on GPU node
|
||||
|
||||
**Symptoms**
|
||||
- `ray status` shows duplicate GPU resources, for example, 24 GPUs when cluster only has 16 GPUs
|
||||
- Model serving hangs when using pipeline parallelism (PP > 1)
|
||||
- Resource allocation conflicts
|
||||
|
||||
**Root Cause** The Ray head pod is incorrectly scheduled on a GPU worker node, causing resource accounting issues.
|
||||
|
||||
**Solution** Configure the head pod to use zero GPUs in your RayCluster specification:
|
||||
|
||||
```yaml
|
||||
apiVersion: ray.io/v1
|
||||
kind: RayCluster
|
||||
metadata:
|
||||
name: my-cluster
|
||||
spec:
|
||||
headGroupSpec:
|
||||
rayStartParams:
|
||||
num-cpus: "0"
|
||||
num-gpus: "0" # Ensure head pod doesn't claim GPU resources.
|
||||
# ... other head group configuration
|
||||
```
|
||||
|
||||
### 2. AWS OFI plugin version issues (H100-specific)
|
||||
|
||||
**Symptoms**
|
||||
- NCCL initialization failures on H100 instances
|
||||
- Works fine on A100 but fails on H100 with identical configuration
|
||||
- Malformed topology files
|
||||
|
||||
**Root Cause** Outdated `aws-ofi-plugin` in container images causes NCCL topology detection to fail on H100 instances.
|
||||
|
||||
**Related issues**
|
||||
- [NVIDIA NCCL Issue #1726](https://github.com/NVIDIA/nccl/issues/1726)
|
||||
- [vLLM Issue #18997](https://github.com/vllm-project/vllm/issues/18997)
|
||||
- [AWS OFI NCCL Fix](https://github.com/aws/aws-ofi-nccl/pull/916)
|
||||
|
||||
**Solution**
|
||||
- Update to a newer container image with an updated `aws-ofi-plugin`
|
||||
- Use the NCCL debugging script below to verify NCCL functions as expected
|
||||
- Consider hardware-specific configuration adjustments
|
||||
|
||||
## Further troubleshooting
|
||||
|
||||
If you continue to experience issues after following this guide:
|
||||
|
||||
1. **Collect diagnostic information**: Run the NCCL debugging script below and save the output
|
||||
2. **Check compatibility**: Verify Ray, vLLM, PyTorch, and CUDA versions are compatible
|
||||
3. **Review logs**: Examine Ray cluster logs and worker pod logs for additional error details
|
||||
4. **Hardware verification**: Test with different GPU types if possible
|
||||
5. **Community support**: Share your findings with the Ray and vLLM communities for additional help
|
||||
|
||||
## Additional resources
|
||||
|
||||
- [Ray Multi-Node GPU Guide](https://docs.ray.io/en/latest/cluster/kubernetes/user-guides/gpu.html)
|
||||
- [vLLM Distributed Serving Documentation](https://docs.vllm.ai/en/latest/serving/distributed_serving.html)
|
||||
- [NCCL Troubleshooting Guide](https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/troubleshooting.html)
|
||||
|
||||
## NCCL debugging script
|
||||
|
||||
Use this diagnostic script to identify NCCL-related issues in your multi-node GPU setup:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
NCCL Diagnostic Script for Multi-Node GPU Serving
|
||||
|
||||
This script helps identify NCCL configuration issues that can cause
|
||||
multi-node GPU serving failures. Run this script on each node to verify
|
||||
NCCL function before deploying distributed workloads.
|
||||
|
||||
Usage: python3 multi-node-nccl-check.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import socket
|
||||
import torch
|
||||
from datetime import datetime
|
||||
|
||||
def log(msg):
|
||||
"""Log messages with timestamp for better debugging."""
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{timestamp}] {msg}", flush=True)
|
||||
|
||||
def print_environment_info():
|
||||
"""Print relevant environment information for debugging."""
|
||||
log("=== Environment Information ===")
|
||||
log(f"Hostname: {socket.gethostname()}")
|
||||
log(f"CUDA_VISIBLE_DEVICES: {os.environ.get('CUDA_VISIBLE_DEVICES', 'not set')}")
|
||||
|
||||
# Print all NCCL-related environment variables.
|
||||
nccl_vars = [var for var in os.environ.keys() if var.startswith('NCCL_')]
|
||||
if nccl_vars:
|
||||
log("NCCL Environment Variables:")
|
||||
for var in sorted(nccl_vars):
|
||||
log(f" {var}: {os.environ[var]}")
|
||||
else:
|
||||
log("No NCCL environment variables set")
|
||||
|
||||
def check_cuda_availability():
|
||||
"""Verify CUDA is available and functional."""
|
||||
log("\n=== CUDA Availability Check ===")
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
log("ERROR: CUDA not available")
|
||||
return False
|
||||
|
||||
device_count = torch.cuda.device_count()
|
||||
log(f"CUDA device count: {device_count}")
|
||||
log(f"PyTorch version: {torch.__version__}")
|
||||
|
||||
# Check NCCL availability in PyTorch.
|
||||
try:
|
||||
import torch.distributed as dist
|
||||
if hasattr(torch.distributed, 'nccl'):
|
||||
log(f"PyTorch NCCL available: {torch.distributed.is_nccl_available()}")
|
||||
except Exception as e:
|
||||
log(f"Error checking NCCL availability: {e}")
|
||||
|
||||
return True
|
||||
|
||||
def test_individual_gpus():
|
||||
"""Test that each GPU is working individually."""
|
||||
log("\n=== Individual GPU Tests ===")
|
||||
|
||||
for gpu_id in range(torch.cuda.device_count()):
|
||||
log(f"\n--- Testing GPU {gpu_id} ---")
|
||||
|
||||
try:
|
||||
torch.cuda.set_device(gpu_id)
|
||||
device = torch.cuda.current_device()
|
||||
|
||||
log(f"Device {device}: {torch.cuda.get_device_name(device)}")
|
||||
|
||||
# Print device properties.
|
||||
props = torch.cuda.get_device_properties(device)
|
||||
log(f" Compute capability: {props.major}.{props.minor}")
|
||||
log(f" Total memory: {props.total_memory / 1024**3:.2f} GB")
|
||||
|
||||
# Test basic CUDA operations.
|
||||
log(" Testing basic CUDA operations...")
|
||||
tensor = torch.ones(1000, device=f'cuda:{gpu_id}')
|
||||
result = tensor.sum()
|
||||
log(f" Basic CUDA test passed: sum = {result.item()}")
|
||||
|
||||
# Test cross-GPU operations if multiple GPUs are available.
|
||||
if torch.cuda.device_count() > 1:
|
||||
log(" Testing cross-GPU operations...")
|
||||
try:
|
||||
other_gpu = (gpu_id + 1) % torch.cuda.device_count()
|
||||
test_tensor = torch.randn(10, 10, device=f'cuda:{gpu_id}')
|
||||
tensor_copy = test_tensor.to(f'cuda:{other_gpu}')
|
||||
log(f" Cross-GPU copy successful: GPU {gpu_id} -> GPU {other_gpu}")
|
||||
except Exception as e:
|
||||
log(f" Cross-GPU copy failed: {e}")
|
||||
|
||||
# Test memory allocation.
|
||||
log(" Testing large memory allocations...")
|
||||
try:
|
||||
large_tensor = torch.zeros(1000, 1000, device=f'cuda:{gpu_id}')
|
||||
log(" Large memory allocation successful")
|
||||
del large_tensor
|
||||
except Exception as e:
|
||||
log(f" Large memory allocation failed: {e}")
|
||||
|
||||
except Exception as e:
|
||||
log(f"ERROR testing GPU {gpu_id}: {e}")
|
||||
import traceback
|
||||
log(f"Traceback:\n{traceback.format_exc()}")
|
||||
|
||||
def test_nccl_initialization():
|
||||
"""Test NCCL initialization and basic operations."""
|
||||
log("\n=== NCCL Initialization Test ===")
|
||||
|
||||
try:
|
||||
import torch.distributed as dist
|
||||
|
||||
# Set up single-process NCCL environment.
|
||||
os.environ['MASTER_ADDR'] = 'localhost'
|
||||
os.environ['MASTER_PORT'] = '29500'
|
||||
os.environ['RANK'] = '0'
|
||||
os.environ['WORLD_SIZE'] = '1'
|
||||
|
||||
log("Attempting single-process NCCL initialization...")
|
||||
dist.init_process_group(
|
||||
backend='nccl',
|
||||
rank=0,
|
||||
world_size=1
|
||||
)
|
||||
|
||||
log("Single-process NCCL initialization successful!")
|
||||
|
||||
# Test basic NCCL operation.
|
||||
if torch.cuda.is_available():
|
||||
device = torch.cuda.current_device()
|
||||
tensor = torch.ones(10, device=device)
|
||||
|
||||
# This is a no-op with world_size=1 but exercises NCCL
|
||||
dist.all_reduce(tensor)
|
||||
log("NCCL all_reduce test successful!")
|
||||
|
||||
dist.destroy_process_group()
|
||||
log("NCCL cleanup successful!")
|
||||
|
||||
except Exception as e:
|
||||
log(f"NCCL initialization failed: {e}")
|
||||
import traceback
|
||||
log(f"Full traceback:\n{traceback.format_exc()}")
|
||||
|
||||
def main():
|
||||
"""Main diagnostic routine."""
|
||||
log("Starting NCCL Diagnostic Script")
|
||||
log("=" * 50)
|
||||
|
||||
print_environment_info()
|
||||
|
||||
if not check_cuda_availability():
|
||||
sys.exit(1)
|
||||
|
||||
test_individual_gpus()
|
||||
test_nccl_initialization()
|
||||
|
||||
log("\n" + "=" * 50)
|
||||
log("NCCL diagnostic script completed")
|
||||
log("If you encountered errors, check the specific error messages above")
|
||||
log("and refer to the troubleshooting guide for solutions.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
(serve-perf-tuning)=
|
||||
# Performance Tuning
|
||||
|
||||
This section should help you:
|
||||
|
||||
- understand Ray Serve's performance characteristics
|
||||
- find ways to debug and tune your Serve application's performance
|
||||
|
||||
:::{note}
|
||||
This section offers some tips and tricks to improve your Ray Serve application's performance. Check out the [architecture page](serve-architecture) for helpful context, including an overview of the HTTP proxy actor and deployment replica actors.
|
||||
:::
|
||||
|
||||
```{contents}
|
||||
```
|
||||
|
||||
## Performance and benchmarks
|
||||
|
||||
Ray Serve is built on top of Ray, so its scalability is bounded by Ray’s scalability. See Ray’s [scalability envelope](https://github.com/ray-project/ray/blob/master/release/benchmarks/README.md) to learn more about the maximum number of nodes and other limitations.
|
||||
|
||||
## Debugging performance issues in request path
|
||||
|
||||
The performance issue you're most likely to encounter is high latency or low throughput for requests.
|
||||
|
||||
Once you set up [monitoring](serve-monitoring) with Ray and Ray Serve, these issues may appear as:
|
||||
|
||||
* `serve_num_router_requests_total` staying constant while your load increases
|
||||
* `serve_deployment_processing_latency_ms` spiking up as queries queue up in the background
|
||||
|
||||
The following are ways to address these issues:
|
||||
|
||||
1. Make sure you are using the right hardware and resources:
|
||||
* Are you reserving GPUs for your deployment replicas using `ray_actor_options` (e.g., `ray_actor_options={“num_gpus”: 1}`)?
|
||||
* Are you reserving one or more cores for your deployment replicas using `ray_actor_options` (e.g., `ray_actor_options={“num_cpus”: 2}`)?
|
||||
* Are you setting [OMP_NUM_THREADS](serve-omp-num-threads) to increase the performance of your deep learning framework?
|
||||
2. Try batching your requests. See [Dynamic Request Batching](serve-performance-batching-requests).
|
||||
3. Consider using `async` methods in your callable. See [the section below](serve-performance-async-methods).
|
||||
4. Set an end-to-end timeout for your HTTP requests. See [the section below](serve-performance-e2e-timeout).
|
||||
|
||||
|
||||
(serve-performance-async-methods)=
|
||||
### Using `async` methods
|
||||
|
||||
:::{note}
|
||||
According to the [FastAPI documentation](https://fastapi.tiangolo.com/async/#very-technical-details), `def` endpoint functions are called in a separate threadpool, so you might observe many requests running at the same time inside one replica, and this scenario might cause OOM or resource starvation. In this case, you can try to use `async def` to control the workload performance.
|
||||
:::
|
||||
|
||||
Are you using `async def` in your callable? If you are using `asyncio` and hitting the same queuing issue mentioned above, you might want to increase `max_ongoing_requests`. By default, Serve sets this to a low value (5) to ensure clients receive proper backpressure. You can increase the value in the deployment decorator; for example, `@serve.deployment(max_ongoing_requests=1000)`.
|
||||
|
||||
(serve-performance-e2e-timeout)=
|
||||
### Set an end-to-end request timeout
|
||||
|
||||
By default, Serve lets client HTTP requests run to completion no matter how long they take. However, slow requests could bottleneck the replica processing, blocking other requests that are waiting. Set an end-to-end timeout, so slow requests can be terminated and retried.
|
||||
|
||||
You can set an end-to-end timeout for HTTP requests by setting the `request_timeout_s` parameter in the `http_options` field of the Serve config. HTTP Proxies wait for that many seconds before terminating an HTTP request. This config is global to your Ray cluster, and you can't update it during runtime. Use [client-side retries](serve-best-practices-http-requests) to retry requests that time out due to transient failures.
|
||||
|
||||
:::{note}
|
||||
Serve returns a response with status code `408` when a request times out. Clients can retry when they receive this `408` response.
|
||||
:::
|
||||
|
||||
(serve-performance-per-request-headers)=
|
||||
### Override timeout and disconnect behavior per request
|
||||
|
||||
Two request headers let callers override the global `request_timeout_s` and client-disconnect policy on a per-request basis.
|
||||
|
||||
`x-request-timeout-seconds`
|
||||
: Overrides `request_timeout_s` for this single request.
|
||||
- A **positive float** sets the timeout in seconds (for example, `x-request-timeout-seconds: 30`).
|
||||
- A **non-positive value** (for example, `0` or `-1`) disables the timeout for this request regardless of the global setting.
|
||||
- An **absent or non-numeric value** falls back to the global `request_timeout_s`.
|
||||
|
||||
`x-request-disconnect-disabled`
|
||||
: Controls whether Serve monitors for client disconnects on this request.
|
||||
- `?1` — disables disconnect detection; Serve continues processing even if the client closes the connection.
|
||||
- `?0` or absent — disconnect detection is enabled (default behavior).
|
||||
|
||||
**Performance impact of disabling both headers together**
|
||||
|
||||
Setting `x-request-timeout-seconds` to a non-positive value *and* `x-request-disconnect-disabled: ?1` on the same request enables a fast path in the direct-ingress handler that yields a meaningful throughput improvement:
|
||||
|
||||
- Serve skips wrapping the user handler in an `asyncio.create_task()` call.
|
||||
- Serve skips the `asyncio.wait()` call that would otherwise coordinate the handler task, the disconnect-monitoring task, and the timeout watcher.
|
||||
- Instead, Serve directly `await`s the user handler, eliminating all per-request event-loop scheduling overhead for monitoring.
|
||||
|
||||
In the proxy path the gain is more modest: `asyncio.wait()` is called per response chunk with one fewer task and no timeout handle, which reduces event-loop overhead at high request rates.
|
||||
|
||||
|
||||
### Set backoff time when choosing replica
|
||||
|
||||
Ray Serve allows you to fine-tune the backoff behavior of the request router, which can help reduce latency when waiting for replicas to become ready. It uses exponential backoff strategy when retrying to route requests to replicas that are temporarily unavailable. You can optimize this behavior for your workload by configuring the following environment variables:
|
||||
|
||||
- `RAY_SERVE_ROUTER_RETRY_INITIAL_BACKOFF_S`: The initial backoff time (in seconds) before retrying a request. Default is `0.025`.
|
||||
- `RAY_SERVE_ROUTER_RETRY_BACKOFF_MULTIPLIER`: The multiplier applied to the backoff time after each retry. Default is `2`.
|
||||
- `RAY_SERVE_ROUTER_RETRY_MAX_BACKOFF_S`: The maximum backoff time (in seconds) between retries. Default is `0.5`.
|
||||
|
||||
### Set timeouts while probing replicas for queue length
|
||||
|
||||
Ray Serve's request router probes replicas for their queue lengths to make intelligent load balancing decisions. You can tune the following environment variables to optimize this behavior for your workload:
|
||||
|
||||
- `RAY_SERVE_QUEUE_LENGTH_RESPONSE_DEADLINE_S`: The initial timeout (in seconds) for waiting for replicas to respond with their queue length information. Default is `0.1`.
|
||||
- `RAY_SERVE_MAX_QUEUE_LENGTH_RESPONSE_DEADLINE_S`: The maximum timeout (in seconds) for queue length responses. When retrying with exponential backoff, the deadline increases but is capped at this value. Default is `1.0`.
|
||||
- `RAY_SERVE_QUEUE_LENGTH_CACHE_TIMEOUT_S`: How long (in seconds) cached queue length information from replicas is considered valid. After this timeout, the cache entry expires and the router must probe the replica again. Default is `10.0`.
|
||||
|
||||
### Configure locality-based routing
|
||||
|
||||
Ray Serve routes requests to replicas based on locality to reduce network latency. The system applies locality routing in two scenarios: proxy-to-replica communication (HTTP/gRPC requests) and inter-deployment communication (replica-to-replica calls through `DeploymentHandle`).
|
||||
|
||||
#### Routing priority
|
||||
|
||||
When locality routing is enabled, the system selects replicas in the following priority order:
|
||||
|
||||
1. **Same node**: Replicas running on the same node as the caller (lowest latency)
|
||||
2. **Same availability zone**: Replicas in the same availability zone as the caller
|
||||
3. **Any replica**: All available replicas (fallback when local replicas are busy)
|
||||
|
||||
If replicas at a higher priority level are busy or unavailable, the system automatically falls back to the next level.
|
||||
|
||||
#### Proxy-to-replica routing
|
||||
|
||||
You can configure proxy routing behavior through environment variables:
|
||||
|
||||
- `RAY_SERVE_PROXY_PREFER_LOCAL_NODE_ROUTING`: When enabled, the proxy prefers routing requests to replicas on the same node. Default is `1` (enabled).
|
||||
- `RAY_SERVE_PROXY_PREFER_LOCAL_AZ_ROUTING`: When enabled, the proxy prefers routing requests to replicas in the same availability zone. Default is `1` (enabled).
|
||||
|
||||
#### Inter-deployment routing
|
||||
|
||||
When one deployment calls another through a `DeploymentHandle`, you can enable locality routing to reduce latency between deployments.
|
||||
|
||||
**Same-node routing**: By default, inter-deployment calls don't prefer same-node replicas. To enable same-node routing, initialize the handle with the `_prefer_local_routing` option:
|
||||
|
||||
```python
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
@serve.deployment
|
||||
class Caller:
|
||||
def __init__(self, target_handle: DeploymentHandle):
|
||||
# Enable same-node routing for this handle
|
||||
self._handle = target_handle.options(_prefer_local_routing=True)
|
||||
|
||||
async def call_target(self):
|
||||
# Requests prefer replicas on the same node as this Caller replica
|
||||
return await self._handle.remote()
|
||||
```
|
||||
|
||||
**Same-AZ routing**: The `RAY_SERVE_PROXY_PREFER_LOCAL_AZ_ROUTING` environment variable controls availability zone routing for both proxy and inter-deployment communication. You can't configure AZ routing per-handle.
|
||||
|
||||
(serve-high-throughput)=
|
||||
### Enable throughput-optimized serving
|
||||
|
||||
:::{note}
|
||||
In Ray v2.54.0, the defaults for `RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD` and `RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP` will change to `0` for improved performance.
|
||||
:::
|
||||
|
||||
This section details how to enable Ray Serve options focused on improving throughput and reducing latency. These configurations focus on the following:
|
||||
|
||||
- Reducing overhead associated with frequent logging.
|
||||
- Disabling behavior that allowed Serve applications to include blocking operations.
|
||||
|
||||
If your Ray Serve code includes thread blocking operations, you must refactor your code to achieve enhanced throughput. The following table shows examples of blocking and non-blocking code:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Blocking operation (❌)</th>
|
||||
<th>Non-blocking operation (✅)</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
```python
|
||||
from ray import serve
|
||||
from fastapi import FastAPI
|
||||
import time
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@serve.deployment
|
||||
@serve.ingress(app)
|
||||
class BlockingDeployment:
|
||||
@app.get("/process")
|
||||
async def process(self):
|
||||
# ❌ Blocking operation
|
||||
time.sleep(2)
|
||||
return {"message": "Processed (blocking)"}
|
||||
|
||||
serve.run(BlockingDeployment.bind())
|
||||
```
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
```python
|
||||
from ray import serve
|
||||
from fastapi import FastAPI
|
||||
import asyncio
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@serve.deployment
|
||||
@serve.ingress(app)
|
||||
class NonBlockingDeployment:
|
||||
@app.get("/process")
|
||||
async def process(self):
|
||||
# ✅ Non-blocking operation
|
||||
await asyncio.sleep(2)
|
||||
return {"message": "Processed (non-blocking)"}
|
||||
|
||||
serve.run(NonBlockingDeployment.bind())
|
||||
```
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
To configure all options to the recommended settings, set the environment variable `RAY_SERVE_THROUGHPUT_OPTIMIZED=1`.
|
||||
|
||||
You can also configure each option individually. The following table details the recommended configurations and their impact:
|
||||
|
||||
| Configured value | Impact |
|
||||
| --- | --- |
|
||||
| `RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD=0` | Your code runs in the same event loop as the replica's main event loop. You must avoid blocking operations in your request path. Set this configuration to `1` to run your code in a separate event loop, which protects the replica's ability to communicate with the Serve Controller if your code has blocking operations. |
|
||||
| `RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP=0`| The request router runs in the same event loop as the your code's event loop. You must avoid blocking operations in your request path. Set this configuration to `1` to run the router in a separate event loop, which protect Ray Serve's request routing ability when your code has blocking operations |
|
||||
| `RAY_SERVE_REQUEST_PATH_LOG_BUFFER_SIZE=1000` | Sets the log buffer to batch writes to every `1000` logs, flushing the buffer on write. The system always flushes the buffer and writes logs when it detects a line with level ERROR. Set the buffer size to `1` to disable buffering and write logs immediately. |
|
||||
| `RAY_SERVE_LOG_TO_STDERR=0` | Only write logs to files under the `logs/serve/` directory. Proxy, Controller, and Replica logs no longer appear in the console, worker files, or the Actor Logs section of the Ray Dashboard. Set this property to `1` to enable additional logging. |
|
||||
|
||||
You may want to enable throughput-optimized serving while customizing the options above. You can do this by setting `RAY_SERVE_THROUGHPUT_OPTIMIZED=1` and overriding the specific options. For example, to enable throughput-optimized serving and continue logging to stderr, you should set `RAY_SERVE_THROUGHPUT_OPTIMIZED=1` and override with `RAY_SERVE_LOG_TO_STDERR=1`.
|
||||
|
||||
(serve-haproxy)=
|
||||
### Use HAProxy load balancing
|
||||
|
||||
By default, Ray Serve uses a Python-based HTTP/gRPC proxy to route requests to replicas. You can replace this with [HAProxy](https://www.haproxy.org/), a high-performance C-based load balancer, for improved throughput and lower latency at high request rates.
|
||||
|
||||
When HAProxy mode is enabled:
|
||||
- An `HAProxyManager` actor runs on each node (by default) and translates Serve's routing table into HAProxy configuration reloads.
|
||||
- Each ingress replica opens a port, and HAProxy routes traffic directly to replicas, replacing the Python proxy entirely.
|
||||
- Live traffic flows through the HAProxy subprocess, not through any Python actor.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
HAProxy must be available on every node that runs a Serve proxy. On Linux, `pip install "ray[serve]"` installs the [`ray-haproxy`](https://pypi.org/project/ray-haproxy/) package, which ships a prebuilt HAProxy binary that Serve uses automatically. To use a different binary, set `RAY_SERVE_HAPROXY_BINARY_PATH` to its absolute path.
|
||||
|
||||
#### Enabling HAProxy
|
||||
|
||||
Set the `RAY_SERVE_ENABLE_HA_PROXY` environment variable to `1` on all nodes before starting Ray:
|
||||
|
||||
```bash
|
||||
export RAY_SERVE_ENABLE_HA_PROXY=1
|
||||
```
|
||||
|
||||
This environment variable must be set on all nodes in the ray cluster.
|
||||
|
||||
::::{tab-set}
|
||||
|
||||
:::{tab-item} KubeRay
|
||||
```yaml
|
||||
# In the Ray container spec for head and worker groups:
|
||||
env:
|
||||
- name: RAY_SERVE_ENABLE_HA_PROXY
|
||||
value: "1"
|
||||
```
|
||||
:::
|
||||
|
||||
:::{tab-item} VM cluster
|
||||
```bash
|
||||
# On every node (head and workers)
|
||||
export RAY_SERVE_ENABLE_HA_PROXY=1
|
||||
ray start --head # or ray start --address=<head-ip>:6379 on workers
|
||||
```
|
||||
:::
|
||||
|
||||
::::
|
||||
|
||||
#### Installing HAProxy manually (example)
|
||||
|
||||
Some platforms have no `ray-haproxy` wheel, such as non-glibc Linux, macOS, and Windows. On those platforms, install HAProxy 2.8 from source on every node. These steps are provided as an example only.
|
||||
|
||||
The following steps are for Ubuntu/Debian:
|
||||
```bash
|
||||
# Install build dependencies
|
||||
apt-get update -y && apt-get install -y --no-install-recommends \
|
||||
build-essential ca-certificates curl libc6-dev \
|
||||
liblua5.3-dev libpcre3-dev libssl-dev zlib1g-dev
|
||||
|
||||
# Build HAProxy from source
|
||||
export HAPROXY_VERSION="2.8.20"
|
||||
curl -sSfL -o /tmp/haproxy.tar.gz \
|
||||
"https://www.haproxy.org/download/2.8/src/haproxy-${HAPROXY_VERSION}.tar.gz"
|
||||
mkdir -p /tmp/haproxy-build && tar -xzf /tmp/haproxy.tar.gz -C /tmp/haproxy-build --strip-components=1
|
||||
make -C /tmp/haproxy-build TARGET=linux-glibc \
|
||||
USE_OPENSSL=1 USE_ZLIB=1 USE_PCRE=1 USE_LUA=1 USE_PROMEX=1 -j$(nproc)
|
||||
make -C /tmp/haproxy-build install SBINDIR=/usr/local/bin
|
||||
rm -rf /tmp/haproxy-build /tmp/haproxy.tar.gz
|
||||
|
||||
# Install runtime dependencies
|
||||
apt-get install -y --no-install-recommends liblua5.3-0
|
||||
|
||||
# Create required directories
|
||||
mkdir -p /etc/haproxy /run/haproxy /var/log/haproxy
|
||||
```
|
||||
|
||||
The required build flags are `USE_OPENSSL=1 USE_ZLIB=1 USE_PCRE=1 USE_LUA=1 USE_PROMEX=1`. The runtime dependency is `liblua5.3-0` (Lua runtime library).
|
||||
|
||||
(serve-interdeployment-grpc)=
|
||||
### Use gRPC for interdeployment communication
|
||||
|
||||
By default, when one deployment calls another via a `DeploymentHandle`, requests are sent through Ray's actor RPC system. You can switch this internal transport to gRPC by setting `RAY_SERVE_USE_GRPC_BY_DEFAULT=1` on all nodes before starting Ray. This makes all `DeploymentHandle` calls use gRPC transport, which serializes requests and sends them directly to the target replica's gRPC server. gRPC transport is most beneficial for high-throughput workloads with small payloads (under ~1 MB), where bypassing Ray's object store reduces per-request overhead.
|
||||
|
||||
#### When not to use gRPC
|
||||
|
||||
1. Since gRPC is required to serialize every payload, it should not be used for large payloads (greater than ~1 MB). If gRPC was enabled by default, individual handles' transport mechanism can be manually set to actor RPC with `handle.options(_by_reference=True)`, and passes larger objects by reference.
|
||||
|
||||
2. When passing a `DeploymentResponse` from one deployment into another (i.e., without `await`-ing it first), gRPC resolves the value at the caller and serializes it over the wire. With actor RPC, the underlying `ObjectRef` is forwarded without materializing the data. If your pipeline chains `DeploymentResponse` objects through multiple deployments with payload sizes >100KB, avoid using gRPC for those handles.
|
||||
|
||||
```{literalinclude} ../doc_code/interdeployment_grpc.py
|
||||
:start-after: __start_grpc_override__
|
||||
:end-before: __end_grpc_override__
|
||||
:language: python
|
||||
```
|
||||
|
||||
## Debugging performance issues in controller
|
||||
|
||||
The Serve Controller runs on the Ray head node and is responsible for a variety of tasks, including receiving autoscaling metrics from other Ray Serve components. If the Serve Controller becomes overloaded (symptoms might include high CPU usage and a large number of pending `ServeController.record_autoscaling_metrics_from_handle` tasks), you can tune the following environment variables:
|
||||
|
||||
- `RAY_SERVE_CONTROL_LOOP_INTERVAL_S`: The interval between cycles of the control loop (defaults to `0.1` seconds). Increasing this value gives the Controller more time to process requests and may help alleviate overload.
|
||||
- `RAY_SERVE_CONTROLLER_MAX_CONCURRENCY`: The maximum number of concurrent requests the Controller can handle (defaults to `15000`). The Controller accepts one long poll request per handle, so its concurrency needs scale with the number of handles. Increase this value if you have a large number of deployment handles.
|
||||
- `RAY_SERVE_MAX_CACHED_HANDLES`: The maximum number of cached deployment handles (defaults to `100`). Each handle maintains a long poll connection to the controller, so limiting the cache size reduces controller overhead. Decrease this value if you're experiencing controller overload due to many handles.
|
||||
|
||||
To get a quantitative view of controller health, query the GET `/api/serve/applications/` endpoint and inspect the [`controller_health_metrics`](../api/doc/ray.serve.schema.ControllerHealthMetrics.rst) field on the response. It exposes rolling-window statistics ([`DurationStats`](../api/doc/ray.serve.schema.DurationStats.rst): mean, std, min, max) for control-loop duration and component update latencies (deployment, application, proxy, and node state managers), along with the event-loop delay (actual vs. expected sleep — positive values indicate the loop is falling behind), the count of pending asyncio tasks, and the delay between when autoscaling metrics are generated and when they reach the controller. High `event_loop_delay_s` or growing `loop_duration_s.mean` are the clearest signals that the Controller is overloaded and the tuning knobs above should be applied.
|
||||
@@ -0,0 +1,186 @@
|
||||
(serve-replica-ranks)=
|
||||
|
||||
# Replica ranks
|
||||
|
||||
:::{warning}
|
||||
This API is experimental and may change between Ray minor versions.
|
||||
:::
|
||||
|
||||
Replica ranks provide a unique identifier for **each replica within a deployment**. Each replica receives a **`ReplicaRank` object** containing rank information and **a world size (the total number of replicas)**. The rank object includes a global rank (an integer from 0 to N-1), a node rank, and a local rank on the node.
|
||||
|
||||
## Access replica ranks
|
||||
|
||||
You can access the rank and world size from within a deployment through the replica context using [`serve.get_replica_context()`](../api/doc/ray.serve.get_replica_context.rst).
|
||||
|
||||
The following example shows how to access replica rank information:
|
||||
|
||||
```{literalinclude} ../doc_code/replica_rank.py
|
||||
:start-after: __replica_rank_start__
|
||||
:end-before: __replica_rank_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
```{literalinclude} ../doc_code/replica_rank.py
|
||||
:start-after: __replica_rank_start_run_main__
|
||||
:end-before: __replica_rank_end_run_main__
|
||||
:language: python
|
||||
```
|
||||
|
||||
The [`ReplicaContext`](../api/doc/ray.serve.context.ReplicaContext.rst) provides two key fields:
|
||||
|
||||
- `rank`: A [`ReplicaRank`](../api/doc/ray.serve.schema.ReplicaRank.rst) object containing rank information for this replica. Access the integer rank value with `.rank`.
|
||||
- `world_size`: The target number of replicas for the deployment.
|
||||
|
||||
The `ReplicaRank` object contains three fields:
|
||||
- `rank`: The global rank (an integer from 0 to N-1) representing this replica's unique identifier across all nodes.
|
||||
- `node_rank`: The rank of the node this replica runs on (an integer from 0 to M-1 where M is the number of nodes).
|
||||
- `local_rank`: The rank of this replica on its node (an integer from 0 to K-1 where K is the number of replicas on this node).
|
||||
|
||||
:::{note}
|
||||
**Accessing rank values:**
|
||||
|
||||
To use the rank in your code, access the `.rank` attribute to get the integer value:
|
||||
|
||||
```python
|
||||
context = serve.get_replica_context()
|
||||
my_rank = context.rank.rank # Get the integer rank value
|
||||
my_node_rank = context.rank.node_rank # Get the node rank
|
||||
my_local_rank = context.rank.local_rank # Get the local rank on this node
|
||||
```
|
||||
|
||||
Most use cases only need the global `rank` value. The `node_rank` and `local_rank` are useful for advanced scenarios such as coordinating replicas on the same node.
|
||||
:::
|
||||
|
||||
## Handle rank changes with reconfigure
|
||||
|
||||
When a replica's rank changes (such as during downscaling), Ray Serve can automatically call the `reconfigure` method on your deployment class to notify it of the new rank. This allows you to update replica-specific state when ranks change.
|
||||
|
||||
The following example shows how to implement `reconfigure` to handle rank changes:
|
||||
|
||||
```{literalinclude} ../doc_code/replica_rank.py
|
||||
:start-after: __reconfigure_rank_start__
|
||||
:end-before: __reconfigure_rank_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
```{literalinclude} ../doc_code/replica_rank.py
|
||||
:start-after: __reconfigure_rank_start_run_main__
|
||||
:end-before: __reconfigure_rank_end_run_main__
|
||||
:language: python
|
||||
```
|
||||
|
||||
### When reconfigure is called
|
||||
|
||||
Ray Serve automatically calls your `reconfigure` method in the following situations:
|
||||
|
||||
1. **At replica startup:** When a replica starts, if your deployment has both a `reconfigure` method and a `user_config`, Ray Serve calls `reconfigure` after running `__init__`. This lets you initialize rank-aware state without duplicating code between `__init__` and `reconfigure`.
|
||||
2. **When you update user_config:** When you redeploy with a new `user_config`, Ray Serve calls `reconfigure` on all running replicas. If your `reconfigure` method includes `rank` as a parameter, Ray Serve passes both the new `user_config` and the current rank as a `ReplicaRank` object.
|
||||
3. **When a replica's rank changes:** During downscaling, ranks may be reassigned to maintain contiguity (0 to N-1). If your `reconfigure` method includes `rank` as a parameter and your deployment has a `user_config`, Ray Serve calls `reconfigure` with the existing `user_config` and the new rank as a `ReplicaRank` object.
|
||||
|
||||
:::{note}
|
||||
**Requirements to receive rank updates:**
|
||||
|
||||
To get rank changes through `reconfigure`, your deployment needs:
|
||||
- A class-based deployment (function deployments don't support `reconfigure`)
|
||||
- A `reconfigure` method with `rank` as a parameter: `def reconfigure(self, user_config, rank: ReplicaRank)`
|
||||
- A `user_config` in your deployment (even if it's just an empty dict: `user_config={}`)
|
||||
|
||||
Without a `user_config`, Ray Serve won't call `reconfigure` for rank changes.
|
||||
:::
|
||||
|
||||
:::{tip}
|
||||
If you'd like different behavior for when `reconfigure` is called with rank changes, [open a GitHub issue](https://github.com/ray-project/ray/issues/new/choose) to discuss your use case with the Ray Serve team.
|
||||
:::
|
||||
|
||||
## How replica ranks work
|
||||
|
||||
:::{note}
|
||||
**Rank reassignment is eventually consistent**
|
||||
|
||||
When replicas are removed during downscaling, rank reassignment to maintain contiguity (0 to N-1) doesn't happen immediately. The controller performs rank consistency checks and reassignment only when the deployment reaches a `HEALTHY` state in its update loop. This means there can be a brief period after downscaling where ranks are non-contiguous before the controller reassigns them.
|
||||
|
||||
This design choice prevents rank reassignment from interfering with ongoing deployment updates and rollouts. If you need immediate rank reassignment or different behavior, [open a GitHub issue](https://github.com/ray-project/ray/issues/new/choose) to discuss your use case with the Ray Serve team.
|
||||
:::
|
||||
|
||||
:::{note}
|
||||
**Ranks don't influence scheduling or eviction decisions**
|
||||
|
||||
Replica ranks are independent of scheduling and eviction decisions. The deployment scheduler doesn't consider ranks when placing replicas on nodes, so there's no guarantee that replicas with contiguous ranks (such as rank 0 and rank 1) will be on the same node. Similarly, during downscaling, the autoscaler's eviction decisions don't take replica ranks into account—any replica can be chosen for removal regardless of its rank.
|
||||
|
||||
If you need rank-aware scheduling or eviction (for example, to colocate replicas with consecutive ranks), [open a GitHub issue](https://github.com/ray-project/ray/issues/new/choose) to discuss your requirements with the Ray Serve team.
|
||||
:::
|
||||
|
||||
Ray Serve manages replica ranks automatically throughout the deployment lifecycle. The system maintains these invariants:
|
||||
|
||||
1. Ranks are contiguous integers from 0 to N-1.
|
||||
2. Each running replica has exactly one rank.
|
||||
3. No two replicas share the same rank.
|
||||
|
||||
### Rank assignment lifecycle
|
||||
|
||||
The following table shows how ranks and world size behave during different events:
|
||||
|
||||
| Event | Local Rank | World Size |
|
||||
|-------|------------|------------|
|
||||
| Upscaling | No change for existing replicas | Increases to target count |
|
||||
| Downscaling | Can change to maintain contiguity | Decreases to target count |
|
||||
| Other replica dies(will be restarted) | No change | No change |
|
||||
| Self replica dies | No change | No change |
|
||||
|
||||
:::{note}
|
||||
World size always reflects the target number of replicas configured for the deployment, not the current number of running replicas. During scaling operations, the world size updates immediately to the new target, even while replicas are still starting or stopping.
|
||||
:::
|
||||
|
||||
### Rank lifecycle state machine
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ DEPLOYMENT LIFECYCLE │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
|
||||
Initial Deployment / Upscaling:
|
||||
┌──────────┐ assign ┌──────────┐
|
||||
│ No Rank │ ───────────────> │ Rank: N-1│
|
||||
└──────────┘ └──────────┘
|
||||
(Contiguous: 0, 1, 2, ..., N-1)
|
||||
|
||||
Replica Crash:
|
||||
┌──────────┐ release ┌──────────┐ assign ┌──────────┐
|
||||
│ Rank: K │ ───────────────> │ Released │ ────────────> │ Rank: K │
|
||||
│ (Dead) │ │ │ │ (New) │
|
||||
└──────────┘ └──────────┘ └──────────┘
|
||||
(K can be any rank from 0 to N-1)
|
||||
|
||||
:::{note}
|
||||
When a replica crashes, Ray Serve automatically starts a replacement replica and assigns it the **same rank** as the crashed replica. This ensures rank contiguity is maintained without reassigning other replicas.
|
||||
:::
|
||||
|
||||
Downscaling:
|
||||
┌──────────┐ release ┌──────────┐
|
||||
│ Rank: K │ ───────────────> │ Released │
|
||||
│ (Stopped)│ │ │
|
||||
└──────────┘ └──────────┘
|
||||
│
|
||||
└──> Remaining replicas may be reassigned to maintain
|
||||
contiguity: [0, 1, 2, ..., M-1] where M < N
|
||||
(K can be any rank from 0 to N-1)
|
||||
|
||||
Controller Recovery:
|
||||
┌──────────┐ recover ┌──────────┐
|
||||
│ Running │ ───────────────> │ Rank: N │
|
||||
│ Replicas │ │(Restored)│
|
||||
└──────────┘ └──────────┘
|
||||
(Controller queries replicas to reconstruct rank state)
|
||||
```
|
||||
|
||||
### Detailed lifecycle events
|
||||
|
||||
1. **Rank assignment on startup**: Ranks are assigned when replicas start, such as during initial deployment, cold starts, or upscaling. The controller assigns ranks and propagates them to replicas during initialization. New replicas receive the lowest available rank.
|
||||
|
||||
2. **Rank release on shutdown**: Ranks are released only after a replica fully stops, which occurs during graceful shutdown or downscaling. Ray Serve preserves existing rank assignments as much as possible to minimize disruption.
|
||||
|
||||
3. **Handling replica crashes**: If a replica crashes unexpectedly, the system releases its rank and assigns the **same rank** to the replacement replica. This means if replica with rank 3 crashes, the new replacement replica will also receive rank 3. The replacement receives its rank during initialization, and other replicas keep their existing ranks unchanged.
|
||||
|
||||
4. **Controller crash and recovery**: When the controller recovers from a crash, it reconstructs the rank state by querying all running replicas for their assigned ranks. Ranks aren't checkpointed; the system re-learns them directly from replicas during recovery.
|
||||
|
||||
5. **Maintaining rank contiguity**: After downscaling, the system may reassign ranks to remaining replicas to maintain contiguity (0 to N-1). Ray Serve minimizes reassignments by only changing ranks when necessary.
|
||||
@@ -0,0 +1,301 @@
|
||||
(serve-replica-scheduling)=
|
||||
|
||||
# Replica scheduling
|
||||
|
||||
This guide explains how Ray Serve schedules deployment replicas across your cluster and the APIs and environment variables you can use to control placement behavior.
|
||||
|
||||
## Quick reference: Choosing the right approach
|
||||
|
||||
| Goal | Solution | Example |
|
||||
|------|----------|---------|
|
||||
| Multi-GPU inference with tensor parallelism | `placement_group_bundles` + `STRICT_PACK` | vLLM with `tensor_parallel_size=4` |
|
||||
| Target specific GPU types or zones | `label_selector` in `ray_actor_options` | Schedule on A100 nodes only |
|
||||
| Limit replicas per node for high availability | `max_replicas_per_node` | Max 2 replicas of each deployment per node |
|
||||
| Reduce cloud costs by packing nodes | `RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY=1` | Many small models sharing nodes |
|
||||
| Reserve resources for worker actors | `placement_group_bundles` | Replica spawns Ray Data workers |
|
||||
| Shard large embeddings across nodes | `placement_group_bundles` + `STRICT_SPREAD` | Recommendation model with distributed embedding table |
|
||||
| Simple deployment, no special needs | Default (just `ray_actor_options`) | Single-GPU model |
|
||||
|
||||
## How replica scheduling works
|
||||
|
||||
When you deploy an application, Ray Serve's deployment scheduler determines where to place each replica actor across the available nodes in your Ray cluster. The scheduler runs on the Serve Controller and makes batch scheduling decisions during each update cycle. For information on configuring CPU, GPU, and other resource requirements for your replicas, see [Resource allocation](serve-resource-allocation).
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────┐
|
||||
│ serve.run(app) │
|
||||
└────────────────┬─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Serve Controller │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Deployment Scheduler │ │
|
||||
│ │ │ │
|
||||
│ │ 1. Check placement_group_bundles ──▶ PlacementGroupSchedulingStrategy │ │
|
||||
│ │ 2. Check target node affinity ──▶ NodeAffinitySchedulingStrategy │ │
|
||||
│ │ 3. Use default strategy ──▶ SPREAD (default) or PACK │ │
|
||||
│ └───────────────────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────────────────┴─────────────────────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
|
||||
│ SPREAD Strategy (default) │ │ PACK Strategy │
|
||||
│ │ │ │
|
||||
│ Distributes replicas across nodes │ │ Packs replicas onto fewer nodes │
|
||||
│ for fault tolerance │ │ to minimize resource waste │
|
||||
│ │ │ │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌───────┐ │ │ ┌─────────┐ ┌─────────┐ ┌───────┐ │
|
||||
│ │ Node 1 │ │ Node 2 │ │Node 3 │ │ │ │ Node 1 │ │ Node 2 │ │Node 3 │ │
|
||||
│ │ ┌─────┐ │ │ ┌─────┐ │ │┌─────┐│ │ │ │ ┌─────┐ │ │ │ │ │ │
|
||||
│ │ │ R1 │ │ │ │ R2 │ │ ││ R3 ││ │ │ │ │ R1 │ │ │ idle │ │ idle │ │
|
||||
│ │ └─────┘ │ │ └─────┘ │ │└─────┘│ │ │ │ │ R2 │ │ │ │ │ │ │
|
||||
│ │ │ │ │ │ │ │ │ │ │ R3 │ │ │ │ │ │ │
|
||||
│ └─────────┘ └─────────┘ └───────┘ │ │ └─────────┘ └─────────┘ └───────┘ │
|
||||
│ │ │ ▲ ▲ │
|
||||
│ ✓ High availability │ │ └───────────┘ │
|
||||
│ ✓ Load balanced │ │ Can be released │
|
||||
│ ✓ Reduced contention │ │ ✓ Fewer nodes = lower cloud costs │
|
||||
└─────────────────────────────────────┘ └────────────────────────────────────┘
|
||||
```
|
||||
|
||||
By default, Ray Serve uses a **spread scheduling strategy** that distributes replicas across nodes with best effort. This approach:
|
||||
- Maximizes fault tolerance by avoiding concentration of replicas on a single node
|
||||
- Balances load across the cluster
|
||||
- Helps prevent resource contention between replicas
|
||||
|
||||
### Scheduling priority
|
||||
|
||||
When scheduling a replica, the scheduler evaluates strategies in the following priority order:
|
||||
|
||||
1. **Placement groups**: If you specify `placement_group_bundles`, the scheduler uses a `PlacementGroupSchedulingStrategy` to co-locate the replica with its required resources. If you specify `placement_group_bundle_label_selector`, the scheduler will only select nodes with the required labels for each bundle.
|
||||
2. **Pack scheduling with node affinity**: If pack scheduling is enabled, the scheduler identifies the best available node by preferring non-idle nodes (nodes already running replicas) and using a best-fit algorithm to minimize resource fragmentation. It then uses a `NodeAffinitySchedulingStrategy` with soft constraints to schedule the replica on that node.
|
||||
- **With labels**: If a `label_selector` is provided, the scheduler strictly filters candidate nodes to match the labels before selecting the best fit.
|
||||
- **With fallback**: If a `fallback_strategy` is provided, the scheduler first attempts to pack on nodes matching the labels. If no matching nodes are available, it retries using the next fallback option.
|
||||
3. **Default strategy**: Falls back to `SPREAD` when pack scheduling isn't enabled.
|
||||
|
||||
### Downscaling behavior
|
||||
|
||||
When Ray Serve scales down a deployment, it intelligently selects which replicas to stop:
|
||||
|
||||
1. **Non-running replicas first**: Pending, launching, or recovering replicas are stopped before running replicas.
|
||||
2. **Minimize node count**: Running replicas are stopped from nodes with the fewest total replicas across all deployments, helping to free up nodes faster. Among replicas on the same node, newer replicas are stopped before older ones.
|
||||
3. **Head node protection**: Replicas on the head node have the lowest priority for removal since the head node can't be released. Among replicas on the head node, newer replicas are stopped before older ones.
|
||||
|
||||
:::{note}
|
||||
Running replicas on the head node isn't recommended for production deployments. The head node runs critical cluster processes such as the GCS and Serve controller, and replica workloads can compete for resources.
|
||||
:::
|
||||
|
||||
## APIs for controlling replica placement
|
||||
|
||||
Ray Serve provides several options to control where replicas are scheduled. These parameters are configured through the [`@serve.deployment`](serve-configure-deployment) decorator. For the full API reference, see the [deployment decorator documentation](../api/doc/ray.serve.deployment_decorator.rst).
|
||||
|
||||
### Limit replicas per node with `max_replicas_per_node`
|
||||
|
||||
Use [`max_replicas_per_node`](../api/doc/ray.serve.deployment_decorator.rst) to cap the number of replicas of a deployment that can run on a single node. This is useful when:
|
||||
- You want to ensure high availability by spreading replicas across nodes
|
||||
- You want to avoid resource contention between replicas of the same deployment
|
||||
|
||||
```{literalinclude} ../doc_code/replica_scheduling.py
|
||||
:start-after: __max_replicas_per_node_start__
|
||||
:end-before: __max_replicas_per_node_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
In this example, if you have 6 replicas and `max_replicas_per_node=2`, Ray Serve requires at least 3 nodes to schedule all replicas.
|
||||
|
||||
:::{note}
|
||||
Valid values for `max_replicas_per_node` are `None` (default, no limit) or an integer. You can't set `max_replicas_per_node` together with `placement_group_bundles`.
|
||||
:::
|
||||
|
||||
You can also specify this in a config file:
|
||||
|
||||
```yaml
|
||||
applications:
|
||||
- name: my_app
|
||||
import_path: my_module:app
|
||||
deployments:
|
||||
- name: MyDeployment
|
||||
num_replicas: 6
|
||||
max_replicas_per_node: 2
|
||||
```
|
||||
|
||||
### Reserve resources with placement groups
|
||||
|
||||
For more details on placement group strategies, see the [Ray Core placement groups documentation](ray-placement-group-doc-ref).
|
||||
|
||||
A **placement group** is a Ray primitive that reserves a group of resources (called **bundles**) across one or more nodes in your cluster. When you configure [`placement_group_bundles`](../api/doc/ray.serve.deployment_decorator.rst) for a Ray Serve deployment, Ray creates a dedicated placement group for *each replica*, ensuring those resources are reserved and available for that replica's use.
|
||||
|
||||
A **bundle** is a dictionary specifying resource requirements, such as `{"CPU": 2, "GPU": 1}`. When you define multiple bundles, you're telling Ray to reserve multiple sets of resources that can be placed according to your chosen strategy.
|
||||
|
||||
#### Controlling placement group location with label selectors
|
||||
You can further refine where placement groups are scheduled using a `placement_group_bundle_label_selector`. This field defines a list of label selectors to apply per-bundle when scheduling the Serve deployment. This allows you to restrict the nodes where your bundles (and therefore your replicas) are placed based on Ray node labels. For more information on Ray label selectors, see [Use labels to control scheduling](https://docs.ray.io/en/latest/ray-core/scheduling/labels.html).
|
||||
|
||||
```{literalinclude} ../doc_code/replica_scheduling.py
|
||||
:start-after: __placement_group_labels_start__
|
||||
:end-before: __placement_group_labels_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
The `placement_group_bundle_label_selector` accepts a list of dictionaries.
|
||||
- Single selector: If you provide a list containing a single dictionary, that selector is applied to all bundles in `placement_group_bundles`.
|
||||
- Per-bundle selector: If you provide a list of multiple dictionaries, the length must match `placement_group_bundles`. The *i*-th selector applies to the *i*-th bundle.
|
||||
|
||||
#### What placement groups and bundles mean
|
||||
|
||||
The following diagram illustrates how a deployment with `placement_group_bundles=[{"GPU": 1}, {"GPU": 1}, {"CPU": 4}]` and [`placement_group_strategy`](../api/doc/ray.serve.deployment_decorator.rst)` set to "STRICT_PACK"` is scheduled:
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Node (8 CPUs, 4 GPUs) │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Placement Group (per replica) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────────┐ │ │
|
||||
│ │ │ Bundle 0 │ │ Bundle 1 │ │ Bundle 2 │ │ │
|
||||
│ │ │ {"GPU": 1} │ │ {"GPU": 1} │ │ {"CPU": 4} │ │ │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────────┐ │ │ │
|
||||
│ │ │ │ Replica │ │ │ │ Worker │ │ │ │ Worker Tasks │ │ │ │
|
||||
│ │ │ │ Actor │ │ │ │ Actor │ │ │ │ (preprocessing)│ │ │ │
|
||||
│ │ │ │ (main GPU) │ │ │ │ (2nd GPU) │ │ │ │ │ │ │ │
|
||||
│ │ │ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────────┘ │ │ │
|
||||
│ │ └─────────────────┘ └─────────────────┘ └─────────────────────┘ │ │
|
||||
│ │ ▲ │ │
|
||||
│ │ │ │ │
|
||||
│ │ Replica runs in │ │
|
||||
│ │ first bundle │ │
|
||||
│ └───────────────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
With STRICT_PACK: All bundles guaranteed on same node
|
||||
```
|
||||
|
||||
Consider a deployment with `placement_group_bundles=[{"GPU": 1}, {"GPU": 1}, {"CPU": 4}]`:
|
||||
|
||||
- Ray reserves 3 bundles of resources for each replica
|
||||
- The replica actor runs in the **first bundle** (so `ray_actor_options` must fit within it)
|
||||
- The remaining bundles are available for worker actors/tasks spawned by the replica
|
||||
- All child actors and tasks are automatically scheduled within the placement group
|
||||
|
||||
This is different from simply requesting resources in `ray_actor_options`. With `ray_actor_options={"num_gpus": 2}`, your replica actor gets 2 GPUs but you have no control over where additional worker processes run. With placement groups, you explicitly reserve resources for both the replica and its workers.
|
||||
|
||||
#### When to use placement groups
|
||||
|
||||
| Scenario | Why placement groups help |
|
||||
|----------|---------------------------|
|
||||
| **Model parallelism** | Tensor parallelism or pipeline parallelism requires multiple GPUs that must communicate efficiently. Use `STRICT_PACK` to guarantee all GPUs are on the same node. For example, vLLM with `tensor_parallel_size=4` and the Ray distributed executor backend spawns 4 Ray worker actors (one per GPU shard), all of which must be on the same node for efficient inter-GPU communication via NVLink/NVSwitch. |
|
||||
| **Replica spawns workers** | Your deployment creates Ray actors or tasks for parallel processing. Placement groups reserve resources for these workers. For example, a video processing service that spawns Ray tasks to decode frames in parallel, or a batch inference service using Ray Data to preprocess inputs before model inference. |
|
||||
| **Cross-node distribution** | You need bundles spread across different nodes. Use `SPREAD` or `STRICT_SPREAD`. For example, serving a model with a massive embedding table (such as a recommendation model with billions of item embeddings) that must be sharded across multiple nodes because it exceeds single-node memory. Each bundle holds one shard, and `STRICT_SPREAD` ensures each shard is on a separate node. |
|
||||
|
||||
Don't use placement groups when:
|
||||
- Your replica is self-contained and doesn't spawn additional actors/tasks
|
||||
- You only need simple resource requirements (use `ray_actor_options` instead)
|
||||
- You want to use `max_replicas_per_node`. The combination of these two options is not supported today.
|
||||
|
||||
:::{note}
|
||||
**How `max_replicas_per_node` works:** Ray Serve creates a synthetic custom resource for each deployment. Every node implicitly has 1.0 of this resource, and each replica requests `1.0 / max_replicas_per_node` of it. For example, with `max_replicas_per_node=3`, each replica requests ~0.33 of the resource, so only 3 replicas can fit on a node before the resource is exhausted. This mechanism relies on Ray's standard resource scheduling, which conflicts with placement group scheduling.
|
||||
:::
|
||||
|
||||
#### Configuring placement groups
|
||||
|
||||
The following example reserves 2 GPUs for each replica using a strict pack strategy:
|
||||
|
||||
```{literalinclude} ../doc_code/replica_scheduling.py
|
||||
:start-after: __placement_group_start__
|
||||
:end-before: __placement_group_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
The replica actor is scheduled in the first bundle, so the resources specified in `ray_actor_options` must be a subset of the first bundle's resources. All actors and tasks created by the replica are scheduled in the placement group by default (`placement_group_capture_child_tasks=True`).
|
||||
|
||||
### Target nodes with labels
|
||||
|
||||
You can use label selectors in [`ray_actor_options`](../api/doc/ray.serve.deployment_decorator.rst) to target replicas to specific nodes. This is the recommended approach for controlling which nodes run your replicas.
|
||||
|
||||
Then configure your deployment to require the specific labels:
|
||||
|
||||
```{literalinclude} ../doc_code/replica_scheduling.py
|
||||
:start-after: __label_selectors_start__
|
||||
:end-before: __label_selectors_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
First, start your Ray nodes with labels that identify their capabilities:
|
||||
|
||||
```{literalinclude} ../doc_code/replica_scheduling.py
|
||||
:start-after: __label_selector_main_start__
|
||||
:end-before: __label_selector_main_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
#### Soft constraints with `fallback_strategy`
|
||||
|
||||
By default, a `label_selector` acts as a hard constraint. If no node matches the selector, the replica remains pending indefinitely. You can relax this requirement by providing a `fallback_strategy` in `ray_actor_options`.
|
||||
|
||||
```{literalinclude} ../doc_code/replica_scheduling.py
|
||||
:start-after: __fallback_strategy_start__
|
||||
:end-before: __fallback_strategy_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
This allows you to express preferences. For example, when using PACK scheduling, the scheduler will attempt to find a node that matches the `label_selector` first. If no available node is found, the scheduler will retry scheduling using the rules defined in your fallback strategy.
|
||||
|
||||
Label selectors and fallback strategies offer several advantages for Ray Serve deployments:
|
||||
|
||||
- **Expressive placement constraints**: Ray automatically detects and populates labels for node attributes like `ray.io/accelerator-type`, or you can add custom labels at startup using the `--labels` flag. You can target these labels utilizing familiar Kubernetes-like syntax with complex operators (equality, negation (`!`), inclusion (`in`), and exclusion (`!in`)) to precisely filter which nodes run your replicas.
|
||||
- **Autoscaler-aware**: The Ray autoscaler understands label selectors and can provision nodes with the required labels automatically.
|
||||
- **Soft constraints**: Unlike custom resources which are strict requirements, label selectors can also be specified in the `fallback_strategy` field. This allows you to define preferred scheduling options while permitting the scheduler to utilize alternative nodes if the primary targets are unavailable, preventing deployments from stalling.
|
||||
|
||||
## Environment variables
|
||||
|
||||
These environment variables modify Ray Serve's scheduling behavior. Set them before starting Ray.
|
||||
|
||||
### `RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY`
|
||||
|
||||
**Default**: `0` (disabled)
|
||||
|
||||
When enabled, switches from spread scheduling to **pack scheduling**. Pack scheduling:
|
||||
- Packs replicas onto fewer nodes to minimize resource fragmentation
|
||||
- Sorts pending replicas by resource requirements (largest first)
|
||||
- Prefers scheduling on nodes that already have replicas (non-idle nodes)
|
||||
- Uses best-fit bin packing to find the optimal node for each replica
|
||||
|
||||
```bash
|
||||
export RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY=1
|
||||
ray start --head
|
||||
```
|
||||
**When to use pack scheduling:** When you run many small deployments (such as 10 models each needing 0.5 CPUs), spread scheduling scatters them across nodes, wasting capacity. Pack scheduling fills nodes efficiently before using new ones. Cloud providers bill per node-hour. Packing replicas onto fewer nodes allows idle nodes to be released by the autoscaler, directly reducing your bill.
|
||||
|
||||
**When to avoid pack scheduling:** High availability is critical and you want replicas spread across nodes
|
||||
|
||||
:::{note}
|
||||
Pack scheduling automatically falls back to spread scheduling when any deployment uses placement groups with `PACK`, `SPREAD`, or `STRICT_SPREAD` strategies. This happens because pack scheduling needs to predict where resources will be consumed to bin-pack effectively. With `STRICT_PACK`, all bundles are guaranteed to land on one node, making resource consumption predictable. With other strategies, bundles may spread across multiple nodes unpredictably, so the scheduler can't accurately track available resources per node.
|
||||
:::
|
||||
|
||||
### `RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES`
|
||||
|
||||
**Default**: empty
|
||||
|
||||
A comma-separated list of custom resource names that should be prioritized when sorting replicas for pack scheduling. Resources listed earlier have higher priority.
|
||||
|
||||
```bash
|
||||
export RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES="TPU,custom_accelerator"
|
||||
ray start --head
|
||||
```
|
||||
|
||||
When pack scheduling is enabled, the scheduler first filters the cluster to find nodes that match the `label_selector` (if specified). It then sorts the pending replicas by resource requirements to pack them efficiently. The priority order for sorting replicas is:
|
||||
1. Custom resources in `RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES` (in order)
|
||||
2. GPU
|
||||
3. CPU
|
||||
4. Memory
|
||||
5. Other custom resources
|
||||
|
||||
This ensures that replicas requiring high-priority resources are scheduled first, reducing the chance of resource fragmentation.
|
||||
|
||||
## See also
|
||||
|
||||
- [Resource allocation](serve-resource-allocation) for configuring CPU, GPU, and other resources
|
||||
- [Autoscaling](serve-autoscaling) for automatically adjusting replica count
|
||||
- [Ray placement groups](ray-placement-group-doc-ref) for advanced resource co-location
|
||||
@@ -0,0 +1,528 @@
|
||||
(serve-api)=
|
||||
# Ray Serve API
|
||||
|
||||
## Python API
|
||||
|
||||
(core-apis)=
|
||||
|
||||
```{eval-rst}
|
||||
.. currentmodule:: ray
|
||||
```
|
||||
|
||||
### Writing Applications
|
||||
|
||||
<!---
|
||||
NOTE: `serve.deployment` and `serve.Deployment` have an autosummary-generated filename collision due to case insensitivity. This is fixed by added custom filename mappings in `source/conf.py` (look for "autosummary_filename_map"). --->
|
||||
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
:template: autosummary/class_without_init_args.rst
|
||||
|
||||
serve.Deployment
|
||||
serve.Application
|
||||
```
|
||||
|
||||
#### Deployment Decorators
|
||||
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
|
||||
serve.deployment
|
||||
:noindex:
|
||||
serve.ingress
|
||||
serve.batch
|
||||
serve.multiplexed
|
||||
```
|
||||
|
||||
#### Deployment Handles
|
||||
|
||||
:::{note}
|
||||
The deprecated `RayServeHandle` and `RayServeSyncHandle` APIs have been fully removed as of Ray 2.10. See the [model composition guide](serve-model-composition) for how to update code to use the {mod}`DeploymentHandle <ray.serve.handle.DeploymentHandle>` API instead.
|
||||
:::
|
||||
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
:template: autosummary/class_without_init_args.rst
|
||||
|
||||
serve.handle.DeploymentHandle
|
||||
serve.handle.DeploymentResponse
|
||||
serve.handle.DeploymentResponseGenerator
|
||||
serve.handle.DeploymentBroadcastResponse
|
||||
```
|
||||
|
||||
### Running Applications
|
||||
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
|
||||
serve.start
|
||||
serve.run
|
||||
serve.delete
|
||||
serve.status
|
||||
serve.shutdown
|
||||
serve.shutdown_async
|
||||
```
|
||||
|
||||
### Configurations
|
||||
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
:template: autosummary/class_without_autosummary.rst
|
||||
|
||||
serve.config.ProxyLocation
|
||||
serve.config.AutoscalingContext
|
||||
serve.autoscaling_policy.replica_queue_length_autoscaling_policy
|
||||
serve.config.AggregationFunction
|
||||
serve.config.GangPlacementStrategy
|
||||
serve.config.GangRuntimeFailurePolicy
|
||||
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
:template: autosummary/autopydantic.rst
|
||||
|
||||
serve.config.ControllerOptions
|
||||
serve.config.gRPCOptions
|
||||
serve.config.HTTPOptions
|
||||
serve.config.AutoscalingConfig
|
||||
serve.config.AutoscalingPolicy
|
||||
serve.config.RequestRouterConfig
|
||||
serve.config.GangSchedulingConfig
|
||||
serve.config.DeploymentActorConfig
|
||||
```
|
||||
|
||||
### Schemas
|
||||
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
:template: autosummary/class_without_init_args.rst
|
||||
|
||||
serve.schema.ServeActorDetails
|
||||
serve.schema.ProxyDetails
|
||||
serve.schema.ApplicationStatusOverview
|
||||
serve.schema.ServeStatus
|
||||
serve.schema.DeploymentStatusOverview
|
||||
serve.schema.EncodingType
|
||||
serve.schema.AutoscalingMetricsHealth
|
||||
serve.schema.AutoscalingStatus
|
||||
serve.schema.ScalingDecision
|
||||
serve.schema.DeploymentAutoscalingDetail
|
||||
serve.schema.ReplicaRank
|
||||
```
|
||||
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
:template: autosummary/class_without_autosummary.rst
|
||||
|
||||
serve.schema.TaskProcessorAdapter
|
||||
```
|
||||
|
||||
### Request Router
|
||||
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
|
||||
serve.request_router.ReplicaID
|
||||
serve.request_router.PendingRequest
|
||||
serve.request_router.RunningReplica
|
||||
serve.request_router.FIFOMixin
|
||||
serve.request_router.LocalityMixin
|
||||
serve.request_router.MultiplexMixin
|
||||
serve.request_router.RequestRouter
|
||||
```
|
||||
|
||||
#### Advanced APIs
|
||||
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
|
||||
serve.get_replica_context
|
||||
serve.get_trace_context
|
||||
serve.get_deployment_actor
|
||||
serve.context.ReplicaContext
|
||||
serve.context.GangContext
|
||||
serve.get_multiplexed_model_id
|
||||
serve.get_app_handle
|
||||
serve.get_deployment_handle
|
||||
serve.grpc_util.RayServegRPCContext
|
||||
serve.grpc_util.gRPCInputStream
|
||||
serve.exceptions.BackPressureError
|
||||
serve.exceptions.RayServeException
|
||||
serve.exceptions.RequestCancelledError
|
||||
serve.exceptions.gRPCStatusError
|
||||
serve.exceptions.DeploymentUnavailableError
|
||||
serve.exceptions.ReplicaUnavailableError
|
||||
```
|
||||
|
||||
|
||||
(serve-cli)=
|
||||
|
||||
## Command Line Interface (CLI)
|
||||
|
||||
```{eval-rst}
|
||||
.. click:: ray.serve.scripts:cli
|
||||
:prog: serve
|
||||
:nested: full
|
||||
```
|
||||
|
||||
(serve-rest-api)=
|
||||
|
||||
## Serve REST API
|
||||
|
||||
The Serve REST API is exposed at the same port as the Ray Dashboard. The Dashboard port is `8265` by default. This port can be changed using the `--dashboard-port` argument when running `ray start`. All example requests in this section use the default port.
|
||||
|
||||
### `PUT "/api/serve/applications/"`
|
||||
|
||||
Declaratively deploys a list of Serve applications. If Serve is already running on the Ray cluster, removes all applications not listed in the new config. If Serve is not running on the Ray cluster, starts Serve. See [multi-app config schema](serve-rest-api-config-schema) for the request's JSON schema.
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
PUT /api/serve/applications/ HTTP/1.1
|
||||
Host: http://localhost:8265/
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"applications": [
|
||||
{
|
||||
"name": "text_app",
|
||||
"route_prefix": "/",
|
||||
"import_path": "text_ml:app",
|
||||
"runtime_env": {
|
||||
"working_dir": "https://github.com/ray-project/serve_config_examples/archive/HEAD.zip"
|
||||
},
|
||||
"deployments": [
|
||||
{"name": "Translator", "user_config": {"language": "french"}},
|
||||
{"name": "Summarizer"},
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
**Example Response**
|
||||
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### `GET "/api/serve/applications/"`
|
||||
|
||||
Gets cluster-level info and comprehensive details on all Serve applications deployed on the Ray cluster. See [metadata schema](serve-rest-api-response-schema) for the response's JSON schema.
|
||||
|
||||
```http
|
||||
GET /api/serve/applications/ HTTP/1.1
|
||||
Host: http://localhost:8265/
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
**Example Response (abridged JSON)**:
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"controller_info": {
|
||||
"node_id": "cef533a072b0f03bf92a6b98cb4eb9153b7b7c7b7f15954feb2f38ec",
|
||||
"node_ip": "10.0.29.214",
|
||||
"actor_id": "1d214b7bdf07446ea0ed9d7001000000",
|
||||
"actor_name": "SERVE_CONTROLLER_ACTOR",
|
||||
"worker_id": "adf416ae436a806ca302d4712e0df163245aba7ab835b0e0f4d85819",
|
||||
"log_file_path": "/serve/controller_29778.log"
|
||||
},
|
||||
"proxy_location": "EveryNode",
|
||||
"http_options": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8000,
|
||||
"root_path": "",
|
||||
"request_timeout_s": null,
|
||||
"keep_alive_timeout_s": 5
|
||||
},
|
||||
"grpc_options": {
|
||||
"port": 9000,
|
||||
"grpc_servicer_functions": [],
|
||||
"request_timeout_s": null
|
||||
},
|
||||
"proxies": {
|
||||
"cef533a072b0f03bf92a6b98cb4eb9153b7b7c7b7f15954feb2f38ec": {
|
||||
"node_id": "cef533a072b0f03bf92a6b98cb4eb9153b7b7c7b7f15954feb2f38ec",
|
||||
"node_ip": "10.0.29.214",
|
||||
"actor_id": "b7a16b8342e1ced620ae638901000000",
|
||||
"actor_name": "SERVE_CONTROLLER_ACTOR:SERVE_PROXY_ACTOR-cef533a072b0f03bf92a6b98cb4eb9153b7b7c7b7f15954feb2f38ec",
|
||||
"worker_id": "206b7fe05b65fac7fdceec3c9af1da5bee82b0e1dbb97f8bf732d530",
|
||||
"log_file_path": "/serve/http_proxy_10.0.29.214.log",
|
||||
"status": "HEALTHY"
|
||||
}
|
||||
},
|
||||
"deploy_mode": "MULTI_APP",
|
||||
"applications": {
|
||||
"app1": {
|
||||
"name": "app1",
|
||||
"route_prefix": "/",
|
||||
"docs_path": null,
|
||||
"status": "RUNNING",
|
||||
"message": "",
|
||||
"last_deployed_time_s": 1694042836.1912267,
|
||||
"deployed_app_config": {
|
||||
"name": "app1",
|
||||
"route_prefix": "/",
|
||||
"import_path": "src.text-test:app",
|
||||
"deployments": [
|
||||
{
|
||||
"name": "Translator",
|
||||
"num_replicas": 1,
|
||||
"user_config": {
|
||||
"language": "german"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"deployments": {
|
||||
"Translator": {
|
||||
"name": "Translator",
|
||||
"status": "HEALTHY",
|
||||
"message": "",
|
||||
"deployment_config": {
|
||||
"name": "Translator",
|
||||
"num_replicas": 1,
|
||||
"max_ongoing_requests": 100,
|
||||
"user_config": {
|
||||
"language": "german"
|
||||
},
|
||||
"graceful_shutdown_wait_loop_s": 2.0,
|
||||
"graceful_shutdown_timeout_s": 20.0,
|
||||
"health_check_period_s": 10.0,
|
||||
"health_check_timeout_s": 30.0,
|
||||
"ray_actor_options": {
|
||||
"runtime_env": {
|
||||
"env_vars": {}
|
||||
},
|
||||
"num_cpus": 1.0
|
||||
},
|
||||
"is_driver_deployment": false
|
||||
},
|
||||
"replicas": [
|
||||
{
|
||||
"node_id": "cef533a072b0f03bf92a6b98cb4eb9153b7b7c7b7f15954feb2f38ec",
|
||||
"node_ip": "10.0.29.214",
|
||||
"actor_id": "4bb8479ad0c9e9087fee651901000000",
|
||||
"actor_name": "SERVE_REPLICA::app1#Translator#oMhRlb",
|
||||
"worker_id": "1624afa1822b62108ead72443ce72ef3c0f280f3075b89dd5c5d5e5f",
|
||||
"log_file_path": "/serve/deployment_Translator_app1#Translator#oMhRlb.log",
|
||||
"replica_id": "app1#Translator#oMhRlb",
|
||||
"state": "RUNNING",
|
||||
"pid": 29892,
|
||||
"start_time_s": 1694042840.577496
|
||||
}
|
||||
]
|
||||
},
|
||||
"Summarizer": {
|
||||
"name": "Summarizer",
|
||||
"status": "HEALTHY",
|
||||
"message": "",
|
||||
"deployment_config": {
|
||||
"name": "Summarizer",
|
||||
"num_replicas": 1,
|
||||
"max_ongoing_requests": 100,
|
||||
"user_config": null,
|
||||
"graceful_shutdown_wait_loop_s": 2.0,
|
||||
"graceful_shutdown_timeout_s": 20.0,
|
||||
"health_check_period_s": 10.0,
|
||||
"health_check_timeout_s": 30.0,
|
||||
"ray_actor_options": {
|
||||
"runtime_env": {},
|
||||
"num_cpus": 1.0
|
||||
},
|
||||
"is_driver_deployment": false
|
||||
},
|
||||
"replicas": [
|
||||
{
|
||||
"node_id": "cef533a072b0f03bf92a6b98cb4eb9153b7b7c7b7f15954feb2f38ec",
|
||||
"node_ip": "10.0.29.214",
|
||||
"actor_id": "7118ae807cffc1c99ad5ad2701000000",
|
||||
"actor_name": "SERVE_REPLICA::app1#Summarizer#cwiPXg",
|
||||
"worker_id": "12de2ac83c18ce4a61a443a1f3308294caf5a586f9aa320b29deed92",
|
||||
"log_file_path": "/serve/deployment_Summarizer_app1#Summarizer#cwiPXg.log",
|
||||
"replica_id": "app1#Summarizer#cwiPXg",
|
||||
"state": "RUNNING",
|
||||
"pid": 29893,
|
||||
"start_time_s": 1694042840.5789504
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `DELETE "/api/serve/applications/"`
|
||||
|
||||
Shuts down Serve and all applications running on the Ray cluster. Has no effect if Serve is not running on the Ray cluster.
|
||||
|
||||
**Example Request**:
|
||||
|
||||
```http
|
||||
DELETE /api/serve/applications/ HTTP/1.1
|
||||
Host: http://localhost:8265/
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
**Example Response**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
(serve-rest-api-config-schema)=
|
||||
## Config Schemas
|
||||
|
||||
```{eval-rst}
|
||||
.. currentmodule:: ray.serve
|
||||
```
|
||||
|
||||
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
:template: autosummary/autopydantic.rst
|
||||
|
||||
schema.ServeDeploySchema
|
||||
schema.gRPCOptionsSchema
|
||||
schema.HTTPOptionsSchema
|
||||
schema.ServeApplicationSchema
|
||||
schema.DeploymentSchema
|
||||
schema.RayActorOptionsSchema
|
||||
schema.CeleryAdapterConfig
|
||||
schema.TaskProcessorConfig
|
||||
schema.TaskResult
|
||||
schema.ScaleDeploymentRequest
|
||||
|
||||
```
|
||||
|
||||
(serve-rest-api-response-schema)=
|
||||
## Response Schemas
|
||||
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
:template: autosummary/autopydantic.rst
|
||||
|
||||
schema.ServeInstanceDetails
|
||||
schema.ApplicationDetails
|
||||
schema.DeploymentDetails
|
||||
schema.ReplicaDetails
|
||||
schema.TargetGroup
|
||||
schema.Target
|
||||
schema.DeploymentNode
|
||||
schema.DeploymentTopology
|
||||
schema.ControllerHealthMetrics
|
||||
schema.DurationStats
|
||||
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
:template: autosummary/class_without_autosummary.rst
|
||||
|
||||
schema.APIType
|
||||
schema.ApplicationStatus
|
||||
schema.ProxyStatus
|
||||
|
||||
```
|
||||
|
||||
## Observability
|
||||
|
||||
```{eval-rst}
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
:template: autosummary/class_without_autosummary.rst
|
||||
|
||||
metrics.Counter
|
||||
metrics.Histogram
|
||||
metrics.Gauge
|
||||
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
:template: autosummary/autopydantic.rst
|
||||
|
||||
schema.LoggingConfig
|
||||
```
|
||||
|
||||
(serve-llm-api)=
|
||||
|
||||
## LLM API
|
||||
|
||||
```{eval-rst}
|
||||
.. currentmodule:: ray
|
||||
```
|
||||
|
||||
|
||||
### Builders
|
||||
|
||||
```{eval-rst}
|
||||
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
|
||||
serve.llm.build_llm_deployment
|
||||
serve.llm.build_openai_app
|
||||
```
|
||||
|
||||
### Configs
|
||||
|
||||
```{eval-rst}
|
||||
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
:template: autosummary/autopydantic.rst
|
||||
|
||||
serve.llm.LLMConfig
|
||||
serve.llm.LLMServingArgs
|
||||
serve.llm.ModelLoadingConfig
|
||||
serve.llm.CloudMirrorConfig
|
||||
serve.llm.LoraConfig
|
||||
```
|
||||
|
||||
|
||||
### Deployments
|
||||
|
||||
```{eval-rst}
|
||||
|
||||
.. autosummary::
|
||||
:nosignatures:
|
||||
:toctree: doc/
|
||||
|
||||
serve.llm.LLMServer
|
||||
serve.llm.LLMRouter
|
||||
```
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 114 KiB |
@@ -0,0 +1,91 @@
|
||||
(serve-architecture)=
|
||||
|
||||
# Architecture
|
||||
|
||||
In this section, we explore Serve's key architectural concepts and components. It will offer insight and overview into:
|
||||
- the role of each component in Serve and how they work
|
||||
- the different types of actors that make up a Serve application
|
||||
|
||||
% Figure source: https://docs.google.com/drawings/d/1jSuBN5dkSj2s9-0eGzlU_ldsRa3TsswQUZM-cMQ29a0/edit?usp=sharing
|
||||
|
||||
```{image} architecture-2.0.svg
|
||||
:align: center
|
||||
:width: 600px
|
||||
```
|
||||
|
||||
(serve-architecture-high-level-view)=
|
||||
## High-Level View
|
||||
|
||||
Serve runs on Ray and utilizes [Ray actors](actor-guide).
|
||||
|
||||
There are three kinds of actors that are created to make up a Serve instance:
|
||||
|
||||
- **Controller**: A global actor unique to each Serve instance that manages the control plane. The Controller is responsible for creating, updating, and destroying other actors. Serve API calls like creating or getting a deployment make remote calls to the Controller.
|
||||
- **HTTP Proxy**: By default there is one HTTP proxy actor on the head node. This actor runs a [Uvicorn](https://www.uvicorn.org/) HTTP server that accepts incoming requests, forwards them to replicas, and responds once they are completed. For scalability and high availability, you can also run a proxy on each node in the cluster via the `proxy_location` field inside [`serve.start()`](core-apis) or [the config file](serve-in-production-config-file).
|
||||
- **gRPC Proxy**: If Serve is started with valid `port` and `grpc_servicer_functions`, then the gRPC proxy is started alongside the HTTP proxy. This Actor runs a [grpcio](https://grpc.github.io/grpc/python/) server. The gRPC server accepts incoming requests, forwards them to replicas, and responds once they are completed.
|
||||
- **Replicas**: Actors that actually execute the code in response to a request. For example, they may contain an instantiation of an ML model. Each replica processes individual requests from the proxy. The replica may batch the requests using `@serve.batch`. See the [batching](serve-performance-batching-requests) docs.
|
||||
|
||||
## Lifetime of a request
|
||||
|
||||
When an HTTP or gRPC request is sent to the corresponding HTTP or gRPC proxy, the following happens:
|
||||
|
||||
1. The request is received and parsed.
|
||||
2. Ray Serve looks up the correct deployment associated with the HTTP URL path or application name metadata. Serve places the request in a queue.
|
||||
3. For each request in a deployment's queue, an available replica is looked up and the request is sent to it. If no replicas are available (that is, more than `max_ongoing_requests` requests are outstanding at each replica), the request is left in the queue until a replica becomes available.
|
||||
|
||||
Each replica maintains a queue of requests and executes requests one at a time, possibly using `asyncio` to process them concurrently. If the handler (the deployment function or the `__call__` method of the deployment class) is declared with `async def`, the replica will not wait for the handler to run. Otherwise, the replica blocks until the handler returns.
|
||||
|
||||
When making a request via a [DeploymentHandle](serve-key-concepts-deployment-handle) instead of HTTP or gRPC for [model composition](serve-model-composition), the request is placed on a queue in the `DeploymentHandle`, and we skip to step 3 above.
|
||||
|
||||
(serve-ft-detail)=
|
||||
|
||||
## Fault tolerance
|
||||
|
||||
Application errors like exceptions in your model evaluation code are caught and wrapped. A 500 status code will be returned with the traceback information. The replica will be able to continue to handle requests.
|
||||
|
||||
Machine errors and faults are handled by Ray Serve as follows:
|
||||
|
||||
- When replica Actors fail, the Controller Actor replaces them with new ones.
|
||||
- When the proxy Actor fails, the Controller Actor restarts it.
|
||||
- When the Controller Actor fails, Ray restarts it.
|
||||
- When using the [KubeRay RayService](kuberay-rayservice-quickstart), KubeRay recovers crashed nodes or a crashed cluster. You can avoid cluster crashes by using the [GCS FT feature](kuberay-gcs-ft).
|
||||
- If you aren't using KubeRay, when the Ray cluster fails, Ray Serve cannot recover.
|
||||
|
||||
When a machine hosting any of the actors crashes, those actors are automatically restarted on another available machine. All data in the Controller (routing policies, deployment configurations, etc) is checkpointed to the Ray Global Control Store (GCS) on the head node. Transient data in the router and the replica (like network connections and internal request queues) will be lost for this kind of failure. See [the end-to-end fault tolerance guide](serve-e2e-ft) for more details on how actor crashes are detected.
|
||||
|
||||
(serve-autoscaling-architecture)=
|
||||
|
||||
## Ray Serve Autoscaling
|
||||
|
||||
Ray Serve's autoscaling feature automatically increases or decreases a deployment's number of replicas based on its load.
|
||||
|
||||

|
||||
|
||||
- The Serve Autoscaler runs in the Serve Controller actor.
|
||||
- Each `DeploymentHandle` and each replica periodically pushes its metrics to the autoscaler.
|
||||
- For each deployment, the autoscaler periodically checks `DeploymentHandle` queues and in-flight queries on replicas to decide whether or not to scale the number of replicas.
|
||||
- Each `DeploymentHandle` continuously polls the controller to check for new deployment replicas. Whenever new replicas are discovered, it sends any buffered or new queries to the replica until `max_ongoing_requests` is reached. Queries are sent to replicas using a [power of two choices](https://ieeexplore.ieee.org/document/963420) scheduling strategy, subject to the constraint that no replica is handling more than `max_ongoing_requests` requests at a time.
|
||||
|
||||
:::{note}
|
||||
When the controller dies, requests can still be sent via HTTP, gRPC and `DeploymentHandle`, but autoscaling is paused. When the controller recovers, the autoscaling resumes, but all previous metrics collected are lost.
|
||||
:::
|
||||
|
||||
## Ray Serve API Server
|
||||
|
||||
Ray Serve provides a [CLI](serve-cli) for managing your Ray Serve instance, as well as a [REST API](serve-rest-api). Each node in your Ray cluster provides a Serve REST API server that can connect to Serve and respond to Serve REST requests.
|
||||
|
||||
## FAQ
|
||||
|
||||
### How does Serve ensure horizontal scalability and availability?
|
||||
|
||||
You can configure Serve to start one proxy Actor per node with the `proxy_location` field inside [`serve.start()`](core-apis) or [the config file](serve-in-production-config-file). Each proxy binds to the same port. You should be able to reach Serve and send requests to any models with any of the servers. You can use your own load balancer on top of Ray Serve.
|
||||
|
||||
This architecture ensures horizontal scalability for Serve. You can scale your HTTP and gRPC ingress by adding more nodes. You can also scale your model inference by increasing the number of replicas via the `num_replicas` option of your deployment.
|
||||
|
||||
### How do DeploymentHandles work?
|
||||
|
||||
{mod}`DeploymentHandles <ray.serve.handle.DeploymentHandle>` wrap a handle to a "router" on the same node which routes requests to replicas for a deployment. When a request is sent from one replica to another via the handle, the requests go through the same data path as incoming HTTP or gRPC requests. This enables the same deployment selection and batching procedures to happen. DeploymentHandles are often used to implement [model composition](serve-model-composition).
|
||||
|
||||
### What happens to large requests?
|
||||
|
||||
Serve utilizes Ray’s [shared memory object store](plasma-store) and in process memory store. Small request objects are directly sent between actors via network call. Larger request objects (100KiB+) are written to the object store and the replica can read them via zero-copy read.
|
||||
@@ -0,0 +1,298 @@
|
||||
(serve-asynchronous-inference)=
|
||||
|
||||
:::{warning}
|
||||
This API is in alpha and may change before becoming stable.
|
||||
:::
|
||||
|
||||
# Asynchronous Inference
|
||||
|
||||
This guide shows how to run long-running inference asynchronously in Ray Serve using background task processing. With asynchronous tasks, your HTTP APIs stay responsive while the system performs work in the background.
|
||||
|
||||
## Why asynchronous inference?
|
||||
|
||||
Ray Serve customers need a way to handle long-running API requests asynchronously. Some inference workloads (such as video processing or large document indexing) take longer than typical HTTP timeouts, so when a user submits one of these requests the system should enqueue the work in a background queue for later processing and immediately return a quick response. This decouples request lifetime from compute time while the task executes asynchronously, while still leveraging Serve's scalability.
|
||||
|
||||
## Use cases
|
||||
|
||||
Common use cases include video inference (such as transcoding, detection, and transcription over long videos) and document indexing pipelines that ingest, parse, and vectorize large files or batches. More broadly, any long-running AI/ML workload where immediate results aren't required benefits from running asynchronously.
|
||||
|
||||
## Key concepts
|
||||
|
||||
- **@task_consumer**: A Serve deployment that consumes and executes tasks from a queue. Requires a `TaskProcessorConfig` parameter to configure the task processor; by default it uses the Celery task processor, but you can provide your own implementation.
|
||||
- **@task_handler**: A decorator applied to a method inside a `@task_consumer` class. Each handler declares the task it handles via `name=...`; if `name` is omitted, the method's function name is used as the task name. All tasks with that name in the consumer's configured queue (set via the `TaskProcessorConfig` above) are routed to this method for execution.
|
||||
|
||||
|
||||
## Components and APIs
|
||||
|
||||
The following sections describe the core APIs for asynchronous inference, with minimal examples to get you started.
|
||||
|
||||
|
||||
### `TaskProcessorConfig`
|
||||
Configures the task processor, including queue name, adapter (default is Celery), adapter config, retry limits, and dead-letter queues. The following example shows how to configure the task processor:
|
||||
|
||||
```python
|
||||
from ray.serve.schema import TaskProcessorConfig, CeleryAdapterConfig
|
||||
|
||||
processor_config = TaskProcessorConfig(
|
||||
queue_name="my_queue",
|
||||
# Optional: Override default adapter string (default is Celery)
|
||||
# adapter="ray.serve.task_processor.CeleryTaskProcessorAdapter",
|
||||
adapter_config=CeleryAdapterConfig(
|
||||
broker_url="redis://localhost:6379/0", # Or "filesystem://" for local testing
|
||||
backend_url="redis://localhost:6379/1", # Result backend (optional for fire-and-forget)
|
||||
),
|
||||
max_retries=5,
|
||||
failed_task_queue_name="failed_tasks", # Application errors after retries
|
||||
)
|
||||
```
|
||||
|
||||
:::{note}
|
||||
The filesystem broker is intended for local testing only and has limited functionality. For example, it doesn't support `cancel_tasks`. For production deployments, use a production-ready broker such as Redis or RabbitMQ. See the [Celery broker documentation](https://docs.celeryq.dev/en/stable/getting-started/backends-and-brokers/) for the full list of supported brokers.
|
||||
:::
|
||||
|
||||
### `@task_consumer`
|
||||
Decorator that turns a Serve deployment into a task consumer using the provided `TaskProcessorConfig`. The following code creates a task consumer:
|
||||
|
||||
```python
|
||||
from ray import serve
|
||||
from ray.serve.task_consumer import task_consumer
|
||||
|
||||
@serve.deployment
|
||||
@task_consumer(task_processor_config=processor_config)
|
||||
class SimpleConsumer:
|
||||
pass
|
||||
```
|
||||
|
||||
### `@task_handler`
|
||||
Decorator that registers a method on the consumer as a named task handler. The following example shows how to define a task handler:
|
||||
|
||||
```python
|
||||
from ray.serve.task_consumer import task_handler, task_consumer
|
||||
|
||||
@serve.deployment
|
||||
@task_consumer(task_processor_config=processor_config)
|
||||
class SimpleConsumer:
|
||||
@task_handler(name="process_request")
|
||||
def process_request(self, data):
|
||||
return f"processed: {data}"
|
||||
```
|
||||
|
||||
:::{note}
|
||||
Ray Serve currently supports only synchronous handlers. Declaring an `async def` handler raises `NotImplementedError`.
|
||||
:::
|
||||
|
||||
|
||||
### `instantiate_adapter_from_config`
|
||||
Factory function that returns a task processor adapter instance for the given `TaskProcessorConfig`. You can use the returned object to enqueue tasks, fetch status, retrieve metrics, and more. The following example demonstrates creating an adapter and enqueuing tasks:
|
||||
|
||||
```python
|
||||
from ray.serve.task_consumer import instantiate_adapter_from_config
|
||||
|
||||
adapter = instantiate_adapter_from_config(task_processor_config=processor_config)
|
||||
# Enqueue synchronously (returns TaskResult)
|
||||
result = adapter.enqueue_task_sync(task_name="process_request", args=["hello"])
|
||||
# Later, fetch status synchronously
|
||||
status = adapter.get_task_status_sync(result.id)
|
||||
```
|
||||
|
||||
:::{note}
|
||||
All Ray actor options specified in the `@serve.deployment` decorator (such as `num_gpus`, `num_cpus`, `resources`, etc.) are applied to the task consumer replicas. This allows you to allocate specific hardware resources for your task processing workloads.
|
||||
:::
|
||||
|
||||
|
||||
## End-to-end example: Document indexing
|
||||
|
||||
This example shows how to configure the processor, build a consumer with a handler, enqueue tasks from an ingress deployment, and check task status.
|
||||
|
||||
```python
|
||||
import io
|
||||
import logging
|
||||
import requests
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
from PyPDF2 import PdfReader
|
||||
from ray import serve
|
||||
from ray.serve.schema import CeleryAdapterConfig, TaskProcessorConfig
|
||||
from ray.serve.task_consumer import (
|
||||
instantiate_adapter_from_config,
|
||||
task_consumer,
|
||||
task_handler,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ray.serve")
|
||||
fastapi_app = FastAPI(title="Async PDF Processing API")
|
||||
|
||||
TASK_PROCESSOR_CONFIG = TaskProcessorConfig(
|
||||
queue_name="pdf_processing_queue",
|
||||
adapter_config=CeleryAdapterConfig(
|
||||
broker_url="redis://127.0.0.1:6379/0",
|
||||
backend_url="redis://127.0.0.1:6379/0",
|
||||
),
|
||||
max_retries=3,
|
||||
failed_task_queue_name="failed_pdfs",
|
||||
)
|
||||
|
||||
class ProcessPDFRequest(BaseModel):
|
||||
pdf_url: HttpUrl
|
||||
max_summary_paragraphs: int = 3
|
||||
|
||||
|
||||
@serve.deployment(num_replicas=2, max_ongoing_requests=5)
|
||||
@task_consumer(task_processor_config=TASK_PROCESSOR_CONFIG)
|
||||
class PDFProcessor:
|
||||
"""Background worker that processes PDF documents asynchronously."""
|
||||
|
||||
@task_handler(name="process_pdf")
|
||||
def process_pdf(self, pdf_url: str, max_summary_paragraphs: int = 3):
|
||||
"""Download PDF, extract text, and generate summary."""
|
||||
try:
|
||||
response = requests.get(pdf_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
pdf_reader = PdfReader(io.BytesIO(response.content))
|
||||
if not pdf_reader.pages:
|
||||
raise ValueError("PDF contains no pages")
|
||||
|
||||
full_text = "\n".join(
|
||||
page.extract_text() for page in pdf_reader.pages if page.extract_text()
|
||||
)
|
||||
if not full_text.strip():
|
||||
raise ValueError("PDF contains no extractable text")
|
||||
|
||||
paragraphs = [p.strip() for p in full_text.split("\n\n") if p.strip()]
|
||||
summary = "\n\n".join(paragraphs[:max_summary_paragraphs])
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"pdf_url": pdf_url,
|
||||
"page_count": len(pdf_reader.pages),
|
||||
"word_count": len(full_text.split()),
|
||||
"summary": summary,
|
||||
}
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise ValueError(f"Failed to download PDF: {str(e)}")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to process PDF: {str(e)}")
|
||||
|
||||
|
||||
@serve.deployment()
|
||||
@serve.ingress(fastapi_app)
|
||||
class AsyncPDFAPI:
|
||||
"""HTTP API for submitting and checking PDF processing tasks."""
|
||||
|
||||
def __init__(self, task_processor_config: TaskProcessorConfig, handler):
|
||||
self.adapter = instantiate_adapter_from_config(task_processor_config)
|
||||
|
||||
@fastapi_app.post("/process")
|
||||
def process_pdf(self, request: ProcessPDFRequest):
|
||||
"""Submit a PDF processing task and return task_id immediately."""
|
||||
task_result = self.adapter.enqueue_task_sync(
|
||||
task_name="process_pdf",
|
||||
kwargs={
|
||||
"pdf_url": str(request.pdf_url),
|
||||
"max_summary_paragraphs": request.max_summary_paragraphs,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"task_id": task_result.id,
|
||||
"status": task_result.status,
|
||||
"message": "PDF processing task submitted successfully",
|
||||
}
|
||||
|
||||
@fastapi_app.get("/status/{task_id}")
|
||||
def get_status(self, task_id: str):
|
||||
"""Get task status and results."""
|
||||
status = self.adapter.get_task_status_sync(task_id)
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": status.status,
|
||||
"result": status.result if status.status == "SUCCESS" else None,
|
||||
"error": str(status.result) if status.status == "FAILURE" else None,
|
||||
}
|
||||
|
||||
app = AsyncPDFAPI.bind(TASK_PROCESSOR_CONFIG, PDFProcessor.bind())
|
||||
```
|
||||
|
||||
In this example:
|
||||
- `DocumentIndexingConsumer` reads tasks from `document_indexing_queue` queue and processes them.
|
||||
- `API` enqueues tasks through `enqueue_task_sync` and fetches status through `get_task_status_sync`.
|
||||
- Passing `consumer` into `API.__init__` ensures both deployments are part of the Serve application graph.
|
||||
|
||||
## Concurrency and reliability
|
||||
|
||||
Manage concurrency by setting `max_ongoing_requests` on the consumer deployment; this caps how many tasks each replica can process simultaneously. For at-least-once delivery, adapters should acknowledge a task only after the handler completes successfully. Failed tasks are retried up to `max_retries`; once exhausted, they are routed to the failed-task DLQ when configured. The default Celery adapter acknowledges on success, providing at-least-once processing.
|
||||
|
||||
(serve-async-inference-autoscaling)=
|
||||
## Autoscaling
|
||||
|
||||
For workloads with variable traffic you can enable autoscaling so that replicas scale up when messages pile up in the queue and scale back down (optionally to zero) when the queue drains.
|
||||
|
||||
Ray Serve provides a built-in `AsyncInferenceAutoscalingPolicy` — a [class-based autoscaling policy](serve-custom-autoscaling-policies) that polls your message broker for queue length and scales replicas to match demand from both pending queue messages and in-flight requests.
|
||||
|
||||
### Basic example
|
||||
|
||||
::::{tab-set}
|
||||
|
||||
:::{tab-item} Python (imperative)
|
||||
|
||||
```{literalinclude} doc_code/async_inference_autoscaling.py
|
||||
:language: python
|
||||
:start-after: __basic_example_begin__
|
||||
:end-before: __basic_example_end__
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
:::{tab-item} YAML (declarative)
|
||||
|
||||
```{literalinclude} doc_code/async_inference_autoscaling.yaml
|
||||
:language: yaml
|
||||
:start-after: __basic_example_begin__
|
||||
:end-before: __basic_example_end__
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
::::
|
||||
|
||||
:::{note}
|
||||
The `broker_url` and `queue_name` in `policy_kwargs` must match the values in your `TaskProcessorConfig`. The policy reads queue length from the same broker that your task consumer reads tasks from.
|
||||
:::
|
||||
|
||||
|
||||
### Policy parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `broker_url` | `str` | *(required)* | URL of the message broker (e.g. `redis://localhost:6379/0` or `amqp://guest:guest@localhost:5672//`). |
|
||||
| `queue_name` | `str` | *(required)* | Name of the queue to monitor. Must match `TaskProcessorConfig.queue_name`. |
|
||||
| `rabbitmq_management_url` | `str` | `None` | RabbitMQ HTTP management API URL (e.g. `http://guest:guest@localhost:15672/api/`). Required only for RabbitMQ brokers. |
|
||||
| `poll_interval_s` | `float` | `10.0` | How often (seconds) to poll the broker for queue length. Lower values increase responsiveness but add broker load. |
|
||||
|
||||
All standard `AutoscalingConfig` parameters (`upscale_delay_s`, `downscale_delay_s`, `upscaling_factor`, `downscaling_factor`, etc.) apply on top of this policy. See [Advanced Ray Serve Autoscaling](serve-advanced-autoscaling) for details.
|
||||
|
||||
## Dead letter queues (DLQs)
|
||||
|
||||
Dead letter queues handle two types of problematic tasks:
|
||||
- **Unprocessable tasks**: The system routes tasks with no matching handler to `unprocessable_task_queue_name` if set.
|
||||
- **Failed tasks**: The system routes tasks that raise application exceptions after exhausting retries, have mismatched arguments, and other errors to `failed_task_queue_name` if set.
|
||||
|
||||
## Rollouts and compatibility
|
||||
|
||||
During deployment upgrades, both old and new consumer replicas may run concurrently and pull from the same queue. If task schemas or names change, either version may see incompatible tasks.
|
||||
|
||||
Recommendations:
|
||||
- **Version task names and payloads** to allow coexistence across versions.
|
||||
- **Don't remove handlers** until you drain old tasks.
|
||||
- **Monitor DLQs** for deserialization or handler resolution failures and re-enqueue or transform as needed.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Ray Serve supports only synchronous `@task_handler` methods.
|
||||
- External (non-Serve) workers are out of scope; all consumers run as Serve deployments.
|
||||
- Delivery guarantees ultimately depend on the configured broker. Results are optional when you don't configure a result backend.
|
||||
|
||||
:::{note}
|
||||
The APIs in this guide reflect the alpha interfaces in `ray.serve.schema` and `ray.serve.task_consumer`.
|
||||
:::
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
(serve-autoscaling)=
|
||||
|
||||
# Ray Serve Autoscaling
|
||||
|
||||
Each [Ray Serve deployment](serve-key-concepts-deployment) has one [replica](serve-architecture-high-level-view) by default. This means there is one worker process running the model and serving requests. When traffic to your deployment increases, the single replica can become overloaded. To maintain high performance of your service, you need to scale out your deployment.
|
||||
|
||||
## Manual Scaling
|
||||
|
||||
Before jumping into autoscaling, which is more complex, the other option to consider is manual scaling. You can increase the number of replicas by setting a higher value for [num_replicas](serve-configure-deployment) in the deployment options through [in place updates](serve-inplace-updates). By default, `num_replicas` is 1. Increasing the number of replicas will horizontally scale out your deployment and improve latency and throughput for increased levels of traffic.
|
||||
|
||||
```yaml
|
||||
# Deploy with a single replica
|
||||
deployments:
|
||||
- name: Model
|
||||
num_replicas: 1
|
||||
|
||||
# Scale up to 10 replicas
|
||||
deployments:
|
||||
- name: Model
|
||||
num_replicas: 10
|
||||
```
|
||||
|
||||
## Autoscaling Basic Configuration
|
||||
|
||||
Instead of setting a fixed number of replicas for a deployment and manually updating it, you can configure a deployment to autoscale based on incoming traffic. The Serve autoscaler reacts to traffic spikes by monitoring queue sizes and making scaling decisions to add or remove replicas. Turn on autoscaling for a deployment by setting `num_replicas="auto"`. You can further configure it by tuning the [autoscaling_config](../serve/api/doc/ray.serve.config.AutoscalingConfig.rst) in deployment options.
|
||||
|
||||
The following config is what we will use in the example in the following section.
|
||||
```yaml
|
||||
- name: Model
|
||||
num_replicas: auto
|
||||
```
|
||||
|
||||
Setting `num_replicas="auto"` is equivalent to the following deployment configuration.
|
||||
```yaml
|
||||
- name: Model
|
||||
max_ongoing_requests: 5
|
||||
autoscaling_config:
|
||||
target_ongoing_requests: 2
|
||||
min_replicas: 1
|
||||
max_replicas: 100
|
||||
```
|
||||
:::{note}
|
||||
When you set `num_replicas="auto"`, Ray Serve applies the defaults shown above, including `max_replicas: 100`. However, if you configure autoscaling manually without using `num_replicas="auto"`, the base default for `max_replicas` is 1, which means autoscaling won't occur unless you explicitly set a higher value. You can override any of these defaults by specifying `autoscaling_config` even when using `num_replicas="auto"`.
|
||||
:::
|
||||
|
||||
Let's dive into what each of these parameters do.
|
||||
|
||||
* **target_ongoing_requests** is the average number of ongoing requests per replica that the Serve autoscaler tries to ensure. You can adjust it based on your request processing length (the longer the requests, the smaller this number should be) as well as your latency objective (the shorter you want your latency to be, the smaller this number should be).
|
||||
* **max_ongoing_requests** is the maximum number of ongoing requests allowed for a replica. Note this parameter is not part of the autoscaling config because it's relevant to all deployments, but it's important to set it relative to the target value if you turn on autoscaling for your deployment.
|
||||
* **min_replicas** is the minimum number of replicas for the deployment. Set this to 0 if there are long periods of no traffic and some extra tail latency during upscale is acceptable. Otherwise, set this to what you think you need for low traffic.
|
||||
* **max_replicas** is the maximum number of replicas for the deployment. Set this to ~20% higher than what you think you need for peak traffic.
|
||||
|
||||
These guidelines are a great starting point. If you decide to further tune your autoscaling config for your application, see [Advanced Ray Serve Autoscaling](serve-advanced-autoscaling).
|
||||
|
||||
(resnet-autoscaling-example)=
|
||||
## Basic example
|
||||
|
||||
This example is a synchronous workload that runs ResNet50. The application code and its autoscaling configuration are below. Alternatively, see the second tab for specifying the autoscaling config through a YAML file.
|
||||
|
||||
::::{tab-set}
|
||||
|
||||
:::{tab-item} Application Code
|
||||
```{literalinclude} doc_code/resnet50_example.py
|
||||
:language: python
|
||||
:start-after: __serve_example_begin__
|
||||
:end-before: __serve_example_end__
|
||||
```
|
||||
:::
|
||||
|
||||
:::{tab-item} (Alternative) YAML config
|
||||
|
||||
```yaml
|
||||
applications:
|
||||
- name: default
|
||||
import_path: resnet:app
|
||||
deployments:
|
||||
- name: Model
|
||||
num_replicas: auto
|
||||
```
|
||||
|
||||
:::
|
||||
::::
|
||||
|
||||
This example uses [Locust](https://locust.io/) to run a load test against this application. The Locust load test runs a certain number of "users" that ping the ResNet50 service, where each user has a [constant wait time](https://docs.locust.io/en/stable/writing-a-locustfile.html#wait-time-attribute) of 0. Each user (repeatedly) sends a request, waits for a response, then immediately sends the next request. The number of users running over time is shown in the following graph:
|
||||
|
||||

|
||||
|
||||
The results of the load test are as follows:
|
||||
|
||||
| | | |
|
||||
| -------- | --- | ------- |
|
||||
| Replicas | <img src="https://raw.githubusercontent.com/ray-project/images/master/docs/serve/autoscaling-guide/resnet50_replicas.png" alt="replicas" width="600"/> |
|
||||
| QPS | <img src="https://raw.githubusercontent.com/ray-project/images/master/docs/serve/autoscaling-guide/resnet50_rps.png" alt="qps"/> |
|
||||
| P50 Latency | <img src="https://raw.githubusercontent.com/ray-project/images/master/docs/serve/autoscaling-guide/resnet50_latency.png" alt="latency"/> |
|
||||
|
||||
Notice the following:
|
||||
- Each Locust user constantly sends a single request and waits for a response. As a result, the number of autoscaled replicas is roughly half the number of Locust users over time as Serve attempts to satisfy the `target_ongoing_requests=2` setting.
|
||||
- The throughput of the system increases with the number of users and replicas.
|
||||
- The latency briefly spikes when traffic increases, but otherwise stays relatively steady.
|
||||
|
||||
## Ray Serve Autoscaler vs Ray Autoscaler
|
||||
|
||||
The Ray Serve Autoscaler is an application-level autoscaler that sits on top of the [Ray Autoscaler](cluster-index). Concretely, this means that the Ray Serve autoscaler asks Ray to start a number of replica actors based on the request demand. If the Ray Autoscaler determines there aren't enough available resources (e.g. CPUs, GPUs, etc.) to place these actors, it responds by requesting more Ray nodes. The underlying cloud provider then responds by adding more nodes. Similarly, when Ray Serve scales down and terminates replica Actors, it attempts to make as many nodes idle as possible so the Ray Autoscaler can remove them. To learn more about the architecture underlying Ray Serve Autoscaling, see [Ray Serve Autoscaling Architecture](serve-autoscaling-architecture).
|
||||
@@ -0,0 +1,113 @@
|
||||
(serve-configure-deployment)=
|
||||
|
||||
# Configure Ray Serve deployments
|
||||
|
||||
Ray Serve default values for deployments are a good starting point for exploration. To further tailor scaling behavior, resource management, or performance tuning, you can configure parameters to alter the default behavior of Ray Serve deployments.
|
||||
|
||||
Use this guide to learn the essentials of configuring deployments:
|
||||
- What parameters you can configure for a Ray Serve deployment
|
||||
- The different locations where you can specify the parameters.
|
||||
|
||||
## Configurable parameters
|
||||
|
||||
You can also refer to the [API reference](../serve/api/doc/ray.serve.deployment_decorator.rst) for the `@serve.deployment` decorator.
|
||||
|
||||
- `name` - Name uniquely identifying this deployment within the application. If not provided, the name of the class or function is used.
|
||||
- `num_replicas` - Controls the number of replicas to run that handle requests to this deployment. This can be a positive integer, in which case the number of replicas stays constant, or `auto`, in which case the number of replicas will autoscale with a default configuration (see [Ray Serve Autoscaling](serve-autoscaling) for more). Defaults to 1.
|
||||
- `ray_actor_options` - Options to pass to the Ray Actor decorator, such as resource requirements. Valid options are: `accelerator_type`, `memory`, `num_cpus`, `num_gpus`, `object_store_memory`, `resources`, and `runtime_env` For more details - [Resource management in Serve](serve-cpus-gpus)
|
||||
- `max_ongoing_requests` - Maximum number of queries that are sent to a replica of this deployment without receiving a response. Defaults to 5 (note the default changed from 100 to 5 in Ray 2.32.0). This may be an important parameter to configure for [performance tuning](serve-perf-tuning).
|
||||
- `autoscaling_config` - Parameters to configure autoscaling behavior. If this is set, you can't set `num_replicas` to a number. For more details on configurable parameters for autoscaling, see [Ray Serve Autoscaling](serve-autoscaling).
|
||||
- `max_queued_requests` - Maximum number of requests to this deployment that will be queued at each caller (proxy or DeploymentHandle). Once this limit is reached, subsequent requests will raise a BackPressureError (for handles) or return an HTTP 503 status code (for HTTP requests). Defaults to -1 (no limit).
|
||||
- `user_config` - Config to pass to the reconfigure method of the deployment. This can be updated dynamically without restarting the replicas of the deployment. The user_config must be fully JSON-serializable. For more details, see [Serve User Config](serve-user-config).
|
||||
- `health_check_period_s` - Duration between health check calls for the replica. Defaults to 10s. The health check is by default a no-op Actor call to the replica, but you can define your own health check using the "check_health" method in your deployment that raises an exception when unhealthy.
|
||||
- `health_check_timeout_s` - Duration in seconds, that replicas wait for a health check method to return before considering it as failed. Defaults to 30s.
|
||||
- `graceful_shutdown_wait_loop_s` - Duration that replicas wait until there is no more work to be done before shutting down. Defaults to 2s.
|
||||
- `graceful_shutdown_timeout_s` - Duration to wait for a replica to gracefully shut down before being forcefully killed. Defaults to 20s.
|
||||
- `logging_config` - Logging Config for the deployment (e.g. log level, log directory, JSON log format and so on). See [LoggingConfig](../serve/api/doc/ray.serve.schema.LoggingConfig.rst) for details.
|
||||
|
||||
## How to specify parameters
|
||||
|
||||
You can specify the above mentioned parameters in two locations:
|
||||
1. In your application code.
|
||||
2. In the Serve Config file, which is the recommended method for production.
|
||||
|
||||
### Specify parameters through the application code
|
||||
|
||||
You can specify parameters in the application code in two ways:
|
||||
- In the `@serve.deployment` decorator when you first define a deployment
|
||||
- With the `options()` method when you want to modify a deployment
|
||||
|
||||
Use the `@serve.deployment` decorator to specify deployment parameters when you are defining a deployment for the first time:
|
||||
|
||||
```{literalinclude} ../serve/doc_code/configure_serve_deployment/model_deployment.py
|
||||
:start-after: __deployment_start__
|
||||
:end-before: __deployment_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Use the [`.options()`](../serve/api/doc/ray.serve.Deployment.rst) method to modify deployment parameters on an already-defined deployment. Modifying an existing deployment lets you reuse deployment definitions and dynamically set parameters at runtime.
|
||||
|
||||
```{literalinclude} ../serve/doc_code/configure_serve_deployment/model_deployment.py
|
||||
:start-after: __deployment_end__
|
||||
:end-before: __options_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
### Specify parameters through the Serve config file
|
||||
|
||||
In production, we recommend configuring individual deployments through the Serve config file. You can change parameter values without modifying your application code. Learn more about how to use the Serve Config in the [production guide](serve-in-production-config-file).
|
||||
|
||||
```yaml
|
||||
applications:
|
||||
- name: app1
|
||||
import_path: configure_serve:translator_app
|
||||
deployments:
|
||||
- name: Translator
|
||||
num_replicas: 2
|
||||
max_ongoing_requests: 100
|
||||
graceful_shutdown_wait_loop_s: 2.0
|
||||
graceful_shutdown_timeout_s: 20.0
|
||||
health_check_period_s: 10.0
|
||||
health_check_timeout_s: 30.0
|
||||
ray_actor_options:
|
||||
num_cpus: 0.2
|
||||
num_gpus: 0.0
|
||||
```
|
||||
|
||||
### Order of Priority
|
||||
|
||||
You can set parameters to different values in various locations. For each individual parameter, the order of priority is (from highest to lowest):
|
||||
|
||||
1. Serve Config file
|
||||
2. Application code (either through the `@serve.deployment` decorator or through `.options()`)
|
||||
3. Serve defaults
|
||||
|
||||
In other words, if you specify a parameter for a deployment in the config file and the application code, Serve uses the config file's value. If it's only specified in the code, Serve uses the value you specified in the code. If you don't specify the parameter anywhere, Serve uses the default for that parameter.
|
||||
|
||||
For example, the following application code contains a single deployment `ExampleDeployment`:
|
||||
|
||||
```python
|
||||
@serve.deployment(num_replicas=2, graceful_shutdown_timeout_s=6)
|
||||
class ExampleDeployment:
|
||||
...
|
||||
|
||||
example_app = ExampleDeployment.bind()
|
||||
```
|
||||
|
||||
Then you deploy the application with the following config file:
|
||||
|
||||
```yaml
|
||||
applications:
|
||||
- name: default
|
||||
import_path: models:example_app
|
||||
deployments:
|
||||
- name: ExampleDeployment
|
||||
num_replicas: 5
|
||||
```
|
||||
|
||||
Serve uses `num_replicas=5` from the value set in the config file and `graceful_shutdown_timeout_s=6` from the value set in the application code. All other deployment settings use Serve defaults because you didn't specify them in the code or the config. For instance, `health_check_period_s=10` because by default Serve health checks deployments once every 10 seconds.
|
||||
|
||||
:::{tip}
|
||||
Remember that `ray_actor_options` counts as a single setting. The entire `ray_actor_options` dictionary in the config file overrides the entire `ray_actor_options` dictionary from the graph code. If you set individual options within `ray_actor_options` (e.g. `runtime_env`, `num_gpus`, `memory`) in the code but not in the config, Serve still won't use the code settings if the config has a `ray_actor_options` dictionary. It treats these missing options as though the user never set them and uses defaults instead. This dictionary overriding behavior also applies to `user_config` and `autoscaling_config`.
|
||||
:::
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
(serve-develop-and-deploy)=
|
||||
|
||||
# Develop and Deploy an ML Application
|
||||
|
||||
The flow for developing a Ray Serve application locally and deploying it in production covers the following steps:
|
||||
|
||||
* Converting a Machine Learning model into a Ray Serve application
|
||||
* Testing the application locally
|
||||
* Building Serve config files for production deployment
|
||||
* Deploying applications using a config file
|
||||
|
||||
## Convert a model into a Ray Serve application
|
||||
|
||||
This example uses a text-translation model:
|
||||
|
||||
```{literalinclude} ../serve/doc_code/getting_started/models.py
|
||||
:start-after: __start_translation_model__
|
||||
:end-before: __end_translation_model__
|
||||
:language: python
|
||||
```
|
||||
|
||||
The Python file, called `model.py`, uses the `Translator` class to translate English text to French.
|
||||
|
||||
- The `Translator`'s `__init__` method loads the [t5-small](https://huggingface.co/t5-small) tokenizer and model. `t5-small` is a text-to-text model that performs a task when its input is prefixed with a description of that task.
|
||||
- The `translate` method prefixes the input with `"translate English to French: "`, calls `model.generate()` to produce the output tokens, and decodes them back into a translated string.
|
||||
|
||||
Copy and paste the script and run it locally. It translates `"Hello world!"` into `"Bonjour Monde!"`.
|
||||
|
||||
```console
|
||||
$ python model.py
|
||||
|
||||
Bonjour Monde!
|
||||
```
|
||||
|
||||
Converting this model into a Ray Serve application with FastAPI requires three changes:
|
||||
1. Import Ray Serve and FastAPI dependencies
|
||||
2. Add decorators for Serve deployment with FastAPI: `@serve.deployment` and `@serve.ingress(app)`
|
||||
3. `bind` the `Translator` deployment to the arguments that are passed into its constructor
|
||||
|
||||
For other HTTP options, see [Set Up FastAPI and HTTP](serve-set-up-fastapi-http).
|
||||
|
||||
```{literalinclude} ../serve/doc_code/develop_and_deploy.py
|
||||
:start-after: __deployment_start__
|
||||
:end-before: __deployment_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
Note that the code configures parameters for the deployment, such as `num_replicas` and `ray_actor_options`. These parameters help configure the number of copies of the deployment and the resource requirements for each copy. In this case, we set up 2 replicas of the model that take 0.2 CPUs and 0 GPUs each. For a complete guide on the configurable parameters on a deployment, see [Configure a Serve deployment](serve-configure-deployment).
|
||||
|
||||
## Test a Ray Serve application locally
|
||||
|
||||
To test locally, run the script with the `serve run` CLI command. This command takes in an import path formatted as `module:application`. Run the command from a directory containing a local copy of the script saved as `model.py`, so it can import the application:
|
||||
|
||||
```console
|
||||
$ serve run model:translator_app
|
||||
```
|
||||
|
||||
This command runs the `translator_app` application and then blocks, streaming logs to the console. You can kill it with `Ctrl-C`, which tears down the application.
|
||||
|
||||
Now test the model over HTTP. Reach it at the following default URL:
|
||||
|
||||
```
|
||||
http://127.0.0.1:8000/
|
||||
```
|
||||
|
||||
Send a POST request with JSON data containing the English text. This client script requests a translation for "Hello world!":
|
||||
|
||||
```{literalinclude} ../serve/doc_code/develop_and_deploy.py
|
||||
:start-after: __client_function_start__
|
||||
:end-before: __client_function_end__
|
||||
:language: python
|
||||
```
|
||||
|
||||
While a Ray Serve application is deployed, use the `serve status` CLI command to check the status of the application and deployment. For more details on the output format of `serve status`, see [Inspect Serve in production](serve-in-production-inspecting).
|
||||
|
||||
```console
|
||||
$ serve status
|
||||
proxies:
|
||||
a85af35da5fcea04e13375bdc7d2c83c7d3915e290f1b25643c55f3a: HEALTHY
|
||||
applications:
|
||||
default:
|
||||
status: RUNNING
|
||||
message: ''
|
||||
last_deployed_time_s: 1693428451.894696
|
||||
deployments:
|
||||
Translator:
|
||||
status: HEALTHY
|
||||
replica_states:
|
||||
RUNNING: 2
|
||||
message: ''
|
||||
```
|
||||
|
||||
## Build Serve config files for production deployment
|
||||
|
||||
To deploy Serve applications in production, you need to generate a Serve config YAML file. A Serve config file is the single source of truth for the cluster, allowing you to specify system-level configuration and your applications in one place. It also allows you to declaratively update your applications. The `serve build` CLI command takes as input the import path and saves to an output file using the `-o` flag. You can specify all deployment parameters in the Serve config files.
|
||||
|
||||
```console
|
||||
$ serve build model:translator_app -o config.yaml
|
||||
```
|
||||
|
||||
The `serve build` command adds a default application name that can be modified. The resulting Serve config file is:
|
||||
|
||||
```
|
||||
proxy_location: EveryNode
|
||||
|
||||
http_options:
|
||||
host: 0.0.0.0
|
||||
port: 8000
|
||||
|
||||
grpc_options:
|
||||
port: 9000
|
||||
grpc_servicer_functions: []
|
||||
|
||||
applications:
|
||||
|
||||
- name: app1
|
||||
route_prefix: /
|
||||
import_path: model:translator_app
|
||||
runtime_env: {}
|
||||
deployments:
|
||||
- name: Translator
|
||||
num_replicas: 2
|
||||
ray_actor_options:
|
||||
num_cpus: 0.2
|
||||
num_gpus: 0.0
|
||||
```
|
||||
|
||||
You can also use the Serve config file with `serve run` for local testing. For example:
|
||||
|
||||
```console
|
||||
$ serve run config.yaml
|
||||
```
|
||||
|
||||
```console
|
||||
$ serve status
|
||||
proxies:
|
||||
1894261b372d34854163ac5ec88405328302eb4e46ac3a2bdcaf8d18: HEALTHY
|
||||
applications:
|
||||
app1:
|
||||
status: RUNNING
|
||||
message: ''
|
||||
last_deployed_time_s: 1693430474.873806
|
||||
deployments:
|
||||
Translator:
|
||||
status: HEALTHY
|
||||
replica_states:
|
||||
RUNNING: 2
|
||||
message: ''
|
||||
```
|
||||
|
||||
For more details, see [Serve Config Files](serve-in-production-config-file).
|
||||
|
||||
## Deploy Ray Serve in production
|
||||
|
||||
Deploy the Ray Serve application in production on Kubernetes using the [KubeRay] operator. Copy the YAML file generated in the previous step directly into the Kubernetes configuration. KubeRay supports zero-downtime upgrades, status reporting, and fault tolerance for your production application. See [Deploying on Kubernetes](serve-in-production-kubernetes) for more information. For production usage, consider implementing the recommended practice of setting up [head node fault tolerance](serve-e2e-ft-guide-gcs).
|
||||
|
||||
## Monitor Ray Serve
|
||||
|
||||
Use the Ray Dashboard to get a high-level overview of your Ray Cluster and Ray Serve application's states. The Ray Dashboard is available both during local testing and on a remote cluster in production. Ray Serve provides some in-built metrics and logging as well as utilities for adding custom metrics and logs in your application. For production deployments, exporting logs and metrics to your observability platforms is recommended. See [Monitoring](serve-monitoring) for more details.
|
||||
|
||||
[KubeRay]: kuberay-index
|
||||
@@ -0,0 +1,85 @@
|
||||
# flake8: noqa
|
||||
|
||||
# __begin_untyped_builder__
|
||||
# hello.py
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from ray import serve
|
||||
from ray.serve import Application
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class HelloWorld:
|
||||
def __init__(self, message: str):
|
||||
self._message = message
|
||||
print("Message:", self._message)
|
||||
|
||||
def __call__(self, request):
|
||||
return self._message
|
||||
|
||||
|
||||
def app_builder(args: Dict[str, str]) -> Application:
|
||||
return HelloWorld.bind(args["message"])
|
||||
|
||||
|
||||
# __end_untyped_builder__
|
||||
|
||||
import requests
|
||||
|
||||
serve.run(app_builder({"message": "Hello bar"}))
|
||||
resp = requests.get("http://localhost:8000")
|
||||
assert resp.text == "Hello bar"
|
||||
|
||||
# __begin_typed_builder__
|
||||
# hello.py
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ray import serve
|
||||
from ray.serve import Application
|
||||
|
||||
|
||||
class HelloWorldArgs(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class HelloWorld:
|
||||
def __init__(self, message: str):
|
||||
self._message = message
|
||||
print("Message:", self._message)
|
||||
|
||||
def __call__(self, request):
|
||||
return self._message
|
||||
|
||||
|
||||
def typed_app_builder(args: HelloWorldArgs) -> Application:
|
||||
return HelloWorld.bind(args.message)
|
||||
|
||||
|
||||
# __end_typed_builder__
|
||||
|
||||
serve.run(typed_app_builder(HelloWorldArgs(message="Hello baz")))
|
||||
resp = requests.get("http://localhost:8000")
|
||||
assert resp.text == "Hello baz"
|
||||
|
||||
# __begin_composed_builder__
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ray.serve import Application
|
||||
|
||||
|
||||
class ComposedArgs(BaseModel):
|
||||
model1_uri: str
|
||||
model2_uri: str
|
||||
|
||||
|
||||
def composed_app_builder(args: ComposedArgs) -> Application:
|
||||
return IngressDeployment.bind(
|
||||
Model1.bind(args.model1_uri),
|
||||
Model2.bind(args.model2_uri),
|
||||
)
|
||||
|
||||
|
||||
# __end_composed_builder__
|
||||
@@ -0,0 +1,36 @@
|
||||
# __serve_example_begin__
|
||||
import time
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Preprocessor:
|
||||
def __call__(self, input_data: str) -> str:
|
||||
# Simulate preprocessing work
|
||||
time.sleep(0.05)
|
||||
return f"preprocessed_{input_data}"
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Model:
|
||||
def __call__(self, preprocessed_data: str) -> str:
|
||||
# Simulate model inference (takes longer than preprocessing)
|
||||
time.sleep(0.1)
|
||||
return f"result_{preprocessed_data}"
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Driver:
|
||||
def __init__(self, preprocessor, model):
|
||||
self._preprocessor = preprocessor
|
||||
self._model = model
|
||||
|
||||
async def __call__(self, input_data: str) -> str:
|
||||
# Coordinate preprocessing and model inference
|
||||
preprocessed = await self._preprocessor.remote(input_data)
|
||||
result = await self._model.remote(preprocessed)
|
||||
return result
|
||||
|
||||
|
||||
app = Driver.bind(Preprocessor.bind(), Model.bind())
|
||||
# __serve_example_end__
|
||||
@@ -0,0 +1,14 @@
|
||||
applications:
|
||||
- name: MyApp
|
||||
import_path: application_level_autoscaling:app
|
||||
autoscaling_policy:
|
||||
policy_function: autoscaling_policy:coordinated_scaling_policy
|
||||
deployments:
|
||||
- name: Preprocessor
|
||||
autoscaling_config:
|
||||
min_replicas: 1
|
||||
max_replicas: 10
|
||||
- name: Model
|
||||
autoscaling_config:
|
||||
min_replicas: 2
|
||||
max_replicas: 20
|
||||
@@ -0,0 +1,20 @@
|
||||
applications:
|
||||
- name: MyApp
|
||||
import_path: application_level_autoscaling:app
|
||||
autoscaling_policy:
|
||||
policy_function: autoscaling_policy:coordinated_scaling_policy_with_defaults
|
||||
deployments:
|
||||
- name: Preprocessor
|
||||
autoscaling_config:
|
||||
min_replicas: 1
|
||||
max_replicas: 10
|
||||
target_ongoing_requests: 1
|
||||
upscale_delay_s: 2
|
||||
downscale_delay_s: 5
|
||||
- name: Model
|
||||
autoscaling_config:
|
||||
min_replicas: 2
|
||||
max_replicas: 20
|
||||
target_ongoing_requests: 1
|
||||
upscale_delay_s: 3
|
||||
downscale_delay_s: 5
|
||||
@@ -0,0 +1,40 @@
|
||||
# __basic_example_begin__
|
||||
from ray import serve
|
||||
from ray.serve.config import AutoscalingConfig, AutoscalingPolicy
|
||||
from ray.serve.schema import CeleryAdapterConfig, TaskProcessorConfig
|
||||
from ray.serve.task_consumer import task_consumer, task_handler
|
||||
|
||||
processor_config = TaskProcessorConfig(
|
||||
queue_name="my_queue",
|
||||
adapter_config=CeleryAdapterConfig(
|
||||
broker_url="redis://localhost:6379/0",
|
||||
backend_url="redis://localhost:6379/1",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
max_ongoing_requests=5,
|
||||
autoscaling_config=AutoscalingConfig(
|
||||
min_replicas=1,
|
||||
max_replicas=10,
|
||||
target_ongoing_requests=2,
|
||||
policy=AutoscalingPolicy(
|
||||
policy_function="ray.serve.async_inference_autoscaling_policy:AsyncInferenceAutoscalingPolicy",
|
||||
policy_kwargs={
|
||||
"broker_url": "redis://localhost:6379/0",
|
||||
"queue_name": "my_queue",
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
@task_consumer(task_processor_config=processor_config)
|
||||
class MyConsumer:
|
||||
@task_handler(name="process")
|
||||
def process(self, data):
|
||||
return f"processed: {data}"
|
||||
|
||||
|
||||
app = MyConsumer.bind()
|
||||
serve.run(app)
|
||||
# __basic_example_end__
|
||||
@@ -0,0 +1,17 @@
|
||||
# __basic_example_begin__
|
||||
applications:
|
||||
- name: default
|
||||
import_path: my_module:app
|
||||
deployments:
|
||||
- name: MyConsumer
|
||||
max_ongoing_requests: 5
|
||||
autoscaling_config:
|
||||
min_replicas: 1
|
||||
max_replicas: 10
|
||||
target_ongoing_requests: 2
|
||||
policy:
|
||||
policy_function: "ray.serve.async_inference_autoscaling_policy:AsyncInferenceAutoscalingPolicy"
|
||||
policy_kwargs:
|
||||
broker_url: "redis://localhost:6379/0"
|
||||
queue_name: "my_queue"
|
||||
# __basic_example_end__
|
||||
@@ -0,0 +1,435 @@
|
||||
# flake8: noqa
|
||||
"""
|
||||
Code examples for the asyncio best practices guide.
|
||||
All examples are structured to be runnable and demonstrate key concepts.
|
||||
"""
|
||||
|
||||
# __imports_begin__
|
||||
from ray import serve
|
||||
import asyncio
|
||||
# __imports_end__
|
||||
|
||||
|
||||
# __echo_async_begin__
|
||||
@serve.deployment
|
||||
class Echo:
|
||||
async def __call__(self, request):
|
||||
await asyncio.sleep(0.1)
|
||||
return "ok"
|
||||
# __echo_async_end__
|
||||
|
||||
|
||||
# __blocking_echo_begin__
|
||||
@serve.deployment
|
||||
class BlockingEcho:
|
||||
def __call__(self, request):
|
||||
# Blocking.
|
||||
import time
|
||||
time.sleep(1)
|
||||
return "ok"
|
||||
# __blocking_echo_end__
|
||||
|
||||
|
||||
# __fastapi_deployment_begin__
|
||||
from fastapi import FastAPI
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@serve.deployment
|
||||
@serve.ingress(app)
|
||||
class FastAPIDeployment:
|
||||
@app.get("/sync")
|
||||
def sync_endpoint(self):
|
||||
# FastAPI runs this in a threadpool.
|
||||
import time
|
||||
time.sleep(1)
|
||||
return "ok"
|
||||
|
||||
@app.get("/async")
|
||||
async def async_endpoint(self):
|
||||
# Runs directly on FastAPI's asyncio loop.
|
||||
await asyncio.sleep(1)
|
||||
return "ok"
|
||||
# __fastapi_deployment_end__
|
||||
|
||||
|
||||
# __blocking_http_begin__
|
||||
@serve.deployment
|
||||
class BlockingHTTP:
|
||||
async def __call__(self, request):
|
||||
# ❌ This blocks the event loop until the HTTP call finishes.
|
||||
import requests
|
||||
resp = requests.get("https://example.com/")
|
||||
return resp.text
|
||||
# __blocking_http_end__
|
||||
|
||||
|
||||
# __async_http_begin__
|
||||
@serve.deployment
|
||||
class AsyncHTTP:
|
||||
async def __call__(self, request):
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get("https://example.com/")
|
||||
return resp.text
|
||||
# __async_http_end__
|
||||
|
||||
|
||||
# __threaded_http_begin__
|
||||
@serve.deployment
|
||||
class ThreadedHTTP:
|
||||
async def __call__(self, request):
|
||||
import requests
|
||||
|
||||
def fetch():
|
||||
return requests.get("https://example.com/").text
|
||||
|
||||
# ✅ Offload blocking I/O to a worker thread.
|
||||
return await asyncio.to_thread(fetch)
|
||||
# __threaded_http_end__
|
||||
|
||||
# __threadpool_override_begin__
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
@serve.deployment
|
||||
class CustomThreadPool:
|
||||
def __init__(self):
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.set_default_executor(ThreadPoolExecutor(max_workers=16))
|
||||
|
||||
async def __call__(self, request):
|
||||
return await asyncio.to_thread(lambda: "ok")
|
||||
# __threadpool_override_end__
|
||||
|
||||
|
||||
# __numpy_deployment_begin__
|
||||
@serve.deployment
|
||||
class NumpyDeployment:
|
||||
def _heavy_numpy(self, array):
|
||||
import numpy as np
|
||||
# Many NumPy ops release the GIL while executing C/Fortran code.
|
||||
return np.linalg.svd(array)[0]
|
||||
|
||||
async def __call__(self, request):
|
||||
import numpy as np
|
||||
# Create a sample array from request data
|
||||
array = np.random.rand(100, 100)
|
||||
# ✅ Multiple threads can run _heavy_numpy in parallel if
|
||||
# the underlying implementation releases the GIL.
|
||||
return await asyncio.to_thread(self._heavy_numpy, array)
|
||||
# __numpy_deployment_end__
|
||||
|
||||
|
||||
# __max_ongoing_requests_begin__
|
||||
@serve.deployment(max_ongoing_requests=32)
|
||||
class MyService:
|
||||
async def __call__(self, request):
|
||||
await asyncio.sleep(1)
|
||||
return "ok"
|
||||
# __max_ongoing_requests_end__
|
||||
|
||||
|
||||
# __async_io_bound_begin__
|
||||
@serve.deployment(max_ongoing_requests=100)
|
||||
class AsyncIOBound:
|
||||
async def __call__(self, request):
|
||||
# Mostly waiting on an external system.
|
||||
await asyncio.sleep(0.1)
|
||||
return "ok"
|
||||
# __async_io_bound_end__
|
||||
|
||||
|
||||
# __blocking_cpu_begin__
|
||||
@serve.deployment(max_ongoing_requests=100)
|
||||
class BlockingCPU:
|
||||
def __call__(self, request):
|
||||
# ❌ Blocks the user event loop.
|
||||
import time
|
||||
time.sleep(1)
|
||||
return "ok"
|
||||
# __blocking_cpu_end__
|
||||
|
||||
|
||||
# __cpu_with_threadpool_begin__
|
||||
@serve.deployment(max_ongoing_requests=100)
|
||||
class CPUWithThreadpool:
|
||||
def __call__(self, request):
|
||||
# With RAY_SERVE_RUN_SYNC_IN_THREADPOOL=1, each call runs in a thread.
|
||||
import time
|
||||
time.sleep(1)
|
||||
return "ok"
|
||||
# __cpu_with_threadpool_end__
|
||||
|
||||
|
||||
# __batched_model_begin__
|
||||
|
||||
@serve.deployment(max_ongoing_requests=64)
|
||||
class BatchedModel:
|
||||
@serve.batch(max_batch_size=32)
|
||||
async def __call__(self, requests):
|
||||
# requests is a list of request objects.
|
||||
inputs = [r for r in requests]
|
||||
outputs = await self._run_model(inputs)
|
||||
return outputs
|
||||
|
||||
async def _run_model(self, inputs):
|
||||
# Placeholder model function
|
||||
return [f"result_{i}" for i in inputs]
|
||||
# __batched_model_end__
|
||||
|
||||
|
||||
# __batched_model_offload_begin__
|
||||
@serve.deployment(max_ongoing_requests=64)
|
||||
class BatchedModelOffload:
|
||||
@serve.batch(max_batch_size=32)
|
||||
async def __call__(self, requests):
|
||||
# requests is a list of request objects.
|
||||
inputs = [r for r in requests]
|
||||
outputs = await self._run_model(inputs)
|
||||
return outputs
|
||||
|
||||
async def _run_model(self, inputs):
|
||||
def run_sync():
|
||||
# Heavy CPU or GIL-releasing native code here.
|
||||
# Placeholder model function
|
||||
return [f"result_{i}" for i in inputs]
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, run_sync)
|
||||
# __batched_model_offload_end__
|
||||
|
||||
|
||||
# __blocking_stream_begin__
|
||||
@serve.deployment
|
||||
class BlockingStream:
|
||||
def __call__(self, request):
|
||||
# ❌ Blocks the event loop between yields.
|
||||
import time
|
||||
for i in range(10):
|
||||
time.sleep(1)
|
||||
yield f"{i}\n"
|
||||
# __blocking_stream_end__
|
||||
|
||||
|
||||
# __async_stream_begin__
|
||||
@serve.deployment
|
||||
class AsyncStream:
|
||||
async def __call__(self, request):
|
||||
# ✅ Yields items without blocking the loop.
|
||||
async def generator():
|
||||
for i in range(10):
|
||||
await asyncio.sleep(1)
|
||||
yield f"{i}\n"
|
||||
|
||||
return generator()
|
||||
# __async_stream_end__
|
||||
|
||||
|
||||
# __offload_io_begin__
|
||||
@serve.deployment
|
||||
class OffloadIO:
|
||||
async def __call__(self, request):
|
||||
import requests
|
||||
|
||||
def fetch():
|
||||
return requests.get("https://example.com/").text
|
||||
|
||||
# Offload to a thread, free the event loop.
|
||||
body = await asyncio.to_thread(fetch)
|
||||
return body
|
||||
# __offload_io_end__
|
||||
|
||||
|
||||
# __offload_cpu_begin__
|
||||
@serve.deployment
|
||||
class OffloadCPU:
|
||||
def _compute(self, x):
|
||||
# CPU-intensive work.
|
||||
total = 0
|
||||
for i in range(10_000_000):
|
||||
total += (i * x) % 7
|
||||
return total
|
||||
|
||||
async def __call__(self, request):
|
||||
x = 123
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(None, self._compute, x)
|
||||
return str(result)
|
||||
# __offload_cpu_end__
|
||||
|
||||
|
||||
# __ray_parallel_begin__
|
||||
import ray
|
||||
|
||||
@ray.remote
|
||||
def heavy_task(x):
|
||||
# Heavy compute runs in its own worker process.
|
||||
return x * x
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class RayParallel:
|
||||
async def __call__(self, request):
|
||||
values = [1, 2, 3, 4]
|
||||
refs = [heavy_task.remote(v) for v in values]
|
||||
results = await asyncio.gather(*[r for r in refs])
|
||||
return {"results": results}
|
||||
# __ray_parallel_end__
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import ray
|
||||
|
||||
# Initialize Ray if not already running
|
||||
if not ray.is_initialized():
|
||||
ray.init()
|
||||
|
||||
print("Testing Echo deployment...")
|
||||
# Test Echo
|
||||
echo_handle = serve.run(Echo.bind())
|
||||
result = echo_handle.remote(None).result()
|
||||
print(f"Echo result: {result}")
|
||||
assert result == "ok"
|
||||
|
||||
print("\nTesting BlockingEcho deployment...")
|
||||
# Test BlockingEcho
|
||||
blocking_handle = serve.run(BlockingEcho.bind())
|
||||
result = blocking_handle.remote(None).result()
|
||||
print(f"BlockingEcho result: {result}")
|
||||
assert result == "ok"
|
||||
|
||||
print("\nTesting MyService deployment...")
|
||||
# Test MyService
|
||||
service_handle = serve.run(MyService.bind())
|
||||
result = service_handle.remote(None).result()
|
||||
print(f"MyService result: {result}")
|
||||
assert result == "ok"
|
||||
|
||||
print("\nTesting AsyncIOBound deployment...")
|
||||
# Test AsyncIOBound
|
||||
io_bound_handle = serve.run(AsyncIOBound.bind())
|
||||
result = io_bound_handle.remote(None).result()
|
||||
print(f"AsyncIOBound result: {result}")
|
||||
assert result == "ok"
|
||||
|
||||
print("\nTesting AsyncStream deployment...")
|
||||
# Test AsyncStream (just create it, don't fully consume)
|
||||
stream_handle = serve.run(AsyncStream.bind())
|
||||
print("AsyncStream deployment created successfully")
|
||||
|
||||
print("\nTesting OffloadCPU deployment...")
|
||||
# Test OffloadCPU
|
||||
cpu_handle = serve.run(OffloadCPU.bind())
|
||||
result = cpu_handle.remote(None).result()
|
||||
print(f"OffloadCPU result: {result}")
|
||||
|
||||
print("\nTesting NumpyDeployment...")
|
||||
# Test NumpyDeployment
|
||||
numpy_handle = serve.run(NumpyDeployment.bind())
|
||||
result = numpy_handle.remote(None).result()
|
||||
print(f"NumpyDeployment result shape: {result.shape}")
|
||||
assert result.shape == (100, 100)
|
||||
|
||||
print("\nTesting BlockingCPU deployment...")
|
||||
# Test BlockingCPU
|
||||
blocking_cpu_handle = serve.run(BlockingCPU.bind())
|
||||
result = blocking_cpu_handle.remote(None).result()
|
||||
print(f"BlockingCPU result: {result}")
|
||||
assert result == "ok"
|
||||
|
||||
print("\nTesting CPUWithThreadpool deployment...")
|
||||
# Test CPUWithThreadpool
|
||||
cpu_threadpool_handle = serve.run(CPUWithThreadpool.bind())
|
||||
result = cpu_threadpool_handle.remote(None).result()
|
||||
print(f"CPUWithThreadpool result: {result}")
|
||||
assert result == "ok"
|
||||
|
||||
print("\nTesting CustomThreadPool deployment...")
|
||||
custom_threadpool_handle = serve.run(CustomThreadPool.bind())
|
||||
result = custom_threadpool_handle.remote(None).result()
|
||||
print(f"CustomThreadPool result: {result}")
|
||||
assert result == "ok"
|
||||
|
||||
print("\nTesting BlockingStream deployment...")
|
||||
# Test BlockingStream - just verify it can be created and called
|
||||
blocking_stream_handle = serve.run(BlockingStream.bind())
|
||||
# For generator responses, we need to handle them differently
|
||||
# Just verify deployment works
|
||||
print("BlockingStream deployment created successfully")
|
||||
|
||||
print("\nTesting RayParallel deployment...")
|
||||
# Test RayParallel
|
||||
ray_parallel_handle = serve.run(RayParallel.bind())
|
||||
result = ray_parallel_handle.remote(None).result()
|
||||
print(f"RayParallel result: {result}")
|
||||
assert result == {"results": [1, 4, 9, 16]}
|
||||
|
||||
print("\nTesting BatchedModel deployment...")
|
||||
# Test BatchedModel
|
||||
batched_model_handle = serve.run(BatchedModel.bind())
|
||||
result = batched_model_handle.remote(1).result()
|
||||
print(f"BatchedModel result: {result}")
|
||||
assert result == "result_1"
|
||||
|
||||
print("\nTesting BatchedModelOffload deployment...")
|
||||
# Test BatchedModelOffload
|
||||
batched_model_offload_handle = serve.run(BatchedModelOffload.bind())
|
||||
result = batched_model_offload_handle.remote(1).result()
|
||||
print(f"BatchedModelOffload result: {result}")
|
||||
assert result == "result_1"
|
||||
|
||||
# Test HTTP-related deployments with try-except
|
||||
print("\n--- Testing HTTP-related deployments (may fail due to network) ---")
|
||||
|
||||
print("\nTesting BlockingHTTP deployment...")
|
||||
try:
|
||||
blocking_http_handle = serve.run(BlockingHTTP.bind())
|
||||
result = blocking_http_handle.remote(None).result()
|
||||
print(f"BlockingHTTP result (first 50 chars): {result[:50]}...")
|
||||
print("✅ BlockingHTTP test passed")
|
||||
except Exception as e:
|
||||
print(f"⚠️ BlockingHTTP test failed (expected): {type(e).__name__}: {e}")
|
||||
|
||||
print("\nTesting AsyncHTTP deployment...")
|
||||
try:
|
||||
async_http_handle = serve.run(AsyncHTTP.bind())
|
||||
result = async_http_handle.remote(None).result()
|
||||
print(f"AsyncHTTP result (first 50 chars): {result[:50]}...")
|
||||
print("✅ AsyncHTTP test passed")
|
||||
except Exception as e:
|
||||
print(f"⚠️ AsyncHTTP test failed (expected): {type(e).__name__}: {e}")
|
||||
|
||||
print("\nTesting ThreadedHTTP deployment...")
|
||||
try:
|
||||
threaded_http_handle = serve.run(ThreadedHTTP.bind())
|
||||
result = threaded_http_handle.remote(None).result()
|
||||
print(f"ThreadedHTTP result (first 50 chars): {result[:50]}...")
|
||||
print("✅ ThreadedHTTP test passed")
|
||||
except Exception as e:
|
||||
print(f"⚠️ ThreadedHTTP test failed (expected): {type(e).__name__}: {e}")
|
||||
|
||||
print("\nTesting OffloadIO deployment...")
|
||||
try:
|
||||
offload_io_handle = serve.run(OffloadIO.bind())
|
||||
result = offload_io_handle.remote(None).result()
|
||||
print(f"OffloadIO result (first 50 chars): {result[:50]}...")
|
||||
print("✅ OffloadIO test passed")
|
||||
except Exception as e:
|
||||
print(f"⚠️ OffloadIO test failed (expected): {type(e).__name__}: {e}")
|
||||
|
||||
print("\nTesting FastAPIDeployment...")
|
||||
fastapi_handle = serve.run(FastAPIDeployment.bind())
|
||||
# Give it a moment to start
|
||||
import time
|
||||
import requests
|
||||
time.sleep(2)
|
||||
# Test the sync endpoint
|
||||
response = requests.get("http://127.0.0.1:8000/sync", timeout=5)
|
||||
print(f"FastAPIDeployment /sync result: {response.json()}")
|
||||
# Test the async endpoint
|
||||
response = requests.get("http://127.0.0.1:8000/async", timeout=5)
|
||||
print(f"FastAPIDeployment /async result: {response.json()}")
|
||||
print("✅ FastAPIDeployment test passed")
|
||||
|
||||
print("\n✅ All core tests passed!")
|
||||
@@ -0,0 +1,48 @@
|
||||
# __serve_example_begin__
|
||||
import time
|
||||
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class LightLoad:
|
||||
async def __call__(self) -> str:
|
||||
start = time.time()
|
||||
while time.time() - start < 0.1:
|
||||
pass
|
||||
|
||||
return "light"
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class HeavyLoad:
|
||||
async def __call__(self) -> str:
|
||||
start = time.time()
|
||||
while time.time() - start < 0.2:
|
||||
pass
|
||||
|
||||
return "heavy"
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Driver:
|
||||
def __init__(self, a_handle, b_handle):
|
||||
self.a_handle: DeploymentHandle = a_handle
|
||||
self.b_handle: DeploymentHandle = b_handle
|
||||
|
||||
async def __call__(self) -> str:
|
||||
a_future = self.a_handle.remote()
|
||||
b_future = self.b_handle.remote()
|
||||
|
||||
return (await a_future), (await b_future)
|
||||
|
||||
|
||||
app = Driver.bind(HeavyLoad.bind(), LightLoad.bind())
|
||||
# __serve_example_end__
|
||||
|
||||
import requests # noqa
|
||||
|
||||
serve.run(app)
|
||||
resp = requests.post("http://localhost:8000")
|
||||
assert resp.json() == ["heavy", "light"]
|
||||
@@ -0,0 +1,178 @@
|
||||
# __begin_scheduled_batch_processing_policy__
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict
|
||||
from ray.serve.config import AutoscalingContext
|
||||
|
||||
|
||||
def scheduled_batch_processing_policy(
|
||||
ctx: AutoscalingContext,
|
||||
) -> tuple[int, Dict[str, Any]]:
|
||||
current_time = datetime.now()
|
||||
current_hour = current_time.hour
|
||||
# Scale up during business hours (9 AM - 5 PM)
|
||||
if 9 <= current_hour < 17:
|
||||
return 2, {"reason": "Business hours"}
|
||||
# Scale up for evening batch processing (6 PM - 8 PM)
|
||||
elif 18 <= current_hour < 20:
|
||||
return 4, {"reason": "Evening batch processing"}
|
||||
# Minimal scaling during off-peak hours
|
||||
else:
|
||||
return 1, {"reason": "Off-peak hours"}
|
||||
|
||||
|
||||
# __end_scheduled_batch_processing_policy__
|
||||
|
||||
|
||||
# __begin_custom_metrics_autoscaling_policy__
|
||||
from typing import Any, Dict
|
||||
from ray.serve.config import AutoscalingContext
|
||||
|
||||
|
||||
def custom_metrics_autoscaling_policy(
|
||||
ctx: AutoscalingContext,
|
||||
) -> tuple[int, Dict[str, Any]]:
|
||||
cpu_usage_metric = ctx.aggregated_metrics.get("cpu_usage", {})
|
||||
memory_usage_metric = ctx.aggregated_metrics.get("memory_usage", {})
|
||||
max_cpu_usage = list(cpu_usage_metric.values())[-1] if cpu_usage_metric else 0
|
||||
max_memory_usage = (
|
||||
list(memory_usage_metric.values())[-1] if memory_usage_metric else 0
|
||||
)
|
||||
|
||||
if max_cpu_usage > 80 or max_memory_usage > 85:
|
||||
return min(ctx.capacity_adjusted_max_replicas, ctx.current_num_replicas + 1), {}
|
||||
elif max_cpu_usage < 30 and max_memory_usage < 40:
|
||||
return max(ctx.capacity_adjusted_min_replicas, ctx.current_num_replicas - 1), {}
|
||||
else:
|
||||
return ctx.current_num_replicas, {}
|
||||
|
||||
|
||||
# __end_custom_metrics_autoscaling_policy__
|
||||
|
||||
|
||||
# __begin_application_level_autoscaling_policy__
|
||||
from typing import Dict, Tuple
|
||||
from ray.serve.config import AutoscalingContext
|
||||
|
||||
from ray.serve._private.common import DeploymentID
|
||||
from ray.serve.config import AutoscalingContext
|
||||
|
||||
|
||||
def coordinated_scaling_policy(
|
||||
contexts: Dict[DeploymentID, AutoscalingContext]
|
||||
) -> Tuple[Dict[DeploymentID, int], Dict]:
|
||||
"""Scale deployments based on coordinated load balancing."""
|
||||
decisions = {}
|
||||
|
||||
# Example: Scale a preprocessing deployment
|
||||
preprocessing_id = [d for d in contexts if d.name == "Preprocessor"][0]
|
||||
preprocessing_ctx = contexts[preprocessing_id]
|
||||
|
||||
# Scale based on queue depth
|
||||
preprocessing_replicas = max(
|
||||
preprocessing_ctx.capacity_adjusted_min_replicas,
|
||||
min(
|
||||
preprocessing_ctx.capacity_adjusted_max_replicas,
|
||||
preprocessing_ctx.total_num_requests // 10,
|
||||
),
|
||||
)
|
||||
decisions[preprocessing_id] = preprocessing_replicas
|
||||
|
||||
# Example: Scale a model deployment proportionally
|
||||
model_id = [d for d in contexts if d.name == "Model"][0]
|
||||
model_ctx = contexts[model_id]
|
||||
|
||||
# Scale model to handle preprocessing output
|
||||
# Assuming model takes 2x longer than preprocessing
|
||||
model_replicas = max(
|
||||
model_ctx.capacity_adjusted_min_replicas,
|
||||
min(model_ctx.capacity_adjusted_max_replicas, preprocessing_replicas * 2),
|
||||
)
|
||||
decisions[model_id] = model_replicas
|
||||
|
||||
return decisions, {}
|
||||
|
||||
# __end_application_level_autoscaling_policy__
|
||||
|
||||
# __begin_stateful_application_level_policy__
|
||||
from typing import Dict, Tuple, Any
|
||||
from ray.serve.config import AutoscalingContext
|
||||
from ray.serve._private.common import DeploymentID
|
||||
|
||||
def stateful_application_level_policy(
|
||||
contexts: Dict[DeploymentID, AutoscalingContext]
|
||||
) -> Tuple[Dict[DeploymentID, int], Dict[DeploymentID, Dict[str, Any]]]:
|
||||
"""Example policy demonstrating per-deployment state persistence."""
|
||||
decisions = {}
|
||||
policy_state = {}
|
||||
|
||||
for deployment_id, ctx in contexts.items():
|
||||
# Read previous state for this deployment (persisted from last iteration)
|
||||
prev_state = ctx.policy_state or {}
|
||||
scale_count = prev_state.get("scale_count", 0)
|
||||
last_replicas = prev_state.get("last_replicas", ctx.current_num_replicas)
|
||||
|
||||
# Simple scaling logic: scale based on queue depth
|
||||
desired_replicas = max(
|
||||
ctx.capacity_adjusted_min_replicas,
|
||||
min(
|
||||
ctx.capacity_adjusted_max_replicas,
|
||||
ctx.total_num_requests // 10,
|
||||
),
|
||||
)
|
||||
decisions[deployment_id] = desired_replicas
|
||||
|
||||
# Store per-deployment state that persists across iterations
|
||||
policy_state[deployment_id] = {
|
||||
"scale_count": scale_count + 1,
|
||||
"last_replicas": desired_replicas,
|
||||
}
|
||||
|
||||
return decisions, policy_state
|
||||
|
||||
|
||||
# __end_stateful_application_level_policy__
|
||||
# __begin_apply_autoscaling_config_example__
|
||||
from typing import Any, Dict
|
||||
from ray.serve.config import AutoscalingContext
|
||||
|
||||
|
||||
def queue_length_based_autoscaling_policy(
|
||||
ctx: AutoscalingContext,
|
||||
) -> tuple[int, Dict[str, Any]]:
|
||||
# This policy calculates the "raw" desired replicas based on queue length.
|
||||
# Ray Serve automatically applies scaling factors, delays, and bounds from
|
||||
# the deployment's autoscaling_config on top of this decision.
|
||||
|
||||
queue_length = ctx.total_num_requests
|
||||
|
||||
if queue_length > 50:
|
||||
return 10, {}
|
||||
elif queue_length > 10:
|
||||
return 5, {}
|
||||
else:
|
||||
return 0, {}
|
||||
# __end_apply_autoscaling_config_example__
|
||||
|
||||
# __begin_apply_autoscaling_config_usage__
|
||||
from ray import serve
|
||||
from ray.serve.config import AutoscalingConfig, AutoscalingPolicy
|
||||
|
||||
@serve.deployment(
|
||||
autoscaling_config=AutoscalingConfig(
|
||||
min_replicas=1,
|
||||
max_replicas=10,
|
||||
metrics_interval_s=0.1,
|
||||
upscale_delay_s=1.0,
|
||||
downscale_delay_s=1.0,
|
||||
policy=AutoscalingPolicy(
|
||||
policy_function=queue_length_based_autoscaling_policy
|
||||
)
|
||||
),
|
||||
max_ongoing_requests=5,
|
||||
)
|
||||
class MyDeployment:
|
||||
def __call__(self) -> str:
|
||||
return "Hello, world!"
|
||||
|
||||
app = MyDeployment.bind()
|
||||
# __end_apply_autoscaling_config_usage__
|
||||
@@ -0,0 +1,102 @@
|
||||
# flake8: noqa
|
||||
# __compile_neuron_code_start__
|
||||
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||
import torch, torch_neuronx
|
||||
|
||||
hf_model = "j-hartmann/emotion-english-distilroberta-base"
|
||||
neuron_model = "./sentiment_neuron.pt"
|
||||
|
||||
model = AutoModelForSequenceClassification.from_pretrained(hf_model)
|
||||
tokenizer = AutoTokenizer.from_pretrained(hf_model)
|
||||
sequence_0 = "The company HuggingFace is based in New York City"
|
||||
sequence_1 = "HuggingFace's headquarters are situated in Manhattan"
|
||||
example_inputs = tokenizer.encode_plus(
|
||||
sequence_0,
|
||||
sequence_1,
|
||||
return_tensors="pt",
|
||||
padding="max_length",
|
||||
truncation=True,
|
||||
max_length=128,
|
||||
)
|
||||
neuron_inputs = example_inputs["input_ids"], example_inputs["attention_mask"]
|
||||
n_model = torch_neuronx.trace(model, neuron_inputs)
|
||||
n_model.save(neuron_model)
|
||||
print(f"Saved Neuron-compiled model {neuron_model}")
|
||||
# __compile_neuron_code_end__
|
||||
|
||||
|
||||
# __neuron_serve_code_start__
|
||||
from fastapi import FastAPI
|
||||
import torch
|
||||
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
hf_model = "j-hartmann/emotion-english-distilroberta-base"
|
||||
neuron_model = "./sentiment_neuron.pt"
|
||||
|
||||
|
||||
@serve.deployment(num_replicas=1)
|
||||
@serve.ingress(app)
|
||||
class APIIngress:
|
||||
def __init__(self, bert_base_model_handle: DeploymentHandle) -> None:
|
||||
self.handle = bert_base_model_handle
|
||||
|
||||
@app.get("/infer")
|
||||
async def infer(self, sentence: str):
|
||||
return await self.handle.infer.remote(sentence)
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
ray_actor_options={"resources": {"neuron_cores": 1}},
|
||||
autoscaling_config={"min_replicas": 1, "max_replicas": 2},
|
||||
)
|
||||
class BertBaseModel:
|
||||
def __init__(self):
|
||||
import torch, torch_neuronx # noqa
|
||||
from transformers import AutoTokenizer
|
||||
|
||||
self.model = torch.jit.load(neuron_model)
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(hf_model)
|
||||
self.classmap = {
|
||||
0: "anger",
|
||||
1: "disgust",
|
||||
2: "fear",
|
||||
3: "joy",
|
||||
4: "neutral",
|
||||
5: "sadness",
|
||||
6: "surprise",
|
||||
}
|
||||
|
||||
def infer(self, sentence: str):
|
||||
inputs = self.tokenizer.encode_plus(
|
||||
sentence,
|
||||
return_tensors="pt",
|
||||
padding="max_length",
|
||||
truncation=True,
|
||||
max_length=128,
|
||||
)
|
||||
output = self.model(*(inputs["input_ids"], inputs["attention_mask"]))
|
||||
class_id = torch.argmax(output["logits"], dim=1).item()
|
||||
return self.classmap[class_id]
|
||||
|
||||
|
||||
entrypoint = APIIngress.bind(BertBaseModel.bind())
|
||||
|
||||
|
||||
# __neuron_serve_code_end__
|
||||
if __name__ == "__main__":
|
||||
import requests
|
||||
import ray
|
||||
|
||||
# On inf2.8xlarge instance, there will be 2 neuron cores.
|
||||
ray.init(resources={"neuron_cores": 2})
|
||||
|
||||
serve.run(entrypoint)
|
||||
prompt = "Ray is super cool."
|
||||
resp = requests.get(f"http://127.0.0.1:8000/infer?sentence={prompt}")
|
||||
print(resp.status_code, resp.json())
|
||||
|
||||
assert resp.status_code == 200
|
||||
@@ -0,0 +1,72 @@
|
||||
# __neuron_serve_code_start__
|
||||
from io import BytesIO
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import Response
|
||||
from ray import serve
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
neuron_cores = 2
|
||||
|
||||
|
||||
@serve.deployment(num_replicas=1)
|
||||
@serve.ingress(app)
|
||||
class APIIngress:
|
||||
def __init__(self, diffusion_model_handle) -> None:
|
||||
self.handle = diffusion_model_handle
|
||||
|
||||
@app.get(
|
||||
"/imagine",
|
||||
responses={200: {"content": {"image/png": {}}}},
|
||||
response_class=Response,
|
||||
)
|
||||
async def generate(self, prompt: str):
|
||||
|
||||
image_ref = await self.handle.generate.remote(prompt)
|
||||
image = image_ref
|
||||
file_stream = BytesIO()
|
||||
image.save(file_stream, "PNG")
|
||||
return Response(content=file_stream.getvalue(), media_type="image/png")
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
ray_actor_options={"resources": {"neuron_cores": neuron_cores}},
|
||||
autoscaling_config={"min_replicas": 1, "max_replicas": 2},
|
||||
)
|
||||
class StableDiffusionV2:
|
||||
def __init__(self):
|
||||
from optimum.neuron import NeuronStableDiffusionXLPipeline
|
||||
|
||||
compiled_model_id = "aws-neuron/stable-diffusion-xl-base-1-0-1024x1024"
|
||||
self.pipe = NeuronStableDiffusionXLPipeline.from_pretrained(
|
||||
compiled_model_id, device_ids=[0, 1]
|
||||
)
|
||||
|
||||
async def generate(self, prompt: str):
|
||||
|
||||
assert len(prompt), "prompt parameter cannot be empty"
|
||||
image = self.pipe(prompt).images[0]
|
||||
return image
|
||||
|
||||
|
||||
entrypoint = APIIngress.bind(StableDiffusionV2.bind())
|
||||
|
||||
# __neuron_serve_code_end__
|
||||
if __name__ == "__main__":
|
||||
import requests
|
||||
import ray
|
||||
|
||||
# On inf2.8xlarge instance, there are 2 Neuron cores.
|
||||
ray.init(resources={"neuron_cores": 2})
|
||||
|
||||
serve.run(entrypoint)
|
||||
prompt = "a zebra is dancing in the grass, river, sunlit"
|
||||
input = "%20".join(prompt.split(" "))
|
||||
resp = requests.get(f"http://127.0.0.1:8000/imagine?prompt={input}")
|
||||
|
||||
print("Write the response to `output.png`.")
|
||||
with open("output.png", "wb") as f:
|
||||
f.write(resp.content)
|
||||
|
||||
assert resp.status_code == 200
|
||||
@@ -0,0 +1,245 @@
|
||||
# flake8: noqa
|
||||
# __single_sample_begin__
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Model:
|
||||
def __call__(self, single_sample: int) -> int:
|
||||
return single_sample * 2
|
||||
|
||||
|
||||
handle: DeploymentHandle = serve.run(Model.bind())
|
||||
assert handle.remote(1).result() == 2
|
||||
# __single_sample_end__
|
||||
|
||||
|
||||
# __batch_begin__
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Model:
|
||||
@serve.batch(max_batch_size=8, batch_wait_timeout_s=0.1)
|
||||
async def __call__(self, multiple_samples: List[int]) -> List[int]:
|
||||
# Use numpy's vectorized computation to efficiently process a batch.
|
||||
return np.array(multiple_samples) * 2
|
||||
|
||||
|
||||
handle: DeploymentHandle = serve.run(Model.bind())
|
||||
responses = [handle.remote(i) for i in range(8)]
|
||||
assert list(r.result() for r in responses) == [i * 2 for i in range(8)]
|
||||
# __batch_end__
|
||||
|
||||
|
||||
# __batch_params_update_begin__
|
||||
from typing import Dict
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
# These values can be overridden in the Serve config.
|
||||
user_config={
|
||||
"max_batch_size": 10,
|
||||
"batch_wait_timeout_s": 0.5,
|
||||
}
|
||||
)
|
||||
class Model:
|
||||
@serve.batch(max_batch_size=8, batch_wait_timeout_s=0.1)
|
||||
async def __call__(self, multiple_samples: List[int]) -> List[int]:
|
||||
# Use numpy's vectorized computation to efficiently process a batch.
|
||||
return np.array(multiple_samples) * 2
|
||||
|
||||
def reconfigure(self, user_config: Dict):
|
||||
self.__call__.set_max_batch_size(user_config["max_batch_size"])
|
||||
self.__call__.set_batch_wait_timeout_s(user_config["batch_wait_timeout_s"])
|
||||
|
||||
|
||||
# __batch_params_update_end__
|
||||
|
||||
|
||||
# __single_stream_begin__
|
||||
import asyncio
|
||||
from typing import AsyncGenerator
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class StreamingResponder:
|
||||
async def generate_numbers(self, max: str) -> AsyncGenerator[str, None]:
|
||||
for i in range(max):
|
||||
yield str(i)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
def __call__(self, request: Request) -> StreamingResponse:
|
||||
max = int(request.query_params.get("max", "25"))
|
||||
gen = self.generate_numbers(max)
|
||||
return StreamingResponse(gen, status_code=200, media_type="text/plain")
|
||||
|
||||
|
||||
# __single_stream_end__
|
||||
|
||||
import requests
|
||||
|
||||
serve.run(StreamingResponder.bind())
|
||||
r = requests.get("http://localhost:8000/", stream=True)
|
||||
chunks = []
|
||||
for chunk in r.iter_content(chunk_size=None, decode_unicode=True):
|
||||
chunks.append(chunk)
|
||||
|
||||
assert ",".join(list(map(str, range(25)))) == ",".join(chunks)
|
||||
|
||||
# __batch_stream_begin__
|
||||
import asyncio
|
||||
from typing import List, AsyncGenerator, Union
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class StreamingResponder:
|
||||
@serve.batch(max_batch_size=5, batch_wait_timeout_s=0.1)
|
||||
async def generate_numbers(
|
||||
self, max_list: List[str]
|
||||
) -> AsyncGenerator[List[Union[int, StopIteration]], None]:
|
||||
for i in range(max(max_list)):
|
||||
next_numbers = []
|
||||
for requested_max in max_list:
|
||||
if requested_max > i:
|
||||
next_numbers.append(str(i))
|
||||
else:
|
||||
next_numbers.append(StopIteration)
|
||||
yield next_numbers
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def __call__(self, request: Request) -> StreamingResponse:
|
||||
max = int(request.query_params.get("max", "25"))
|
||||
gen = self.generate_numbers(max)
|
||||
return StreamingResponse(gen, status_code=200, media_type="text/plain")
|
||||
|
||||
|
||||
# __batch_stream_end__
|
||||
|
||||
import requests
|
||||
from functools import partial
|
||||
from concurrent.futures.thread import ThreadPoolExecutor
|
||||
|
||||
serve.run(StreamingResponder.bind())
|
||||
|
||||
|
||||
def issue_request(max) -> List[str]:
|
||||
url = "http://localhost:8000/?max="
|
||||
response = requests.get(url + str(max), stream=True)
|
||||
chunks = []
|
||||
for chunk in response.iter_content(chunk_size=None, decode_unicode=True):
|
||||
chunks.append(chunk)
|
||||
return chunks
|
||||
|
||||
|
||||
requested_maxes = [1, 2, 5, 6, 9]
|
||||
with ThreadPoolExecutor(max_workers=5) as pool:
|
||||
futs = [pool.submit(partial(issue_request, max)) for max in requested_maxes]
|
||||
chunks_list = [fut.result() for fut in futs]
|
||||
for max, chunks in zip(requested_maxes, chunks_list):
|
||||
assert chunks == [str(i) for i in range(max)]
|
||||
|
||||
# __batch_size_fn_begin__
|
||||
from typing import List
|
||||
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
|
||||
class Graph:
|
||||
"""Simple graph data structure for GNN workloads."""
|
||||
|
||||
def __init__(self, num_nodes: int, node_features: list):
|
||||
self.num_nodes = num_nodes
|
||||
self.node_features = node_features
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class GraphNeuralNetwork:
|
||||
@serve.batch(
|
||||
max_batch_size=10000, # Maximum total nodes per batch
|
||||
batch_wait_timeout_s=0.1,
|
||||
batch_size_fn=lambda graphs: sum(g.num_nodes for g in graphs),
|
||||
)
|
||||
async def predict(self, graphs: List[Graph]) -> List[float]:
|
||||
"""Process a batch of graphs, batching by total node count."""
|
||||
# The batch_size_fn ensures that the total number of nodes
|
||||
# across all graphs in the batch doesn't exceed max_batch_size.
|
||||
# This prevents GPU memory overflow.
|
||||
results = []
|
||||
for graph in graphs:
|
||||
# Your GNN model inference logic here
|
||||
# For this example, just return a simple score
|
||||
score = float(graph.num_nodes * 0.1)
|
||||
results.append(score)
|
||||
return results
|
||||
|
||||
async def __call__(self, graph: Graph) -> float:
|
||||
return await self.predict(graph)
|
||||
|
||||
|
||||
handle: DeploymentHandle = serve.run(GraphNeuralNetwork.bind())
|
||||
|
||||
# Create test graphs with varying node counts
|
||||
graphs = [
|
||||
Graph(num_nodes=100, node_features=[1.0] * 100),
|
||||
Graph(num_nodes=5000, node_features=[2.0] * 5000),
|
||||
Graph(num_nodes=3000, node_features=[3.0] * 3000),
|
||||
]
|
||||
|
||||
# Send requests - they'll be batched by total node count
|
||||
results = [handle.remote(g).result() for g in graphs]
|
||||
print(f"Results: {results}")
|
||||
# __batch_size_fn_end__
|
||||
|
||||
# __batch_size_fn_nlp_begin__
|
||||
from typing import List
|
||||
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class TokenBatcher:
|
||||
@serve.batch(
|
||||
max_batch_size=512, # Maximum total tokens per batch
|
||||
batch_wait_timeout_s=0.1,
|
||||
batch_size_fn=lambda sequences: sum(len(s.split()) for s in sequences),
|
||||
)
|
||||
async def process(self, sequences: List[str]) -> List[int]:
|
||||
"""Process text sequences, batching by total token count."""
|
||||
# The batch_size_fn ensures total tokens don't exceed max_batch_size.
|
||||
# This is useful for transformer models with fixed context windows.
|
||||
return [len(seq.split()) for seq in sequences]
|
||||
|
||||
async def __call__(self, sequence: str) -> int:
|
||||
return await self.process(sequence)
|
||||
|
||||
|
||||
handle: DeploymentHandle = serve.run(TokenBatcher.bind())
|
||||
|
||||
# Create sequences with different lengths
|
||||
sequences = [
|
||||
"This is a short sentence",
|
||||
"This is a much longer sentence with many more words to process",
|
||||
"Short",
|
||||
]
|
||||
|
||||
# Send requests - they'll be batched by total token count
|
||||
results = [handle.remote(seq).result() for seq in sequences]
|
||||
print(f"Token counts: {results}")
|
||||
# __batch_size_fn_nlp_end__
|
||||
@@ -0,0 +1,56 @@
|
||||
# flake8: noqa
|
||||
|
||||
# __begin_deploy_app_with_capacity_queue_router__
|
||||
import ray
|
||||
from ray import serve
|
||||
from ray.serve.config import DeploymentActorConfig, RequestRouterConfig
|
||||
from ray.serve.context import _get_internal_replica_context
|
||||
|
||||
from ray.serve.experimental.capacity_queue import (
|
||||
CapacityQueue,
|
||||
)
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
deployment_actors=[
|
||||
DeploymentActorConfig(
|
||||
name="capacity_queue",
|
||||
actor_class=CapacityQueue,
|
||||
init_kwargs={
|
||||
"acquire_timeout_s": 0.5,
|
||||
"token_ttl_s": 5,
|
||||
},
|
||||
actor_options={"num_cpus": 0},
|
||||
),
|
||||
],
|
||||
request_router_config=RequestRouterConfig(
|
||||
request_router_class=(
|
||||
"ray.serve.experimental.capacity_queue_router:CapacityQueueRouter"
|
||||
),
|
||||
request_router_kwargs={
|
||||
"capacity_queue_actor_name": "capacity_queue",
|
||||
# Fall back to Pow2 after this many consecutive CapacityQueue faults.
|
||||
"max_fault_retries": 3,
|
||||
},
|
||||
# Backoff between retries when the CapacityQueue is unavailable or capacity is exhausted.
|
||||
initial_backoff_s=0.05,
|
||||
backoff_multiplier=2.0,
|
||||
max_backoff_s=1.0,
|
||||
),
|
||||
num_replicas=3,
|
||||
max_ongoing_requests=5,
|
||||
ray_actor_options={"num_cpus": 0},
|
||||
)
|
||||
class CapacityQueueApp:
|
||||
def __init__(self):
|
||||
context = _get_internal_replica_context()
|
||||
self.replica_id = context.replica_id
|
||||
|
||||
async def __call__(self):
|
||||
return self.replica_id
|
||||
|
||||
|
||||
handle = serve.run(CapacityQueueApp.bind())
|
||||
response = handle.remote().result()
|
||||
print(f"Response from CapacityQueueApp: {response}")
|
||||
# __end_deploy_app_with_capacity_queue_router__
|
||||
@@ -0,0 +1,46 @@
|
||||
# __serve_example_begin__
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
from ray import serve
|
||||
from ray.serve.config import AutoscalingConfig, AutoscalingPolicy
|
||||
|
||||
# Create a JSON file with the initial target replica count.
|
||||
# In production this file would be written by an external system.
|
||||
scaling_file = tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".json", delete=False
|
||||
)
|
||||
json.dump({"replicas": 2}, scaling_file)
|
||||
scaling_file.close()
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
autoscaling_config=AutoscalingConfig(
|
||||
min_replicas=1,
|
||||
max_replicas=10,
|
||||
upscale_delay_s=3,
|
||||
downscale_delay_s=10,
|
||||
policy=AutoscalingPolicy(
|
||||
policy_function="class_based_autoscaling_policy:FileBasedAutoscalingPolicy",
|
||||
policy_kwargs={
|
||||
"file_path": scaling_file.name,
|
||||
"poll_interval_s": 2.0,
|
||||
},
|
||||
),
|
||||
),
|
||||
max_ongoing_requests=100,
|
||||
)
|
||||
class MyDeployment:
|
||||
async def __call__(self) -> str:
|
||||
return "Hello, world!"
|
||||
|
||||
|
||||
app = MyDeployment.bind()
|
||||
# __serve_example_end__
|
||||
|
||||
if __name__ == "__main__":
|
||||
import requests # noqa
|
||||
|
||||
serve.run(app)
|
||||
resp = requests.get("http://localhost:8000/")
|
||||
assert resp.text == "Hello, world!"
|
||||
@@ -0,0 +1,57 @@
|
||||
# __begin_class_based_autoscaling_policy__
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from ray.serve.config import AutoscalingContext
|
||||
|
||||
logger = logging.getLogger("ray.serve")
|
||||
|
||||
|
||||
class FileBasedAutoscalingPolicy:
|
||||
"""Scale replicas based on a target written to a JSON file.
|
||||
|
||||
A background asyncio task re-reads the file every ``poll_interval_s``
|
||||
seconds. ``__call__`` returns the latest value on every autoscaling
|
||||
tick. In production you could replace the file read with an HTTP
|
||||
call, a message-queue consumer, or any other async IO operation.
|
||||
"""
|
||||
|
||||
def __init__(self, file_path: str, poll_interval_s: float = 5.0):
|
||||
self._file_path = Path(file_path)
|
||||
self._poll_interval_s = poll_interval_s
|
||||
self._desired_replicas: int = 1
|
||||
self._task: asyncio.Task = None
|
||||
self._started: bool = False
|
||||
|
||||
def _ensure_started(self) -> None:
|
||||
"""Lazily start the background poll on the controller event loop."""
|
||||
if self._started:
|
||||
return
|
||||
self._started = True
|
||||
loop = asyncio.get_running_loop()
|
||||
self._task = loop.create_task(self._poll_file())
|
||||
|
||||
async def _poll_file(self) -> None:
|
||||
"""Read the target replica count from the JSON file in a loop."""
|
||||
while True:
|
||||
try:
|
||||
text = self._file_path.read_text()
|
||||
data = json.loads(text)
|
||||
self._desired_replicas = int(data["replicas"])
|
||||
except Exception:
|
||||
pass # Keep the last known value on failure.
|
||||
await asyncio.sleep(self._poll_interval_s)
|
||||
|
||||
def __call__(
|
||||
self, ctx: AutoscalingContext
|
||||
) -> Tuple[int, Dict[str, Any]]:
|
||||
self._ensure_started()
|
||||
|
||||
desired = self._desired_replicas
|
||||
return desired, {"last_polled_value": self._desired_replicas}
|
||||
|
||||
|
||||
# __end_class_based_autoscaling_policy__
|
||||
@@ -0,0 +1,36 @@
|
||||
# flake8: noqa
|
||||
|
||||
import ray
|
||||
|
||||
# __deployment_start__
|
||||
# File name: configure_serve.py
|
||||
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
name="Translator",
|
||||
num_replicas=2,
|
||||
ray_actor_options={"num_cpus": 0.2, "num_gpus": 0},
|
||||
max_ongoing_requests=100,
|
||||
health_check_period_s=10,
|
||||
health_check_timeout_s=30,
|
||||
graceful_shutdown_timeout_s=20,
|
||||
graceful_shutdown_wait_loop_s=2,
|
||||
)
|
||||
class Example:
|
||||
...
|
||||
|
||||
|
||||
example_app = Example.bind()
|
||||
# __deployment_end__
|
||||
|
||||
example_app = Example.options(
|
||||
ray_actor_options={"num_cpus": 0.2, "num_gpus": 0.0}
|
||||
).bind()
|
||||
|
||||
# __options_end__
|
||||
serve.run(example_app)
|
||||
|
||||
serve.shutdown()
|
||||
ray.shutdown()
|
||||
@@ -0,0 +1,197 @@
|
||||
# flake8: noqa
|
||||
"""
|
||||
Cross-node parallelism examples for Ray Serve LLM.
|
||||
|
||||
TP / PP / custom placement group strategies
|
||||
for multi-node LLM deployments.
|
||||
"""
|
||||
|
||||
# __cross_node_tp_example_start__
|
||||
import vllm
|
||||
from ray import serve
|
||||
from ray.serve.llm import LLMConfig, build_openai_app
|
||||
|
||||
# Configure a model with tensor parallelism across 2 GPUs
|
||||
# Tensor parallelism splits model weights across GPUs
|
||||
llm_config = LLMConfig(
|
||||
model_loading_config=dict(
|
||||
model_id="llama-3.1-8b",
|
||||
model_source="meta-llama/Llama-3.1-8B-Instruct",
|
||||
),
|
||||
deployment_config=dict(
|
||||
autoscaling_config=dict(
|
||||
min_replicas=1,
|
||||
max_replicas=2,
|
||||
)
|
||||
),
|
||||
accelerator_type="L4",
|
||||
engine_kwargs=dict(
|
||||
tensor_parallel_size=2,
|
||||
max_model_len=8192,
|
||||
),
|
||||
)
|
||||
|
||||
# Deploy the application
|
||||
app = build_openai_app({"llm_configs": [llm_config]})
|
||||
serve.run(app, blocking=True)
|
||||
# __cross_node_tp_example_end__
|
||||
|
||||
# __cross_node_pp_example_start__
|
||||
from ray import serve
|
||||
from ray.serve.llm import LLMConfig, build_openai_app
|
||||
|
||||
# Configure a model with pipeline parallelism across 2 GPUs
|
||||
# Pipeline parallelism splits model layers across GPUs
|
||||
llm_config = LLMConfig(
|
||||
model_loading_config=dict(
|
||||
model_id="llama-3.1-8b",
|
||||
model_source="meta-llama/Llama-3.1-8B-Instruct",
|
||||
),
|
||||
deployment_config=dict(
|
||||
autoscaling_config=dict(
|
||||
min_replicas=1,
|
||||
max_replicas=1,
|
||||
)
|
||||
),
|
||||
accelerator_type="L4",
|
||||
engine_kwargs=dict(
|
||||
pipeline_parallel_size=2,
|
||||
max_model_len=8192,
|
||||
),
|
||||
)
|
||||
|
||||
# Deploy the application
|
||||
app = build_openai_app({"llm_configs": [llm_config]})
|
||||
serve.run(app, blocking=True)
|
||||
# __cross_node_pp_example_end__
|
||||
|
||||
# __cross_node_tp_pp_example_start__
|
||||
from ray import serve
|
||||
from ray.serve.llm import LLMConfig, build_openai_app
|
||||
|
||||
# Configure a model with both tensor and pipeline parallelism
|
||||
# This example uses 4 GPUs total (2 TP * 2 PP)
|
||||
llm_config = LLMConfig(
|
||||
model_loading_config=dict(
|
||||
model_id="llama-3.1-8b",
|
||||
model_source="meta-llama/Llama-3.1-8B-Instruct",
|
||||
),
|
||||
deployment_config=dict(
|
||||
autoscaling_config=dict(
|
||||
min_replicas=1,
|
||||
max_replicas=1,
|
||||
)
|
||||
),
|
||||
accelerator_type="L4",
|
||||
engine_kwargs=dict(
|
||||
tensor_parallel_size=2,
|
||||
pipeline_parallel_size=2,
|
||||
max_model_len=8192,
|
||||
enable_chunked_prefill=True,
|
||||
max_num_batched_tokens=4096,
|
||||
),
|
||||
)
|
||||
|
||||
# Deploy the application
|
||||
app = build_openai_app({"llm_configs": [llm_config]})
|
||||
serve.run(app, blocking=True)
|
||||
# __cross_node_tp_pp_example_end__
|
||||
|
||||
# __custom_placement_group_pack_example_start__
|
||||
from ray import serve
|
||||
from ray.serve.llm import LLMConfig, build_openai_app
|
||||
|
||||
# Configure a model with custom placement group using PACK strategy
|
||||
# PACK tries to place workers on as few nodes as possible for locality
|
||||
llm_config = LLMConfig(
|
||||
model_loading_config=dict(
|
||||
model_id="llama-3.1-8b",
|
||||
model_source="meta-llama/Llama-3.1-8B-Instruct",
|
||||
),
|
||||
deployment_config=dict(
|
||||
autoscaling_config=dict(
|
||||
min_replicas=1,
|
||||
max_replicas=1,
|
||||
)
|
||||
),
|
||||
accelerator_type="L4",
|
||||
engine_kwargs=dict(
|
||||
tensor_parallel_size=2,
|
||||
max_model_len=8192,
|
||||
),
|
||||
placement_group_config=dict(
|
||||
bundles=[{"GPU": 1}] * 2,
|
||||
strategy="PACK",
|
||||
),
|
||||
)
|
||||
|
||||
# Deploy the application
|
||||
app = build_openai_app({"llm_configs": [llm_config]})
|
||||
serve.run(app, blocking=True)
|
||||
# __custom_placement_group_pack_example_end__
|
||||
|
||||
# __custom_placement_group_spread_example_start__
|
||||
from ray import serve
|
||||
from ray.serve.llm import LLMConfig, build_openai_app
|
||||
|
||||
# Configure a model with custom placement group using SPREAD strategy
|
||||
# SPREAD distributes workers across nodes for fault tolerance
|
||||
llm_config = LLMConfig(
|
||||
model_loading_config=dict(
|
||||
model_id="llama-3.1-8b",
|
||||
model_source="meta-llama/Llama-3.1-8B-Instruct",
|
||||
),
|
||||
deployment_config=dict(
|
||||
autoscaling_config=dict(
|
||||
min_replicas=1,
|
||||
max_replicas=1,
|
||||
)
|
||||
),
|
||||
accelerator_type="L4",
|
||||
engine_kwargs=dict(
|
||||
tensor_parallel_size=4,
|
||||
max_model_len=8192,
|
||||
),
|
||||
placement_group_config=dict(
|
||||
bundles=[{"GPU": 1}] * 4,
|
||||
strategy="SPREAD",
|
||||
),
|
||||
)
|
||||
|
||||
# Deploy the application
|
||||
app = build_openai_app({"llm_configs": [llm_config]})
|
||||
serve.run(app, blocking=True)
|
||||
# __custom_placement_group_spread_example_end__
|
||||
|
||||
# __custom_placement_group_strict_pack_example_start__
|
||||
from ray import serve
|
||||
from ray.serve.llm import LLMConfig, build_openai_app
|
||||
|
||||
# Configure a model with custom placement group using STRICT_PACK strategy
|
||||
# STRICT_PACK ensures all workers are placed on the same node
|
||||
llm_config = LLMConfig(
|
||||
model_loading_config=dict(
|
||||
model_id="llama-3.1-8b",
|
||||
model_source="meta-llama/Llama-3.1-8B-Instruct",
|
||||
),
|
||||
deployment_config=dict(
|
||||
autoscaling_config=dict(
|
||||
min_replicas=1,
|
||||
max_replicas=2,
|
||||
)
|
||||
),
|
||||
accelerator_type="A100",
|
||||
engine_kwargs=dict(
|
||||
tensor_parallel_size=2,
|
||||
max_model_len=8192,
|
||||
),
|
||||
placement_group_config=dict(
|
||||
bundles=[{"GPU": 1}] * 2,
|
||||
strategy="STRICT_PACK",
|
||||
),
|
||||
)
|
||||
|
||||
# Deploy the application
|
||||
app = build_openai_app({"llm_configs": [llm_config]})
|
||||
serve.run(app, blocking=True)
|
||||
# __custom_placement_group_strict_pack_example_end__
|
||||
@@ -0,0 +1,54 @@
|
||||
# __serve_example_begin__
|
||||
import time
|
||||
from typing import Dict
|
||||
|
||||
import psutil
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
autoscaling_config={
|
||||
"min_replicas": 1,
|
||||
"max_replicas": 5,
|
||||
"metrics_interval_s": 0.1,
|
||||
"policy": {
|
||||
"policy_function": "autoscaling_policy:custom_metrics_autoscaling_policy"
|
||||
},
|
||||
},
|
||||
max_ongoing_requests=5,
|
||||
)
|
||||
class CustomMetricsDeployment:
|
||||
def __init__(self):
|
||||
self.process = psutil.Process()
|
||||
|
||||
def __call__(self) -> str:
|
||||
# Simulate some work
|
||||
time.sleep(0.5)
|
||||
return "Hello, world!"
|
||||
|
||||
def record_autoscaling_stats(self) -> Dict[str, float]:
|
||||
# Get CPU usage as a percentage
|
||||
cpu_usage = self.process.cpu_percent(interval=0.1)
|
||||
|
||||
# Get memory usage as a percentage of system memory
|
||||
memory_info = self.process.memory_full_info()
|
||||
system_memory = psutil.virtual_memory().total
|
||||
memory_usage = (memory_info.uss / system_memory) * 100
|
||||
|
||||
return {
|
||||
"cpu_usage": cpu_usage,
|
||||
"memory_usage": memory_usage,
|
||||
}
|
||||
|
||||
|
||||
# Create the app
|
||||
app = CustomMetricsDeployment.bind()
|
||||
# __serve_example_end__
|
||||
|
||||
if __name__ == "__main__":
|
||||
import requests # noqa
|
||||
|
||||
serve.run(app)
|
||||
for _ in range(10):
|
||||
resp = requests.get("http://localhost:8000/")
|
||||
assert resp.text == "Hello, world!"
|
||||
@@ -0,0 +1,123 @@
|
||||
# flake8: noqa
|
||||
# __begin_define_uniform_request_router__
|
||||
import random
|
||||
from ray.serve.request_router import (
|
||||
PendingRequest,
|
||||
RequestRouter,
|
||||
ReplicaID,
|
||||
ReplicaResult,
|
||||
RunningReplica,
|
||||
)
|
||||
from typing import (
|
||||
List,
|
||||
Optional,
|
||||
)
|
||||
|
||||
|
||||
class UniformRequestRouter(RequestRouter):
|
||||
async def choose_replicas(
|
||||
self,
|
||||
candidate_replicas: List[RunningReplica],
|
||||
pending_request: Optional[PendingRequest] = None,
|
||||
) -> List[List[RunningReplica]]:
|
||||
print("UniformRequestRouter routing request")
|
||||
index = random.randint(0, len(candidate_replicas) - 1)
|
||||
return [[candidate_replicas[index]]]
|
||||
|
||||
def on_request_routed(
|
||||
self,
|
||||
pending_request: PendingRequest,
|
||||
replica_id: ReplicaID,
|
||||
result: ReplicaResult,
|
||||
):
|
||||
print("on_request_routed callback is called!!")
|
||||
|
||||
|
||||
# __end_define_uniform_request_router__
|
||||
|
||||
|
||||
# __begin_define_throughput_aware_request_router__
|
||||
from ray.serve.request_router import (
|
||||
FIFOMixin,
|
||||
LocalityMixin,
|
||||
MultiplexMixin,
|
||||
PendingRequest,
|
||||
RequestRouter,
|
||||
ReplicaID,
|
||||
ReplicaResult,
|
||||
RunningReplica,
|
||||
)
|
||||
from typing import (
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
)
|
||||
|
||||
|
||||
class ThroughputAwareRequestRouter(
|
||||
FIFOMixin, MultiplexMixin, LocalityMixin, RequestRouter
|
||||
):
|
||||
async def choose_replicas(
|
||||
self,
|
||||
candidate_replicas: List[RunningReplica],
|
||||
pending_request: Optional[PendingRequest] = None,
|
||||
) -> List[List[RunningReplica]]:
|
||||
"""
|
||||
This method chooses the best replica for the request based on
|
||||
multiplexed, locality, and custom throughput stats. The algorithm
|
||||
works as follows:
|
||||
|
||||
1. Populate top_ranked_replicas based on available replicas based on
|
||||
multiplex_id
|
||||
2. Populate and override top_ranked_replicas info based on locality
|
||||
information of replicas (we want to prefer replicas that are in the
|
||||
same vicinity to this deployment)
|
||||
3. Select the replica with minimum throughput.
|
||||
"""
|
||||
|
||||
# Dictionary to hold the top-ranked replicas
|
||||
top_ranked_replicas: Dict[ReplicaID, RunningReplica] = {}
|
||||
# Take the best set of replicas for the multiplexed model
|
||||
if (
|
||||
pending_request is not None
|
||||
and pending_request.metadata.multiplexed_model_id
|
||||
):
|
||||
ranked_replicas_multiplex: List[RunningReplica] = (
|
||||
self.rank_replicas_via_multiplex(
|
||||
replicas=candidate_replicas,
|
||||
multiplexed_model_id=pending_request.metadata.multiplexed_model_id,
|
||||
)
|
||||
)[0]
|
||||
|
||||
# Filter out replicas that are not available (queue length exceed max ongoing request)
|
||||
ranked_replicas_multiplex = self.select_available_replicas(
|
||||
candidates=ranked_replicas_multiplex
|
||||
)
|
||||
|
||||
for replica in ranked_replicas_multiplex:
|
||||
top_ranked_replicas[replica.replica_id] = replica
|
||||
|
||||
# Take the best set of replicas in terms of locality
|
||||
ranked_replicas_locality: List[
|
||||
RunningReplica
|
||||
] = self.rank_replicas_via_locality(replicas=candidate_replicas)[0]
|
||||
|
||||
# Filter out replicas that are not available (queue length exceed max ongoing request)
|
||||
ranked_replicas_locality = self.select_available_replicas(
|
||||
candidates=ranked_replicas_locality
|
||||
)
|
||||
|
||||
for replica in ranked_replicas_locality:
|
||||
top_ranked_replicas[replica.replica_id] = replica
|
||||
|
||||
print("ThroughputAwareRequestRouter routing request")
|
||||
|
||||
# Take the replica with minimum throughput.
|
||||
min_throughput_replicas = min(
|
||||
[replica for replica in top_ranked_replicas.values()],
|
||||
key=lambda r: r.routing_stats.get("throughput", 0),
|
||||
)
|
||||
return [[min_throughput_replicas]]
|
||||
|
||||
|
||||
# __end_define_throughput_aware_request_router__
|
||||
@@ -0,0 +1,164 @@
|
||||
# flake8: noqa
|
||||
|
||||
# __begin_deploy_app_with_uniform_request_router__
|
||||
from ray import serve
|
||||
from ray.serve.request_router import ReplicaID
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from ray.serve.context import _get_internal_replica_context
|
||||
from typing import Any, Dict
|
||||
from ray.serve.config import RequestRouterConfig
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
request_router_config=RequestRouterConfig(
|
||||
request_router_class="custom_request_router:UniformRequestRouter",
|
||||
),
|
||||
num_replicas=10,
|
||||
ray_actor_options={"num_cpus": 0},
|
||||
)
|
||||
class UniformRequestRouterApp:
|
||||
def __init__(self):
|
||||
context = _get_internal_replica_context()
|
||||
self.replica_id: ReplicaID = context.replica_id
|
||||
|
||||
async def __call__(self):
|
||||
return self.replica_id
|
||||
|
||||
|
||||
handle = serve.run(UniformRequestRouterApp.bind())
|
||||
response = handle.remote().result()
|
||||
print(f"Response from UniformRequestRouterApp: {response}")
|
||||
# Example output:
|
||||
# Response from UniformRequestRouterApp:
|
||||
# Replica(id='67vc4ts5', deployment='UniformRequestRouterApp', app='default')
|
||||
# __end_deploy_app_with_uniform_request_router__
|
||||
|
||||
|
||||
# __begin_deploy_app_with_throughput_aware_request_router__
|
||||
def _time_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
request_router_config=RequestRouterConfig(
|
||||
request_router_class="custom_request_router:ThroughputAwareRequestRouter",
|
||||
request_routing_stats_period_s=1,
|
||||
request_routing_stats_timeout_s=1,
|
||||
),
|
||||
num_replicas=3,
|
||||
ray_actor_options={"num_cpus": 0},
|
||||
)
|
||||
class ThroughputAwareRequestRouterApp:
|
||||
def __init__(self):
|
||||
self.throughput_buckets: Dict[int, int] = defaultdict(int)
|
||||
self.last_throughput_buckets = _time_ms()
|
||||
context = _get_internal_replica_context()
|
||||
self.replica_id: ReplicaID = context.replica_id
|
||||
|
||||
def __call__(self):
|
||||
self.update_throughput()
|
||||
return self.replica_id
|
||||
|
||||
def update_throughput(self):
|
||||
current_timestamp_ms = _time_ms()
|
||||
|
||||
# Under high concurrency, requests can come in at different times. This
|
||||
# early return helps to skip if the current_timestamp_ms is more than a
|
||||
# second older than the last throughput bucket.
|
||||
if current_timestamp_ms < self.last_throughput_buckets - 1000:
|
||||
return
|
||||
|
||||
# Record the request to the bucket
|
||||
self.throughput_buckets[current_timestamp_ms] += 1
|
||||
self.last_throughput_buckets = current_timestamp_ms
|
||||
|
||||
def record_routing_stats(self) -> Dict[str, Any]:
|
||||
current_timestamp_ms = _time_ms()
|
||||
throughput = 0
|
||||
|
||||
for t, c in list(self.throughput_buckets.items()):
|
||||
if t < current_timestamp_ms - 1000:
|
||||
# Remove the bucket if it is older than 1 second
|
||||
self.throughput_buckets.pop(t)
|
||||
else:
|
||||
throughput += c
|
||||
|
||||
return {
|
||||
"throughput": throughput,
|
||||
}
|
||||
|
||||
|
||||
handle = serve.run(ThroughputAwareRequestRouterApp.bind())
|
||||
response = handle.remote().result()
|
||||
print(f"Response from ThroughputAwareRequestRouterApp: {response}")
|
||||
# Example output:
|
||||
# Response from ThroughputAwareRequestRouterApp:
|
||||
# Replica(id='tkywafya', deployment='ThroughputAwareRequestRouterApp', app='default')
|
||||
# __end_deploy_app_with_throughput_aware_request_router__
|
||||
|
||||
|
||||
# __begin_deploy_app_with_round_robin_router__
|
||||
@serve.deployment(
|
||||
request_router_config=RequestRouterConfig(
|
||||
request_router_class=(
|
||||
"ray.serve.experimental.round_robin_router:RoundRobinRouter"
|
||||
),
|
||||
),
|
||||
num_replicas=4,
|
||||
ray_actor_options={"num_cpus": 0},
|
||||
)
|
||||
class RoundRobinRouterApp:
|
||||
def __init__(self):
|
||||
context = _get_internal_replica_context()
|
||||
self.replica_id: ReplicaID = context.replica_id
|
||||
|
||||
async def __call__(self):
|
||||
return self.replica_id
|
||||
|
||||
|
||||
handle = serve.run(RoundRobinRouterApp.bind())
|
||||
response = handle.remote().result()
|
||||
print(f"Response from RoundRobinRouterApp: {response}")
|
||||
# __end_deploy_app_with_round_robin_router__
|
||||
|
||||
|
||||
# __begin_deploy_app_with_consistent_hash_router__
|
||||
import requests
|
||||
from starlette.requests import Request
|
||||
|
||||
@serve.deployment(
|
||||
request_router_config=RequestRouterConfig(
|
||||
request_router_class=(
|
||||
"ray.serve.experimental.consistent_hash_router:ConsistentHashRouter"
|
||||
),
|
||||
request_router_kwargs={
|
||||
"num_virtual_nodes": 100,
|
||||
"num_fallback_replicas": 2,
|
||||
},
|
||||
),
|
||||
num_replicas=4,
|
||||
ray_actor_options={"num_cpus": 0},
|
||||
)
|
||||
class ConsistentHashRouterApp:
|
||||
def __init__(self):
|
||||
context = _get_internal_replica_context()
|
||||
self.replica_id: ReplicaID = context.replica_id
|
||||
|
||||
async def __call__(self, request: Request) -> str:
|
||||
return str(self.replica_id)
|
||||
|
||||
|
||||
serve.run(ConsistentHashRouterApp.bind())
|
||||
|
||||
# Clients pin a session to a replica by sending the same `x-session-id`
|
||||
# on every request.
|
||||
response = requests.get(
|
||||
"http://localhost:8000/",
|
||||
headers={"x-session-id": "example-session-id"},
|
||||
)
|
||||
print(f"Response from ConsistentHashRouterApp: {response.text}")
|
||||
# Example output:
|
||||
# Response from ConsistentHashRouterApp:
|
||||
# Replica(id='...', deployment='ConsistentHashRouterApp', app='default')
|
||||
# __end_deploy_app_with_consistent_hash_router__
|
||||
@@ -0,0 +1,9 @@
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class MyDeployment:
|
||||
def __call__(self, model_path):
|
||||
from my_module import my_model
|
||||
|
||||
self.model = my_model.load(model_path)
|
||||
@@ -0,0 +1,88 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import ray
|
||||
from ray import serve
|
||||
from ray.exceptions import RayActorError
|
||||
from ray.serve.config import DeploymentActorConfig
|
||||
|
||||
|
||||
# __begin_define_deployment_scoped_actor__
|
||||
@ray.remote
|
||||
class SharedCounter:
|
||||
def __init__(self, start: int = 0):
|
||||
self._value = start
|
||||
|
||||
def increment(self) -> int:
|
||||
self._value += 1
|
||||
return self._value
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
deployment_actors=[
|
||||
DeploymentActorConfig(
|
||||
name="counter",
|
||||
actor_class=SharedCounter,
|
||||
init_kwargs={"start": 10},
|
||||
actor_options={"num_cpus": 0},
|
||||
),
|
||||
],
|
||||
)
|
||||
class SharedCounterDeployment:
|
||||
async def __call__(self) -> int:
|
||||
counter = serve.get_deployment_actor("counter")
|
||||
return await counter.increment.remote()
|
||||
|
||||
|
||||
# __end_define_deployment_scoped_actor__
|
||||
|
||||
|
||||
# __begin_cached_handle_refresh__
|
||||
@serve.deployment(
|
||||
deployment_actors=[
|
||||
DeploymentActorConfig(
|
||||
name="counter",
|
||||
actor_class=SharedCounter,
|
||||
init_kwargs={"start": 0},
|
||||
actor_options={"num_cpus": 0},
|
||||
),
|
||||
],
|
||||
)
|
||||
class CachedHandleDeployment:
|
||||
def __init__(self):
|
||||
self._counter = serve.get_deployment_actor("counter")
|
||||
|
||||
async def _refresh_counter(self) -> None:
|
||||
deadline = time.monotonic() + 30
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
self._counter = serve.get_deployment_actor("counter")
|
||||
return
|
||||
except ValueError:
|
||||
# The replacement actor might not be registered yet.
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
raise TimeoutError("Timed out waiting for the deployment-scoped actor.")
|
||||
|
||||
async def __call__(self) -> int:
|
||||
try:
|
||||
return await self._counter.increment.remote()
|
||||
except RayActorError:
|
||||
await self._refresh_counter()
|
||||
return await self._counter.increment.remote()
|
||||
|
||||
|
||||
# __end_cached_handle_refresh__
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ray.init()
|
||||
try:
|
||||
# __begin_run_deployment_scoped_actor_example__
|
||||
handle = serve.run(SharedCounterDeployment.bind())
|
||||
print(handle.remote().result())
|
||||
print(handle.remote().result())
|
||||
# __end_run_deployment_scoped_actor_example__
|
||||
finally:
|
||||
serve.shutdown()
|
||||
ray.shutdown()
|
||||
@@ -0,0 +1,58 @@
|
||||
# flake8: noqa
|
||||
|
||||
# __deployment_start__
|
||||
import ray
|
||||
from ray import serve
|
||||
from fastapi import FastAPI
|
||||
|
||||
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@serve.deployment(num_replicas=2, ray_actor_options={"num_cpus": 0.2, "num_gpus": 0})
|
||||
@serve.ingress(app)
|
||||
class Translator:
|
||||
def __init__(self):
|
||||
# Load model
|
||||
self.tokenizer = AutoTokenizer.from_pretrained("t5-small")
|
||||
self.model = AutoModelForSeq2SeqLM.from_pretrained("t5-small")
|
||||
|
||||
@app.post("/")
|
||||
def translate(self, text: str) -> str:
|
||||
# Run inference
|
||||
input_ids = self.tokenizer(
|
||||
f"translate English to French: {text}", return_tensors="pt"
|
||||
).input_ids
|
||||
output_ids = self.model.generate(
|
||||
input_ids, num_beams=4, early_stopping=True, max_length=300
|
||||
)
|
||||
|
||||
# Post-process output to return only the translation text
|
||||
translation = self.tokenizer.decode(
|
||||
output_ids[0], skip_special_tokens=True, clean_up_tokenization_spaces=False
|
||||
)
|
||||
|
||||
return translation
|
||||
|
||||
|
||||
translator_app = Translator.bind()
|
||||
# __deployment_end__
|
||||
|
||||
translator_app = Translator.options(ray_actor_options={}).bind()
|
||||
serve.run(translator_app)
|
||||
|
||||
# __client_function_start__
|
||||
# File name: model_client.py
|
||||
import requests
|
||||
|
||||
response = requests.post("http://127.0.0.1:8000/", params={"text": "Hello world!"})
|
||||
french_text = response.json()
|
||||
|
||||
print(french_text)
|
||||
# __client_function_end__
|
||||
|
||||
assert french_text == "Bonjour monde!"
|
||||
|
||||
serve.shutdown()
|
||||
ray.shutdown()
|
||||
@@ -0,0 +1,61 @@
|
||||
# __example_code_start__
|
||||
from transformers import pipeline
|
||||
from fastapi import FastAPI
|
||||
import torch
|
||||
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@serve.deployment(num_replicas=1)
|
||||
@serve.ingress(app)
|
||||
class APIIngress:
|
||||
def __init__(self, distilbert_model_handle: DeploymentHandle) -> None:
|
||||
self.handle = distilbert_model_handle
|
||||
|
||||
@app.get("/classify")
|
||||
async def classify(self, sentence: str):
|
||||
return await self.handle.classify.remote(sentence)
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
ray_actor_options={"num_gpus": 1},
|
||||
autoscaling_config={"min_replicas": 0, "max_replicas": 2},
|
||||
)
|
||||
class DistilBertModel:
|
||||
def __init__(self):
|
||||
self.classifier = pipeline(
|
||||
"sentiment-analysis",
|
||||
model="distilbert-base-uncased",
|
||||
framework="pt",
|
||||
# Transformers requires you to pass device with index
|
||||
device=torch.device("cuda:0"),
|
||||
)
|
||||
|
||||
def classify(self, sentence: str):
|
||||
return self.classifier(sentence)
|
||||
|
||||
|
||||
entrypoint = APIIngress.bind(DistilBertModel.bind())
|
||||
|
||||
# __example_code_end__
|
||||
|
||||
if __name__ == "__main__":
|
||||
import requests
|
||||
import ray
|
||||
|
||||
ray.init(runtime_env={"pip": ["transformers==4.36.2", "accelerate==0.28.0"]})
|
||||
serve.run(entrypoint)
|
||||
|
||||
prompt = (
|
||||
"This was a masterpiece. Not completely faithful to the books, but "
|
||||
"enthralling from beginning to end. Might be my favorite of the three."
|
||||
)
|
||||
input = "%20".join(prompt.split(" "))
|
||||
resp = requests.get(f"http://127.0.0.1:8000/classify?sentence={prompt}")
|
||||
print(resp.status_code, resp.json())
|
||||
|
||||
assert resp.status_code == 200
|
||||
@@ -0,0 +1,10 @@
|
||||
# __external_scaler_config_begin__
|
||||
applications:
|
||||
- name: my-app
|
||||
import_path: external_scaler_predictive:app
|
||||
external_scaler_enabled: true
|
||||
deployments:
|
||||
- name: TextProcessor
|
||||
num_replicas: 1
|
||||
# __external_scaler_config_end__
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# __serve_example_begin__
|
||||
import time
|
||||
from ray import serve
|
||||
from typing import Any
|
||||
|
||||
@serve.deployment(num_replicas=3)
|
||||
class TextProcessor:
|
||||
"""A simple text processing deployment that can be scaled externally."""
|
||||
def __init__(self):
|
||||
self.request_count = 0
|
||||
|
||||
def __call__(self, text: Any) -> dict:
|
||||
# Simulate text processing work
|
||||
time.sleep(0.1)
|
||||
self.request_count += 1
|
||||
return {
|
||||
"request_count": self.request_count,
|
||||
}
|
||||
|
||||
|
||||
app = TextProcessor.bind()
|
||||
# __serve_example_end__
|
||||
|
||||
def main():
|
||||
import requests
|
||||
|
||||
serve.run(app)
|
||||
|
||||
# Test the deployment
|
||||
resp = requests.post(
|
||||
"http://localhost:8000/",
|
||||
json="hello world"
|
||||
)
|
||||
print(f"Response: {resp.json()}")
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# __client_script_begin__
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
import requests
|
||||
|
||||
APPLICATION_NAME = "my-app"
|
||||
DEPLOYMENT_NAME = "TextProcessor"
|
||||
SERVE_ENDPOINT = "http://localhost:8265"
|
||||
SCALING_INTERVAL = 300 # Check every 5 minutes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_current_replicas(app_name: str, deployment_name: str) -> int:
|
||||
"""Get current replica count. Returns -1 on error."""
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{SERVE_ENDPOINT}/api/serve/applications/",
|
||||
timeout=10
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"Failed to get applications: {resp.status_code}")
|
||||
return -1
|
||||
|
||||
apps = resp.json().get("applications", {})
|
||||
if app_name not in apps:
|
||||
logger.error(f"Application {app_name} not found")
|
||||
return -1
|
||||
|
||||
deployments = apps[app_name].get("deployments", {})
|
||||
if deployment_name in deployments:
|
||||
return deployments[deployment_name]["target_num_replicas"]
|
||||
|
||||
logger.error(f"Deployment {deployment_name} not found")
|
||||
return -1
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Request failed: {e}")
|
||||
return -1
|
||||
|
||||
|
||||
def scale_deployment(app_name: str, deployment_name: str):
|
||||
"""Scale deployment based on time of day."""
|
||||
hour = datetime.now().hour
|
||||
current = get_current_replicas(app_name, deployment_name)
|
||||
|
||||
# Check if we successfully retrieved the current replica count
|
||||
if current == -1:
|
||||
logger.error("Failed to get current replicas, skipping scaling decision")
|
||||
return
|
||||
|
||||
target = 10 if 9 <= hour < 17 else 3 # Peak hours: 9am-5pm
|
||||
|
||||
delta = target - current
|
||||
if delta == 0:
|
||||
logger.info(f"Already at target ({current} replicas)")
|
||||
return
|
||||
|
||||
action = "Adding" if delta > 0 else "Removing"
|
||||
logger.info(f"{action} {abs(delta)} replicas ({current} -> {target})")
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{SERVE_ENDPOINT}/api/v1/applications/{app_name}/deployments/{deployment_name}/scale",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json={"target_num_replicas": target},
|
||||
timeout=10
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
logger.info("Successfully scaled deployment")
|
||||
else:
|
||||
logger.error(f"Scale failed: {resp.status_code} - {resp.text}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Request failed: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
logger.info(f"Starting predictive scaling for {APPLICATION_NAME}/{DEPLOYMENT_NAME}")
|
||||
while True:
|
||||
scale_deployment(APPLICATION_NAME, DEPLOYMENT_NAME)
|
||||
time.sleep(SCALING_INTERVAL)
|
||||
# __client_script_end__
|
||||
@@ -0,0 +1,16 @@
|
||||
# __fake_start__
|
||||
from faker import Faker
|
||||
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment
|
||||
def create_fake_email():
|
||||
return Faker().email()
|
||||
|
||||
|
||||
app = create_fake_email.bind()
|
||||
# __fake_end__
|
||||
|
||||
handle = serve.run(app)
|
||||
assert handle.remote().result() == "fake@fake.com"
|
||||
@@ -0,0 +1,61 @@
|
||||
# __fake_config_start__
|
||||
apiVersion: ray.io/v1alpha1
|
||||
kind: RayService
|
||||
metadata:
|
||||
name: rayservice-fake-emails
|
||||
spec:
|
||||
serviceUnhealthySecondThreshold: 300
|
||||
deploymentUnhealthySecondThreshold: 300
|
||||
serveConfigV2: |
|
||||
applications:
|
||||
- name: fake
|
||||
import_path: fake:app
|
||||
route_prefix: /
|
||||
rayClusterConfig:
|
||||
rayVersion: '2.5.0' # Should match Ray version in the containers
|
||||
headGroupSpec:
|
||||
rayStartParams:
|
||||
dashboard-host: '0.0.0.0'
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: ray-head
|
||||
image: shrekrisanyscale/serve-fake-email-example:example
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
name: gcs-server
|
||||
- containerPort: 8265 # Ray dashboard
|
||||
name: dashboard
|
||||
- containerPort: 10001
|
||||
name: client
|
||||
- containerPort: 8000
|
||||
name: serve
|
||||
workerGroupSpecs:
|
||||
- replicas: 1
|
||||
minReplicas: 1
|
||||
maxReplicas: 1
|
||||
groupName: small-group
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: ray-worker
|
||||
image: shrekrisanyscale/serve-fake-email-example:example
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["/bin/sh","-c","ray stop"]
|
||||
resources:
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: "2Gi"
|
||||
requests:
|
||||
cpu: "500m"
|
||||
memory: "2Gi"
|
||||
# __fake_config_end__
|
||||
@@ -0,0 +1,8 @@
|
||||
class Faker:
|
||||
"""Mock Faker class to test fake_email_creator.py.
|
||||
|
||||
Meant to mock https://github.com/joke2k/faker package.
|
||||
"""
|
||||
|
||||
def email(self) -> str:
|
||||
return "fake@fake.com"
|
||||
@@ -0,0 +1,23 @@
|
||||
import requests
|
||||
from fastapi import FastAPI
|
||||
from ray import serve
|
||||
|
||||
# 1: Define a FastAPI app and wrap it in a deployment with a route handler.
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@serve.deployment
|
||||
@serve.ingress(app)
|
||||
class FastAPIDeployment:
|
||||
# FastAPI will automatically parse the HTTP request for us.
|
||||
@app.get("/hello")
|
||||
def say_hello(self, name: str) -> str:
|
||||
return f"Hello {name}!"
|
||||
|
||||
|
||||
# 2: Deploy the deployment.
|
||||
serve.run(FastAPIDeployment.bind(), route_prefix="/")
|
||||
|
||||
# 3: Query the deployment and print the result.
|
||||
print(requests.get("http://localhost:8000/hello", params={"name": "Theodore"}).json())
|
||||
# "Hello Theodore!"
|
||||
@@ -0,0 +1,178 @@
|
||||
# File name: config.yaml
|
||||
|
||||
kind: ConfigMap
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: redis-config
|
||||
labels:
|
||||
app: redis
|
||||
data:
|
||||
redis.conf: |-
|
||||
port 6379
|
||||
bind 0.0.0.0
|
||||
protected-mode no
|
||||
requirepass 5241590000000000
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- name: redis
|
||||
port: 6379
|
||||
selector:
|
||||
app: redis
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:5.0.8
|
||||
command:
|
||||
- "sh"
|
||||
- "-c"
|
||||
- "redis-server /usr/local/etc/redis/redis.conf"
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /usr/local/etc/redis/redis.conf
|
||||
subPath: redis.conf
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: redis-config
|
||||
---
|
||||
apiVersion: ray.io/v1alpha1
|
||||
kind: RayService
|
||||
metadata:
|
||||
name: rayservice-sample
|
||||
annotations:
|
||||
ray.io/ft-enabled: "true"
|
||||
spec:
|
||||
serviceUnhealthySecondThreshold: 300
|
||||
deploymentUnhealthySecondThreshold: 300
|
||||
serveConfig:
|
||||
importPath: "sleepy_pid:app"
|
||||
runtimeEnv: |
|
||||
working_dir: "https://github.com/ray-project/serve_config_examples/archive/42d10bab77741b40d11304ad66d39a4ec2345247.zip"
|
||||
deployments:
|
||||
- name: SleepyPid
|
||||
numReplicas: 6
|
||||
rayActorOptions:
|
||||
numCpus: 0
|
||||
rayClusterConfig:
|
||||
rayVersion: '2.3.0'
|
||||
headGroupSpec:
|
||||
replicas: 1
|
||||
rayStartParams:
|
||||
num-cpus: '2'
|
||||
dashboard-host: '0.0.0.0'
|
||||
redis-password: "5241590000000000"
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: ray-head
|
||||
image: rayproject/ray:2.3.0
|
||||
imagePullPolicy: Always
|
||||
env:
|
||||
- name: MY_POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
- name: RAY_REDIS_ADDRESS
|
||||
value: redis:6379
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: 2
|
||||
memory: 2Gi
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
name: redis
|
||||
- containerPort: 8265
|
||||
name: dashboard
|
||||
- containerPort: 10001
|
||||
name: client
|
||||
- containerPort: 8000
|
||||
name: serve
|
||||
workerGroupSpecs:
|
||||
- replicas: 2
|
||||
minReplicas: 2
|
||||
maxReplicas: 2
|
||||
groupName: small-group
|
||||
rayStartParams:
|
||||
node-ip-address: $MY_POD_IP
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: machine-learning
|
||||
image: rayproject/ray:2.3.0
|
||||
imagePullPolicy: Always
|
||||
env:
|
||||
- name: RAY_DISABLE_DOCKER_CPU_WARNING
|
||||
value: "1"
|
||||
- name: TYPE
|
||||
value: "worker"
|
||||
- name: CPU_REQUEST
|
||||
valueFrom:
|
||||
resourceFieldRef:
|
||||
containerName: machine-learning
|
||||
resource: requests.cpu
|
||||
- name: CPU_LIMITS
|
||||
valueFrom:
|
||||
resourceFieldRef:
|
||||
containerName: machine-learning
|
||||
resource: limits.cpu
|
||||
- name: MEMORY_LIMITS
|
||||
valueFrom:
|
||||
resourceFieldRef:
|
||||
containerName: machine-learning
|
||||
resource: limits.memory
|
||||
- name: MEMORY_REQUESTS
|
||||
valueFrom:
|
||||
resourceFieldRef:
|
||||
containerName: machine-learning
|
||||
resource: requests.memory
|
||||
- name: MY_POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
- name: MY_POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
ports:
|
||||
- containerPort: 80
|
||||
name: client
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["/bin/sh","-c","ray stop"]
|
||||
resources:
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: "2Gi"
|
||||
requests:
|
||||
cpu: "500m"
|
||||
memory: "2Gi"
|
||||
@@ -0,0 +1,24 @@
|
||||
# flake8: noqa
|
||||
|
||||
from ray import serve
|
||||
|
||||
# Stubs
|
||||
def connect_to_db(*args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
# __health_check_start__
|
||||
@serve.deployment(health_check_period_s=10, health_check_timeout_s=30)
|
||||
class MyDeployment:
|
||||
def __init__(self, db_addr: str):
|
||||
self._my_db_connection = connect_to_db(db_addr)
|
||||
|
||||
def __call__(self, request):
|
||||
return self._do_something_cool()
|
||||
|
||||
# Called by Serve to check the replica's health.
|
||||
def check_health(self):
|
||||
if not self._my_db_connection.is_connected():
|
||||
# The specific type of exception is not important.
|
||||
raise RuntimeError("uh-oh, DB connection is broken.")
|
||||
# __health_check_end__
|
||||
@@ -0,0 +1,23 @@
|
||||
# flake8: noqa
|
||||
|
||||
# __start__
|
||||
# File name: sleepy_pid.py
|
||||
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class SleepyPid:
|
||||
def __init__(self):
|
||||
import time
|
||||
|
||||
time.sleep(10)
|
||||
|
||||
def __call__(self) -> int:
|
||||
import os
|
||||
|
||||
return os.getpid()
|
||||
|
||||
|
||||
app = SleepyPid.bind()
|
||||
# __end__
|
||||
@@ -0,0 +1,183 @@
|
||||
from ray import serve
|
||||
|
||||
# __basic_gang_start__
|
||||
from ray import serve
|
||||
from ray.serve.config import GangSchedulingConfig
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
num_replicas=8,
|
||||
ray_actor_options={"num_cpus": 0.25},
|
||||
gang_scheduling_config=GangSchedulingConfig(gang_size=4),
|
||||
)
|
||||
class Gang:
|
||||
def __call__(self, request):
|
||||
return "Hello!"
|
||||
|
||||
|
||||
app = Gang.bind()
|
||||
# __basic_gang_end__
|
||||
|
||||
# __gang_context_start__
|
||||
@serve.deployment(
|
||||
num_replicas=4,
|
||||
ray_actor_options={"num_cpus": 0.25},
|
||||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||||
)
|
||||
class GangWithContext:
|
||||
def __init__(self):
|
||||
ctx = serve.get_replica_context()
|
||||
gc = ctx.gang_context
|
||||
self.rank = gc.rank
|
||||
self.world_size = gc.world_size
|
||||
self.gang_id = gc.gang_id
|
||||
self.member_ids = gc.member_replica_ids
|
||||
|
||||
def __call__(self, request):
|
||||
return {
|
||||
"gang_id": self.gang_id,
|
||||
"rank": self.rank,
|
||||
"world_size": self.world_size,
|
||||
}
|
||||
|
||||
|
||||
gang_context_app = GangWithContext.bind()
|
||||
# __gang_context_end__
|
||||
|
||||
# __pack_strategy_start__
|
||||
from ray import serve
|
||||
from ray.serve.config import GangPlacementStrategy, GangSchedulingConfig
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
num_replicas=4,
|
||||
ray_actor_options={"num_cpus": 0.25},
|
||||
gang_scheduling_config=GangSchedulingConfig(
|
||||
gang_size=4,
|
||||
gang_placement_strategy=GangPlacementStrategy.PACK,
|
||||
),
|
||||
)
|
||||
class PackedGang:
|
||||
def __call__(self, request):
|
||||
return "Packed on same node"
|
||||
|
||||
|
||||
packed_app = PackedGang.bind()
|
||||
# __pack_strategy_end__
|
||||
|
||||
# __spread_strategy_start__
|
||||
@serve.deployment(
|
||||
num_replicas=4,
|
||||
ray_actor_options={"num_cpus": 0.25},
|
||||
gang_scheduling_config=GangSchedulingConfig(
|
||||
gang_size=2,
|
||||
gang_placement_strategy=GangPlacementStrategy.SPREAD,
|
||||
),
|
||||
)
|
||||
class SpreadGang:
|
||||
def __call__(self, request):
|
||||
return "Spread across nodes"
|
||||
|
||||
|
||||
spread_app = SpreadGang.bind()
|
||||
# __spread_strategy_end__
|
||||
|
||||
# __options_start__
|
||||
@serve.deployment
|
||||
class BaseGang:
|
||||
def __call__(self, request):
|
||||
return "Hello!"
|
||||
|
||||
|
||||
app_with_gang = BaseGang.options(
|
||||
num_replicas=8,
|
||||
ray_actor_options={"num_cpus": 0.25},
|
||||
gang_scheduling_config=GangSchedulingConfig(gang_size=4),
|
||||
).bind()
|
||||
# __options_end__
|
||||
|
||||
# __autoscaling_start__
|
||||
@serve.deployment(
|
||||
autoscaling_config={
|
||||
"min_replicas": 4,
|
||||
"max_replicas": 16,
|
||||
"initial_replicas": 8,
|
||||
"target_ongoing_requests": 5,
|
||||
},
|
||||
ray_actor_options={"num_cpus": 0.25},
|
||||
gang_scheduling_config=GangSchedulingConfig(gang_size=4),
|
||||
)
|
||||
class AutoscaledGang:
|
||||
def __call__(self, request):
|
||||
return "Hello!"
|
||||
|
||||
|
||||
autoscaled_app = AutoscaledGang.bind()
|
||||
# __autoscaling_end__
|
||||
|
||||
# __fault_tolerance_start__
|
||||
from ray import serve
|
||||
from ray.serve.config import GangRuntimeFailurePolicy, GangSchedulingConfig
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
num_replicas=8,
|
||||
ray_actor_options={"num_cpus": 0.25},
|
||||
gang_scheduling_config=GangSchedulingConfig(
|
||||
gang_size=4,
|
||||
runtime_failure_policy=GangRuntimeFailurePolicy.RESTART_GANG,
|
||||
),
|
||||
)
|
||||
class FaultTolerantGang:
|
||||
def __call__(self, request):
|
||||
return "Hello!"
|
||||
|
||||
|
||||
fault_tolerant_app = FaultTolerantGang.bind()
|
||||
# __fault_tolerance_end__
|
||||
|
||||
# __placement_group_bundles_start__
|
||||
@serve.deployment(
|
||||
num_replicas=4,
|
||||
ray_actor_options={"num_cpus": 0},
|
||||
placement_group_bundles=[{"CPU": 1, "GPU": 1}],
|
||||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||||
)
|
||||
class GangWithSingleBundleReplica:
|
||||
def __call__(self, request):
|
||||
return "Running on reserved GPUs"
|
||||
|
||||
|
||||
gang_single_bundle_replica_app = GangWithSingleBundleReplica.bind()
|
||||
# __placement_group_bundles_end__
|
||||
|
||||
# __multi_placement_group_bundles_start__
|
||||
@serve.deployment(
|
||||
num_replicas=4,
|
||||
ray_actor_options={"num_cpus": 1},
|
||||
placement_group_bundles=[{"CPU": 1, "GPU": 1}, {"GPU": 1}],
|
||||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||||
)
|
||||
class GangWithMultiBundlesReplica:
|
||||
def __call__(self, request):
|
||||
return "Running on reserved GPUs"
|
||||
|
||||
|
||||
gang_multi_bundles_replica_app = GangWithMultiBundlesReplica.bind()
|
||||
# __multi_placement_group_bundles_end__
|
||||
|
||||
# __label_selector_start__
|
||||
@serve.deployment(
|
||||
num_replicas=4,
|
||||
ray_actor_options={"num_cpus": 0},
|
||||
placement_group_bundles=[{"CPU": 1, "GPU": 1}],
|
||||
placement_group_bundle_label_selector=[{"ray.io/accelerator-type": "A100"}],
|
||||
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
|
||||
)
|
||||
class GangOnA100:
|
||||
def __call__(self, request):
|
||||
return "Running on A100"
|
||||
|
||||
|
||||
gang_a100_app = GangOnA100.bind()
|
||||
# __label_selector_end__
|
||||
@@ -0,0 +1,67 @@
|
||||
# flake8: noqa
|
||||
|
||||
# __import_start__
|
||||
from starlette.requests import Request
|
||||
|
||||
import ray
|
||||
from ray import serve
|
||||
|
||||
# __import_end__
|
||||
|
||||
# __model_start__
|
||||
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
||||
|
||||
|
||||
@serve.deployment(num_replicas=2, ray_actor_options={"num_cpus": 0.2, "num_gpus": 0})
|
||||
class Translator:
|
||||
def __init__(self):
|
||||
# Load model
|
||||
self.tokenizer = AutoTokenizer.from_pretrained("t5-small")
|
||||
self.model = AutoModelForSeq2SeqLM.from_pretrained("t5-small")
|
||||
|
||||
def translate(self, text: str) -> str:
|
||||
# Run inference
|
||||
input_ids = self.tokenizer(
|
||||
f"translate English to French: {text}", return_tensors="pt"
|
||||
).input_ids
|
||||
output_ids = self.model.generate(
|
||||
input_ids, num_beams=4, early_stopping=True, max_length=300
|
||||
)
|
||||
|
||||
# Post-process output to return only the translation text
|
||||
translation = self.tokenizer.decode(
|
||||
output_ids[0], skip_special_tokens=True, clean_up_tokenization_spaces=False
|
||||
)
|
||||
|
||||
return translation
|
||||
|
||||
async def __call__(self, http_request: Request) -> str:
|
||||
english_text: str = await http_request.json()
|
||||
return self.translate(english_text)
|
||||
|
||||
|
||||
# __model_end__
|
||||
|
||||
# __model_deploy_start__
|
||||
translator_app = Translator.bind()
|
||||
# __model_deploy_end__
|
||||
|
||||
translator_app = Translator.options(ray_actor_options={}).bind()
|
||||
serve.run(translator_app)
|
||||
|
||||
# __client_function_start__
|
||||
# File name: model_client.py
|
||||
import requests
|
||||
|
||||
english_text = "Hello world!"
|
||||
|
||||
response = requests.post("http://127.0.0.1:8000/", json=english_text)
|
||||
french_text = response.text
|
||||
|
||||
print(french_text)
|
||||
# __client_function_end__
|
||||
|
||||
assert french_text == "Bonjour monde!"
|
||||
|
||||
serve.shutdown()
|
||||
ray.shutdown()
|
||||
@@ -0,0 +1,53 @@
|
||||
# flake8: noqa
|
||||
|
||||
# __deployment_full_start__
|
||||
# File name: serve_quickstart.py
|
||||
from starlette.requests import Request
|
||||
|
||||
import ray
|
||||
from ray import serve
|
||||
|
||||
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
||||
|
||||
|
||||
@serve.deployment(num_replicas=2, ray_actor_options={"num_cpus": 0.2, "num_gpus": 0})
|
||||
class Translator:
|
||||
def __init__(self):
|
||||
# Load model
|
||||
self.tokenizer = AutoTokenizer.from_pretrained("t5-small")
|
||||
self.model = AutoModelForSeq2SeqLM.from_pretrained("t5-small")
|
||||
|
||||
def translate(self, text: str) -> str:
|
||||
# Run inference
|
||||
input_ids = self.tokenizer(
|
||||
f"translate English to French: {text}", return_tensors="pt"
|
||||
).input_ids
|
||||
output_ids = self.model.generate(
|
||||
input_ids, num_beams=4, early_stopping=True, max_length=300
|
||||
)
|
||||
|
||||
# Post-process output to return only the translation text
|
||||
translation = self.tokenizer.decode(
|
||||
output_ids[0], skip_special_tokens=True, clean_up_tokenization_spaces=False
|
||||
)
|
||||
|
||||
return translation
|
||||
|
||||
async def __call__(self, http_request: Request) -> str:
|
||||
english_text: str = await http_request.json()
|
||||
return self.translate(english_text)
|
||||
|
||||
|
||||
translator_app = Translator.bind()
|
||||
# __deployment_full_end__
|
||||
|
||||
translator_app = Translator.options(ray_actor_options={}).bind()
|
||||
serve.run(translator_app)
|
||||
|
||||
import requests
|
||||
|
||||
response = requests.post("http://127.0.0.1:8000/", json="Hello world!").text
|
||||
assert response == "Bonjour monde!"
|
||||
|
||||
serve.shutdown()
|
||||
ray.shutdown()
|
||||
@@ -0,0 +1,83 @@
|
||||
# flake8: noqa
|
||||
|
||||
# __start_translation_model__
|
||||
# File name: model.py
|
||||
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
||||
|
||||
|
||||
class Translator:
|
||||
def __init__(self):
|
||||
# Load model
|
||||
self.tokenizer = AutoTokenizer.from_pretrained("t5-small")
|
||||
self.model = AutoModelForSeq2SeqLM.from_pretrained("t5-small")
|
||||
|
||||
def translate(self, text: str) -> str:
|
||||
# Run inference
|
||||
input_ids = self.tokenizer(
|
||||
f"translate English to French: {text}", return_tensors="pt"
|
||||
).input_ids
|
||||
output_ids = self.model.generate(
|
||||
input_ids, num_beams=4, early_stopping=True, max_length=300
|
||||
)
|
||||
|
||||
# Post-process output to return only the translation text
|
||||
translation = self.tokenizer.decode(
|
||||
output_ids[0], skip_special_tokens=True, clean_up_tokenization_spaces=False
|
||||
)
|
||||
|
||||
return translation
|
||||
|
||||
|
||||
translator = Translator()
|
||||
|
||||
translation = translator.translate("Hello world!")
|
||||
print(translation)
|
||||
# __end_translation_model__
|
||||
|
||||
# Test model behavior
|
||||
assert translation == "Bonjour monde!"
|
||||
|
||||
|
||||
# __start_summarization_model__
|
||||
# File name: summary_model.py
|
||||
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
||||
|
||||
|
||||
class Summarizer:
|
||||
def __init__(self):
|
||||
# Load model
|
||||
self.tokenizer = AutoTokenizer.from_pretrained("t5-small")
|
||||
self.model = AutoModelForSeq2SeqLM.from_pretrained("t5-small")
|
||||
|
||||
def summarize(self, text: str) -> str:
|
||||
# Run inference
|
||||
input_ids = self.tokenizer(f"summarize: {text}", return_tensors="pt").input_ids
|
||||
output_ids = self.model.generate(
|
||||
input_ids,
|
||||
num_beams=4,
|
||||
early_stopping=True,
|
||||
length_penalty=2.0,
|
||||
no_repeat_ngram_size=3,
|
||||
min_length=5,
|
||||
max_length=15,
|
||||
)
|
||||
|
||||
# Post-process output to return only the summary text
|
||||
summary = self.tokenizer.decode(
|
||||
output_ids[0], skip_special_tokens=True, clean_up_tokenization_spaces=False
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
summarizer = Summarizer()
|
||||
|
||||
summary = summarizer.summarize(
|
||||
"It was the best of times, it was the worst of times, it was the age "
|
||||
"of wisdom, it was the age of foolishness, it was the epoch of belief"
|
||||
)
|
||||
print(summary)
|
||||
# __end_summarization_model__
|
||||
|
||||
# Test model behavior
|
||||
assert summary == "it was the best of times, it was worst of times ."
|
||||
@@ -0,0 +1,91 @@
|
||||
# flake8: noqa
|
||||
|
||||
# __start_graph__
|
||||
# File name: serve_quickstart_composed.py
|
||||
from starlette.requests import Request
|
||||
|
||||
import ray
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Translator:
|
||||
def __init__(self):
|
||||
# Load model
|
||||
self.tokenizer = AutoTokenizer.from_pretrained("t5-small")
|
||||
self.model = AutoModelForSeq2SeqLM.from_pretrained("t5-small")
|
||||
|
||||
def translate(self, text: str) -> str:
|
||||
# Run inference
|
||||
input_ids = self.tokenizer(
|
||||
f"translate English to French: {text}", return_tensors="pt"
|
||||
).input_ids
|
||||
output_ids = self.model.generate(
|
||||
input_ids, num_beams=4, early_stopping=True, max_length=300
|
||||
)
|
||||
|
||||
# Post-process output to return only the translation text
|
||||
translation = self.tokenizer.decode(
|
||||
output_ids[0], skip_special_tokens=True, clean_up_tokenization_spaces=False
|
||||
)
|
||||
|
||||
return translation
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Summarizer:
|
||||
def __init__(self, translator: DeploymentHandle):
|
||||
self.translator = translator
|
||||
|
||||
# Load model.
|
||||
self.tokenizer = AutoTokenizer.from_pretrained("t5-small")
|
||||
self.model = AutoModelForSeq2SeqLM.from_pretrained("t5-small")
|
||||
|
||||
def summarize(self, text: str) -> str:
|
||||
# Run inference
|
||||
input_ids = self.tokenizer(f"summarize: {text}", return_tensors="pt").input_ids
|
||||
output_ids = self.model.generate(
|
||||
input_ids, num_beams=4, early_stopping=True, max_length=15
|
||||
)
|
||||
|
||||
# Post-process output to return only the summary text
|
||||
summary = self.tokenizer.decode(
|
||||
output_ids[0], skip_special_tokens=True, clean_up_tokenization_spaces=False
|
||||
)
|
||||
|
||||
return summary
|
||||
|
||||
async def __call__(self, http_request: Request) -> str:
|
||||
english_text: str = await http_request.json()
|
||||
summary = self.summarize(english_text)
|
||||
|
||||
translation = await self.translator.translate.remote(summary)
|
||||
return translation
|
||||
|
||||
|
||||
app = Summarizer.bind(Translator.bind())
|
||||
# __end_graph__
|
||||
|
||||
serve.run(app)
|
||||
|
||||
# __start_client__
|
||||
# File name: composed_client.py
|
||||
import requests
|
||||
|
||||
english_text = (
|
||||
"It was the best of times, it was the worst of times, it was the age "
|
||||
"of wisdom, it was the age of foolishness, it was the epoch of belief"
|
||||
)
|
||||
response = requests.post("http://127.0.0.1:8000/", json=english_text)
|
||||
french_text = response.text
|
||||
|
||||
print(french_text)
|
||||
# __end_client__
|
||||
|
||||
assert french_text == "C'était le meilleur des temps, c'était le pire des temps,"
|
||||
|
||||
serve.shutdown()
|
||||
ray.shutdown()
|
||||
@@ -0,0 +1,74 @@
|
||||
import requests
|
||||
|
||||
# __doc_import_begin__
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
from ray.serve.gradio_integrations import GradioIngress
|
||||
|
||||
import gradio as gr
|
||||
|
||||
import asyncio
|
||||
from transformers import pipeline
|
||||
|
||||
# __doc_import_end__
|
||||
|
||||
|
||||
# __doc_models_begin__
|
||||
@serve.deployment
|
||||
class TextGenerationModel:
|
||||
def __init__(self, model_name):
|
||||
self.generator = pipeline("text-generation", model=model_name)
|
||||
|
||||
def __call__(self, text):
|
||||
generated_list = self.generator(
|
||||
text, do_sample=True, min_length=20, max_length=100
|
||||
)
|
||||
generated = generated_list[0]["generated_text"]
|
||||
return generated
|
||||
|
||||
|
||||
app1 = TextGenerationModel.bind("gpt2")
|
||||
app2 = TextGenerationModel.bind("distilgpt2")
|
||||
# __doc_models_end__
|
||||
|
||||
|
||||
# __doc_gradio_server_begin__
|
||||
@serve.deployment
|
||||
class MyGradioServer(GradioIngress):
|
||||
def __init__(
|
||||
self, downstream_model_1: DeploymentHandle, downstream_model_2: DeploymentHandle
|
||||
):
|
||||
self._d1 = downstream_model_1
|
||||
self._d2 = downstream_model_2
|
||||
|
||||
super().__init__(
|
||||
lambda: gr.Interface(
|
||||
self.fanout, "textbox", "textbox", api_name="predict"
|
||||
)
|
||||
)
|
||||
|
||||
async def fanout(self, text):
|
||||
[result1, result2] = await asyncio.gather(
|
||||
self._d1.remote(text), self._d2.remote(text)
|
||||
)
|
||||
return (
|
||||
f"[Generated text version 1]\n{result1}\n\n"
|
||||
f"[Generated text version 2]\n{result2}"
|
||||
)
|
||||
# __doc_gradio_server_end__
|
||||
|
||||
|
||||
# __doc_app_begin__
|
||||
app = MyGradioServer.bind(app1, app2)
|
||||
# __doc_app_end__
|
||||
|
||||
# Test example code
|
||||
serve.run(app)
|
||||
response = requests.post(
|
||||
"http://127.0.0.1:8000/gradio_api/run/predict/", json={"data": ["My name is Lewis"]}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
print(
|
||||
"gradio-integration-parallel.py: Response from example code is",
|
||||
response.json()["data"],
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
import requests
|
||||
from ray import serve
|
||||
|
||||
# __doc_import_begin__
|
||||
from ray.serve.gradio_integrations import GradioServer
|
||||
|
||||
import gradio as gr
|
||||
|
||||
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
||||
|
||||
# __doc_import_end__
|
||||
|
||||
|
||||
# __doc_gradio_app_begin__
|
||||
example_input = (
|
||||
"HOUSTON -- Men have landed and walked on the moon. "
|
||||
"Two Americans, astronauts of Apollo 11, steered their fragile "
|
||||
"four-legged lunar module safely and smoothly to the historic landing "
|
||||
"yesterday at 4:17:40 P.M., Eastern daylight time. Neil A. Armstrong, the "
|
||||
"38-year-old commander, radioed to earth and the mission control room "
|
||||
'here: "Houston, Tranquility Base here. The Eagle has landed." The '
|
||||
"first men to reach the moon -- Armstrong and his co-pilot, Col. Edwin E. "
|
||||
"Aldrin Jr. of the Air Force -- brought their ship to rest on a level, "
|
||||
"rock-strewn plain near the southwestern shore of the arid Sea of "
|
||||
"Tranquility. About six and a half hours later, Armstrong opened the "
|
||||
"landing craft's hatch, stepped slowly down the ladder and declared as "
|
||||
"he planted the first human footprint on the lunar crust: \"That's one "
|
||||
'small step for man, one giant leap for mankind." His first step on the '
|
||||
"moon came at 10:56:20 P.M., as a television camera outside the craft "
|
||||
"transmitted his every move to an awed and excited audience of hundreds "
|
||||
"of millions of people on earth."
|
||||
)
|
||||
|
||||
|
||||
def gradio_summarizer_builder():
|
||||
tokenizer = AutoTokenizer.from_pretrained("t5-small")
|
||||
summarizer_model = AutoModelForSeq2SeqLM.from_pretrained("t5-small")
|
||||
|
||||
def model(text):
|
||||
input_ids = tokenizer(f"summarize: {text}", return_tensors="pt").input_ids
|
||||
output_ids = summarizer_model.generate(
|
||||
input_ids, num_beams=4, early_stopping=True, max_length=200
|
||||
)
|
||||
return tokenizer.decode(
|
||||
output_ids[0], skip_special_tokens=True, clean_up_tokenization_spaces=False
|
||||
)
|
||||
|
||||
return gr.Interface(
|
||||
fn=model,
|
||||
inputs=[gr.Textbox(value=example_input, label="Input prompt")],
|
||||
outputs=[gr.Textbox(label="Model output")],
|
||||
api_name="predict",
|
||||
)
|
||||
# __doc_gradio_app_end__
|
||||
|
||||
|
||||
# __doc_app_begin__
|
||||
app = GradioServer.options(ray_actor_options={"num_cpus": 4}).bind(
|
||||
gradio_summarizer_builder
|
||||
)
|
||||
# __doc_app_end__
|
||||
|
||||
# Test example code
|
||||
serve.run(app)
|
||||
response = requests.post(
|
||||
"http://127.0.0.1:8000/gradio_api/run/predict/", json={"data": [example_input]}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
print("gradio-integration.py: Response from example code is", response.json()["data"])
|
||||
serve.shutdown()
|
||||
@@ -0,0 +1,36 @@
|
||||
import gradio as gr
|
||||
from transformers import pipeline
|
||||
import requests
|
||||
|
||||
# __doc_code_begin__
|
||||
generator1 = pipeline("text-generation", model="gpt2")
|
||||
generator2 = pipeline("text-generation", model="distilgpt2")
|
||||
|
||||
|
||||
def model1(text):
|
||||
generated_list = generator1(text, do_sample=True, min_length=20, max_length=100)
|
||||
generated = generated_list[0]["generated_text"]
|
||||
return generated
|
||||
|
||||
|
||||
def model2(text):
|
||||
generated_list = generator2(text, do_sample=True, min_length=20, max_length=100)
|
||||
generated = generated_list[0]["generated_text"]
|
||||
return generated
|
||||
|
||||
|
||||
demo = gr.Interface(
|
||||
lambda text: f"{model1(text)}\n------------\n{model2(text)}",
|
||||
"textbox",
|
||||
"textbox",
|
||||
api_name="predict",
|
||||
)
|
||||
# __doc_code_end__
|
||||
|
||||
# Test example code
|
||||
demo.launch(prevent_thread_lock=True)
|
||||
response = requests.post(
|
||||
"http://127.0.0.1:7860/gradio_api/run/predict/", json={"data": ["My name is Lewis"]}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
print("gradio-original.py: Response from example code is", response.json()["data"])
|
||||
@@ -0,0 +1,513 @@
|
||||
# flake8: noqa
|
||||
import ray
|
||||
|
||||
|
||||
ray.init()
|
||||
|
||||
# __begin_start_grpc_proxy__
|
||||
from ray import serve
|
||||
from ray.serve.config import gRPCOptions
|
||||
|
||||
|
||||
grpc_port = 9000
|
||||
grpc_servicer_functions = [
|
||||
"user_defined_protos_pb2_grpc.add_UserDefinedServiceServicer_to_server",
|
||||
"user_defined_protos_pb2_grpc.add_ImageClassificationServiceServicer_to_server",
|
||||
]
|
||||
serve.start(
|
||||
grpc_options=gRPCOptions(
|
||||
port=grpc_port,
|
||||
grpc_servicer_functions=grpc_servicer_functions,
|
||||
),
|
||||
)
|
||||
# __end_start_grpc_proxy__
|
||||
|
||||
|
||||
# __begin_grpc_deployment__
|
||||
import time
|
||||
|
||||
from typing import Generator
|
||||
from user_defined_protos_pb2 import (
|
||||
UserDefinedMessage,
|
||||
UserDefinedMessage2,
|
||||
UserDefinedResponse,
|
||||
UserDefinedResponse2,
|
||||
)
|
||||
|
||||
import ray
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class GrpcDeployment:
|
||||
def __call__(self, user_message: UserDefinedMessage) -> UserDefinedResponse:
|
||||
greeting = f"Hello {user_message.name} from {user_message.origin}"
|
||||
num = user_message.num * 2
|
||||
user_response = UserDefinedResponse(
|
||||
greeting=greeting,
|
||||
num=num,
|
||||
)
|
||||
return user_response
|
||||
|
||||
@serve.multiplexed(max_num_models_per_replica=1)
|
||||
async def get_model(self, model_id: str) -> str:
|
||||
return f"loading model: {model_id}"
|
||||
|
||||
async def Multiplexing(
|
||||
self, user_message: UserDefinedMessage2
|
||||
) -> UserDefinedResponse2:
|
||||
model_id = serve.get_multiplexed_model_id()
|
||||
model = await self.get_model(model_id)
|
||||
user_response = UserDefinedResponse2(
|
||||
greeting=f"Method2 called model, {model}",
|
||||
)
|
||||
return user_response
|
||||
|
||||
def Streaming(
|
||||
self, user_message: UserDefinedMessage
|
||||
) -> Generator[UserDefinedResponse, None, None]:
|
||||
for i in range(10):
|
||||
greeting = f"{i}: Hello {user_message.name} from {user_message.origin}"
|
||||
num = user_message.num * 2 + i
|
||||
user_response = UserDefinedResponse(
|
||||
greeting=greeting,
|
||||
num=num,
|
||||
)
|
||||
yield user_response
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
g = GrpcDeployment.bind()
|
||||
# __end_grpc_deployment__
|
||||
|
||||
|
||||
# __begin_deploy_grpc_app__
|
||||
app1 = "app1"
|
||||
serve.run(target=g, name=app1, route_prefix=f"/{app1}")
|
||||
# __end_deploy_grpc_app__
|
||||
|
||||
|
||||
# __begin_send_grpc_requests__
|
||||
import grpc
|
||||
from user_defined_protos_pb2_grpc import UserDefinedServiceStub
|
||||
from user_defined_protos_pb2 import UserDefinedMessage
|
||||
|
||||
|
||||
channel = grpc.insecure_channel("localhost:9000")
|
||||
stub = UserDefinedServiceStub(channel)
|
||||
request = UserDefinedMessage(name="foo", num=30, origin="bar")
|
||||
|
||||
response, call = stub.__call__.with_call(request=request)
|
||||
print(f"status code: {call.code()}") # grpc.StatusCode.OK
|
||||
print(f"greeting: {response.greeting}") # "Hello foo from bar"
|
||||
print(f"num: {response.num}") # 60
|
||||
# __end_send_grpc_requests__
|
||||
|
||||
|
||||
# __begin_health_check__
|
||||
import grpc
|
||||
from ray.serve.generated.serve_pb2_grpc import RayServeAPIServiceStub
|
||||
from ray.serve.generated.serve_pb2 import HealthzRequest, ListApplicationsRequest
|
||||
|
||||
|
||||
channel = grpc.insecure_channel("localhost:9000")
|
||||
stub = RayServeAPIServiceStub(channel)
|
||||
request = ListApplicationsRequest()
|
||||
response = stub.ListApplications(request=request)
|
||||
print(f"Applications: {response.application_names}") # ["app1"]
|
||||
|
||||
request = HealthzRequest()
|
||||
response = stub.Healthz(request=request)
|
||||
print(f"Health: {response.message}") # "success"
|
||||
# __end_health_check__
|
||||
|
||||
|
||||
# __begin_metadata__
|
||||
import grpc
|
||||
from user_defined_protos_pb2_grpc import UserDefinedServiceStub
|
||||
from user_defined_protos_pb2 import UserDefinedMessage2
|
||||
|
||||
|
||||
channel = grpc.insecure_channel("localhost:9000")
|
||||
stub = UserDefinedServiceStub(channel)
|
||||
request = UserDefinedMessage2()
|
||||
app_name = "app1"
|
||||
request_id = "123"
|
||||
multiplexed_model_id = "999"
|
||||
metadata = (
|
||||
("application", app_name),
|
||||
("request_id", request_id),
|
||||
("multiplexed_model_id", multiplexed_model_id),
|
||||
)
|
||||
|
||||
response, call = stub.Multiplexing.with_call(request=request, metadata=metadata)
|
||||
print(f"greeting: {response.greeting}") # "Method2 called model, loading model: 999"
|
||||
for key, value in call.trailing_metadata():
|
||||
print(f"trailing metadata key: {key}, value {value}") # "request_id: 123"
|
||||
# __end_metadata__
|
||||
|
||||
|
||||
# __begin_streaming__
|
||||
import grpc
|
||||
from user_defined_protos_pb2_grpc import UserDefinedServiceStub
|
||||
from user_defined_protos_pb2 import UserDefinedMessage
|
||||
|
||||
|
||||
channel = grpc.insecure_channel("localhost:9000")
|
||||
stub = UserDefinedServiceStub(channel)
|
||||
request = UserDefinedMessage(name="foo", num=30, origin="bar")
|
||||
metadata = (("application", "app1"),)
|
||||
|
||||
responses = stub.Streaming(request=request, metadata=metadata)
|
||||
for response in responses:
|
||||
print(f"greeting: {response.greeting}") # greeting: n: Hello foo from bar
|
||||
print(f"num: {response.num}") # num: 60 + n
|
||||
# __end_streaming__
|
||||
|
||||
|
||||
# __begin_model_composition_deployment__
|
||||
import requests
|
||||
import torch
|
||||
from typing import List
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
from torchvision import transforms
|
||||
from torchvision.models import resnet18, ResNet18_Weights
|
||||
from user_defined_protos_pb2 import (
|
||||
ImageClass,
|
||||
ImageData,
|
||||
)
|
||||
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class ImageClassifier:
|
||||
def __init__(
|
||||
self,
|
||||
_image_downloader: DeploymentHandle,
|
||||
_data_preprocessor: DeploymentHandle,
|
||||
):
|
||||
self._image_downloader = _image_downloader
|
||||
self._data_preprocessor = _data_preprocessor
|
||||
self.model = resnet18(weights=ResNet18_Weights.DEFAULT)
|
||||
self.model.eval()
|
||||
self.categories = self._image_labels()
|
||||
|
||||
def _image_labels(self) -> List[str]:
|
||||
categories = []
|
||||
url = (
|
||||
"https://raw.githubusercontent.com/pytorch/hub/master/imagenet_classes.txt"
|
||||
)
|
||||
labels = requests.get(url).text
|
||||
for label in labels.split("\n"):
|
||||
categories.append(label.strip())
|
||||
return categories
|
||||
|
||||
async def Predict(self, image_data: ImageData) -> ImageClass:
|
||||
# Download image
|
||||
image = await self._image_downloader.remote(image_data.url)
|
||||
|
||||
# Preprocess image
|
||||
input_batch = await self._data_preprocessor.remote(image)
|
||||
# Predict image
|
||||
with torch.no_grad():
|
||||
output = self.model(input_batch)
|
||||
|
||||
probabilities = torch.nn.functional.softmax(output[0], dim=0)
|
||||
return self.process_model_outputs(probabilities)
|
||||
|
||||
def process_model_outputs(self, probabilities: torch.Tensor) -> ImageClass:
|
||||
image_classes = []
|
||||
image_probabilities = []
|
||||
# Show top categories per image
|
||||
top5_prob, top5_catid = torch.topk(probabilities, 5)
|
||||
for i in range(top5_prob.size(0)):
|
||||
image_classes.append(self.categories[top5_catid[i]])
|
||||
image_probabilities.append(top5_prob[i].item())
|
||||
|
||||
return ImageClass(
|
||||
classes=image_classes,
|
||||
probabilities=image_probabilities,
|
||||
)
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class ImageDownloader:
|
||||
def __call__(self, image_url: str):
|
||||
image_bytes = requests.get(image_url).content
|
||||
return Image.open(BytesIO(image_bytes)).convert("RGB")
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class DataPreprocessor:
|
||||
def __init__(self):
|
||||
self.preprocess = transforms.Compose(
|
||||
[
|
||||
transforms.Resize(256),
|
||||
transforms.CenterCrop(224),
|
||||
transforms.ToTensor(),
|
||||
transforms.Normalize(
|
||||
mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def __call__(self, image: Image):
|
||||
input_tensor = self.preprocess(image)
|
||||
return input_tensor.unsqueeze(0) # create a mini-batch as expected by the model
|
||||
|
||||
|
||||
image_downloader = ImageDownloader.bind()
|
||||
data_preprocessor = DataPreprocessor.bind()
|
||||
g2 = ImageClassifier.options(name="grpc-image-classifier").bind(
|
||||
image_downloader, data_preprocessor
|
||||
)
|
||||
# __end_model_composition_deployment__
|
||||
|
||||
|
||||
# __begin_model_composition_deploy__
|
||||
app2 = "app2"
|
||||
serve.run(target=g2, name=app2, route_prefix=f"/{app2}")
|
||||
# __end_model_composition_deploy__
|
||||
|
||||
|
||||
# __begin_model_composition_client__
|
||||
import grpc
|
||||
from user_defined_protos_pb2_grpc import ImageClassificationServiceStub
|
||||
from user_defined_protos_pb2 import ImageData
|
||||
|
||||
|
||||
channel = grpc.insecure_channel("localhost:9000")
|
||||
stub = ImageClassificationServiceStub(channel)
|
||||
request = ImageData(url="https://github.com/pytorch/hub/raw/master/images/dog.jpg")
|
||||
metadata = (("application", "app2"),) # Make sure application metadata is passed.
|
||||
|
||||
response, call = stub.Predict.with_call(request=request, metadata=metadata)
|
||||
print(f"status code: {call.code()}") # grpc.StatusCode.OK
|
||||
print(f"Classes: {response.classes}") # ['Samoyed', ...]
|
||||
print(f"Probabilities: {response.probabilities}") # [0.8846230506896973, ...]
|
||||
# __end_model_composition_client__
|
||||
|
||||
|
||||
# __begin_error_handle__
|
||||
import grpc
|
||||
from user_defined_protos_pb2_grpc import UserDefinedServiceStub
|
||||
from user_defined_protos_pb2 import UserDefinedMessage
|
||||
|
||||
|
||||
channel = grpc.insecure_channel("localhost:9000")
|
||||
stub = UserDefinedServiceStub(channel)
|
||||
request = UserDefinedMessage(name="foo", num=30, origin="bar")
|
||||
|
||||
try:
|
||||
response = stub.__call__(request=request)
|
||||
except grpc.RpcError as rpc_error:
|
||||
print(f"status code: {rpc_error.code()}") # StatusCode.NOT_FOUND
|
||||
print(f"details: {rpc_error.details()}") # Application metadata not set...
|
||||
# __end_error_handle__
|
||||
|
||||
|
||||
# __begin_grpc_context_define_app__
|
||||
from user_defined_protos_pb2 import UserDefinedMessage, UserDefinedResponse
|
||||
|
||||
from ray import serve
|
||||
from ray.serve.grpc_util import RayServegRPCContext
|
||||
|
||||
import grpc
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class GrpcDeployment:
|
||||
def __init__(self):
|
||||
self.nums = {}
|
||||
|
||||
def num_lookup(self, name: str) -> Tuple[int, grpc.StatusCode, str]:
|
||||
if name not in self.nums:
|
||||
self.nums[name] = len(self.nums)
|
||||
code = grpc.StatusCode.INVALID_ARGUMENT
|
||||
message = f"{name} not found, adding to nums."
|
||||
else:
|
||||
code = grpc.StatusCode.OK
|
||||
message = f"{name} found."
|
||||
return self.nums[name], code, message
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
user_message: UserDefinedMessage,
|
||||
grpc_context: RayServegRPCContext, # to use grpc context, add this kwarg
|
||||
) -> UserDefinedResponse:
|
||||
greeting = f"Hello {user_message.name} from {user_message.origin}"
|
||||
num, code, message = self.num_lookup(user_message.name)
|
||||
|
||||
# Set custom code, details, and trailing metadata.
|
||||
grpc_context.set_code(code)
|
||||
grpc_context.set_details(message)
|
||||
grpc_context.set_trailing_metadata([("num", str(num))])
|
||||
|
||||
# You can also set a status code before raising an exception.
|
||||
# The status code will be preserved in the response.
|
||||
if user_message.name == "error":
|
||||
grpc_context.set_code(grpc.StatusCode.RESOURCE_EXHAUSTED)
|
||||
grpc_context.set_details("Resource exhausted, please retry later.")
|
||||
raise RuntimeError("Simulated error")
|
||||
|
||||
user_response = UserDefinedResponse(
|
||||
greeting=greeting,
|
||||
num=num,
|
||||
)
|
||||
return user_response
|
||||
|
||||
|
||||
g = GrpcDeployment.bind()
|
||||
app1 = "app1"
|
||||
serve.run(target=g, name=app1, route_prefix=f"/{app1}")
|
||||
# __end_grpc_context_define_app__
|
||||
|
||||
|
||||
# __begin_grpc_context_client__
|
||||
import grpc
|
||||
from user_defined_protos_pb2_grpc import UserDefinedServiceStub
|
||||
from user_defined_protos_pb2 import UserDefinedMessage
|
||||
|
||||
|
||||
channel = grpc.insecure_channel("localhost:9000")
|
||||
stub = UserDefinedServiceStub(channel)
|
||||
request = UserDefinedMessage(name="foo", num=30, origin="bar")
|
||||
metadata = (("application", "app1"),)
|
||||
|
||||
# First call is going to page miss and return INVALID_ARGUMENT status code.
|
||||
try:
|
||||
response, call = stub.__call__.with_call(request=request, metadata=metadata)
|
||||
except grpc.RpcError as rpc_error:
|
||||
assert rpc_error.code() == grpc.StatusCode.INVALID_ARGUMENT
|
||||
assert rpc_error.details() == "foo not found, adding to nums."
|
||||
assert any(
|
||||
[key == "num" and value == "0" for key, value in rpc_error.trailing_metadata()]
|
||||
)
|
||||
assert any([key == "request_id" for key, _ in rpc_error.trailing_metadata()])
|
||||
|
||||
# Second call is going to page hit and return OK status code.
|
||||
response, call = stub.__call__.with_call(request=request, metadata=metadata)
|
||||
assert call.code() == grpc.StatusCode.OK
|
||||
assert call.details() == "foo found."
|
||||
assert any([key == "num" and value == "0" for key, value in call.trailing_metadata()])
|
||||
assert any([key == "request_id" for key, _ in call.trailing_metadata()])
|
||||
# __end_grpc_context_client__
|
||||
|
||||
|
||||
# __begin_client_streaming_deployment__
|
||||
from ray import serve
|
||||
from ray.serve.grpc_util import gRPCInputStream
|
||||
from user_defined_protos_pb2 import UserDefinedResponse
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class ClientStreamingService:
|
||||
async def ClientStreaming(self, request_stream: gRPCInputStream):
|
||||
"""Receives stream of requests, returns a single response."""
|
||||
total = 0
|
||||
count = 0
|
||||
async for request in request_stream:
|
||||
total += request.num
|
||||
count += 1
|
||||
return UserDefinedResponse(
|
||||
greeting=f"Received {count} messages",
|
||||
num=total * 2,
|
||||
)
|
||||
|
||||
|
||||
serve.run(ClientStreamingService.bind())
|
||||
# __end_client_streaming_deployment__
|
||||
|
||||
|
||||
# __begin_client_streaming_client__
|
||||
import grpc
|
||||
from user_defined_protos_pb2_grpc import UserDefinedServiceStub
|
||||
from user_defined_protos_pb2 import UserDefinedMessage
|
||||
|
||||
|
||||
channel = grpc.insecure_channel("localhost:9000")
|
||||
stub = UserDefinedServiceStub(channel)
|
||||
metadata = (("application", "default"),)
|
||||
|
||||
|
||||
def request_generator():
|
||||
for i in range(5):
|
||||
yield UserDefinedMessage(name=f"msg_{i}", num=i + 1, origin="client")
|
||||
|
||||
|
||||
response = stub.ClientStreaming(request_generator(), metadata=metadata)
|
||||
print(f"greeting: {response.greeting}") # greeting: Received 5 messages
|
||||
print(f"num: {response.num}") # num: 30
|
||||
# __end_client_streaming_client__
|
||||
|
||||
|
||||
# __begin_bidi_streaming_deployment__
|
||||
from ray import serve
|
||||
from ray.serve.grpc_util import gRPCInputStream
|
||||
from user_defined_protos_pb2 import UserDefinedResponse
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class BidiStreamingService:
|
||||
async def BidiStreaming(self, request_stream: gRPCInputStream):
|
||||
"""Receives stream of requests, yields response for each."""
|
||||
async for request in request_stream:
|
||||
yield UserDefinedResponse(
|
||||
greeting=f"Hello {request.name}",
|
||||
num=request.num * 2,
|
||||
)
|
||||
|
||||
|
||||
serve.run(BidiStreamingService.bind())
|
||||
# __end_bidi_streaming_deployment__
|
||||
|
||||
|
||||
# __begin_bidi_streaming_client__
|
||||
import grpc
|
||||
from user_defined_protos_pb2_grpc import UserDefinedServiceStub
|
||||
from user_defined_protos_pb2 import UserDefinedMessage
|
||||
|
||||
|
||||
channel = grpc.insecure_channel("localhost:9000")
|
||||
stub = UserDefinedServiceStub(channel)
|
||||
metadata = (("application", "default"),)
|
||||
|
||||
|
||||
def request_generator():
|
||||
for i in range(3):
|
||||
yield UserDefinedMessage(name=f"user_{i}", num=i * 10, origin="client")
|
||||
|
||||
|
||||
responses = stub.BidiStreaming(request_generator(), metadata=metadata)
|
||||
for response in responses:
|
||||
print(f"greeting: {response.greeting}")
|
||||
print(f"num: {response.num}")
|
||||
# __end_bidi_streaming_client__
|
||||
|
||||
|
||||
# __begin_streaming_with_context__
|
||||
from ray import serve
|
||||
from ray.serve.grpc_util import gRPCInputStream, RayServegRPCContext
|
||||
from user_defined_protos_pb2 import UserDefinedResponse
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class StreamingWithContext:
|
||||
async def ClientStreaming(
|
||||
self,
|
||||
request_stream: gRPCInputStream,
|
||||
grpc_context: RayServegRPCContext,
|
||||
):
|
||||
"""Receives stream and can modify gRPC context."""
|
||||
count = 0
|
||||
async for request in request_stream:
|
||||
count += 1
|
||||
|
||||
grpc_context.set_trailing_metadata([("processed-count", str(count))])
|
||||
return UserDefinedResponse(greeting=f"Processed {count} messages")
|
||||
# __end_streaming_with_context__
|
||||
@@ -0,0 +1,51 @@
|
||||
// __begin_proto__
|
||||
// user_defined_protos.proto
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "io.ray.examples.user_defined_protos";
|
||||
option java_outer_classname = "UserDefinedProtos";
|
||||
|
||||
package userdefinedprotos;
|
||||
|
||||
message UserDefinedMessage {
|
||||
string name = 1;
|
||||
string origin = 2;
|
||||
int64 num = 3;
|
||||
}
|
||||
|
||||
message UserDefinedResponse {
|
||||
string greeting = 1;
|
||||
int64 num = 2;
|
||||
}
|
||||
|
||||
message UserDefinedMessage2 {}
|
||||
|
||||
message UserDefinedResponse2 {
|
||||
string greeting = 1;
|
||||
}
|
||||
|
||||
message ImageData {
|
||||
string url = 1;
|
||||
string filename = 2;
|
||||
}
|
||||
|
||||
message ImageClass {
|
||||
repeated string classes = 1;
|
||||
repeated float probabilities = 2;
|
||||
}
|
||||
|
||||
service UserDefinedService {
|
||||
rpc __call__(UserDefinedMessage) returns (UserDefinedResponse);
|
||||
rpc Multiplexing(UserDefinedMessage2) returns (UserDefinedResponse2);
|
||||
rpc Streaming(UserDefinedMessage) returns (stream UserDefinedResponse);
|
||||
rpc ClientStreaming(stream UserDefinedMessage) returns (UserDefinedResponse);
|
||||
rpc BidiStreaming(stream UserDefinedMessage) returns (stream UserDefinedResponse);
|
||||
}
|
||||
|
||||
service ImageClassificationService {
|
||||
rpc Predict(ImageData) returns (ImageClass);
|
||||
}
|
||||
|
||||
// __end_proto__
|
||||
@@ -0,0 +1,259 @@
|
||||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
|
||||
import user_defined_protos_pb2 as user__defined__protos__pb2
|
||||
|
||||
|
||||
class UserDefinedServiceStub(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.__call__ = channel.unary_unary(
|
||||
'/userdefinedprotos.UserDefinedService/__call__',
|
||||
request_serializer=user__defined__protos__pb2.UserDefinedMessage.SerializeToString,
|
||||
response_deserializer=user__defined__protos__pb2.UserDefinedResponse.FromString,
|
||||
)
|
||||
self.Multiplexing = channel.unary_unary(
|
||||
'/userdefinedprotos.UserDefinedService/Multiplexing',
|
||||
request_serializer=user__defined__protos__pb2.UserDefinedMessage2.SerializeToString,
|
||||
response_deserializer=user__defined__protos__pb2.UserDefinedResponse2.FromString,
|
||||
)
|
||||
self.Streaming = channel.unary_stream(
|
||||
'/userdefinedprotos.UserDefinedService/Streaming',
|
||||
request_serializer=user__defined__protos__pb2.UserDefinedMessage.SerializeToString,
|
||||
response_deserializer=user__defined__protos__pb2.UserDefinedResponse.FromString,
|
||||
)
|
||||
self.ClientStreaming = channel.stream_unary(
|
||||
'/userdefinedprotos.UserDefinedService/ClientStreaming',
|
||||
request_serializer=user__defined__protos__pb2.UserDefinedMessage.SerializeToString,
|
||||
response_deserializer=user__defined__protos__pb2.UserDefinedResponse.FromString,
|
||||
)
|
||||
self.BidiStreaming = channel.stream_stream(
|
||||
'/userdefinedprotos.UserDefinedService/BidiStreaming',
|
||||
request_serializer=user__defined__protos__pb2.UserDefinedMessage.SerializeToString,
|
||||
response_deserializer=user__defined__protos__pb2.UserDefinedResponse.FromString,
|
||||
)
|
||||
|
||||
|
||||
class UserDefinedServiceServicer(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def __call__(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def Multiplexing(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def Streaming(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def ClientStreaming(self, request_iterator, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
def BidiStreaming(self, request_iterator, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_UserDefinedServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'__call__': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.__call__,
|
||||
request_deserializer=user__defined__protos__pb2.UserDefinedMessage.FromString,
|
||||
response_serializer=user__defined__protos__pb2.UserDefinedResponse.SerializeToString,
|
||||
),
|
||||
'Multiplexing': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.Multiplexing,
|
||||
request_deserializer=user__defined__protos__pb2.UserDefinedMessage2.FromString,
|
||||
response_serializer=user__defined__protos__pb2.UserDefinedResponse2.SerializeToString,
|
||||
),
|
||||
'Streaming': grpc.unary_stream_rpc_method_handler(
|
||||
servicer.Streaming,
|
||||
request_deserializer=user__defined__protos__pb2.UserDefinedMessage.FromString,
|
||||
response_serializer=user__defined__protos__pb2.UserDefinedResponse.SerializeToString,
|
||||
),
|
||||
'ClientStreaming': grpc.stream_unary_rpc_method_handler(
|
||||
servicer.ClientStreaming,
|
||||
request_deserializer=user__defined__protos__pb2.UserDefinedMessage.FromString,
|
||||
response_serializer=user__defined__protos__pb2.UserDefinedResponse.SerializeToString,
|
||||
),
|
||||
'BidiStreaming': grpc.stream_stream_rpc_method_handler(
|
||||
servicer.BidiStreaming,
|
||||
request_deserializer=user__defined__protos__pb2.UserDefinedMessage.FromString,
|
||||
response_serializer=user__defined__protos__pb2.UserDefinedResponse.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'userdefinedprotos.UserDefinedService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class UserDefinedService(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
@staticmethod
|
||||
def __call__(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/userdefinedprotos.UserDefinedService/__call__',
|
||||
user__defined__protos__pb2.UserDefinedMessage.SerializeToString,
|
||||
user__defined__protos__pb2.UserDefinedResponse.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def Multiplexing(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/userdefinedprotos.UserDefinedService/Multiplexing',
|
||||
user__defined__protos__pb2.UserDefinedMessage2.SerializeToString,
|
||||
user__defined__protos__pb2.UserDefinedResponse2.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def Streaming(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_stream(request, target, '/userdefinedprotos.UserDefinedService/Streaming',
|
||||
user__defined__protos__pb2.UserDefinedMessage.SerializeToString,
|
||||
user__defined__protos__pb2.UserDefinedResponse.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def ClientStreaming(request_iterator,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.stream_unary(request_iterator, target, '/userdefinedprotos.UserDefinedService/ClientStreaming',
|
||||
user__defined__protos__pb2.UserDefinedMessage.SerializeToString,
|
||||
user__defined__protos__pb2.UserDefinedResponse.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
@staticmethod
|
||||
def BidiStreaming(request_iterator,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.stream_stream(request_iterator, target, '/userdefinedprotos.UserDefinedService/BidiStreaming',
|
||||
user__defined__protos__pb2.UserDefinedMessage.SerializeToString,
|
||||
user__defined__protos__pb2.UserDefinedResponse.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
|
||||
|
||||
class ImageClassificationServiceStub(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def __init__(self, channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.Predict = channel.unary_unary(
|
||||
'/userdefinedprotos.ImageClassificationService/Predict',
|
||||
request_serializer=user__defined__protos__pb2.ImageData.SerializeToString,
|
||||
response_deserializer=user__defined__protos__pb2.ImageClass.FromString,
|
||||
)
|
||||
|
||||
|
||||
class ImageClassificationServiceServicer(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def Predict(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')
|
||||
|
||||
|
||||
def add_ImageClassificationServiceServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
'Predict': grpc.unary_unary_rpc_method_handler(
|
||||
servicer.Predict,
|
||||
request_deserializer=user__defined__protos__pb2.ImageData.FromString,
|
||||
response_serializer=user__defined__protos__pb2.ImageClass.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
'userdefinedprotos.ImageClassificationService', rpc_method_handlers)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class ImageClassificationService(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
@staticmethod
|
||||
def Predict(request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None):
|
||||
return grpc.experimental.unary_unary(request, target, '/userdefinedprotos.ImageClassificationService/Predict',
|
||||
user__defined__protos__pb2.ImageData.SerializeToString,
|
||||
user__defined__protos__pb2.ImageClass.FromString,
|
||||
options, channel_credentials,
|
||||
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
||||
@@ -0,0 +1,121 @@
|
||||
# flake8: noqa
|
||||
# fmt: off
|
||||
|
||||
import ray
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
from ray._common.test_utils import wait_for_condition
|
||||
|
||||
|
||||
# Overwrite print statement to make doc code testable
|
||||
@ray.remote
|
||||
class PrintStorage:
|
||||
def __init__(self):
|
||||
self.print_storage: List[str] = []
|
||||
|
||||
def add(self, s: str):
|
||||
self.print_storage.append(s)
|
||||
|
||||
def clear(self):
|
||||
self.print_storage.clear()
|
||||
|
||||
def get(self) -> List[str]:
|
||||
return self.print_storage
|
||||
|
||||
|
||||
print_storage_handle = PrintStorage.remote()
|
||||
|
||||
|
||||
def print(string: str):
|
||||
ray.get(print_storage_handle.add.remote(string))
|
||||
sys.stdout.write(f"{string}\n")
|
||||
|
||||
|
||||
# __start_basic_disconnect__
|
||||
import asyncio
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment
|
||||
async def startled():
|
||||
try:
|
||||
print("Replica received request!")
|
||||
await asyncio.sleep(10000)
|
||||
except asyncio.CancelledError:
|
||||
# Add custom behavior that should run
|
||||
# upon cancellation here.
|
||||
print("Request got cancelled!")
|
||||
# __end_basic_disconnect__
|
||||
|
||||
serve.run(startled.bind())
|
||||
|
||||
import requests
|
||||
from requests.exceptions import Timeout
|
||||
|
||||
# Intentionally time out request to test cancellation behavior
|
||||
try:
|
||||
requests.get("http://localhost:8000", timeout=0.5)
|
||||
except Timeout:
|
||||
pass
|
||||
|
||||
wait_for_condition(
|
||||
lambda: {"Replica received request!", "Request got cancelled!"}
|
||||
== set(ray.get(print_storage_handle.get.remote())),
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
sys.stdout.write(f"{ray.get(print_storage_handle.get.remote())}\n")
|
||||
|
||||
ray.get(print_storage_handle.clear.remote())
|
||||
|
||||
|
||||
# __start_shielded_disconnect__
|
||||
import asyncio
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class SnoringSleeper:
|
||||
async def snore(self):
|
||||
await asyncio.sleep(1)
|
||||
print("ZZZ")
|
||||
|
||||
async def __call__(self):
|
||||
try:
|
||||
print("SnoringSleeper received request!")
|
||||
|
||||
# Prevent the snore() method from being cancelled
|
||||
await asyncio.shield(self.snore())
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print("SnoringSleeper's request was cancelled!")
|
||||
|
||||
|
||||
app = SnoringSleeper.bind()
|
||||
# __end_shielded_disconnect__
|
||||
|
||||
serve.run(app)
|
||||
|
||||
import requests
|
||||
from requests.exceptions import Timeout
|
||||
|
||||
# Intentionally time out request to test cancellation behavior
|
||||
try:
|
||||
requests.get("http://localhost:8000", timeout=0.5)
|
||||
except Timeout:
|
||||
pass
|
||||
|
||||
wait_for_condition(
|
||||
lambda: {
|
||||
"SnoringSleeper received request!",
|
||||
"SnoringSleeper's request was cancelled!",
|
||||
"ZZZ",
|
||||
}
|
||||
== set(ray.get(print_storage_handle.get.remote())),
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
sys.stdout.write(f"{ray.get(print_storage_handle.get.remote())}\n")
|
||||
|
||||
ray.get(print_storage_handle.clear.remote())
|
||||
@@ -0,0 +1,176 @@
|
||||
# flake8: noqa
|
||||
|
||||
# __begin_starlette__
|
||||
import starlette.requests
|
||||
import requests
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Counter:
|
||||
def __call__(self, request: starlette.requests.Request):
|
||||
return request.query_params
|
||||
|
||||
|
||||
serve.run(Counter.bind())
|
||||
resp = requests.get("http://localhost:8000?a=b&c=d")
|
||||
assert resp.json() == {"a": "b", "c": "d"}
|
||||
# __end_starlette__
|
||||
|
||||
# __begin_fastapi__
|
||||
import ray
|
||||
import requests
|
||||
from fastapi import FastAPI
|
||||
from ray import serve
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@serve.deployment
|
||||
@serve.ingress(app)
|
||||
class MyFastAPIDeployment:
|
||||
@app.get("/")
|
||||
def root(self):
|
||||
return "Hello, world!"
|
||||
|
||||
|
||||
serve.run(MyFastAPIDeployment.bind(), route_prefix="/hello")
|
||||
resp = requests.get("http://localhost:8000/hello")
|
||||
assert resp.json() == "Hello, world!"
|
||||
# __end_fastapi__
|
||||
|
||||
|
||||
# __begin_fastapi_multi_routes__
|
||||
import ray
|
||||
import requests
|
||||
from fastapi import FastAPI
|
||||
from ray import serve
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@serve.deployment
|
||||
@serve.ingress(app)
|
||||
class MyFastAPIDeployment:
|
||||
@app.get("/")
|
||||
def root(self):
|
||||
return "Hello, world!"
|
||||
|
||||
@app.post("/{subpath}")
|
||||
def root(self, subpath: str):
|
||||
return f"Hello from {subpath}!"
|
||||
|
||||
|
||||
serve.run(MyFastAPIDeployment.bind(), route_prefix="/hello")
|
||||
resp = requests.post("http://localhost:8000/hello/Serve")
|
||||
assert resp.json() == "Hello from Serve!"
|
||||
# __end_fastapi_multi_routes__
|
||||
|
||||
# __begin_byo_fastapi__
|
||||
import ray
|
||||
import requests
|
||||
from fastapi import FastAPI
|
||||
from ray import serve
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def f():
|
||||
return "Hello from the root!"
|
||||
|
||||
|
||||
@serve.deployment
|
||||
@serve.ingress(app)
|
||||
class FastAPIWrapper:
|
||||
pass
|
||||
|
||||
|
||||
serve.run(FastAPIWrapper.bind(), route_prefix="/")
|
||||
resp = requests.get("http://localhost:8000/")
|
||||
assert resp.json() == "Hello from the root!"
|
||||
# __end_byo_fastapi__
|
||||
|
||||
# __begin_fastapi_middleware__
|
||||
import requests
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from ray import serve
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["https://example.com"],
|
||||
allow_methods=["GET", "POST"],
|
||||
)
|
||||
|
||||
|
||||
@serve.deployment
|
||||
@serve.ingress(app)
|
||||
class Ingress:
|
||||
@app.get("/")
|
||||
def root(self):
|
||||
return "ok"
|
||||
|
||||
|
||||
serve.run(Ingress.bind())
|
||||
resp = requests.get(
|
||||
"http://localhost:8000/",
|
||||
headers={"Origin": "https://example.com"},
|
||||
)
|
||||
assert resp.json() == "ok"
|
||||
assert resp.headers["access-control-allow-origin"] == "https://example.com"
|
||||
# __end_fastapi_middleware__
|
||||
|
||||
|
||||
# __begin_fastapi_factory_pattern__
|
||||
import requests
|
||||
from fastapi import FastAPI
|
||||
from ray import serve
|
||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class ChildDeployment:
|
||||
def __call__(self):
|
||||
return "Hello from the child deployment!"
|
||||
|
||||
|
||||
def fastapi_factory():
|
||||
"""Factory-style FastAPI app used as Serve ingress.
|
||||
|
||||
We build the FastAPI app inside a factory and pass the callable to
|
||||
@serve.ingress.
|
||||
"""
|
||||
app = FastAPI()
|
||||
|
||||
# In an object-based ingress (where the FastAPI app is stored on the
|
||||
# deployment instance), Ray would need to serialize the app and its
|
||||
# instrumentation. Some instrumentors (like FastAPIInstrumentor) are not
|
||||
# picklable, which can cause serialization failures. Creating and
|
||||
# instrumenting the app here sidesteps that issue.
|
||||
FastAPIInstrumentor.instrument_app(app)
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
# Handlers defined inside this factory don't have access to the
|
||||
# ParentDeployment instance (i.e., there's no `self` here), so we
|
||||
# can't call `self.child`. Instead, fetch a handle by deployment name.
|
||||
handle = serve.get_deployment_handle("ChildDeployment", app_name="default")
|
||||
return {"message": await handle.remote()}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@serve.deployment
|
||||
@serve.ingress(fastapi_factory)
|
||||
class ParentDeployment:
|
||||
def __init__(self, child):
|
||||
self.child = child
|
||||
|
||||
|
||||
serve.run(ParentDeployment.bind(ChildDeployment.bind()))
|
||||
|
||||
resp = requests.get("http://localhost:8000/")
|
||||
assert resp.json() == {"message": "Hello from the child deployment!"}
|
||||
# __end_fastapi_factory_pattern__
|
||||
@@ -0,0 +1,82 @@
|
||||
# flake8: noqa
|
||||
|
||||
# __begin_example__
|
||||
import time
|
||||
from typing import Generator
|
||||
|
||||
import requests
|
||||
from starlette.responses import StreamingResponse
|
||||
from starlette.requests import Request
|
||||
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class StreamingResponder:
|
||||
def generate_numbers(self, max: int) -> Generator[str, None, None]:
|
||||
for i in range(max):
|
||||
yield str(i)
|
||||
time.sleep(0.1)
|
||||
|
||||
def __call__(self, request: Request) -> StreamingResponse:
|
||||
max = request.query_params.get("max", "25")
|
||||
gen = self.generate_numbers(int(max))
|
||||
return StreamingResponse(gen, status_code=200, media_type="text/plain")
|
||||
|
||||
|
||||
serve.run(StreamingResponder.bind())
|
||||
|
||||
r = requests.get("http://localhost:8000?max=10", stream=True)
|
||||
start = time.time()
|
||||
r.raise_for_status()
|
||||
for chunk in r.iter_content(chunk_size=None, decode_unicode=True):
|
||||
print(f"Got result {round(time.time()-start, 1)}s after start: '{chunk}'")
|
||||
# __end_example__
|
||||
|
||||
|
||||
r = requests.get("http://localhost:8000?max=10", stream=True)
|
||||
r.raise_for_status()
|
||||
for i, chunk in enumerate(r.iter_content(chunk_size=None, decode_unicode=True)):
|
||||
assert chunk == str(i)
|
||||
|
||||
|
||||
# __begin_cancellation__
|
||||
import asyncio
|
||||
import time
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import requests
|
||||
from starlette.responses import StreamingResponse
|
||||
from starlette.requests import Request
|
||||
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class StreamingResponder:
|
||||
async def generate_forever(self) -> AsyncGenerator[str, None]:
|
||||
try:
|
||||
i = 0
|
||||
while True:
|
||||
yield str(i)
|
||||
i += 1
|
||||
await asyncio.sleep(0.1)
|
||||
except asyncio.CancelledError:
|
||||
print("Cancelled! Exiting.")
|
||||
|
||||
def __call__(self, request: Request) -> StreamingResponse:
|
||||
gen = self.generate_forever()
|
||||
return StreamingResponse(gen, status_code=200, media_type="text/plain")
|
||||
|
||||
|
||||
serve.run(StreamingResponder.bind())
|
||||
|
||||
r = requests.get("http://localhost:8000?max=10", stream=True)
|
||||
start = time.time()
|
||||
r.raise_for_status()
|
||||
for i, chunk in enumerate(r.iter_content(chunk_size=None, decode_unicode=True)):
|
||||
print(f"Got result {round(time.time()-start, 1)}s after start: '{chunk}'")
|
||||
if i == 10:
|
||||
print("Client disconnecting")
|
||||
break
|
||||
# __end_cancellation__
|
||||
@@ -0,0 +1,39 @@
|
||||
# flake8: noqa
|
||||
|
||||
# __websocket_serve_app_start__
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
|
||||
from ray import serve
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@serve.deployment
|
||||
@serve.ingress(app)
|
||||
class EchoServer:
|
||||
@app.websocket("/")
|
||||
async def echo(self, ws: WebSocket):
|
||||
await ws.accept()
|
||||
|
||||
try:
|
||||
while True:
|
||||
text = await ws.receive_text()
|
||||
await ws.send_text(text)
|
||||
except WebSocketDisconnect:
|
||||
print("Client disconnected.")
|
||||
|
||||
|
||||
serve_app = serve.run(EchoServer.bind())
|
||||
# __websocket_serve_app_end__
|
||||
|
||||
# __websocket_serve_client_start__
|
||||
from websockets.sync.client import connect
|
||||
|
||||
with connect("ws://localhost:8000") as websocket:
|
||||
websocket.send("Eureka!")
|
||||
assert websocket.recv() == "Eureka!"
|
||||
|
||||
websocket.send("I've found it!")
|
||||
assert websocket.recv() == "I've found it!"
|
||||
# __websocket_serve_client_end__
|
||||
@@ -0,0 +1,98 @@
|
||||
# __serve_example_begin__
|
||||
import requests
|
||||
import starlette
|
||||
|
||||
from transformers import pipeline
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
|
||||
@serve.deployment
|
||||
def downloader(image_url: str):
|
||||
image_bytes = requests.get(image_url).content
|
||||
image = Image.open(BytesIO(image_bytes)).convert("RGB")
|
||||
return image
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class ImageClassifier:
|
||||
def __init__(self, downloader: DeploymentHandle):
|
||||
self.downloader = downloader
|
||||
self.model = pipeline(
|
||||
"image-classification", model="google/vit-base-patch16-224"
|
||||
)
|
||||
|
||||
async def classify(self, image_url: str) -> str:
|
||||
image = await self.downloader.remote(image_url)
|
||||
results = self.model(image)
|
||||
return results[0]["label"]
|
||||
|
||||
async def __call__(self, req: starlette.requests.Request):
|
||||
req = await req.json()
|
||||
return await self.classify(req["image_url"])
|
||||
|
||||
|
||||
app = ImageClassifier.bind(downloader.bind())
|
||||
# __serve_example_end__
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class ModifiedImageClassifier:
|
||||
def __init__(self, downloader: DeploymentHandle):
|
||||
self.downloader = downloader
|
||||
self.model = pipeline(
|
||||
"image-classification", model="google/vit-base-patch16-224"
|
||||
)
|
||||
|
||||
async def classify(self, image_url: str) -> str:
|
||||
image = await self.downloader.remote(image_url)
|
||||
results = self.model(image)
|
||||
return results[0]["label"]
|
||||
|
||||
# __serve_example_modified_begin__
|
||||
async def __call__(self, req: starlette.requests.Request):
|
||||
req = await req.json()
|
||||
result = await self.classify(req["image_url"])
|
||||
|
||||
if req.get("should_translate") is True:
|
||||
handle: DeploymentHandle = serve.get_app_handle("app2")
|
||||
return await handle.translate.remote(result)
|
||||
|
||||
return result
|
||||
# __serve_example_modified_end__
|
||||
|
||||
|
||||
serve.run(app, name="app1", route_prefix="/classify")
|
||||
# __request_begin__
|
||||
bear_url = "https://cdn.britannica.com/41/156441-050-A4424AEC/Grizzly-bear-Jasper-National-Park-Canada-Alberta.jpg" # noqa
|
||||
resp = requests.post("http://localhost:8000/classify", json={"image_url": bear_url})
|
||||
|
||||
print(resp.text)
|
||||
# 'brown bear, bruin, Ursus arctos'
|
||||
# __request_end__
|
||||
assert resp.text == "brown bear, bruin, Ursus arctos"
|
||||
|
||||
from translator_example import app as translator_app # noqa
|
||||
|
||||
serve.run(
|
||||
ModifiedImageClassifier.bind(downloader.bind()),
|
||||
name="app1",
|
||||
route_prefix="/classify",
|
||||
)
|
||||
serve.run(translator_app, name="app2")
|
||||
|
||||
# __second_request_begin__
|
||||
bear_url = "https://cdn.britannica.com/41/156441-050-A4424AEC/Grizzly-bear-Jasper-National-Park-Canada-Alberta.jpg" # noqa
|
||||
resp = requests.post(
|
||||
"http://localhost:8000/classify",
|
||||
json={"image_url": bear_url, "should_translate": True},
|
||||
)
|
||||
|
||||
print(resp.text)
|
||||
# 'Braunbär, Bruin, Ursus arctos'
|
||||
# __second_request_end__
|
||||
|
||||
assert resp.text == "Braunbär, Bruin, Ursus arctos"
|
||||
@@ -0,0 +1,23 @@
|
||||
# __main_code_start__
|
||||
import requests
|
||||
|
||||
# Prompt for the model
|
||||
prompt = "Once upon a time,"
|
||||
|
||||
# Add generation config here
|
||||
config = {}
|
||||
|
||||
# Non-streaming response
|
||||
sample_input = {"text": prompt, "config": config, "stream": False}
|
||||
outputs = requests.post("http://127.0.0.1:8000/", json=sample_input, stream=False)
|
||||
print(outputs.text, flush=True)
|
||||
|
||||
# Streaming response
|
||||
sample_input["stream"] = True
|
||||
outputs = requests.post("http://127.0.0.1:8000/", json=sample_input, stream=True)
|
||||
outputs.raise_for_status()
|
||||
for output in outputs.iter_content(chunk_size=None, decode_unicode=True):
|
||||
print(output, end="", flush=True)
|
||||
print()
|
||||
|
||||
# __main_code_end__
|
||||
@@ -0,0 +1,137 @@
|
||||
# __model_def_start__
|
||||
import asyncio
|
||||
from functools import partial
|
||||
from queue import Empty
|
||||
from typing import Dict, Any
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import StreamingResponse
|
||||
import torch
|
||||
|
||||
from ray import serve
|
||||
|
||||
|
||||
# Define the Ray Serve deployment
|
||||
@serve.deployment(ray_actor_options={"num_cpus": 10, "resources": {"HPU": 1}})
|
||||
class LlamaModel:
|
||||
def __init__(self, model_id_or_path: str):
|
||||
from transformers import AutoTokenizer, AutoModelForCausalLM, AutoConfig
|
||||
from optimum.habana.transformers.modeling_utils import (
|
||||
adapt_transformers_to_gaudi,
|
||||
)
|
||||
|
||||
# Tweak transformers to optimize performance
|
||||
adapt_transformers_to_gaudi()
|
||||
|
||||
self.device = torch.device("hpu")
|
||||
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(
|
||||
model_id_or_path, use_fast=False, use_auth_token=""
|
||||
)
|
||||
hf_config = AutoConfig.from_pretrained(
|
||||
model_id_or_path,
|
||||
torchscript=True,
|
||||
use_auth_token="",
|
||||
trust_remote_code=False,
|
||||
)
|
||||
# Load the model in Gaudi
|
||||
model = AutoModelForCausalLM.from_pretrained(
|
||||
model_id_or_path,
|
||||
config=hf_config,
|
||||
torch_dtype=torch.float32,
|
||||
low_cpu_mem_usage=True,
|
||||
use_auth_token="",
|
||||
)
|
||||
model = model.eval().to(self.device)
|
||||
|
||||
from habana_frameworks.torch.hpu import wrap_in_hpu_graph
|
||||
|
||||
# Enable hpu graph runtime
|
||||
self.model = wrap_in_hpu_graph(model)
|
||||
|
||||
# Set pad token, etc.
|
||||
self.tokenizer.pad_token_id = self.model.generation_config.pad_token_id
|
||||
self.tokenizer.padding_side = "left"
|
||||
|
||||
# Use async loop in streaming
|
||||
self.loop = asyncio.get_running_loop()
|
||||
|
||||
def tokenize(self, prompt: str):
|
||||
"""Tokenize the input and move to HPU."""
|
||||
|
||||
input_tokens = self.tokenizer(prompt, return_tensors="pt", padding=True)
|
||||
return input_tokens.input_ids.to(device=self.device)
|
||||
|
||||
def generate(self, prompt: str, **config: Dict[str, Any]):
|
||||
"""Take a prompt and generate a response."""
|
||||
|
||||
input_ids = self.tokenize(prompt)
|
||||
gen_tokens = self.model.generate(input_ids, **config)
|
||||
return self.tokenizer.batch_decode(gen_tokens, skip_special_tokens=True)[0]
|
||||
|
||||
async def consume_streamer_async(self, streamer):
|
||||
"""Consume the streamer asynchronously."""
|
||||
|
||||
while True:
|
||||
try:
|
||||
for token in streamer:
|
||||
yield token
|
||||
break
|
||||
except Empty:
|
||||
await asyncio.sleep(0.001)
|
||||
|
||||
def streaming_generate(self, prompt: str, streamer, **config: Dict[str, Any]):
|
||||
"""Generate a streamed response given an input."""
|
||||
|
||||
input_ids = self.tokenize(prompt)
|
||||
self.model.generate(input_ids, streamer=streamer, **config)
|
||||
|
||||
async def __call__(self, http_request: Request):
|
||||
"""Handle HTTP requests."""
|
||||
|
||||
# Load fields from the request
|
||||
json_request: str = await http_request.json()
|
||||
text = json_request["text"]
|
||||
# Config used in generation
|
||||
config = json_request.get("config", {})
|
||||
streaming_response = json_request["stream"]
|
||||
|
||||
# Prepare prompts
|
||||
prompts = []
|
||||
if isinstance(text, list):
|
||||
prompts.extend(text)
|
||||
else:
|
||||
prompts.append(text)
|
||||
|
||||
# Process config
|
||||
config.setdefault("max_new_tokens", 128)
|
||||
|
||||
# Enable HPU graph runtime
|
||||
config["hpu_graphs"] = True
|
||||
# Lazy mode should be True when using HPU graphs
|
||||
config["lazy_mode"] = True
|
||||
|
||||
# Non-streaming case
|
||||
if not streaming_response:
|
||||
return self.generate(prompts, **config)
|
||||
|
||||
# Streaming case
|
||||
from transformers import TextIteratorStreamer
|
||||
|
||||
streamer = TextIteratorStreamer(
|
||||
self.tokenizer, skip_prompt=True, timeout=0, skip_special_tokens=True
|
||||
)
|
||||
# Convert the streamer into a generator
|
||||
self.loop.run_in_executor(
|
||||
None, partial(self.streaming_generate, prompts, streamer, **config)
|
||||
)
|
||||
return StreamingResponse(
|
||||
self.consume_streamer_async(streamer),
|
||||
status_code=200,
|
||||
media_type="text/plain",
|
||||
)
|
||||
|
||||
|
||||
# Replace the model ID with path if necessary
|
||||
entrypoint = LlamaModel.bind("meta-llama/Llama-2-7b-chat-hf")
|
||||
# __model_def_end__
|
||||
@@ -0,0 +1,273 @@
|
||||
# __worker_def_start__
|
||||
import tempfile
|
||||
from typing import Dict, Any
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
import torch
|
||||
from transformers import TextStreamer
|
||||
|
||||
import ray
|
||||
from ray import serve
|
||||
from ray.util.queue import Queue
|
||||
from ray.runtime_env import RuntimeEnv
|
||||
|
||||
|
||||
@ray.remote(resources={"HPU": 1})
|
||||
class DeepSpeedInferenceWorker:
|
||||
def __init__(self, model_id_or_path: str, world_size: int, local_rank: int):
|
||||
"""An actor that runs a DeepSpeed inference engine.
|
||||
|
||||
Arguments:
|
||||
model_id_or_path: Either a Hugging Face model ID
|
||||
or a path to a cached model.
|
||||
world_size: Total number of worker processes.
|
||||
local_rank: Rank of this worker process.
|
||||
The rank 0 worker is the head worker.
|
||||
"""
|
||||
from transformers import AutoTokenizer, AutoConfig
|
||||
from optimum.habana.transformers.modeling_utils import (
|
||||
adapt_transformers_to_gaudi,
|
||||
)
|
||||
|
||||
# Tweak transformers for better performance on Gaudi.
|
||||
adapt_transformers_to_gaudi()
|
||||
|
||||
self.model_id_or_path = model_id_or_path
|
||||
self._world_size = world_size
|
||||
self._local_rank = local_rank
|
||||
self.device = torch.device("hpu")
|
||||
|
||||
self.model_config = AutoConfig.from_pretrained(
|
||||
model_id_or_path,
|
||||
torch_dtype=torch.bfloat16,
|
||||
token="",
|
||||
trust_remote_code=False,
|
||||
)
|
||||
|
||||
# Load and configure the tokenizer.
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(
|
||||
model_id_or_path, use_fast=False, token=""
|
||||
)
|
||||
self.tokenizer.padding_side = "left"
|
||||
if self.tokenizer.pad_token is None:
|
||||
self.tokenizer.pad_token = self.tokenizer.eos_token
|
||||
|
||||
import habana_frameworks.torch.distributed.hccl as hccl
|
||||
|
||||
# Initialize the distributed backend.
|
||||
hccl.initialize_distributed_hpu(
|
||||
world_size=world_size, rank=local_rank, local_rank=local_rank
|
||||
)
|
||||
torch.distributed.init_process_group(backend="hccl")
|
||||
|
||||
def load_model(self):
|
||||
"""Load the model to HPU and initialize the DeepSpeed inference engine."""
|
||||
|
||||
import deepspeed
|
||||
from transformers import AutoModelForCausalLM
|
||||
from optimum.habana.checkpoint_utils import (
|
||||
get_ds_injection_policy,
|
||||
write_checkpoints_json,
|
||||
)
|
||||
|
||||
# Construct the model with fake meta Tensors.
|
||||
# Loads the model weights from the checkpoint later.
|
||||
with deepspeed.OnDevice(dtype=torch.bfloat16, device="meta"):
|
||||
model = AutoModelForCausalLM.from_config(
|
||||
self.model_config, torch_dtype=torch.bfloat16
|
||||
)
|
||||
model = model.eval()
|
||||
|
||||
# Create a file to indicate where the checkpoint is.
|
||||
checkpoints_json = tempfile.NamedTemporaryFile(suffix=".json", mode="w+")
|
||||
write_checkpoints_json(
|
||||
self.model_id_or_path, self._local_rank, checkpoints_json, token=""
|
||||
)
|
||||
|
||||
# Prepare the DeepSpeed inference configuration.
|
||||
kwargs = {"dtype": torch.bfloat16}
|
||||
kwargs["checkpoint"] = checkpoints_json.name
|
||||
kwargs["tensor_parallel"] = {"tp_size": self._world_size}
|
||||
# Enable the HPU graph, similar to the cuda graph.
|
||||
kwargs["enable_cuda_graph"] = True
|
||||
# Specify the injection policy, required by DeepSpeed Tensor parallelism.
|
||||
kwargs["injection_policy"] = get_ds_injection_policy(self.model_config)
|
||||
|
||||
# Initialize the inference engine.
|
||||
self.model = deepspeed.init_inference(model, **kwargs).module
|
||||
|
||||
def tokenize(self, prompt: str):
|
||||
"""Tokenize the input and move it to HPU."""
|
||||
|
||||
input_tokens = self.tokenizer(prompt, return_tensors="pt", padding=True)
|
||||
return input_tokens.input_ids.to(device=self.device)
|
||||
|
||||
def generate(self, prompt: str, **config: Dict[str, Any]):
|
||||
"""Take in a prompt and generate a response."""
|
||||
|
||||
input_ids = self.tokenize(prompt)
|
||||
gen_tokens = self.model.generate(input_ids, **config)
|
||||
return self.tokenizer.batch_decode(gen_tokens, skip_special_tokens=True)[0]
|
||||
|
||||
def streaming_generate(self, prompt: str, streamer, **config: Dict[str, Any]):
|
||||
"""Generate a streamed response given an input."""
|
||||
|
||||
input_ids = self.tokenize(prompt)
|
||||
self.model.generate(input_ids, streamer=streamer, **config)
|
||||
|
||||
def get_streamer(self):
|
||||
"""Return a streamer.
|
||||
|
||||
We only need the rank 0 worker's result.
|
||||
Other workers return a fake streamer.
|
||||
"""
|
||||
|
||||
if self._local_rank == 0:
|
||||
return RayTextIteratorStreamer(self.tokenizer, skip_special_tokens=True)
|
||||
else:
|
||||
|
||||
class FakeStreamer:
|
||||
def put(self, value):
|
||||
pass
|
||||
|
||||
def end(self):
|
||||
pass
|
||||
|
||||
return FakeStreamer()
|
||||
|
||||
|
||||
class RayTextIteratorStreamer(TextStreamer):
|
||||
def __init__(
|
||||
self,
|
||||
tokenizer,
|
||||
skip_prompt: bool = False,
|
||||
timeout: int = None,
|
||||
**decode_kwargs: Dict[str, Any],
|
||||
):
|
||||
super().__init__(tokenizer, skip_prompt, **decode_kwargs)
|
||||
self.text_queue = Queue()
|
||||
self.stop_signal = None
|
||||
self.timeout = timeout
|
||||
|
||||
def on_finalized_text(self, text: str, stream_end: bool = False):
|
||||
self.text_queue.put(text, timeout=self.timeout)
|
||||
if stream_end:
|
||||
self.text_queue.put(self.stop_signal, timeout=self.timeout)
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
value = self.text_queue.get(timeout=self.timeout)
|
||||
if value == self.stop_signal:
|
||||
raise StopIteration()
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
# __worker_def_end__
|
||||
|
||||
# __deploy_def_start__
|
||||
# We need to set these variables for this example.
|
||||
HABANA_ENVS = {
|
||||
"PT_HPU_LAZY_ACC_PAR_MODE": "0",
|
||||
"PT_HPU_ENABLE_REFINE_DYNAMIC_SHAPES": "0",
|
||||
"PT_HPU_ENABLE_WEIGHT_CPU_PERMUTE": "0",
|
||||
"PT_HPU_ENABLE_LAZY_COLLECTIVES": "true",
|
||||
"HABANA_VISIBLE_MODULES": "0,1,2,3,4,5,6,7",
|
||||
}
|
||||
|
||||
|
||||
# Define the Ray Serve deployment.
|
||||
@serve.deployment
|
||||
class DeepSpeedLlamaModel:
|
||||
def __init__(self, world_size: int, model_id_or_path: str):
|
||||
self._world_size = world_size
|
||||
|
||||
# Create the DeepSpeed workers
|
||||
self.deepspeed_workers = []
|
||||
for i in range(world_size):
|
||||
self.deepspeed_workers.append(
|
||||
DeepSpeedInferenceWorker.options(
|
||||
runtime_env=RuntimeEnv(env_vars=HABANA_ENVS)
|
||||
).remote(model_id_or_path, world_size, i)
|
||||
)
|
||||
|
||||
# Load the model to all workers.
|
||||
for worker in self.deepspeed_workers:
|
||||
worker.load_model.remote()
|
||||
|
||||
# Get the workers' streamers.
|
||||
self.streamers = ray.get(
|
||||
[worker.get_streamer.remote() for worker in self.deepspeed_workers]
|
||||
)
|
||||
|
||||
def generate(self, prompt: str, **config: Dict[str, Any]):
|
||||
"""Send the prompt to workers for generation.
|
||||
|
||||
Return after all workers finish the generation.
|
||||
Only return the rank 0 worker's result.
|
||||
"""
|
||||
|
||||
futures = [
|
||||
worker.generate.remote(prompt, **config)
|
||||
for worker in self.deepspeed_workers
|
||||
]
|
||||
return ray.get(futures)[0]
|
||||
|
||||
def streaming_generate(self, prompt: str, **config: Dict[str, Any]):
|
||||
"""Send the prompt to workers for streaming generation.
|
||||
|
||||
Only use the rank 0 worker's result.
|
||||
"""
|
||||
|
||||
for worker, streamer in zip(self.deepspeed_workers, self.streamers):
|
||||
worker.streaming_generate.remote(prompt, streamer, **config)
|
||||
|
||||
def consume_streamer(self, streamer):
|
||||
"""Consume the streamer and return a generator."""
|
||||
for token in streamer:
|
||||
yield token
|
||||
|
||||
async def __call__(self, http_request: Request):
|
||||
"""Handle received HTTP requests."""
|
||||
|
||||
# Load fields from the request
|
||||
json_request: str = await http_request.json()
|
||||
text = json_request["text"]
|
||||
# Config used in generation
|
||||
config = json_request.get("config", {})
|
||||
streaming_response = json_request["stream"]
|
||||
|
||||
# Prepare prompts
|
||||
prompts = []
|
||||
if isinstance(text, list):
|
||||
prompts.extend(text)
|
||||
else:
|
||||
prompts.append(text)
|
||||
|
||||
# Process the configuration.
|
||||
config.setdefault("max_new_tokens", 128)
|
||||
|
||||
# Enable HPU graph runtime.
|
||||
config["hpu_graphs"] = True
|
||||
# Lazy mode should be True when using HPU graphs.
|
||||
config["lazy_mode"] = True
|
||||
|
||||
# Non-streaming case
|
||||
if not streaming_response:
|
||||
return self.generate(prompts, **config)
|
||||
|
||||
# Streaming case
|
||||
self.streaming_generate(prompts, **config)
|
||||
return StreamingResponse(
|
||||
self.consume_streamer(self.streamers[0]),
|
||||
status_code=200,
|
||||
media_type="text/plain",
|
||||
)
|
||||
|
||||
|
||||
# Replace the model ID with a path if necessary.
|
||||
entrypoint = DeepSpeedLlamaModel.bind(8, "meta-llama/Llama-2-70b-chat-hf")
|
||||
# __deploy_def_end__
|
||||
@@ -0,0 +1,32 @@
|
||||
# flake8: noqa
|
||||
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
# __start_grpc_override__
|
||||
@serve.deployment
|
||||
class Caller:
|
||||
def __init__(self, target: DeploymentHandle):
|
||||
# Override this specific handle to use actor RPC instead of gRPC.
|
||||
# This is useful for large payloads (over ~1 MB) where passing
|
||||
# objects by reference through Ray's object store is more efficient.
|
||||
self._target = target.options(_by_reference=True)
|
||||
|
||||
async def __call__(self, data: bytes) -> str:
|
||||
return await self._target.remote(data)
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class LargePayloadProcessor:
|
||||
def __call__(self, data: bytes) -> str:
|
||||
return f"processed {len(data)} bytes"
|
||||
|
||||
|
||||
processor = LargePayloadProcessor.bind()
|
||||
app = Caller.bind(processor)
|
||||
|
||||
handle: DeploymentHandle = serve.run(app)
|
||||
assert handle.remote(b"x" * 1024).result() == "processed 1024 bytes"
|
||||
# __end_grpc_override__
|
||||
|
||||
serve.shutdown()
|
||||
@@ -0,0 +1,108 @@
|
||||
# flake8: noqa
|
||||
|
||||
# __start_my_first_deployment__
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class MyFirstDeployment:
|
||||
# Take the message to return as an argument to the constructor.
|
||||
def __init__(self, msg):
|
||||
self.msg = msg
|
||||
|
||||
def __call__(self):
|
||||
return self.msg
|
||||
|
||||
|
||||
my_first_deployment = MyFirstDeployment.bind("Hello world!")
|
||||
handle: DeploymentHandle = serve.run(my_first_deployment)
|
||||
assert handle.remote().result() == "Hello world!"
|
||||
# __end_my_first_deployment__
|
||||
|
||||
# __start_deployment_handle__
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Hello:
|
||||
def __call__(self) -> str:
|
||||
return "Hello"
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class World:
|
||||
def __call__(self) -> str:
|
||||
return " world!"
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Ingress:
|
||||
def __init__(self, hello_handle: DeploymentHandle, world_handle: DeploymentHandle):
|
||||
self._hello_handle = hello_handle
|
||||
self._world_handle = world_handle
|
||||
|
||||
async def __call__(self) -> str:
|
||||
hello_response = self._hello_handle.remote()
|
||||
world_response = self._world_handle.remote()
|
||||
return (await hello_response) + (await world_response)
|
||||
|
||||
|
||||
hello = Hello.bind()
|
||||
world = World.bind()
|
||||
|
||||
# The deployments passed to the Ingress constructor are replaced with handles.
|
||||
app = Ingress.bind(hello, world)
|
||||
|
||||
# Deploys Hello, World, and Ingress.
|
||||
handle: DeploymentHandle = serve.run(app)
|
||||
|
||||
# `DeploymentHandle`s can also be used to call the ingress deployment of an application.
|
||||
assert handle.remote().result() == "Hello world!"
|
||||
# __end_deployment_handle__
|
||||
|
||||
# __start_basic_ingress__
|
||||
import requests
|
||||
from starlette.requests import Request
|
||||
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class MostBasicIngress:
|
||||
async def __call__(self, request: Request) -> str:
|
||||
name = (await request.json())["name"]
|
||||
return f"Hello {name}!"
|
||||
|
||||
|
||||
app = MostBasicIngress.bind()
|
||||
serve.run(app)
|
||||
assert (
|
||||
requests.get("http://127.0.0.1:8000/", json={"name": "Corey"}).text
|
||||
== "Hello Corey!"
|
||||
)
|
||||
# __end_basic_ingress__
|
||||
|
||||
# __start_fastapi_ingress__
|
||||
import requests
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from ray import serve
|
||||
|
||||
fastapi_app = FastAPI()
|
||||
|
||||
|
||||
@serve.deployment
|
||||
@serve.ingress(fastapi_app)
|
||||
class FastAPIIngress:
|
||||
@fastapi_app.get("/{name}")
|
||||
async def say_hi(self, name: str) -> str:
|
||||
return PlainTextResponse(f"Hello {name}!")
|
||||
|
||||
|
||||
app = FastAPIIngress.bind()
|
||||
serve.run(app)
|
||||
assert requests.get("http://127.0.0.1:8000/Corey").text == "Hello Corey!"
|
||||
# __end_fastapi_ingress__
|
||||
@@ -0,0 +1,55 @@
|
||||
# flake8: noqa
|
||||
# fmt: off
|
||||
|
||||
# __example_deployment_start__
|
||||
import time
|
||||
from ray import serve
|
||||
from starlette.requests import Request
|
||||
|
||||
@serve.deployment(
|
||||
# Each replica will be sent 2 requests at a time.
|
||||
max_ongoing_requests=2,
|
||||
# Each caller queues up to 2 requests at a time.
|
||||
# (beyond those that are sent to replicas).
|
||||
max_queued_requests=2,
|
||||
)
|
||||
class SlowDeployment:
|
||||
def __call__(self, request: Request) -> str:
|
||||
# Emulate a long-running request, such as ML inference.
|
||||
time.sleep(2)
|
||||
return "Hello!"
|
||||
# __example_deployment_end__
|
||||
|
||||
# __client_test_start__
|
||||
import ray
|
||||
import aiohttp
|
||||
|
||||
@ray.remote
|
||||
class Requester:
|
||||
async def do_request(self) -> int:
|
||||
async with aiohttp.ClientSession("http://localhost:8000/") as session:
|
||||
return (await session.get("/")).status
|
||||
|
||||
r = Requester.remote()
|
||||
serve.run(SlowDeployment.bind())
|
||||
|
||||
# Send 4 requests first.
|
||||
# 2 of these will be sent to the replica. These requests take a few seconds to execute.
|
||||
first_refs = [r.do_request.remote() for _ in range(2)]
|
||||
_, pending = ray.wait(first_refs, timeout=1)
|
||||
assert len(pending) == 2
|
||||
# 2 will be queued in the proxy.
|
||||
queued_refs = [r.do_request.remote() for _ in range(2)]
|
||||
_, pending = ray.wait(queued_refs, timeout=0.1)
|
||||
assert len(pending) == 2
|
||||
|
||||
# Send an additional 5 requests. These will be rejected immediately because
|
||||
# the replica and the proxy queue are already full.
|
||||
for status_code in ray.get([r.do_request.remote() for _ in range(5)]):
|
||||
assert status_code == 503
|
||||
|
||||
# The initial requests will finish successfully.
|
||||
for ref in first_refs:
|
||||
print(f"Request finished with status code {ray.get(ref)}.")
|
||||
|
||||
# __client_test_end__
|
||||
@@ -0,0 +1,38 @@
|
||||
# __local_dev_start__
|
||||
# Filename: local_dev.py
|
||||
from starlette.requests import Request
|
||||
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle, DeploymentResponse
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Doubler:
|
||||
def double(self, s: str):
|
||||
return s + " " + s
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class HelloDeployment:
|
||||
def __init__(self, doubler: DeploymentHandle):
|
||||
self.doubler = doubler
|
||||
|
||||
async def say_hello_twice(self, name: str):
|
||||
return await self.doubler.double.remote(f"Hello, {name}!")
|
||||
|
||||
async def __call__(self, request: Request):
|
||||
return await self.say_hello_twice(request.query_params["name"])
|
||||
|
||||
|
||||
app = HelloDeployment.bind(Doubler.bind())
|
||||
# __local_dev_end__
|
||||
|
||||
# __local_dev_handle_start__
|
||||
handle: DeploymentHandle = serve.run(app)
|
||||
response: DeploymentResponse = handle.say_hello_twice.remote(name="Ray")
|
||||
assert response.result() == "Hello, Ray! Hello, Ray!"
|
||||
# __local_dev_handle_end__
|
||||
|
||||
# __local_dev_testing_start__
|
||||
serve.run(app, _local_testing_mode=True)
|
||||
# __local_dev_testing_end__
|
||||
@@ -0,0 +1,80 @@
|
||||
from ray import serve
|
||||
import time
|
||||
import os
|
||||
|
||||
|
||||
# __updating_a_deployment_start__
|
||||
@serve.deployment(name="my_deployment", num_replicas=1)
|
||||
class SimpleDeployment:
|
||||
pass
|
||||
|
||||
|
||||
# Creates one initial replica.
|
||||
serve.run(SimpleDeployment.bind())
|
||||
|
||||
|
||||
# Re-deploys, creating an additional replica.
|
||||
# This could be the SAME Python script, modified and re-run.
|
||||
@serve.deployment(name="my_deployment", num_replicas=2)
|
||||
class SimpleDeployment:
|
||||
pass
|
||||
|
||||
|
||||
serve.run(SimpleDeployment.bind())
|
||||
|
||||
# You can also use Deployment.options() to change options without redefining
|
||||
# the class. This is useful for programmatically updating deployments.
|
||||
serve.run(SimpleDeployment.options(num_replicas=2).bind())
|
||||
# __updating_a_deployment_end__
|
||||
|
||||
|
||||
# __scaling_out_start__
|
||||
# Create with a single replica.
|
||||
@serve.deployment(num_replicas=1)
|
||||
def func(*args):
|
||||
pass
|
||||
|
||||
|
||||
serve.run(func.bind())
|
||||
|
||||
# Scale up to 3 replicas.
|
||||
serve.run(func.options(num_replicas=3).bind())
|
||||
|
||||
# Scale back down to 1 replica.
|
||||
serve.run(func.options(num_replicas=1).bind())
|
||||
# __scaling_out_end__
|
||||
|
||||
|
||||
# __autoscaling_start__
|
||||
@serve.deployment(
|
||||
autoscaling_config={
|
||||
"min_replicas": 1,
|
||||
"initial_replicas": 2,
|
||||
"max_replicas": 5,
|
||||
"target_ongoing_requests": 10,
|
||||
}
|
||||
)
|
||||
def func(_):
|
||||
time.sleep(1)
|
||||
return ""
|
||||
|
||||
|
||||
serve.run(
|
||||
func.bind()
|
||||
) # The func deployment will now autoscale based on requests demand.
|
||||
# __autoscaling_end__
|
||||
|
||||
|
||||
# __configure_parallism_start__
|
||||
@serve.deployment
|
||||
class MyDeployment:
|
||||
def __init__(self, parallelism: str):
|
||||
os.environ["OMP_NUM_THREADS"] = parallelism
|
||||
# Download model weights, initialize model, etc.
|
||||
|
||||
def __call__(self):
|
||||
pass
|
||||
|
||||
|
||||
serve.run(MyDeployment.bind("12"))
|
||||
# __configure_parallism_end__
|
||||
@@ -0,0 +1,139 @@
|
||||
# __train_model_start__
|
||||
from sklearn.datasets import make_regression
|
||||
from sklearn.ensemble import RandomForestRegressor
|
||||
from sklearn.metrics import mean_squared_error
|
||||
from sklearn.model_selection import train_test_split
|
||||
from sklearn.pipeline import Pipeline
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
import mlflow
|
||||
import mlflow.sklearn
|
||||
import mlflow.pyfunc
|
||||
from mlflow.entities import LoggedModelStatus
|
||||
from mlflow.models import infer_signature
|
||||
import numpy as np
|
||||
|
||||
|
||||
def train_and_register_model():
|
||||
# Initialize model in PENDING state
|
||||
logged_model = mlflow.initialize_logged_model(
|
||||
name="sk-learn-random-forest-reg-model",
|
||||
model_type="sklearn",
|
||||
tags={"model_type": "random_forest"},
|
||||
)
|
||||
|
||||
try:
|
||||
with mlflow.start_run() as run:
|
||||
X, y = make_regression(n_features=4, n_informative=2, random_state=0, shuffle=False)
|
||||
X_train, X_test, y_train, y_test = train_test_split(
|
||||
X, y, test_size=0.2, random_state=42
|
||||
)
|
||||
|
||||
params = {"max_depth": 2, "random_state": 42}
|
||||
|
||||
# Best Practice: Use sklearn Pipeline to persist preprocessing
|
||||
# This ensures training and serving transformations stay aligned
|
||||
pipeline = Pipeline([
|
||||
("scaler", StandardScaler()),
|
||||
("regressor", RandomForestRegressor(**params))
|
||||
])
|
||||
pipeline.fit(X_train, y_train)
|
||||
|
||||
# Log parameters and metrics
|
||||
mlflow.log_params(params)
|
||||
|
||||
y_pred = pipeline.predict(X_test)
|
||||
mlflow.log_metrics({"mse": mean_squared_error(y_test, y_pred)})
|
||||
|
||||
# Best Practice: Infer model signature for input validation
|
||||
# Prevents silent failures from mismatched feature order or missing columns
|
||||
signature = infer_signature(X_train, y_pred)
|
||||
|
||||
# Best Practice: Pin dependency versions explicitly
|
||||
# Ensures identical behavior across training, evaluation, and serving
|
||||
pip_requirements = [
|
||||
f"scikit-learn=={__import__('sklearn').__version__}",
|
||||
f"numpy=={np.__version__}",
|
||||
]
|
||||
|
||||
# Log the sklearn pipeline with signature and dependencies
|
||||
mlflow.sklearn.log_model(
|
||||
sk_model=pipeline,
|
||||
name="sklearn-model",
|
||||
input_example=X_train[:1],
|
||||
signature=signature,
|
||||
pip_requirements=pip_requirements,
|
||||
registered_model_name="sk-learn-random-forest-reg-model",
|
||||
model_id=logged_model.model_id,
|
||||
)
|
||||
|
||||
# Finalize model as READY
|
||||
mlflow.finalize_logged_model(logged_model.model_id, LoggedModelStatus.READY)
|
||||
|
||||
mlflow.set_logged_model_tags(
|
||||
logged_model.model_id,
|
||||
tags={"production": "true"},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Mark model as FAILED if issues occur
|
||||
mlflow.finalize_logged_model(logged_model.model_id, LoggedModelStatus.FAILED)
|
||||
raise
|
||||
|
||||
# Retrieve and work with the logged model
|
||||
final_model = mlflow.get_logged_model(logged_model.model_id)
|
||||
print(f"Model {final_model.name} is {final_model.status}")
|
||||
# __train_model_end__
|
||||
|
||||
|
||||
# __deployment_start__
|
||||
from ray import serve
|
||||
import mlflow.pyfunc
|
||||
import numpy as np
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class MLflowModelDeployment:
|
||||
def __init__(self):
|
||||
# Search for models with production tag
|
||||
models = mlflow.search_logged_models(
|
||||
filter_string="tags.production='true' AND name='sk-learn-random-forest-reg-model'",
|
||||
order_by=[{"field_name": "creation_time", "ascending": False}],
|
||||
)
|
||||
if models.empty:
|
||||
raise ValueError("No model with production tag found")
|
||||
|
||||
# Get the most recent production model
|
||||
model_row = models.iloc[0]
|
||||
artifact_location = model_row["artifact_location"]
|
||||
|
||||
# Best Practice: Load model once during initialization (warm-start)
|
||||
# This eliminates first-request latency spikes
|
||||
self.model = mlflow.pyfunc.load_model(artifact_location)
|
||||
|
||||
# Pre-warm the model with a dummy prediction
|
||||
dummy_input = np.zeros((1, 4))
|
||||
_ = self.model.predict(dummy_input)
|
||||
|
||||
async def __call__(self, request):
|
||||
data = await request.json()
|
||||
features = np.array(data["features"])
|
||||
|
||||
# MLflow validates input against the logged signature automatically
|
||||
prediction = self.model.predict(features)
|
||||
return {"prediction": prediction.tolist()}
|
||||
|
||||
|
||||
app = MLflowModelDeployment.bind()
|
||||
# __deployment_end__
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import requests
|
||||
from ray import serve
|
||||
|
||||
train_and_register_model()
|
||||
serve.run(app)
|
||||
|
||||
# Test prediction
|
||||
response = requests.post("http://localhost:8000/", json={"features": [[0.1, 0.2, 0.3, 0.4]]})
|
||||
print(response.json())
|
||||
@@ -0,0 +1,50 @@
|
||||
# flake8: noqa
|
||||
# __chaining_example_start__
|
||||
# File name: chain.py
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle, DeploymentResponse
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Adder:
|
||||
def __init__(self, increment: int):
|
||||
self._increment = increment
|
||||
|
||||
def __call__(self, val: int) -> int:
|
||||
return val + self._increment
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Multiplier:
|
||||
def __init__(self, multiple: int):
|
||||
self._multiple = multiple
|
||||
|
||||
def __call__(self, val: int) -> int:
|
||||
return val * self._multiple
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Ingress:
|
||||
def __init__(self, adder: DeploymentHandle, multiplier: DeploymentHandle):
|
||||
self._adder = adder
|
||||
self._multiplier = multiplier
|
||||
|
||||
async def __call__(self, input: int) -> int:
|
||||
adder_response: DeploymentResponse = self._adder.remote(input)
|
||||
# Pass the adder response directly into the multiplier (no `await` needed).
|
||||
multiplier_response: DeploymentResponse = self._multiplier.remote(
|
||||
adder_response
|
||||
)
|
||||
# `await` the final chained response.
|
||||
return await multiplier_response
|
||||
|
||||
|
||||
app = Ingress.bind(
|
||||
Adder.bind(increment=1),
|
||||
Multiplier.bind(multiple=2),
|
||||
)
|
||||
|
||||
handle: DeploymentHandle = serve.run(app)
|
||||
response = handle.remote(5)
|
||||
assert response.result() == 12, "(5 + 1) * 2 = 12"
|
||||
# __chaining_example_end__
|
||||
@@ -0,0 +1,98 @@
|
||||
# flake8: noqa
|
||||
|
||||
import requests
|
||||
|
||||
# __echo_class_start__
|
||||
# File name: echo.py
|
||||
from starlette.requests import Request
|
||||
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class EchoClass:
|
||||
def __init__(self, echo_str: str):
|
||||
self.echo_str = echo_str
|
||||
|
||||
def __call__(self, request: Request) -> str:
|
||||
return self.echo_str
|
||||
|
||||
|
||||
# You can create ClassNodes from the EchoClass deployment
|
||||
foo_node = EchoClass.bind("foo")
|
||||
bar_node = EchoClass.bind("bar")
|
||||
baz_node = EchoClass.bind("baz")
|
||||
# __echo_class_end__
|
||||
|
||||
for node, echo in [(foo_node, "foo"), (bar_node, "bar"), (baz_node, "baz")]:
|
||||
serve.run(node)
|
||||
assert requests.get("http://localhost:8000/").text == echo
|
||||
|
||||
# __echo_client_start__
|
||||
# File name: echo_client.py
|
||||
import requests
|
||||
|
||||
response = requests.get("http://localhost:8000/")
|
||||
echo = response.text
|
||||
print(echo)
|
||||
# __echo_client_end__
|
||||
|
||||
# __hello_start__
|
||||
# File name: hello.py
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class LanguageClassifer:
|
||||
def __init__(
|
||||
self, spanish_responder: DeploymentHandle, french_responder: DeploymentHandle
|
||||
):
|
||||
self.spanish_responder = spanish_responder
|
||||
self.french_responder = french_responder
|
||||
|
||||
async def __call__(self, http_request):
|
||||
request = await http_request.json()
|
||||
language, name = request["language"], request["name"]
|
||||
|
||||
if language == "spanish":
|
||||
response = self.spanish_responder.say_hello.remote(name)
|
||||
elif language == "french":
|
||||
response = self.french_responder.say_hello.remote(name)
|
||||
else:
|
||||
return "Please try again."
|
||||
|
||||
return await response
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class SpanishResponder:
|
||||
def say_hello(self, name: str):
|
||||
return f"Hola {name}"
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class FrenchResponder:
|
||||
def say_hello(self, name: str):
|
||||
return f"Bonjour {name}"
|
||||
|
||||
|
||||
spanish_responder = SpanishResponder.bind()
|
||||
french_responder = FrenchResponder.bind()
|
||||
language_classifier = LanguageClassifer.bind(spanish_responder, french_responder)
|
||||
# __hello_end__
|
||||
|
||||
serve.run(language_classifier)
|
||||
|
||||
# __hello_client_start__
|
||||
# File name: hello_client.py
|
||||
import requests
|
||||
|
||||
response = requests.post(
|
||||
"http://localhost:8000", json={"language": "spanish", "name": "Dora"}
|
||||
)
|
||||
greeting = response.text
|
||||
print(greeting)
|
||||
# __hello_client_end__
|
||||
|
||||
assert greeting == "Hola Dora"
|
||||
@@ -0,0 +1,37 @@
|
||||
# flake8: noqa
|
||||
# __response_to_object_ref_example_start__
|
||||
# File name: response_to_object_ref.py
|
||||
import ray
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle, DeploymentResponse
|
||||
|
||||
|
||||
@ray.remote
|
||||
def say_hi_task(inp: str):
|
||||
return f"Ray task got message: '{inp}'"
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class SayHi:
|
||||
def __call__(self) -> str:
|
||||
return "Hi from Serve deployment"
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Ingress:
|
||||
def __init__(self, say_hi: DeploymentHandle):
|
||||
self._say_hi = say_hi
|
||||
|
||||
async def __call__(self):
|
||||
# Make a call to the SayHi deployment and pass the result ref to
|
||||
# a downstream Ray task.
|
||||
response: DeploymentResponse = self._say_hi.remote()
|
||||
response_obj_ref: ray.ObjectRef = await response._to_object_ref()
|
||||
final_obj_ref: ray.ObjectRef = say_hi_task.remote(response_obj_ref)
|
||||
return await final_obj_ref
|
||||
|
||||
|
||||
app = Ingress.bind(SayHi.bind())
|
||||
handle: DeploymentHandle = serve.run(app)
|
||||
assert handle.remote().result() == "Ray task got message: 'Hi from Serve deployment'"
|
||||
# __response_to_object_ref_example_end__
|
||||
@@ -0,0 +1,42 @@
|
||||
# flake8: noqa
|
||||
# __streaming_example_start__
|
||||
# File name: stream.py
|
||||
from typing import AsyncGenerator, Generator
|
||||
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle, DeploymentResponseGenerator
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Streamer:
|
||||
def __call__(self, limit: int) -> Generator[int, None, None]:
|
||||
for i in range(limit):
|
||||
yield i
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Caller:
|
||||
def __init__(self, streamer: DeploymentHandle):
|
||||
self._streamer = streamer.options(
|
||||
# Must set `stream=True` on the handle, then the output will be a
|
||||
# response generator.
|
||||
stream=True,
|
||||
)
|
||||
|
||||
async def __call__(self, limit: int) -> AsyncGenerator[int, None]:
|
||||
# Response generator can be used in an `async for` block.
|
||||
r: DeploymentResponseGenerator = self._streamer.remote(limit)
|
||||
async for i in r:
|
||||
yield i
|
||||
|
||||
|
||||
app = Caller.bind(Streamer.bind())
|
||||
|
||||
handle: DeploymentHandle = serve.run(app).options(
|
||||
stream=True,
|
||||
)
|
||||
|
||||
# Response generator can also be used as a regular generator in a sync context.
|
||||
r: DeploymentResponseGenerator = handle.remote(10)
|
||||
assert list(r) == list(range(10))
|
||||
# __streaming_example_end__
|
||||
@@ -0,0 +1,36 @@
|
||||
# __start__
|
||||
from ray import serve
|
||||
from ray.serve import metrics
|
||||
|
||||
import time
|
||||
import requests
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class MyDeployment:
|
||||
def __init__(self):
|
||||
self.num_requests = 0
|
||||
self.my_counter = metrics.Counter(
|
||||
"my_counter",
|
||||
description=("The number of odd-numbered requests to this deployment."),
|
||||
tag_keys=("model",),
|
||||
)
|
||||
self.my_counter.set_default_tags({"model": "123"})
|
||||
|
||||
def __call__(self):
|
||||
self.num_requests += 1
|
||||
if self.num_requests % 2 == 1:
|
||||
self.my_counter.inc()
|
||||
|
||||
|
||||
my_deployment = MyDeployment.bind()
|
||||
serve.run(my_deployment)
|
||||
|
||||
while True:
|
||||
requests.get("http://localhost:8000/")
|
||||
time.sleep(1)
|
||||
|
||||
# __end__
|
||||
break
|
||||
response = requests.get("http://localhost:8000/")
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,29 @@
|
||||
# __start__
|
||||
from ray import serve
|
||||
|
||||
import logging
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger("ray.serve")
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Counter:
|
||||
def __init__(self):
|
||||
self.count = 0
|
||||
|
||||
def __call__(self, request):
|
||||
self.count += 1
|
||||
logger.info(f"count: {self.count}")
|
||||
return {"count": self.count}
|
||||
|
||||
|
||||
counter = Counter.bind()
|
||||
serve.run(counter)
|
||||
|
||||
for i in range(10):
|
||||
requests.get("http://127.0.0.1:8000/")
|
||||
# __end__
|
||||
|
||||
response = requests.get("http://127.0.0.1:8000/")
|
||||
assert response.json() == {"count": 11}
|
||||
@@ -0,0 +1,129 @@
|
||||
# flake8: noqa
|
||||
# __deployment_json_start__
|
||||
import requests
|
||||
from ray import serve
|
||||
from ray.serve.schema import LoggingConfig
|
||||
|
||||
|
||||
@serve.deployment(logging_config=LoggingConfig(encoding="JSON"))
|
||||
class Model:
|
||||
def __call__(self) -> int:
|
||||
return "hello world"
|
||||
|
||||
|
||||
serve.run(Model.bind())
|
||||
|
||||
resp = requests.get("http://localhost:8000/")
|
||||
|
||||
# __deployment_json_end__
|
||||
|
||||
|
||||
# __serve_run_json_start__
|
||||
import requests
|
||||
from ray import serve
|
||||
from ray.serve.schema import LoggingConfig
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Model:
|
||||
def __call__(self) -> int:
|
||||
return "hello world"
|
||||
|
||||
|
||||
serve.run(Model.bind(), logging_config=LoggingConfig(encoding="JSON"))
|
||||
|
||||
resp = requests.get("http://localhost:8000/")
|
||||
|
||||
# __serve_run_json_end__
|
||||
|
||||
|
||||
# __level_start__
|
||||
|
||||
|
||||
@serve.deployment(logging_config=LoggingConfig(log_level="DEBUG"))
|
||||
class Model:
|
||||
def __call__(self) -> int:
|
||||
logger = logging.getLogger("ray.serve")
|
||||
logger.debug("This debug message is from the router.")
|
||||
return "hello world"
|
||||
|
||||
|
||||
# __level_end__
|
||||
|
||||
serve.run(Model.bind())
|
||||
|
||||
resp = requests.get("http://localhost:8000/")
|
||||
|
||||
|
||||
# __logs_dir_start__
|
||||
@serve.deployment(logging_config=LoggingConfig(logs_dir="/my_dirs"))
|
||||
class Model:
|
||||
def __call__(self) -> int:
|
||||
return "hello world"
|
||||
|
||||
|
||||
# __logs_dir_end__
|
||||
|
||||
|
||||
# __enable_access_log_start__
|
||||
import requests
|
||||
import logging
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment(logging_config={"enable_access_log": False})
|
||||
class Model:
|
||||
def __call__(self):
|
||||
logger = logging.getLogger("ray.serve")
|
||||
logger.info("hello world")
|
||||
|
||||
|
||||
serve.run(Model.bind())
|
||||
|
||||
resp = requests.get("http://localhost:8000/")
|
||||
|
||||
# __enable_access_log_end__
|
||||
|
||||
|
||||
# __application_and_deployment_start__
|
||||
import requests
|
||||
import logging
|
||||
from ray import serve
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Router:
|
||||
def __init__(self, handle):
|
||||
self.handle = handle
|
||||
|
||||
async def __call__(self):
|
||||
logger = logging.getLogger("ray.serve")
|
||||
logger.debug("This debug message is from the router.")
|
||||
return await self.handle.remote()
|
||||
|
||||
|
||||
@serve.deployment(logging_config={"log_level": "INFO"})
|
||||
class Model:
|
||||
def __call__(self) -> int:
|
||||
logger = logging.getLogger("ray.serve")
|
||||
logger.debug("This debug message is from the model.")
|
||||
return "hello world"
|
||||
|
||||
|
||||
serve.run(Router.bind(Model.bind()), logging_config={"log_level": "DEBUG"})
|
||||
resp = requests.get("http://localhost:8000/")
|
||||
# __application_and_deployment_end__
|
||||
|
||||
# __configure_serve_component_start__
|
||||
from ray import serve
|
||||
|
||||
|
||||
serve.start(
|
||||
logging_config={
|
||||
"encoding": "JSON",
|
||||
"log_level": "DEBUG",
|
||||
"enable_access_log": False,
|
||||
}
|
||||
)
|
||||
|
||||
# __configure_serve_component_end__
|
||||
@@ -0,0 +1,23 @@
|
||||
# __start__
|
||||
from ray import serve
|
||||
|
||||
import time
|
||||
import requests
|
||||
|
||||
|
||||
@serve.deployment
|
||||
def sleeper():
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
s = sleeper.bind()
|
||||
|
||||
serve.run(s)
|
||||
|
||||
while True:
|
||||
requests.get("http://localhost:8000/")
|
||||
# __end__
|
||||
break
|
||||
|
||||
response = requests.get("http://localhost:8000/")
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,33 @@
|
||||
# flake8: noqa
|
||||
# fmt: off
|
||||
|
||||
# __monitor_start__
|
||||
from typing import List, Dict
|
||||
|
||||
from ray import serve
|
||||
from ray.serve.schema import ServeStatus, ApplicationStatusOverview
|
||||
|
||||
|
||||
@serve.deployment
|
||||
def get_healthy_apps() -> List[str]:
|
||||
serve_status: ServeStatus = serve.status()
|
||||
app_statuses: Dict[str, ApplicationStatusOverview] = serve_status.applications
|
||||
|
||||
running_apps = []
|
||||
for app_name, app_status in app_statuses.items():
|
||||
if app_status.status == "RUNNING":
|
||||
running_apps.append(app_name)
|
||||
|
||||
return running_apps
|
||||
|
||||
|
||||
monitoring_app = get_healthy_apps.bind()
|
||||
# __monitor_end__
|
||||
|
||||
serve.run(monitoring_app, name="monitor")
|
||||
|
||||
import requests
|
||||
|
||||
resp = requests.get("http://localhost:8000/")
|
||||
|
||||
assert requests.get("http://localhost:8000/").json() == ["monitor"]
|
||||
@@ -0,0 +1,27 @@
|
||||
# flake8: noqa
|
||||
|
||||
# __start__
|
||||
# File name: monitoring.py
|
||||
|
||||
from ray import serve
|
||||
import logging
|
||||
from starlette.requests import Request
|
||||
|
||||
logger = logging.getLogger("ray.serve")
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class SayHello:
|
||||
async def __call__(self, request: Request) -> str:
|
||||
logger.info("Hello world!")
|
||||
return "hi"
|
||||
|
||||
|
||||
say_hello = SayHello.bind()
|
||||
# __end__
|
||||
|
||||
# serve.run(say_hello)
|
||||
|
||||
# import requests
|
||||
# response = requests.get("http://localhost:8000/")
|
||||
# assert response.text == "hi"
|
||||
@@ -0,0 +1,15 @@
|
||||
from ray import serve
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Model:
|
||||
def __call__(self) -> int:
|
||||
return 1
|
||||
|
||||
|
||||
serve.run(Model.bind())
|
||||
resp = requests.get("http://localhost:8000", headers={"X-Request-ID": "123-234"})
|
||||
|
||||
print(resp.headers["X-Request-ID"])
|
||||
@@ -0,0 +1,99 @@
|
||||
# __serve_deployment_example_begin__
|
||||
|
||||
from ray import serve
|
||||
import aioboto3
|
||||
import torch
|
||||
import starlette
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class ModelInferencer:
|
||||
def __init__(self):
|
||||
self.bucket_name = "my_bucket"
|
||||
|
||||
@serve.multiplexed(max_num_models_per_replica=3)
|
||||
async def get_model(self, model_id: str):
|
||||
session = aioboto3.Session()
|
||||
async with session.resource("s3") as s3:
|
||||
obj = await s3.Bucket(self.bucket_name)
|
||||
await obj.download_file(f"{model_id}/model.pt", f"model_{model_id}.pt")
|
||||
return torch.load(f"model_{model_id}.pt", weights_only=False)
|
||||
|
||||
async def __call__(self, request: starlette.requests.Request):
|
||||
model_id = serve.get_multiplexed_model_id()
|
||||
model = await self.get_model(model_id)
|
||||
return model.forward(torch.rand(64, 3, 512, 512))
|
||||
|
||||
|
||||
entry = ModelInferencer.bind()
|
||||
|
||||
# __serve_deployment_example_end__
|
||||
|
||||
handle = serve.run(entry)
|
||||
|
||||
# __serve_request_send_example_begin__
|
||||
import requests # noqa: E402
|
||||
|
||||
resp = requests.get(
|
||||
"http://localhost:8000", headers={"serve_multiplexed_model_id": str("1")}
|
||||
)
|
||||
# __serve_request_send_example_end__
|
||||
|
||||
# __serve_handle_send_example_begin__
|
||||
obj_ref = handle.options(multiplexed_model_id="1").remote("<your param>")
|
||||
# __serve_handle_send_example_end__
|
||||
|
||||
|
||||
from ray.serve.handle import DeploymentHandle # noqa: E402
|
||||
|
||||
|
||||
# __serve_model_composition_example_begin__
|
||||
@serve.deployment
|
||||
class Downstream:
|
||||
def __call__(self):
|
||||
return serve.get_multiplexed_model_id()
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Upstream:
|
||||
def __init__(self, downstream: DeploymentHandle):
|
||||
self._h = downstream
|
||||
|
||||
async def __call__(self, request: starlette.requests.Request):
|
||||
return await self._h.options(multiplexed_model_id="bar").remote()
|
||||
|
||||
|
||||
serve.run(Upstream.bind(Downstream.bind()))
|
||||
resp = requests.get("http://localhost:8000")
|
||||
# __serve_model_composition_example_end__
|
||||
|
||||
|
||||
# __serve_multiplexed_batching_example_begin__
|
||||
from typing import List # noqa: E402
|
||||
from starlette.requests import Request
|
||||
|
||||
|
||||
@serve.deployment(max_ongoing_requests=15)
|
||||
class BatchedMultiplexModel:
|
||||
@serve.multiplexed(max_num_models_per_replica=3)
|
||||
async def get_model(self, model_id: str):
|
||||
# Load and return your model here
|
||||
return model_id
|
||||
|
||||
@serve.batch(max_batch_size=10, batch_wait_timeout_s=0.1)
|
||||
async def batched_predict(self, inputs: List[str]) -> List[str]:
|
||||
# Get the model ID - this works correctly inside batched functions
|
||||
# because all requests in the batch target the same model
|
||||
model_id = serve.get_multiplexed_model_id()
|
||||
model = await self.get_model(model_id)
|
||||
|
||||
# Process the batch with the loaded model
|
||||
return [f"{model}:{inp}" for inp in inputs]
|
||||
|
||||
async def __call__(self, request: Request):
|
||||
# Extract input from the request body
|
||||
input_text = await request.body()
|
||||
return await self.batched_predict(input_text.decode())
|
||||
|
||||
|
||||
# __serve_multiplexed_batching_example_end__
|
||||
@@ -0,0 +1,69 @@
|
||||
# __example_code_start__
|
||||
import torch
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
from io import BytesIO
|
||||
from fastapi.responses import Response
|
||||
from fastapi import FastAPI
|
||||
|
||||
from ray import serve
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@serve.deployment(num_replicas=1)
|
||||
@serve.ingress(app)
|
||||
class APIIngress:
|
||||
def __init__(self, object_detection_handle: DeploymentHandle):
|
||||
self.handle = object_detection_handle
|
||||
|
||||
@app.get(
|
||||
"/detect",
|
||||
responses={200: {"content": {"image/jpeg": {}}}},
|
||||
response_class=Response,
|
||||
)
|
||||
async def detect(self, image_url: str):
|
||||
image = await self.handle.detect.remote(image_url)
|
||||
file_stream = BytesIO()
|
||||
image.save(file_stream, "jpeg")
|
||||
return Response(content=file_stream.getvalue(), media_type="image/jpeg")
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
ray_actor_options={"num_gpus": 1},
|
||||
autoscaling_config={"min_replicas": 1, "max_replicas": 2},
|
||||
)
|
||||
class ObjectDetection:
|
||||
def __init__(self):
|
||||
self.model = torch.hub.load("ultralytics/yolov5", "yolov5s")
|
||||
self.model.cuda()
|
||||
self.model.to(torch.device(0))
|
||||
|
||||
def detect(self, image_url: str):
|
||||
result_im = self.model(image_url)
|
||||
return Image.fromarray(result_im.render()[0].astype(np.uint8))
|
||||
|
||||
|
||||
entrypoint = APIIngress.bind(ObjectDetection.bind())
|
||||
|
||||
|
||||
# __example_code_end__
|
||||
|
||||
if __name__ == "__main__":
|
||||
import ray
|
||||
import requests
|
||||
import os
|
||||
|
||||
ray.init(runtime_env={"pip": ["seaborn", "ultralytics"]})
|
||||
|
||||
serve.run(entrypoint)
|
||||
image_url = "https://ultralytics.com/images/zidane.jpg"
|
||||
resp = requests.get(f"http://127.0.0.1:8000/detect?image_url={image_url}")
|
||||
|
||||
with open("output.jpeg", "wb") as f:
|
||||
f.write(resp.content)
|
||||
|
||||
assert os.path.exists("output.jpeg")
|
||||
os.remove("output.jpeg")
|
||||
@@ -0,0 +1,17 @@
|
||||
# LMCache configuration for Mooncake store backend
|
||||
chunk_size: 256
|
||||
local_device: "cpu"
|
||||
remote_url: "mooncakestore://storage-server:49999/"
|
||||
remote_serde: "naive"
|
||||
pipelined_backend: false
|
||||
local_cpu: false
|
||||
max_local_cpu_size: 5
|
||||
extra_config:
|
||||
local_hostname: "compute-node-001"
|
||||
metadata_server: "etcd://metadata-server:2379"
|
||||
protocol: "rdma"
|
||||
device_name: "rdma0"
|
||||
master_server_address: "storage-server:49999"
|
||||
global_segment_size: 3355443200 # 3.125 GB
|
||||
local_buffer_size: 1073741824 # 1 GB
|
||||
transfer_timeout: 1
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user