Skip to content

[Python][RunInference] Add prefix-aware batching across workers - #40905

Merged
damccorm merged 6 commits into
apache:masterfrom
AMOOOMA:pr-prefix-batching
Nov 6, 2026
Merged

damccorm merged 6 commits into
apache:masterfrom
AMOOOMA:pr-prefix-batching

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. This PR is the generic prefix-aware batching; vLLM support builds on it in a follow-up.

Why

BatchElements batches per worker, so an LLM model handler behind RunInference sends its server batches that mix unrelated prompt prefixes, and the server's prefix (KV) cache gets thrashed instead of reused.

What

BatchElements(key_fn=..., num_shards=...) (transforms/util.py)

  • With key_fn set, a new WithBatchKey DoFn keys each element without the per-worker UUID that WithSharedKey / WithLengthBucketKey add. GroupIntoBatches then routes elements that share a key to the same worker, so every batch holds a single key.
  • num_shards (an int, a per-key mapping, or a callable) spreads a hot key over a bounded number of random sub-keys, so one key isn't capped at one worker.
  • Incomplete keyed batches flush after max_batch_duration_secs, or 1s when unset (GroupIntoBatches treats 0 as "no limit", which would stall low-traffic keys in streaming).
  • key_fn can't be combined with length_fn or max_batch_weight (raises ValueError): GroupIntoBatches only bounds the element count, so those options would be silently ignored.
  • Keyed batching works in streaming, unlike the unkeyed in-bundle path.

RunInference plumbing (ml/inference/base.py)

  • ModelHandler(batch_key_fn=..., batch_key_num_shards=...) forwards to BatchElements.

apache_beam.ml.inference.prefix_batching (new, generic for LLM handlers)

  • PrefixBatchingConfig, plus default extractors PromptPrefixKeyFn (completions) and ChatPrefixKeyFn (OpenAI-style chat messages; unknown message schemas raise rather than collapsing onto one key).
  • Defaults to 8 shards per prefix. On a 5–40 RPS sine load (OpenOrca, T4, up to 50 workers), 4 shards left 18–33k requests queued behind hot prefixes at the end of the load. 8 shards wrote all 160k published requests and still scaled the pool down to 4 workers at low traffic.
  • The generic BatchElements / ModelHandler defaults stay at 1 shard.

Testing

  • util_test.py: homogeneous keyed batches, shard specs (int / mapping / callable), validation, default buffering.
  • prefix_batching_test.py: extractors and config.
  • base_test.py: batch_key_fn / num_shards reach BatchElements, and an end-to-end RunInference run produces prefix-pure batches.

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.

@github-actions github-actions Bot added python core size/l L: 250-999 lines changed labels Oct 30, 2026
@AMOOOMA
AMOOOMA marked this pull request as ready for review October 30, 2026 17:06
@AMOOOMA

AMOOOMA commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

R: @damccorm

@github-actions

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

BatchElements batches per worker, so an LLM server behind RunInference sees
batches that mix unrelated prompt prefixes and its prefix (KV) cache is
thrashed.

Add key_fn and num_shards to BatchElements. With key_fn set, a new WithBatchKey
DoFn keys each element without the per-worker UUID that WithSharedKey and
WithLengthBucketKey add, and GroupIntoBatches routes elements that share a key
to the same worker, so every batch holds a single key. num_shards (an int, a
per-key mapping, or a callable) spreads a hot key over a bounded number of
random sub-keys. Incomplete keyed batches flush after max_batch_duration_secs,
or 1s when it is unset, so low-traffic keys still make progress in streaming.

ModelHandler forwards batch_key_fn / batch_key_num_shards to BatchElements, and
the new apache_beam.ml.inference.prefix_batching module gives LLM handlers a
PrefixBatchingConfig plus default prompt and chat prefix extractors. The config
defaults to 8 shards per prefix: GroupIntoBatches serves each shard on one
worker at a time, so a single shard caps a prefix shared by most traffic at
one worker's throughput.
@AMOOOMA
AMOOOMA force-pushed the pr-prefix-batching branch from 465e446 to 07aac31 Compare October 30, 2026 17:20
@codecov

codecov Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.72611% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.29%. Comparing base (fff61b5) to head (56cf5bf).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
sdks/python/apache_beam/transforms/util.py 96.92% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #40905      +/-   ##
============================================
+ Coverage     58.27%   58.29%   +0.02%     
- Complexity     2276     2277       +1     
============================================
  Files          1136     1137       +1     
  Lines        169393   169544     +151     
  Branches       1482     1482              
============================================
+ Hits          98708    98837     +129     
- Misses        68132    68160      +28     
+ Partials       2553     2547       -6     
Flag Coverage Δ
python 80.13% <98.72%> (+0.02%) ⬆️

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.

BatchElements(key_fn=...) groups with GroupIntoBatches, which only bounds
the element count, so length_fn and max_batch_weight were silently ignored;
reject the combination instead. WithBatchKey now matches an unhashable key
against a num_shards mapping by its str() form instead of raising, and
rejects a num_shards callable that returns a bool.
batch_key_num_shards: Optional[Union[int,
Mapping[Any, int],
Callable[[Any], int]]] = None,
large_model: bool = False,

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.

Nit: ModelHandler.__init__ calls _validate_batching_args(...) below to eagerly validate max_batch_weight, element_size_fn, batch_length_fn, and batch_bucket_boundaries at handler construction time (before pipeline expansion). Could we pass batch_key_fn and batch_key_num_shards into _validate_batching_args as well so invalid combinations (e.g. batch_key_num_shards without batch_key_fn, or batch_key_fn + batch_length_fn) fail eagerly at init time?

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.

Done in 7acd8e9. _validate_batching_args now also covers batch_key_fn / batch_key_num_shards, using the same _validate_num_shards as BatchElements, so bad combinations (e.g. batch_key_num_shards without batch_key_fn, or batch_key_fn + batch_length_fn) fail when the handler is constructed.

if batch_bucket_boundaries is not None:
self._batching_kwargs['bucket_boundaries'] = batch_bucket_boundaries
if batch_key_fn is not None:
self._batching_kwargs['key_fn'] = batch_key_fn

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 _keyed_batch_elements_kwargs (around line 774), we wrap element_size_fn and length_fn in _ApplyToExample so they receive example (element[1]) instead of the (key, example) tuple when a handler is wrapped in KeyedModelHandler or MaybeKeyedModelHandler:

def _keyed_batch_elements_kwargs(batch_elements_kwargs: Mapping[str, Any]):
  kwargs = dict(batch_elements_kwargs)
  for name in ('element_size_fn', 'length_fn', 'key_fn'):
    if name in kwargs and kwargs[name] is not None:
      kwargs[name] = _ApplyToExample(kwargs[name])
  return kwargs

Right now 'key_fn' is missing from that loop, so wrapping a prefix-batched handler in KeyedModelHandler / MaybeKeyedModelHandler passes (key, example) into key_fn (PromptPrefixKeyFn raises TypeError: Expected str prompt for prefix key extraction, got tuple, and ChatPrefixKeyFn inspects element[0]—the key—as the first chat message). Could we add 'key_fn' there and add a unit test with KeyedModelHandler?

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 in 7acd8e9. key_fn is now wrapped in _ApplyToExample like element_size_fn / length_fn. I added a unit test for keyed and maybe-keyed handlers and an end-to-end RunInference(KeyedModelHandler(...)) test.

Comment on lines +1468 to +1485
if self._key_fn is not None:
element_type = pcoll.element_type
# GroupIntoBatches treats a buffering duration of 0 as "no limit", which
# in a streaming global window would hold an incomplete batch forever.
buffering_secs = (
self._max_batch_dur if self._max_batch_dur is not None else
_DEFAULT_KEYED_MAX_BUFFERING_SECS)
return (
pcoll
| 'WithBatchKey' >> ParDo(
WithBatchKey(self._key_fn, self._num_shards)).with_output_types(
tuple[str, element_type])
| 'GroupIntoBatchesByKey' >> GroupIntoBatches(
self._batch_size_estimator._max_batch_size,
max_buffering_duration_secs=buffering_secs,
clock=self._clock)
| 'DropBatchKey' >> Map(lambda kv: list(kv[1])).with_output_types(
list[element_type]))

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.

Why use GroupIntoBatches(self._batch_size_estimator._max_batch_size, ...) here instead of plugging WithBatchKey into the existing stateful ParDo(keying_dofn) | ParDo(batching_dofn) path below (_pardo_stateful_batch_elements / _pardo_stateful_weighted_batch_elements)?

In Python Beam, GroupIntoBatches expands to _pardo_group_into_batches (a stateful DoFn with the same BagStateSpec / CombiningValueStateSpec / TimerSpec setup as _pardo_stateful_batch_elements), and any stateful DoFn already shuffles/groups elements across workers by the key emitted by keying_dofn (WithSharedKey and WithLengthBucketKey only stay worker-local because they include a per-worker UUID in the key).

Using GroupIntoBatches(self._batch_size_estimator._max_batch_size) has two downsides:

  1. Default max_batch_size=10000 footgun: BatchElements defaults max_batch_size to 10000 (expecting _BatchSizeEstimator to start at min_batch_size=1 and adapt based on target_batch_duration_secs). If a user enables PrefixBatchingConfig() on a ModelHandler without explicitly setting max_batch_size, GroupIntoBatches uses a fixed batch size of 10,000 and will buffer up to 10,000 LLM requests into a single run_inference call.
  2. It silently ignores _BatchSizeEstimator parameters (min_batch_size, target_batch_overhead, target_batch_duration_secs, record_metrics) and cannot be combined with element_size_fn / max_batch_weight (which _pardo_stateful_weighted_batch_elements already supports and is particularly useful for LLM token-budget batching).

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.

Agreed, switched in 7acd8e9. key_fn now just swaps the keying DoFn (WithBatchKey instead of WithSharedKey / WithLengthBucketKey) in front of the existing stateful batching DoFn. The estimator, element_size_fn and max_batch_weight all apply per key, and batches start at min_batch_size and grow from there instead of using the 10k max_batch_size. Setting min_batch_size == max_batch_size keeps fixed batch sizes. key_fn + length_fn is still rejected because both pick the state key.

"""Default prefix key extractor for plain-text prompts.

Keys a prompt string by its leading ``prefix_char_len`` characters, or by
everything before the first ``\\n\\n`` when that comes sooner, so prompts

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.

Should the delimiter(s) be configurable here? I've seen some prompts like:

Foo, bar, baz. Choose from one of these options when responding:
- x
- y
- z
- ```

where I could imagine a `:` being a useful delimeter.

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.

Yes, done in 7acd8e9. PromptPrefixKeyFn / ChatPrefixKeyFn take delimiters, and PrefixBatchingConfig(prefix_delimiters=(':',)) sets them for the handler's default extractor. The defaults are unchanged: a blank line for prompts, none for chat. The earliest delimiter within prefix_char_len wins.

raise TypeError(
f'Expected str prompt for prefix key extraction, got '
f'{type(element).__name__}.')
sep_idx = element.find('\n\n')

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.

Nit: Should we limit the search to element[:_prefix_char_len].find('\n\n') to shrink the search space for large prompts.

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.

Done in 7acd8e9. The search is bounded to the first prefix_char_len characters (plus the delimiter length), so long prompts cost the same as short ones.

Address review feedback on keyed (prefix-aware) batching:

- BatchElements(key_fn=...) now keys with WithBatchKey and reuses the
  existing stateful batching DoFns instead of GroupIntoBatches, so batch
  sizes adapt via _BatchSizeEstimator between min_batch_size and
  max_batch_size, and element_size_fn / max_batch_weight apply per key.
  key_fn + length_fn is still rejected since both choose the state key.
- ModelHandler validates batch_key_fn / batch_key_num_shards eagerly in
  _validate_batching_args.
- Keyed and maybe-keyed handlers pass the example, not the (key, example)
  pair, to batch_key_fn.
- PromptPrefixKeyFn / ChatPrefixKeyFn take configurable delimiters
  (PrefixBatchingConfig.prefix_delimiters), and only search the first
  prefix_char_len characters for them.

@damccorm damccorm left a comment

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.

Thanks - could you resolve the conflict. After that and checks run we can merge

@damccorm

damccorm commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Oh and please fix linting

@AMOOOMA

AMOOOMA commented Nov 5, 2026

Copy link
Copy Markdown
Contributor Author

Oh and please fix linting

Yep for sure!

# Conflicts:
#	sdks/python/apache_beam/ml/inference/base_test.py
resolve() also calls the default extractor with delimiters=, so type it
as Callable[..., Callable[[Any], Any]] instead of Callable[[int], ...].

@damccorm damccorm left a comment

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.

Thanks!

@damccorm
damccorm merged commit 2b0fa95 into apache:master Nov 6, 2026
87 checks passed
AMOOOMA added a commit to AMOOOMA/beam that referenced this pull request Nov 6, 2026
…ling

Wire the RunInference prefix batching (apache#40905) and the capacity-based
autoscaling signal (apache#40907) into the vLLM handlers:

- VLLMCompletionsModelHandler / VLLMChatModelHandler accept a
  prefix_batching_config. When set, requests sharing a prompt prefix (the
  leading characters of the prompt, or the role and leading content of the
  first chat message, unless a custom key_fn is given) are routed to the same
  worker and batched together so vLLM's prefix cache is reused.
- The handlers publish num_loaded_models_<model_identifier> (on by default;
  enable_autoscaling_metric / autoscaling_config to tune or disable) so
  Dataflow's GPU autoscaling sizes the pool by each worker's capacity. The
  server passes vLLM's vllm:request_success_total counter and
  vllm:num_requests_running gauge to an
  autoscaling_metrics.PrometheusNumLoadedModelsPublisher, which does the
  scraping and estimation. The metric tag is the identifier RunInference
  reports through ModelHandler.set_model_identifier.
- The publisher starts once the vLLM server is reachable and stops with it.
  Its thread reaches the server only through a weak reference, so a dropped
  server is still garbage-collected, which stops the publisher and the vLLM
  processes.

The only vLLM-specific parts are the two metric names, the default prefix
extractors and the server lifecycle.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core python size/l L: 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants