Files
2026-07-13 12:24:33 +08:00

322 lines
11 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
Public API for Batched Stream, a wrapper of Streams that are batched together.
"""
# Future
from __future__ import annotations
# Standard
from collections.abc import Callable, Sequence
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any
import time
# Third Party
import torch
# First Party
from lmcache.cli.metrics import Metrics, StreamHandler, get_formatter
import lmcache.sdk.stream as lmc_stream
class LMCacheBatchedStreamError(RuntimeError):
"""Raised when a BatchedStream operation fails."""
class LMCacheBatchedStream:
"""A collection of LMCacheStreams run together as one batch."""
def __init__(self) -> None:
"""Create a batch.
Attributes:
streams: Member streams keyed by stream id.
perf_metrics: Latest StreamPerfMetrics per stream id, overwritten
on each call to run_streams.
"""
self.streams: dict[str, lmc_stream.LMCacheStream] = {}
self.perf_metrics: dict[str, lmc_stream.StreamPerfMetrics] = {}
def add(self, stream: lmc_stream.LMCacheStream) -> None:
"""Add a stream to the batch, keyed by its stream id.
Args:
stream: The stream to add.
Raises:
LMCacheBatchedStreamError: If a stream with the same id is
in the batch.
"""
if stream.stream_id() in self.streams:
raise LMCacheBatchedStreamError(
f"Stream {stream.stream_id()} already exists."
)
self.streams[stream.stream_id()] = stream
def get_stream(self, stream_id: str) -> lmc_stream.LMCacheStream:
"""Return the stream registered under stream_id.
Args:
stream_id: The id of the stream to fetch.
Returns:
The matching LMCacheStream.
Raises:
LMCacheBatchedStreamError: If no stream has that id.
"""
if stream_id not in self.streams:
raise LMCacheBatchedStreamError(
f"Stream with id {stream_id} does not exist."
)
return self.streams[stream_id]
def run_streams(
self, sampling_params: dict[str, Any], stream_ids: list[str] | None = None
) -> float:
"""Decode every selected stream concurrently and store their metrics.
Submits stream.generate(sampling_params) on a thread pool (one worker
per stream) and records each StreamPerfMetrics in perf_metrics.
Args:
sampling_params: Engine sampling params (e.g. max_tokens,
temperature) passed to every stream's generate().
stream_ids: Streams to run; None means all streams in the batch.
Returns:
Wall-clock duration of the batch, in seconds (0.0 if no streams).
Raises:
LMCacheBatchedStreamError: If any requested stream id is unknown.
"""
if stream_ids is None:
stream_ids = list(self.streams.keys())
for stream_id in stream_ids:
if stream_id not in self.streams:
raise LMCacheBatchedStreamError(f"Stream {stream_id} does not exist.")
if not stream_ids:
return 0.0
self.perf_metrics.clear()
start_time = time.perf_counter()
with ThreadPoolExecutor(max_workers=len(stream_ids)) as executor:
futures = {}
for stream_id in stream_ids:
stream = self.streams[stream_id]
future = executor.submit(
stream.generate, sampling_params=sampling_params
)
futures[future] = stream_id
# collect metrics
for future in as_completed(futures):
stream_id = futures[future]
self.perf_metrics[stream_id] = future.result()
return time.perf_counter() - start_time # in seconds
def modify_stream(
self,
fn: Callable[[torch.Tensor, Sequence[int]], tuple[torch.Tensor, Sequence[int]]],
stream_ids: list[str] | None = None,
timeout: float = 30.0,
poll_interval: float = 0.2,
) -> float:
"""Apply KV edit to every selected stream concurrently.
Submits stream.modify_kv(fn) on a thread pool (one worker per stream).
Args:
fn: KV editor applied to each stream. Given (kv, tokens) and
returning (new_kv, new_tokens). See LMCacheStream.modify_kv().
stream_ids: Streams to modify. None means all streams in the batch.
timeout: Max seconds to wait for the cached KV to appear.
poll_interval: Seconds between retrieve attempts.
Returns:
Wall-clock duration of the batch, in seconds (0.0 if no streams).
Raises:
LMCacheBatchedStreamError: If any requested stream id is unknown.
"""
if stream_ids is None:
stream_ids = list(self.streams.keys())
for stream_id in stream_ids:
if stream_id not in self.streams:
raise LMCacheBatchedStreamError(f"Stream {stream_id} does not exist.")
if not stream_ids:
return 0.0
start_time = time.perf_counter()
with ThreadPoolExecutor(max_workers=len(stream_ids)) as executor:
futures = {}
for stream_id in stream_ids:
stream = self.streams[stream_id]
future = executor.submit(
stream.modify_kv, fn, timeout=timeout, poll_interval=poll_interval
)
futures[future] = stream_id
for future in as_completed(futures):
stream_id = futures[future]
future.result()
return time.perf_counter() - start_time # in seconds
def get_perf_metrics(
self,
duration: float,
fmt: str,
width: int,
mode: str,
stream_ids: list[str] | None = None,
) -> Metrics:
"""Aggregate the stored per-stream metrics into a Metrics report.
Args:
duration: Batch wall-clock duration in seconds, for throughput.
fmt: Formatter name for the output handler (e.g. "terminal").
width: Output width passed to the formatter.
mode: "prefill" (input tokens + TTFT) or "decode" (output tokens +
TPOT + decoding speed).
stream_ids: Streams to include. None means all streams in batch.
Returns:
The aggregated Metrics report.
Raises:
LMCacheBatchedStreamError: If mode is not "prefill" or "decode".
"""
if stream_ids is None:
stream_ids = list(self.streams.keys())
if mode not in ["prefill", "decode"]:
raise LMCacheBatchedStreamError(f"Invalid mode {mode}.")
metrics = Metrics(title=f"Batched Stream Metrics ({mode})")
metrics.add_handler(StreamHandler(get_formatter(fmt, width=width)))
cfg_section = metrics.add_section("config", "Configuration")
cfg_section.add("num_streams", "Number of Streams", len(self.streams))
result_section = metrics.add_section("results", "Results")
result_section.add("duration", "Total Duration (s)", duration)
input_tokens = 0
output_tokens = 0
for stream_id, perf_metrics in self.perf_metrics.items():
if stream_id in stream_ids:
input_tokens += perf_metrics.input_tokens
output_tokens += perf_metrics.output_tokens
if mode == "prefill":
result_section.add("input_tokens", "Total Input Tokens", input_tokens)
result_section.add(
"input_tput",
"Input Throughput (tokens/s)",
input_tokens / duration if duration > 0 else 0.0,
)
if mode == "decode":
result_section.add("output_tokens", "Total Output Tokens", output_tokens)
result_section.add(
"output_tput",
"Decode Throughput (tokens/s)",
output_tokens / duration if duration > 0 else 0.0,
)
return metrics
def prefill(
self,
sampling_params: dict[str, Any],
fmt: str = "terminal",
width: int = 80,
stream_ids: list[str] | None = None,
) -> Metrics:
"""Prefill every stream once (max_tokens forced to 1) and report.
Args:
sampling_params: Engine sampling params, max_tokens is set 1.
fmt: Formatter name for the output handler.
width: Output width passed to the formatter.
stream_ids: Streams to run; None means all streams in the batch.
Returns:
A prefill-mode Metrics report (input tokens, throughput, TTFT).
"""
sampling_params = {**sampling_params, "max_tokens": 1}
duration = self.run_streams(
sampling_params=sampling_params, stream_ids=stream_ids
)
return self.get_perf_metrics(
duration=duration,
fmt=fmt,
width=width,
mode="prefill",
stream_ids=stream_ids,
)
def modify(
self,
fn: Callable[[torch.Tensor, Sequence[int]], tuple[torch.Tensor, Sequence[int]]],
fmt: str = "terminal",
width: int = 80,
stream_ids: list[str] | None = None,
) -> Metrics:
"""Apply a KV edit to every stream and report the time taken.
Args:
fn: KV editor applied to each stream (see modify_stream).
fmt: Formatter name for the output handler.
width: Output width passed to the formatter.
stream_ids: Streams to modify. None means all streams in the batch.
Returns:
A Metrics report containing the total modify duration.
"""
duration = self.modify_stream(fn, stream_ids=stream_ids)
metrics = Metrics(title="Batched Stream Modify Metrics")
metrics.add_handler(StreamHandler(get_formatter(fmt, width=width)))
result_section = metrics.add_section("results", "Results")
result_section.add("duration", "Total Duration (s)", duration)
return metrics
def decode(
self,
sampling_params: dict[str, Any],
fmt: str = "terminal",
width: int = 80,
stream_ids: list[str] | None = None,
) -> Metrics:
"""Decode every stream and report decode metrics.
Args:
sampling_params: Engine sampling params (e.g. max_tokens,
temperature) passed to every stream's generate().
fmt: Formatter name for the output handler.
width: Output width passed to the formatter.
stream_ids: Streams to run. None means all streams in the batch.
Returns:
A decode-mode Metrics report (output tokens, throughput, TPOT,
decoding speed).
"""
duration = self.run_streams(
sampling_params=sampling_params, stream_ids=stream_ids
)
return self.get_perf_metrics(
duration=duration,
fmt=fmt,
width=width,
mode="decode",
stream_ids=stream_ids,
)