blob: 2ffff12a9381a6bb5021a848b64007dea0991896 [file]
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Prefix-aware batching for LLM model handlers.
Generic across LLM servers that keep a prefix (KV) cache, such as vLLM, SGLang
or TensorRT-LLM; nothing here depends on a particular server.
``ModelHandler(batch_key_fn=...)`` makes ``BatchElements`` route elements that
share a key to the same worker and batch them together, so each batch the
server sees is homogeneous and its prefix cache gets hit instead of thrashed.
This module packages the knobs for that into one :class:`PrefixBatchingConfig`
plus the default key extractors a handler installs when the user does not
supply their own:
* :class:`PromptPrefixKeyFn` -- for plain-text prompts (completions API).
* :class:`ChatPrefixKeyFn` -- for chat message lists (chat API).
Both see the element *after* any ``with_preprocess_fn`` has run, i.e. the
prompt / message list the handler will actually send. A handler exposes a
``PrefixBatchingConfig`` argument, picks which extractor is its default, and
passes :func:`resolve_batching_kwargs` to ``ModelHandler.__init__``.
"""
# pytype: skip-file
import dataclasses
from collections.abc import Callable
from collections.abc import Mapping
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any
from typing import Optional
from typing import Union
__all__ = [
'ChatPrefixKeyFn',
'PrefixBatchingConfig',
'PromptPrefixKeyFn',
]
# Characters of prompt (or leading message content) the default extractors key
# on. Long enough to cover a typical system prompt, short enough that keys are
# cheap to hash and shuffle.
DEFAULT_PREFIX_CHAR_LEN = 128
# Batch buffering applied when the handler's own ``max_batch_duration_secs`` is
# unset, so low-traffic keys still flush in streaming instead of waiting for a
# full batch.
DEFAULT_KEYED_BATCH_DURATION_SECS = 1.0
# Shards per prefix key. A single shard serves a hot prefix (e.g. a common
# system prompt) on one worker at a time; a few shards keep that prefix's
# throughput scaling with the pool while every shard still batches a single
# prefix.
DEFAULT_NUM_SHARDS = 8
# Where PromptPrefixKeyFn cuts a prompt short of ``prefix_char_len``: the end of
# the first paragraph, which usually ends a system prompt or instruction header.
DEFAULT_PROMPT_DELIMITERS = ('\n\n', )
# Accepted forms of ``num_shards``: one count for every key, a per-key table,
# or a callable computed per key (see ``WithBatchKey`` in transforms/util.py).
NumShardsSpec = Union[int, Mapping[Any, int], Callable[[Any], int]]
def _validate_delimiters(delimiters: Sequence[str]) -> tuple[str, ...]:
if isinstance(delimiters, str):
raise TypeError(
'delimiters must be a sequence of strings, e.g. (":",), not a str.')
delimiters = tuple(delimiters)
if not all(isinstance(d, str) and d for d in delimiters):
raise ValueError('delimiters must be non-empty strings.')
return delimiters
def _cut_at_delimiter(
text: str, prefix_char_len: int, delimiters: tuple[str, ...]) -> str:
"""Returns ``text`` up to the earliest delimiter, capped at prefix_char_len.
A delimiter counts if it starts within the first ``prefix_char_len``
characters (and not at position 0). Only that leading part of ``text`` is
searched, so long prompts cost the same as short ones.
"""
end = prefix_char_len
for delimiter in delimiters:
idx = text.find(delimiter, 1, prefix_char_len + len(delimiter))
if idx > 0:
end = min(end, idx)
return text[:end]
class PromptPrefixKeyFn:
"""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 of ``delimiters`` when that comes sooner, so
prompts sharing the same system prompt or instruction header are routed to
the same worker and batched together.
Args:
prefix_char_len: Most leading characters to key on.
delimiters: Strings that end the shared prefix early. Defaults to a blank
line (``"\\n\\n"``). Prompts with a different structure can use e.g.
``(":", "\\n\\n")`` to key on an instruction that ends with a colon,
or ``()`` to always key on exactly ``prefix_char_len`` characters.
"""
def __init__(
self,
prefix_char_len: int = DEFAULT_PREFIX_CHAR_LEN,
delimiters: Sequence[str] = DEFAULT_PROMPT_DELIMITERS):
if prefix_char_len <= 0:
raise ValueError('prefix_char_len must be positive.')
self._prefix_char_len = prefix_char_len
self._delimiters = _validate_delimiters(delimiters)
def __call__(self, element: str) -> str:
if not isinstance(element, str):
raise TypeError(
f'Expected str prompt for prefix key extraction, got '
f'{type(element).__name__}.')
return _cut_at_delimiter(element, self._prefix_char_len, self._delimiters)
class ChatPrefixKeyFn:
"""Default prefix key extractor for chat message lists.
Keys a conversation by the role and leading ``prefix_char_len`` characters of
its first (typically system) message, so conversations sharing the same
system prompt are routed to the same worker and batched together. Messages
may be objects with ``role``/``content`` attributes (e.g.
``OpenAIChatMessage``) or dicts with those keys. Any other message schema
raises ``TypeError`` rather than silently collapsing every conversation onto
one key; pass ``PrefixBatchingConfig(key_fn=...)`` for custom schemas.
Args:
prefix_char_len: Most leading characters of the first message to key on.
delimiters: Strings that end the shared part of the first message early,
as in :class:`PromptPrefixKeyFn`. Defaults to none, since the first
message is usually the whole system prompt.
"""
def __init__(
self,
prefix_char_len: int = DEFAULT_PREFIX_CHAR_LEN,
delimiters: Sequence[str] = ()):
if prefix_char_len <= 0:
raise ValueError('prefix_char_len must be positive.')
self._prefix_char_len = prefix_char_len
self._delimiters = _validate_delimiters(delimiters)
def __call__(self, element: Sequence[Any]) -> str:
if not isinstance(element, (list, tuple)):
raise TypeError(
f'Expected a sequence of chat messages for prefix key extraction, '
f'got {type(element).__name__}.')
if not element:
return ''
first_msg = element[0]
if hasattr(first_msg, 'role') and hasattr(first_msg, 'content'):
role, content = first_msg.role, first_msg.content
elif (isinstance(first_msg, Mapping) and 'role' in first_msg and
'content' in first_msg):
role, content = first_msg['role'], first_msg['content']
else:
raise TypeError(
'Chat message has no role/content attributes or keys (got '
f'{type(first_msg).__name__}); pass '
'PrefixBatchingConfig(key_fn=...) to key a custom message schema.')
content_str = _cut_at_delimiter(
str(content or ''), self._prefix_char_len, self._delimiters)
return f'{role or ""}:{content_str}'
@dataclass(frozen=True)
class PrefixBatchingConfig:
"""User-facing knobs for prefix-aware batching.
Pass an instance to an LLM model handler that accepts one (the handler
forwards it to ``ModelHandler`` via :func:`resolve_batching_kwargs`) to turn
keyed batching on; leave it unset to keep the default unkeyed batching. Each
field:
* ``key_fn`` -- callable mapping an element (as it enters batching, i.e.
after any ``with_preprocess_fn``) to a grouping key. Leave unset (the
default) to use the handler's default prefix extractor
(:class:`PromptPrefixKeyFn` or :class:`ChatPrefixKeyFn`).
* ``prefix_char_len`` -- how many leading characters the default extractor
keys on (default 128). Ignored when ``key_fn`` is set.
* ``prefix_delimiters`` -- strings that end the key early, e.g. ``(":",)``
for prompts whose shared instruction ends with a colon. Leave unset (the
default) for the extractor's own default: a blank line for
:class:`PromptPrefixKeyFn`, none for :class:`ChatPrefixKeyFn`. Ignored
when ``key_fn`` is set.
* ``num_shards`` -- bounds hot-key fanout: an ``int`` applied to every key,
a ``Mapping[key, int]`` with per-key shard counts for a known skewed
prefix distribution (e.g. ``{'<the common system prompt>': 32}``;
unlisted keys get 1), or a ``Callable[[key], int]``. Each key is spread
across at most that many workers (default 8).
**Sharding is what lets skewed workloads scale.** Keyed batching keeps
per-key state, so all batches for one shard of a key are processed
by a single worker at a time. With a single shard, a prefix shared by most
traffic (e.g. a common system prompt) is served at the throughput of one
worker no matter how many workers the pipeline has, and its requests
queue up behind that key. Runners that size the pool from the number of
keys with pending work, such as Dataflow's GPU autoscaling, also do not
see that queue and will not scale up to drain it. The default of 8 covers
moderate skew; raise it, or map the hot prefixes to a larger count, when
one prefix carries most of the traffic and the pool scales well past 8
workers. The trade-off is prefix-cache locality: each shard of a prefix
warms its own copy of that prefix's KV cache, so set ``num_shards=1`` only
when prefixes are evenly spread and cache reuse matters most.
The handler calls :meth:`resolve` with its default extractor and then
:meth:`batching_kwargs` to obtain the ``ModelHandler`` arguments.
"""
key_fn: Optional[Callable[[Any], Any]] = None
prefix_char_len: int = DEFAULT_PREFIX_CHAR_LEN
num_shards: Optional[NumShardsSpec] = DEFAULT_NUM_SHARDS
prefix_delimiters: Optional[Sequence[str]] = None
def resolve(
self, default_key_fn: Callable[..., Callable[[Any], Any]]
) -> 'PrefixBatchingConfig':
"""Returns a copy whose ``key_fn`` is set.
Args:
default_key_fn: Factory (e.g. :class:`PromptPrefixKeyFn`) invoked with
``prefix_char_len``, and ``delimiters`` when ``prefix_delimiters`` is
set, when ``key_fn`` is unset.
"""
if self.key_fn is not None:
return self
if self.prefix_delimiters is None:
key_fn = default_key_fn(self.prefix_char_len)
else:
key_fn = default_key_fn(
self.prefix_char_len, delimiters=self.prefix_delimiters)
return dataclasses.replace(self, key_fn=key_fn)
def batching_kwargs(self,
max_batch_duration_secs: Optional[float] = None
) -> dict[str, Any]:
"""Returns the ``ModelHandler`` batching kwargs for a resolved config.
Args:
max_batch_duration_secs: The handler's own buffering duration; when
unset, ``DEFAULT_KEYED_BATCH_DURATION_SECS`` is used so low-traffic
keys still flush in streaming.
"""
if self.key_fn is None:
raise ValueError(
'PrefixBatchingConfig.key_fn is unset; call resolve(default_key_fn) '
'first.')
return {
'batch_key_fn': self.key_fn,
'batch_key_num_shards': self.num_shards,
'max_batch_duration_secs': (
DEFAULT_KEYED_BATCH_DURATION_SECS
if max_batch_duration_secs is None else max_batch_duration_secs),
}
def resolve_batching_kwargs(
config: Optional[PrefixBatchingConfig],
default_key_fn: Callable[..., Callable[[Any], Any]],
max_batch_duration_secs: Optional[float] = None,
) -> dict[str, Any]:
"""Batching kwargs for an optional config; unkeyed when ``config`` is None.
Convenience for handlers: always yields ``max_batch_duration_secs`` and, when
prefix batching is on, ``batch_key_fn`` / ``batch_key_num_shards`` too, so
the result can be splatted straight into ``ModelHandler.__init__``.
"""
if config is None:
return {'max_batch_duration_secs': max_batch_duration_secs}
return config.resolve(default_key_fn).batching_kwargs(max_batch_duration_secs)