Repository navigation
Conversation
…er handlers Dataflow's GPU autoscaling reads num_loaded_models_<tag> in the BeamML_ModelManager namespace as the number of messages each worker can hold. A model handler that serves inference through an out-of-process server (vLLM, Triton, ...) always has exactly one loaded copy per worker, so it needs to publish a capacity estimate under that metric instead. Add a generic library for that in apache_beam.ml.inference.autoscaling_metrics: - NumLoadedModelsPublisher publishes a capacity signal on a background thread, resetting the process-wide distribution so the latest value (not a lifetime mean) is reported, with optional aggregation policies on top. - PrometheusCounterGaugeSampler reads a server's completed-requests counter and in-flight gauge from its Prometheus /metrics endpoint; the metric names are the only server-specific input. - CompletionRateCapacityEstimator turns those samples into a per-worker capacity estimate: per-slot throughput times p90 concurrency over a trailing window. It does no I/O. - AutoscalingMetricConfig holds the user-facing knobs, resolves the metric tag (explicit tag, then the RunInference model identifier verbatim, then a sanitized model name), and builds the estimator and publisher.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
- Docstrings: Google-style Args/Attributes/Raises throughout; module docstring shows how a handler wires the pieces together; the publisher docstring now reflects that 'last' is the default policy; the estimator docstring states which servers it suits (latencies long enough to be seen in flight) and shows the formula in one block. - Parse Prometheus sample and label lines with regexes instead of a hand-rolled index parser. Quoted label values may now contain commas, braces and escapes. - Validate the publisher's aggregation policy up front (unknown policies raise ValueError instead of silently behaving like 'max') and split ingestion into _record(), which also stops 'last' from retaining samples. - Store estimator samples as a NamedTuple instead of positional tuples. Outputs are unchanged for the estimator, every valid aggregation policy, and valid Prometheus exposition (checked against the previous commit on randomized inputs).
Each component now has one job: the sampler reads, the estimator computes
(the only window and the only multiplier), the publisher publishes, and the
config wires them together.
Remove the publisher's aggregation policies, its aggregation window and its
multiplier, and the matching AutoscalingMetricConfig fields. The capacity
estimate is already smoothed and its multiplier already applied, so for the
estimator these were a no-op ('last', 1.0) that read like a second smoothing
layer. A handler whose signal needs extra smoothing can apply it in its
signal_fn.
The estimator, sampler and Prometheus parsing are unchanged.
Add AutoscalingMetricConfig.make_model_server_publisher(metrics_port_fn, completed_metric, running_metric). A handler for a model server that exposes a completed-requests counter and an in-flight gauge now only supplies those two metric names and how to find the server's port; the library builds the sampler and estimator, wires them together, honors load_signal_fn, skips ticks while the server is down, and returns a ready publisher. The sampler, estimator and make_publisher stay public for handlers that need a different signal.
Producing one capacity value crossed five classes (publisher -> signal -> sampler -> estimator -> publisher), two of which were only glue. Write the poll as a single function inside make_model_server_publisher (port, scrape, parse the two metrics, estimate) and remove PrometheusCounterGaugeSampler, _ModelServerCapacitySignal and AutoscalingMetricConfig.make_capacity_estimator. What remains is three classes with one job each (estimator computes, publisher publishes, config configures) plus the scrape/parse functions. Published values are unchanged.
The config class mixed knobs with construction: resolve_tag() returned a copy with the tag filled in, and make_model_server_publisher() held the polling logic. Move construction to module-level functions: - make_model_server_publisher(...): the one call a model-server handler makes (metric names, port, model identifier, optional config). - make_publisher(...): for a handler's own capacity signal. Both resolve the metric tag internally, so handlers no longer call a separate resolve_tag step, and both take keyword-only arguments so the identifier, model name and metric names can't be swapped. AutoscalingMetricConfig is now just the knobs. Published values are unchanged.
Remove the make_publisher and make_model_server_publisher wrappers. A handler now constructs NumLoadedModelsPublisher directly with its server's metric names, a metrics_port_fn, the model identifier and an optional AutoscalingMetricConfig; the publisher resolves the metric tag, owns its CompletionRateCapacityEstimator, and on each poll reads the port, scrapes /metrics (or calls config.load_signal_fn), updates the estimate and publishes it. Behavior is unchanged.
CompletionRateCapacityEstimator and AutoscalingMetricConfig take an aggregation: 'concurrency' (the default and the existing estimate), 'window_mean', 'window_max' or 'lifetime_max'. The last three use the raw completion rate and ignore concurrency, so they also work for servers whose in-flight gauge is almost always 0. The default is unchanged.
Group the estimator docstring by mode, and make start() a no-op once a publisher is stopped: previously a superseded publisher that was started again would take its metric back from the newer one and then publish nothing. Add tests for scrape failures, non-numeric samples, negative gauge readings, running_percentile, window_secs=0, errors in the polling loop, and repeated start() calls; the module now has full line and branch coverage.
|
R: @damccorm |
|
Stopping reviewer notifications for this pull request: review requested by someone other than the bot, ceding control. If you'd like to restart, comment |
The SDK harness reads process-wide metrics from another thread, and reset() followed by update() briefly left the distribution empty (count=0), which a concurrent report could pick up. Swap the cell's data in one step under its lock instead. Time estimator samples with time.monotonic() so wall-clock adjustments can't distort intervals.
| total_completed = sum(s.completed for s in self._samples) | ||
| slot_secs = sum(s.running * s.secs for s in self._samples) | ||
| if self._aggregation == CONCURRENCY and slot_secs <= 0: | ||
| return None # Nothing was in flight during the window. | ||
| if self._first_active_ts is None: | ||
| self._first_active_ts = prev_ts | ||
| if (total_completed < self._min_requests and | ||
| now - self._first_active_ts < self._warmup_timeout_secs): | ||
| # Wait until a new worker is calibrated, so it doesn't briefly report | ||
| # the floor value for the whole job. | ||
| return None |
There was a problem hiding this comment.
What happens when total_completed == 0 while running > 0 (requests are in flight, but none have completed in the window yet) and now - self._first_active_ts >= self._warmup_timeout_secs?
Right now, update() falls through, computes mu = 0.0 / slot_secs = 0.0 (or 0.0 in window_mean / window_max), and returns max(float(self._min_value), 0.0) = 1.0. This causes the estimator to report the floor value 1 in two cases:
- Slow initial LLM requests: If the first batch of LLM requests takes > 10s (
DEFAULT_WARMUP_TIMEOUT_SECS = 10.0), att = 10s(completed = 0, running > 0) the worker publishes1before a single request has finished. - First tick after an idle period >=
window_secs: Becauseself._first_active_tsis set once for the lifetime of the estimator, if a worker is idle for >=window_secs(during whichupdate()returnsNoneand preserves the last published capacity) and then receives a new request (completed = 0, running > 0on the first poll), all prior samples are pruned bycutoff = now - self._window_secs,now - self._first_active_ts >= warmup_timeout_secsis already true, andupdate()immediately returns1.0—overwriting the worker's previously published capacity with1.
Should we return None when total_completed <= 0 (except in LIFETIME_MAX mode when self._peak_rate > 0), and add a unit test covering this?
There was a problem hiding this comment.
Good catch, fixed as suggested in e2ff8e3. With nothing completed in the window, update() returns None (keep the previous value) unless it's lifetime_max with a peak. I added a test for slow first requests and for the first poll after an idle period.
| cell = MetricsEnvironment.process_wide_container().get_distribution( | ||
| MetricName(MODEL_MANAGER_METRICS_NAMESPACE, self._metric_name)) | ||
| current = cell.get_cumulative() | ||
| if current.count != 1 or current.sum != published: | ||
| with cell._lock: # pylint: disable=protected-access | ||
| cell.data = DistributionData(published, 1, published, published) |
There was a problem hiding this comment.
In sdks/python/apache_beam/metrics/cells.pxd, MetricCell and DistributionCell are Cython cdef class extension types where _lock is a private cdef object _lock attribute and data is cdef readonly DistributionData data:
cdef class MetricCell(object):
cdef object _lock
...
cdef class DistributionCell(MetricCell):
cdef readonly DistributionData dataWhile unit tests run against pure-Python source, released Beam wheels and Dataflow worker containers compile cells.py with Cython—where accessing cell._lock from Python raises AttributeError: 'apache_beam.metrics.cells.DistributionCell' object has no attribute '_lock' and assigning cell.data = ... raises AttributeError: attribute 'data' of 'apache_beam.metrics.cells.DistributionCell' objects is not writable.
Could we either add a helper method on DistributionCell in cells.py (where self._lock and self.data are accessible inside the Cython class) or update cells.pxd so this works in Cython-compiled wheels?
There was a problem hiding this comment.
Thanks, CI hit exactly this. I went with the helper (e2ff8e3): DistributionCell.reset_to(value) in cells.py swaps in a single-sample DistributionData under the cell's lock, so readers never see an empty distribution. _publish just calls it now. It's covered in cells_test.py, and I verified it with a Cython-compiled cells module as well as pure Python.
The previous atomic swap took DistributionCell._lock and assigned cell.data, neither of which is reachable from Python when apache_beam.metrics is compiled with Cython (as in CI and on workers), so _publish raised AttributeError. Install a fresh DistributionCell holding (sum=value, count=1) in the process-wide container with a single dict store instead; that is still atomic under the GIL, so a concurrent reader never sees an empty cell. Tests re-fetch the cell for every read since it is now replaced rather than mutated.
- Add DistributionCell.reset_to(value), which replaces the distribution with a single sample under the cell's lock. It works when apache_beam.metrics is compiled with Cython, where _lock and data are not reachable from outside the class. NumLoadedModelsPublisher uses it instead of swapping the cell in the container. - CompletionRateCapacityEstimator returns None when requests are in flight but none completed within the window (slow first requests, first poll after an idle period), keeping the previously published capacity instead of reporting the floor. lifetime_max keeps reporting its peak.
Split out of #40760, following review feedback to separate the generic pieces from the vLLM-specific ones and to split autoscaling into collection/publishing vs. vLLM consumption. This PR is the generic library; the vLLM handler uses it in a follow-up, and a Triton handler is planned to reuse it.
Why
Dataflow's GPU autoscaling reads
num_loaded_models_<tag>in theBeamML_ModelManagernamespace as how many requests each worker can handle, and sizes the pool roughly asceil(backlog / reported). The estimate is the worker's sustained throughput in requests per second, so each worker is sized for about one second of it (scaled bymultiplier). A handler that serves inference through an out-of-process model server always has exactly one loaded copy per worker, so it needs to publish an estimate of the worker's capacity under that metric instead.What (
apache_beam.ml.inference.autoscaling_metrics, new)Nothing in this module depends on a particular model server.
Usage: a model-server handler constructs the publisher directly, supplying only what it knows:
Components, each with one job:
NumLoadedModelsPublisher: reads the server, estimates its capacity and publishes it from a background thread./metrics, parses the two metrics, feeds them to its ownCompletionRateCapacityEstimatorand writes the estimate tonum_loaded_models_<tag>. Ifconfig.load_signal_fnis set, it replaces the scrape and the estimate.config.tag, then the RunInference model identifier verbatim, then a sanitized model name.CompletionRateCapacityEstimator: turns a server's completed-requests counter and in-flight gauge intocapacity = per-slot throughput × p90 concurrency × multiplierover a trailing window, with warmup and idle-tick handling.(completed_total, running)samples.config.load_signal_fn.aggregationselects the estimate:'concurrency'(default, above), or the raw completion rate without concurrency as'window_mean'(trailing window),'window_max'(peak interval in the window) or'lifetime_max'(peak interval ever, never decays).AutoscalingMetricConfig: the user-facing knobs. Plain data.scrape_prometheus_metrics/parse_prometheus_metric: read Prometheus text exposition.Pairs with the
ModelHandler.set_model_identifier()hook (#40906), which supplies themodel_identifierthe publisher takes. No dependency between the two PRs.Testing
autoscaling_metrics_test.py(100% line and branch coverage of the module):running_percentile, counter reset, clock skew, negative gauge, warmup, idle ticks,window_secs=0, and each aggregation mode.Noneand errors, resets the cell, supersession (a stopped publisher can't take its metric back), metric-tag precedence, config defaults.load_signal_fnoverride.The estimator gives identical results to the version validated in #40760's staging runs (checked on 2,000 randomized traces).
Thank you for your contribution! Follow this checklist to help us incorporate your contribution quickly and easily:
addresses #123orfixes #456).CHANGES.mdwith noteworthy changes.