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

* [RunInference] Add a capacity-based autoscaling signal for model-server 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.

* Clean up docstrings and readability in autoscaling_metrics

- 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).

* Make NumLoadedModelsPublisher a plain sink

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.

* Package the model-server capacity signal behind one call

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.

* Fold the sampler and signal glue into make_model_server_publisher

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.

* Make AutoscalingMetricConfig plain data

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.

* Let NumLoadedModelsPublisher read the server itself

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.

* Describe the capacity signal in requests and state its units

* Add completion-rate aggregation modes to the capacity estimator

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.

* Tidy the estimator docstring and fill test gaps

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.

* Publish the autoscaling metric atomically and time samples monotonically

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.

* Publish the autoscaling metric without touching cell internals

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 and skip estimates with no completions

- 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.

* Narrow the autoscaling_metrics public API and state its requirements

Address review feedback:

- Rename NumLoadedModelsPublisher to PrometheusNumLoadedModelsPublisher and
  document what the model server must provide: a local Prometheus
  text-format /metrics endpoint with a completed-requests counter and an
  in-flight gauge (or AutoscalingMetricConfig.load_signal_fn otherwise).
- Export only AutoscalingMetricConfig and PrometheusNumLoadedModelsPublisher.
  The capacity estimator and the Prometheus scrape/parse helpers are now
  private; the aggregation modes are documented on AutoscalingMetricConfig.
4 files changed
tree: e0cc8de8332eff9dd6c4275d8b640dfde127deea
  1. .agent/
  2. .gemini/
  3. .github/
  4. .test-infra/
  5. buildSrc/
  6. contributor-docs/
  7. dev-support/
  8. examples/
  9. gradle/
  10. infra/
  11. it/
  12. learning/
  13. model/
  14. playground/
  15. plugins/
  16. release/
  17. runners/
  18. scripts/
  19. sdks/
  20. vendor/
  21. website/
  22. .asf.yaml
  23. .editorconfig
  24. .gitattributes
  25. .gitignore
  26. .gitmodules
  27. .mailmap
  28. .pre-commit-config.yaml
  29. .yamllint.yml
  30. assembly.xml
  31. build.gradle.kts
  32. CHANGES.md
  33. CI.md
  34. CONTRIBUTING.md
  35. gradle.properties
  36. gradlew
  37. gradlew.bat
  38. LICENCE.cloudpickle
  39. LICENSE
  40. LICENSE.python
  41. local-env-setup.sh
  42. NOTICE
  43. README.md
  44. settings.gradle.kts
  45. start-build-env.sh
README.md

Apache Beam

Apache Beam is a unified model for defining both batch and streaming data-parallel processing pipelines, as well as a set of language-specific SDKs for constructing pipelines and Runners for executing them on distributed processing backends, including Apache Flink, Apache Spark, Google Cloud Dataflow, and Hazelcast Jet.

πŸš€ Quick Start (Beginner Friendly)

If you're new to Apache Beam, start here:

  1. Choose a language:

  2. Run your first example:

    • Minimal WordCount example (available in this repository)
  3. Understand core concepts:

    • PCollection
    • PTransform
    • Pipeline

Status

Maven Central PyPI version Go version Python coverage Build python source distribution and wheels Python tests Java tests Go tests

Overview

Beam provides a general approach to expressing embarrassingly parallel data processing pipelines and supports three categories of users, each of which have relatively disparate backgrounds and needs.

  1. End Users: Writing pipelines with an existing SDK, running it on an existing runner. These users want to focus on writing their application logic and have everything else just work.
  2. SDK Writers: Developing a Beam SDK targeted at a specific user community (Java, Python, Scala, Go, R, graphical, etc). These users are language geeks and would prefer to be shielded from all the details of various runners and their implementations.
  3. Runner Writers: Have an execution environment for distributed processing and would like to support programs written against the Beam Model. Would prefer to be shielded from details of multiple SDKs.

The Beam Model

The model behind Beam evolved from several internal Google data processing projects, including MapReduce, FlumeJava, and Millwheel. This model was originally known as the β€œDataflow Model”.

To learn more about the Beam Model (though still under the original name of Dataflow), see the World Beyond Batch: Streaming 101 and Streaming 102 posts on O’Reilly’s Radar site, and the VLDB 2015 paper.

The key concepts in the Beam programming model are:

  • PCollection: represents a collection of data, which could be bounded or unbounded in size.
  • PTransform: represents a computation that transforms input PCollections into output PCollections.
  • Pipeline: manages a directed acyclic graph of PTransforms and PCollections that is ready for execution.
  • PipelineRunner: specifies where and how the pipeline should execute.

SDKs

Beam supports multiple language-specific SDKs for writing pipelines against the Beam Model.

Currently, this repository contains SDKs for Java, Python and Go.

Have ideas for new SDKs or DSLs? See the sdk-ideas label.

Specific SDK Readmes

Runners

Beam supports executing programs on multiple distributed processing backends through PipelineRunners. Currently, the following PipelineRunners are available:

  • The DirectRunner runs the pipeline on your local machine.
  • The PrismRunner runs the pipeline on your local machine using Beam Portability.
  • The DataflowRunner submits the pipeline to the Google Cloud Dataflow.
  • The FlinkRunner runs the pipeline on an Apache Flink cluster. The code has been donated from dataArtisans/flink-dataflow and is now part of Beam.
  • The SparkRunner runs the pipeline on an Apache Spark cluster.
  • The JetRunner runs the pipeline on a Hazelcast Jet cluster. The code has been donated from hazelcast/hazelcast-jet and is now part of Beam.

Have ideas for new Runners? See the runner-ideas label.

Instructions for building and testing Beam itself are in the contribution guide.

πŸ“š Learn More

Here are some resources actively maintained by the Beam community to help you get started:

Contact Us

To get involved with Apache Beam: