Skip to content

Commit 00cd3f3

Browse files
Address review on dropping source-side aggregation
Docs: the autoscaling guide still described the removed flag, simple mode as the default, and aggregation_function as inert. Rewrite Stage 2/3 and add an upgrade note, since aggregation_function now always applies -- a deployment that set max or min and never enabled the flag changes behavior -- and controller-side merging costs more than summing pre-computed scalars. HandleMetricReport.total_requests summed the last sample of each series, but a handle is only dropped once its report is stale, so a queue that drained on that final sample logged nothing: exactly the case the log exists for. Sum the per-series peak instead, and pin it in test_common.py. aggregate_sum, aggregate_avg, timeseries_count and _aggregate_reduce lost their last callers along with simple mode; delete them and re-point the store tests at the stored series. Fold _calculate_total_requests_aggregate_mode into get_total_num_requests now that there is one mode, drop the duplicate empty check in _get_queued_requests, drop the guard in _get_metrics_report that re-tested an already-asserted local, and route the async-inference delay through _record_metrics_delay so the third copy of the delay-and-tag block goes away. _collect_handle_running_requests is the sole path under the collect-on-handle default and scanned every running replica for every handle; iterate the report of each handle instead, which the order-independence of the merge allows. Give test_num_replicas_auto_basic a collect-on-replica target so its looser upscaling assertion is reachable at all, and set RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE explicitly in both metric variants rather than inheriting the default. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: John Taylor <john.taylor@anyscale.com>
1 parent f7765ef commit 00cd3f3

11 files changed

Lines changed: 113 additions & 208 deletions

File tree

doc/source/serve/advanced-guides/advanced-autoscaling.md

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -125,33 +125,19 @@ Replicas and deployment handles continuously record autoscaling metrics:
125125

126126
Periodically, replicas and handles push their metrics to the controller:
127127
- **Frequency**: Every 10s (configurable via `RAY_SERVE_REPLICA_AUTOSCALING_METRIC_PUSH_INTERVAL_S` and `RAY_SERVE_HANDLE_AUTOSCALING_METRIC_PUSH_INTERVAL_S`)
128-
- **Data sent**: Both raw timeseries data and pre-aggregated metrics
129-
- **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)
130-
- **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
131-
- **Controller usage**: The controller decides which data to use based on the `RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER` setting (see Stage 3 below)
128+
- **Data sent**: Raw timeseries data. 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)
132129

133130
#### Stage 3: Metric aggregation
134131

135-
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`):
136-
137-
**Simple mode (default - `RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER=0`):**
138-
- **Input**: Pre-aggregated simple averages from replicas/handles (already clipped to [`look_back_period_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst))
139-
- **Method**: Sums the pre-aggregated values from all sources. Each component computes a simple average (arithmetic mean) before sending.
140-
- **Output**: Single value representing total ongoing requests
141-
- **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.
142-
143-
**Aggregate mode (experimental - `RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER=1`):**
132+
The controller aggregates the raw timeseries to compute total ongoing requests across all replicas:
144133
- **Input**: Raw timeseries data from replicas/handles (already clipped to [`look_back_period_s`](../api/doc/ray.serve.config.AutoscalingConfig.rst))
145134
- **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.
146135
- **Output**: Single value representing total ongoing requests
147-
- **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.
148-
149-
:::{note}
150-
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.
151-
:::
152136

153137
:::{note}
154-
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.
138+
Earlier releases also supported a "simple mode" that summed averages pre-computed at each replica and handle, selected by `RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER`. That flag and that mode are removed: the controller now always aggregates raw timeseries. Two consequences if you are upgrading from a release that had the flag, where it defaulted to simple mode:
139+
- [`aggregation_function`](../api/doc/ray.serve.config.AutoscalingConfig.rst) now always applies. Under simple mode it was ignored, so a deployment that set `max` or `min` and never enabled the flag scaled on a mean and now scales on a peak or a trough.
140+
- Aggregating timeseries costs the controller more than summing pre-computed averages, and the cost grows with the number of replicas. Watch controller CPU on deployments with very high replica counts.
155141
:::
156142

157143
#### Stage 4: Policy execution
@@ -201,10 +187,6 @@ Several environment variables control autoscaling behavior at a lower level. The
201187

202188
* **`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.
203189

204-
#### Advanced feature flags
205-
206-
* **`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.
207-
208190

209191
## Model composition example
210192

python/ray/serve/_private/autoscaling_state.py

Lines changed: 18 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -462,10 +462,11 @@ def _collect_handle_running_requests(self) -> List[TimeSeries]:
462462

463463
for handle_metric in self._handle_requests.values():
464464
running_reqs = handle_metric.metrics.get(RUNNING_REQUESTS_KEY, {})
465-
for replica_str in self._cached_running_replica_strs:
466-
if replica_str not in running_reqs:
467-
continue
468-
timeseries_list.append(running_reqs[replica_str])
465+
# Iterate the handle's own replicas, not every running replica: a handle
466+
# usually routes to a subset, and the merge is order-independent.
467+
for replica_str, timeseries in running_reqs.items():
468+
if replica_str in self._cached_running_replica_strs:
469+
timeseries_list.append(timeseries)
469470

470471
return timeseries_list
471472

@@ -543,11 +544,17 @@ def _merge_and_aggregate_timeseries(
543544

544545
return 0.0
545546

546-
def _calculate_total_requests_aggregate_mode(self) -> float:
547-
"""Calculate total requests using aggregate metrics mode with timeseries data.
547+
def get_total_num_requests(self) -> float:
548+
"""Get average total number of requests aggregated over the past
549+
`look_back_period_s` number of seconds.
548550
549-
This method works with raw timeseries metrics data and performs aggregation
550-
at the controller level.
551+
Works with raw timeseries metrics data and performs aggregation at the
552+
controller level. If there are 0 running replicas, then returns the total
553+
number of requests queued at handles.
554+
555+
This code assumes that the metrics are either emmited on handles
556+
or on replicas, but not both. Its the responsibility of the writer
557+
to ensure enclusivity of the metrics.
551558
552559
Processing Steps:
553560
1. Collect raw timeseries data (eg: running request) from replicas (if available)
@@ -556,12 +563,6 @@ def _calculate_total_requests_aggregate_mode(self) -> float:
556563
4. Merge timeseries using instantaneous approach for mathematically correct totals
557564
5. Calculate time-weighted average running requests from the merged timeseries
558565
559-
Key Differences from Simple Mode:
560-
- Uses raw timeseries data instead of pre-aggregated metrics
561-
- Performs instantaneous merging for exact gauge semantics
562-
- Aggregates at the controller level rather than using pre-computed averages
563-
- Uses time-weighted averaging over the look_back_period_s interval for accurate calculations
564-
565566
Metrics Collection:
566567
Running requests are collected with either replica-level or handle-level metrics.
567568
@@ -637,19 +638,6 @@ def _calculate_total_requests_aggregate_mode(self) -> float:
637638

638639
return ongoing_requests
639640

640-
def get_total_num_requests(self) -> float:
641-
"""Get average total number of requests aggregated over the past
642-
`look_back_period_s` number of seconds.
643-
644-
If there are 0 running replicas, then returns the total number
645-
of requests queued at handles
646-
647-
This code assumes that the metrics are either emmited on handles
648-
or on replicas, but not both. Its the responsibility of the writer
649-
to ensure enclusivity of the metrics.
650-
"""
651-
return self._calculate_total_requests_aggregate_mode()
652-
653641
def get_replica_metrics(self) -> Dict[str, List[TimeSeries]]:
654642
"""Get the raw replica metrics dict."""
655643
metric_values: Dict[str, List[TimeSeries]] = defaultdict(list)
@@ -666,11 +654,9 @@ def _get_queued_requests(self) -> float:
666654
Returns:
667655
Sum of queued requests at all handles, aggregated from handle timeseries.
668656
"""
669-
queued_timeseries = self._collect_handle_queued_requests()
670-
if not queued_timeseries:
671-
return 0.0
672-
673-
return self._merge_and_aggregate_timeseries(queued_timeseries)
657+
return self._merge_and_aggregate_timeseries(
658+
self._collect_handle_queued_requests()
659+
)
674660

675661
def _get_aggregated_custom_metrics(self) -> Dict[str, Dict[ReplicaID, float]]:
676662
"""Aggregate custom metrics from replica metric reports.

python/ray/serve/_private/common.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,17 +1010,12 @@ class HandleMetricReport:
10101010

10111011
@property
10121012
def total_requests(self) -> float:
1013-
"""Most recently observed queued + running requests across this handle's
1014-
replicas. Diagnostic only (reported when a handle's metrics are dropped), so
1015-
the latest sample is enough and no windowing is applied."""
1016-
1017-
def latest(series: TimeSeries) -> float:
1018-
return series[-1].value if series else 0.0
1019-
1020-
return latest(self.queued_requests) + sum(
1021-
latest(series)
1022-
for series in self.metrics.get(RUNNING_REQUESTS_KEY, {}).values()
1023-
)
1013+
"""Upper bound on queued + running requests over this handle's reported window.
1014+
Diagnostic only (logged when a handle's metrics are dropped); peaks are summed
1015+
so a handle that was busy earlier in the window is not reported as idle."""
1016+
running = self.metrics.get(RUNNING_REQUESTS_KEY, {}).values()
1017+
series = [self.queued_requests, *running]
1018+
return sum(max(point.value for point in s) for s in series if s)
10241019

10251020
@property
10261021
def is_serve_component_source(self) -> bool:

python/ray/serve/_private/constants.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1118,7 +1118,6 @@
11181118
if RAY_SERVE_INGRESS_REQUEST_ROUTER_METRICS_ENABLED:
11191119
RAY_SERVE_HAPROXY_METRICS_ENABLED = True
11201120

1121-
11221121
# Feature flag to include high-cardinality source tags on Serve controller metrics.
11231122
# Disable this to keep deployment/application tags while dropping source identifiers
11241123
# like replica IDs from controller-emitted metrics.

python/ray/serve/_private/controller.py

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -390,20 +390,21 @@ def _record_metrics_delay(
390390
self,
391391
timestamp: float,
392392
deployment_id: DeploymentID,
393-
histogram: metrics.Histogram,
394-
record_delay: Callable[[float], None],
393+
report_delay: Callable[..., None],
394+
record_delay: Optional[Callable[[float], None]] = None,
395395
) -> None:
396-
"""Report ingest delay. A histogram lets Prometheus aggregate reports from all
397-
sources of a deployment, so the per-source tag is omitted to bound cardinality."""
396+
"""Report ingest delay. Prometheus aggregates reports from all sources of a
397+
deployment, so the per-source tag is omitted to bound cardinality."""
398398
delay_ms = (time.time() - timestamp) * 1000
399-
histogram.observe(
399+
report_delay(
400400
delay_ms,
401401
tags={
402402
"deployment": deployment_id.name,
403403
"application": deployment_id.app_name,
404404
},
405405
)
406-
record_delay(delay_ms)
406+
if record_delay is not None:
407+
record_delay(delay_ms)
407408

408409
def record_autoscaling_metrics_from_replica(
409410
self, replica_metric_report: Union[ReplicaMetricReport, bytes]
@@ -415,7 +416,7 @@ def record_autoscaling_metrics_from_replica(
415416
self._record_metrics_delay(
416417
replica_metric_report.timestamp,
417418
replica_metric_report.replica_id.deployment_id,
418-
self.replica_metrics_delay_histogram,
419+
self.replica_metrics_delay_histogram.observe,
419420
self._health_metrics_tracker.record_replica_metrics_delay,
420421
)
421422
self.autoscaling_state_manager.record_request_metrics_for_replica(
@@ -432,7 +433,7 @@ def record_autoscaling_metrics_from_handle(
432433
self._record_metrics_delay(
433434
handle_metric_report.timestamp,
434435
handle_metric_report.deployment_id,
435-
self.handle_metrics_delay_histogram,
436+
self.handle_metrics_delay_histogram.observe,
436437
self._health_metrics_tracker.record_handle_metrics_delay,
437438
)
438439
self.autoscaling_state_manager.record_request_metrics_for_handle(
@@ -443,15 +444,10 @@ def record_autoscaling_metrics_from_async_inference_task_queue(
443444
self, report: AsyncInferenceTaskQueueMetricReport
444445
):
445446
"""Record async inference task queue metrics pushed from QueueMonitor."""
446-
latency = time.time() - report.timestamp_s
447-
latency_ms = latency * 1000
448-
# Record the metrics delay for observability
449-
self.async_inference_task_queue_metrics_delay_gauge.set(
450-
latency_ms,
451-
tags={
452-
"deployment": report.deployment_id.name,
453-
"application": report.deployment_id.app_name,
454-
},
447+
self._record_metrics_delay(
448+
report.timestamp_s,
449+
report.deployment_id,
450+
self.async_inference_task_queue_metrics_delay_gauge.set,
455451
)
456452
self.autoscaling_state_manager.record_async_inference_task_queue_metrics(report)
457453

python/ray/serve/_private/metrics_utils.py

Lines changed: 0 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import asyncio
22
import bisect
33
import logging
4-
import statistics
54
from collections import defaultdict
65
from dataclasses import dataclass
7-
from itertools import chain
86
from typing import (
97
Any,
108
Awaitable,
@@ -13,10 +11,8 @@
1311
DefaultDict,
1412
Dict,
1513
Hashable,
16-
Iterable,
1714
List,
1815
Optional,
19-
Tuple,
2016
Union,
2117
)
2218

@@ -194,66 +190,6 @@ def _get_datapoints(
194190
)
195191
return datapoints[idx:]
196192

197-
def _aggregate_reduce(
198-
self,
199-
keys: Iterable[Hashable],
200-
aggregate_fn: Callable[[Iterable[float]], float],
201-
) -> Tuple[Optional[float], int]:
202-
"""Reduce the entire set of timeseries values across the specified keys.
203-
204-
Args:
205-
keys: Iterable of keys to aggregate across.
206-
aggregate_fn: Function to apply across all float values, e.g., sum, max.
207-
208-
Returns:
209-
A tuple of (float, int) where the first element is the aggregated value
210-
and the second element is the number of valid keys used.
211-
Returns (None, 0) if no valid keys have data.
212-
213-
Example:
214-
Suppose the store contains:
215-
>>> store = InMemoryMetricsStore()
216-
>>> store.data.update({
217-
... "a": [TimeStampedValue(0, 1.0), TimeStampedValue(1, 2.0)],
218-
... "b": [],
219-
... "c": [TimeStampedValue(0, 10.0)],
220-
... })
221-
222-
Using sum across keys:
223-
224-
>>> store._aggregate_reduce(keys=["a", "b", "c"], aggregate_fn=sum)
225-
(13.0, 2)
226-
227-
Here:
228-
- The aggregated value is 1.0 + 2.0 + 10.0 = 13.0
229-
- Only keys "a" and "c" contribute values, so report_count = 2
230-
"""
231-
valid_key_count = 0
232-
233-
def _values_generator():
234-
"""Generator that yields values from valid keys without storing them all in memory."""
235-
nonlocal valid_key_count
236-
for key in keys:
237-
series = self.data.get(key, [])
238-
if not series:
239-
continue
240-
241-
valid_key_count += 1
242-
for timestamp_value in series:
243-
yield timestamp_value.value
244-
245-
# Create the generator and check if it has any values
246-
values_gen = _values_generator()
247-
try:
248-
first_value = next(values_gen)
249-
except StopIteration:
250-
# No valid data found
251-
return None, 0
252-
253-
# Apply aggregation to the generator (memory efficient)
254-
aggregated_result = aggregate_fn(chain([first_value], values_gen))
255-
return aggregated_result, valid_key_count
256-
257193
def get_latest(
258194
self,
259195
key: Hashable,
@@ -263,47 +199,6 @@ def get_latest(
263199
return None
264200
return self.data[key][-1].value
265201

266-
def aggregate_sum(
267-
self,
268-
keys: Iterable[Hashable],
269-
) -> Tuple[Optional[float], int]:
270-
"""Sum the entire set of timeseries values across the specified keys.
271-
Args:
272-
keys: Iterable of keys to aggregate across.
273-
Returns:
274-
A tuple of (float, int) where the first element is the sum across
275-
all values found at `keys`, and the second is the number of valid
276-
keys used to compute the sum.
277-
Returns (None, 0) if no valid keys have data.
278-
"""
279-
return self._aggregate_reduce(keys, sum)
280-
281-
def aggregate_avg(
282-
self,
283-
keys: Iterable[Hashable],
284-
) -> Tuple[Optional[float], int]:
285-
"""Average the entire set of timeseries values across the specified keys.
286-
287-
Args:
288-
keys: Iterable of keys to aggregate across.
289-
Returns:
290-
A tuple of (float, int) where the first element is the mean across
291-
all values found at `keys`, and the second is the number of valid
292-
keys used to compute the mean.
293-
Returns (None, 0) if no valid keys have data.
294-
"""
295-
return self._aggregate_reduce(keys, statistics.mean)
296-
297-
def timeseries_count(
298-
self,
299-
key: Hashable,
300-
) -> int:
301-
"""Count the number of values across all timeseries values at the specified keys."""
302-
series = self.data.get(key, [])
303-
if not series:
304-
return 0
305-
return len(series)
306-
307202

308203
def time_weighted_average(
309204
step_series: TimeSeries,

python/ray/serve/_private/router.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,7 @@ def _get_metrics_report(self) -> HandleMetricReport:
498498
queued_requests = self.metrics_store.data.get(
499499
QUEUED_REQUESTS_KEY, [TimeStampedValue(timestamp, self.num_queued_requests)]
500500
)
501-
if RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE and self.autoscaling_config:
501+
if RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE:
502502
for replica_id, num_requests in self.num_requests_sent_to_replicas.items():
503503
running_requests[
504504
replica_id.to_full_id_str()

0 commit comments

Comments
 (0)