Skip to content

[Python][RunInference] Add a capacity-based autoscaling signal for model-server handlers - #40907

Open
AMOOOMA wants to merge 13 commits into
apache:masterfrom
AMOOOMA:pr-model-server-autoscaling
Open

AMOOOMA wants to merge 13 commits into
apache:masterfrom
AMOOOMA:pr-model-server-autoscaling

Conversation

@AMOOOMA

@AMOOOMA AMOOOMA commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

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 the BeamML_ModelManager namespace as how many requests each worker can handle, and sizes the pool roughly as ceil(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 by multiplier). 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:

publisher = autoscaling_metrics.NumLoadedModelsPublisher(
    metrics_port_fn=lambda: server.port if server.is_up else None,
    completed_metric='vllm:request_success_total',
    running_metric='vllm:num_requests_running',
    model_identifier=model_identifier,
    model_name=model_name,
    config=autoscaling_config)  # An AutoscalingMetricConfig, or None.
publisher.start()  # Once the server is reachable; publisher.stop() at exit.

Components, each with one job:

  • NumLoadedModelsPublisher: reads the server, estimates its capacity and publishes it from a background thread.
    • Each poll reads the port, scrapes /metrics, parses the two metrics, feeds them to its own CompletionRateCapacityEstimator and writes the estimate to num_loaded_models_<tag>. If config.load_signal_fn is set, it replaces the scrape and the estimate.
    • Metric tag: an explicit config.tag, then the RunInference model identifier verbatim, then a sanitized model name.
    • Resets the process-wide distribution before each update, so the runner sees the latest value rather than a lifetime mean.
    • Never injects a 0 on start, stop or a skipped poll, and a reloaded server's publisher supersedes the stale one.
  • CompletionRateCapacityEstimator: turns a server's completed-requests counter and in-flight gauge into capacity = per-slot throughput × p90 concurrency × multiplier over a trailing window, with warmup and idle-tick handling.
    • Does no I/O: it's fed (completed_total, running) samples.
    • Normalizing by concurrency keeps the estimate steady when a worker is briefly starved after a rebalance, instead of cratering and triggering a downscale spiral.
    • Suits servers whose requests take long enough to be seen in flight (e.g. LLM servers); millisecond-latency servers can use a completion-rate mode or supply their own signal through config.load_signal_fn.
    • aggregation selects 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 the model_identifier the publisher takes. No dependency between the two PRs.

Testing

autoscaling_metrics_test.py (100% line and branch coverage of the module):

  • Prometheus scraping and parsing (scrape failure, summed series, exact names, timestamps, non-numeric values, escaped label values).
  • Estimator math, running_percentile, counter reset, clock skew, negative gauge, warmup, idle ticks, window_secs=0, and each aggregation mode.
  • Publisher: publishes values rounded, skips None and errors, resets the cell, supersession (a stopped publisher can't take its metric back), metric-tag precedence, config defaults.
  • Publisher end to end against a fake server: server down, unreachable or incomplete scrapes, label filter, estimate, load_signal_fn override.

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:

  • Replace these instructions with a description of your change and why you have made it.
  • Mention the appropriate issue in your description (for example: addresses #123 or fixes #456).
  • Update CHANGES.md with noteworthy changes.

…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

codecov Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.15%. Comparing base (12bbb17) to head (e2ff8e3).
⚠️ Report is 89 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master   #40907      +/-   ##
==========================================
+ Coverage   79.83%   80.15%   +0.31%     
==========================================
  Files         494      495       +1     
  Lines       83575    83854     +279     
==========================================
+ Hits        66722    67213     +491     
+ Misses      16853    16641     -212     
Flag Coverage Δ
python 80.15% <100.00%> (+0.31%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

- 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.
@github-actions github-actions Bot added size/xl XL: >=1000 lines changed and removed size/l L: 250-999 lines changed labels Nov 3, 2026
@AMOOOMA
AMOOOMA marked this pull request as ready for review November 3, 2026 01:34
@AMOOOMA

AMOOOMA commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

R: @damccorm

@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Stopping reviewer notifications for this pull request: review requested by someone other than the bot, ceding control. If you'd like to restart, comment assign set of reviewers

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.
Comment on lines +347 to +357
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Slow initial LLM requests: If the first batch of LLM requests takes > 10s (DEFAULT_WARMUP_TIMEOUT_SECS = 10.0), at t = 10s (completed = 0, running > 0) the worker publishes 1 before a single request has finished.
  2. First tick after an idle period >= window_secs: Because self._first_active_ts is set once for the lifetime of the estimator, if a worker is idle for >= window_secs (during which update() returns None and preserves the last published capacity) and then receives a new request (completed = 0, running > 0 on the first poll), all prior samples are pruned by cutoff = now - self._window_secs, now - self._first_active_ts >= warmup_timeout_secs is already true, and update() immediately returns 1.0—overwriting the worker's previously published capacity with 1.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +563 to +568
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 data

While 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python size/xl XL: >=1000 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants