Skip to content

Commit 78a9151

Browse files
[serve] Columnar zero-copy autoscaling-metrics ingest
Wide handle metric reports are serialized as flat float64 arrays (SCR1 framing) instead of cloudpickled Python objects, and aggregated at the controller without materializing per-point objects. The format self-identifies on the wire, so a fleet running a mix of columnar and object producers aggregates correctly mid-rollout. Producers pick columnar only for handle reports covering at least RAY_SERVE_COLUMNAR_METRICS_MIN_REPLICAS replicas (default 64); below that crossover the array machinery costs more than it saves. Replica reports stay on the object path. The array merge is exactly equivalent to the Cython object kernel: change detection runs on raw values before 10ms rounding, and rounding follows C round()s half-away-from-zero rule rather than numpys half-to-even. Verified against the compiled kernel over 6000 randomized dense-bucket cases, covering empty sources, the single-source passthrough and exact rounding ties. The merge is one vectorized pass over the flat CSR arrays rather than a loop per source, which would cost numpy dispatch overhead linear in the number of sources. numpy is optional: it is in neither ray cores install_requires nor the ray[serve] extra, and the minimal-install test imports ray.serve with no extras at all. Without it the producer falls back to objects, warning once per process when a report was wide enough to have benefited. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: John Taylor <john.taylor@anyscale.com>
1 parent 7a97d5c commit 78a9151

16 files changed

Lines changed: 2737 additions & 78 deletions

python/ray/serve/_private/autoscaling_metrics_codec.py

Lines changed: 486 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Array-native reference for the autoscaling decision-time merge.
2+
3+
This is the EXACT spec for an array-input port of the two _raylet Cython kernels
4+
(`merge_instantaneous_total_cython`, `time_weighted_average_cython`), operating on
5+
the columnar layout — flat float64 `ts`/`val` arrays + CSR-style per-source
6+
`offsets` — with no per-point Python objects. Kept exact-equivalent to the
7+
object-list kernels (randomized equivalence tests in
8+
tests/unit/test_columnar_review_hardening.py).
9+
10+
Pinned semantics of merge_instantaneous_total_cython (verified 8000/8000):
11+
1. Drop empty sources.
12+
2. If exactly ONE active source -> return it as-is (identity; no dedup, no merge).
13+
3. Else, per source: keep only points where the value CHANGED from that source's
14+
previous RAW value (LOCF, baseline 0), recording the delta (first point delta =
15+
value); THEN round those points' ts to 2 decimals (10ms). Order matters -- see
16+
_round_10ms and the change-detect comment below.
17+
4. Merge all per-source change-points by rounded timestamp (sum deltas at equal ts),
18+
cumsum -> instantaneous total. Keep EVERY change-point timestamp (an event
19+
survives if any source changed there, even if deltas net to zero).
20+
"""
21+
from __future__ import annotations
22+
23+
from typing import Tuple
24+
25+
try:
26+
import numpy as np
27+
except ModuleNotFoundError: # numpy is only needed on the columnar (opt-in) path;
28+
np = None # serve-minimal installs lack it and never reach the columnar code.
29+
30+
31+
def _round_10ms(ts: np.ndarray) -> np.ndarray:
32+
"""Kernel-exact 10ms rounding. The kernel uses c_round (half away from zero);
33+
np.round is half-to-even, so the two disagree on exact ties. ts is a positive
34+
unix timestamp, so floor(x+0.5) reproduces the C rule."""
35+
return np.floor(ts * 100.0 + 0.5) / 100.0
36+
37+
38+
def merge_instantaneous_total_arrays(
39+
ts: np.ndarray, val: np.ndarray, offsets: np.ndarray
40+
) -> Tuple[np.ndarray, np.ndarray]:
41+
"""Columnar form of merge_instantaneous_total. `offsets` is CSR: source i is
42+
ts[offsets[i]:offsets[i+1]]. Returns (merged_ts, merged_total) float64 arrays."""
43+
starts, ends = offsets[:-1], offsets[1:]
44+
nonempty = ends > starts
45+
n_active = int(nonempty.sum())
46+
if n_active == 0:
47+
return np.zeros(0), np.zeros(0)
48+
if n_active == 1:
49+
# Pass through unrounded: the object path returns the lone series as-is.
50+
i = int(np.argmax(nonempty))
51+
return ts[starts[i] : ends[i]].astype(float), val[starts[i] : ends[i]].astype(
52+
float
53+
)
54+
55+
# One pass over the flat arrays -- sources are already contiguous, so a per-source
56+
# loop costs dispatch overhead linear in source count. LOCF change-detect on RAW
57+
# values (baseline 0), rounding only after, exactly as the kernel orders it: a
58+
# v->w->v inside one 10ms bucket must still emit an event there.
59+
prev = np.empty_like(val, dtype=np.float64)
60+
prev[1:] = val[:-1]
61+
prev[0] = 0.0
62+
prev[starts[nonempty]] = 0.0
63+
delta = val - prev
64+
changed = delta != 0
65+
uts, inv = np.unique(_round_10ms(ts)[changed], return_inverse=True)
66+
summed = np.bincount(inv, weights=delta[changed], minlength=len(uts))
67+
return uts, np.cumsum(summed)
68+
69+
70+
def time_weighted_average_arrays(
71+
mts: np.ndarray, mtot: np.ndarray, window_start, last_window_s: float
72+
) -> float:
73+
"""Columnar form of time_weighted_average (MEAN), right-continuous/LOCF.
74+
75+
Integrates the step function over [window_start, end], end = last event +
76+
last_window_s. The value active on each segment is the LOCF total at the
77+
segment's start, so a window_start that falls BETWEEN events still counts the
78+
value carried forward from the prior event (not skipped)."""
79+
if mts.size == 0:
80+
return 0.0
81+
ws = mts[0] if window_start is None else window_start
82+
end = mts[-1] + last_window_s
83+
if end <= ws:
84+
return 0.0
85+
after = mts[mts > ws] # event times strictly inside the window
86+
starts = np.concatenate(([ws], after))
87+
ends = np.concatenate((after, [end]))
88+
si = (
89+
np.searchsorted(mts, starts, side="right") - 1
90+
) # last event <= each start (LOCF)
91+
active = np.where(si >= 0, mtot[np.clip(si, 0, mtot.size - 1)], 0.0)
92+
durs = ends - starts
93+
return float(np.dot(active, durs) / durs.sum())
94+
95+
96+
def aggregate_arrays(mts, mtot, agg_function, window_start, last_window_s) -> float:
97+
"""Columnar form of aggregate_timeseries. agg_function is AggregationFunction
98+
(str enum: 'mean'|'max'|'min')."""
99+
if mts.size == 0:
100+
return 0.0
101+
if agg_function == "mean":
102+
return time_weighted_average_arrays(mts, mtot, window_start, last_window_s)
103+
# max/min over event values, filtered by window_start (matches aggregate_timeseries)
104+
vals = mtot if window_start is None else mtot[mts >= window_start]
105+
if vals.size == 0:
106+
return 0.0
107+
return float(vals.max() if agg_function == "max" else vals.min())
108+
109+
110+
def merge_and_aggregate_arrays(
111+
ts, val, offsets, now: float, agg_function="mean"
112+
) -> float:
113+
"""Columnar form of DeploymentAutoscalingState._merge_and_aggregate_timeseries."""
114+
mts, mtot = merge_instantaneous_total_arrays(ts, val, offsets)
115+
if mts.size == 0:
116+
return 0.0
117+
last_window_s = now - mts[-1]
118+
if last_window_s <= 0:
119+
last_window_s = 1e-3
120+
window_start = None
121+
starts = offsets[:-1]
122+
nonempty = offsets[1:] > starts
123+
if int(nonempty.sum()) > 1:
124+
# Unrounded, matching the object path: the bound is compared against merged
125+
# timestamps that ARE rounded, and rounding it too can shift which points fall
126+
# inside the window.
127+
aligned = float(ts[starts[nonempty]].max())
128+
if aligned <= mts[-1]:
129+
window_start = max(aligned, mts[0])
130+
return aggregate_arrays(mts, mtot, agg_function, window_start, last_window_s)

0 commit comments

Comments
 (0)