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

618 lines
18 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""``lmcache bench engine`` subcommand implementation.
This module provides argument registration via :func:`add_engine_arguments`
and the execution orchestrator :func:`run_engine_bench` for the inference
engine benchmark.
"""
# Future
from __future__ import annotations
# Standard
from typing import TYPE_CHECKING
import argparse
import os
import sys
# First Party
from lmcache.cli.commands.bench.engine_bench.config import (
EngineBenchConfig,
parse_args_to_config,
)
from lmcache.cli.commands.bench.engine_bench.interactive import run_interactive
from lmcache.cli.commands.bench.engine_bench.interactive.state import (
InteractiveState,
)
from lmcache.cli.commands.bench.engine_bench.progress import ProgressMonitor
from lmcache.cli.commands.bench.engine_bench.request_sender import (
RequestSender,
)
from lmcache.cli.commands.bench.engine_bench.stats import (
FinalStats,
StatsCollector,
)
from lmcache.cli.commands.bench.engine_bench.workloads import (
create_workload,
validate_max_output_length_supported,
)
from lmcache.logging import init_logger
if TYPE_CHECKING:
# First Party
from lmcache.cli.commands.base import BaseCommand
logger = init_logger(__name__)
# Default for --ldqa-max-output-length; centralized so the "max output length
# explicitly set" check stays in sync with the parser.
_LDQA_MAX_OUTPUT_LENGTH_DEFAULT = 128
# ---------------------------------------------------------------------------
# Parser registration
# ---------------------------------------------------------------------------
def add_engine_arguments(parser: argparse.ArgumentParser) -> None:
"""Add ``lmcache bench engine`` arguments to *parser*.
Args:
parser: The ``ArgumentParser`` for the engine bench subcommand.
"""
# --- Config file ---
parser.add_argument(
"--config",
default=None,
metavar="FILE",
help="Load configuration from a JSON file (skips interactive mode).",
)
# --- General args ---
parser.add_argument(
"--engine-url",
default=None,
help=(
"Inference engine URL (e.g., http://localhost:8000). "
"Set OPENAI_API_KEY env var if authentication is needed."
),
)
parser.add_argument(
"--lmcache-url",
default=None,
help="LMCache MP server URL for auto-detecting tokens per GB.",
)
parser.add_argument(
"--model",
default=None,
help="Model name (auto-detected from engine if omitted).",
)
parser.add_argument(
"--workload",
default=None,
choices=[
"long-doc-permutator",
"long-doc-qa",
"multi-round-chat",
"prefix-suffix-tuner",
"random-prefill",
],
help="Workload type.",
)
parser.add_argument(
"--kv-cache-volume",
type=float,
default=100.0,
help="Target active KV cache in GB (default: 100).",
)
parser.add_argument(
"--tokens-per-gb-kvcache",
type=int,
default=None,
help=("Tokens per GB of KV cache (required if --lmcache-url is not set)."),
)
parser.add_argument(
"--seed",
type=int,
default=42,
help="Random seed (default: 42).",
)
parser.add_argument(
"--output-dir",
default=".",
help="Directory for output files (default: current).",
)
parser.add_argument(
"--no-csv",
action="store_true",
help="Skip CSV export.",
)
parser.add_argument(
"--json",
action="store_true",
help="Export JSON summary.",
)
parser.add_argument(
"-q",
"--quiet",
action="store_true",
help="Suppress real-time progress display.",
)
parser.add_argument(
"--ignore-eos",
action="store_true",
help=(
"Force generation to run for the full output length by ignoring "
"the model's EOS token (vLLM sampling extension). Makes decode "
"throughput reproducible regardless of when the model would stop."
),
)
parser.add_argument(
"--no-interactive",
action="store_true",
help=("Disable interactive mode. Errors if required arguments are missing."),
)
parser.add_argument(
"--export-config",
default=None,
metavar="FILE",
help=(
"Export resolved configuration to a JSON file and exit. "
"Does not run the benchmark or enter interactive mode."
),
)
# --- Long-doc-permutator workload args ---
ldp_group = parser.add_argument_group("long-doc-permutator workload options")
ldp_group.add_argument(
"--ldp-num-contexts",
type=int,
default=5,
help="Number of unique context documents (default: 5).",
)
ldp_group.add_argument(
"--ldp-context-length",
type=int,
default=5000,
help="Token length of each context (default: 5000).",
)
ldp_group.add_argument(
"--ldp-system-prompt-length",
type=int,
default=1000,
help="Token length of the shared system prompt (default: 1000). "
"Use 0 for no system prompt.",
)
ldp_group.add_argument(
"--ldp-num-permutations",
type=int,
default=10,
help="Number of distinct permutations to send (default: 10). "
"Capped at N! where N = --ldp-num-contexts.",
)
ldp_group.add_argument(
"--ldp-num-inflight-requests",
type=int,
default=1,
help="Max concurrent in-flight requests (default: 1).",
)
# --- Long-doc-qa workload args ---
group = parser.add_argument_group("long-doc-qa workload options")
group.add_argument(
"--ldqa-document-length",
type=int,
default=10000,
help="Token length per document (default: 10000).",
)
group.add_argument(
"--ldqa-query-per-document",
type=int,
default=2,
help="Questions per document (default: 2).",
)
group.add_argument(
"--ldqa-shuffle-policy",
default="random",
choices=["random", "tile"],
help="Request ordering (default: random).",
)
group.add_argument(
"--ldqa-num-inflight-requests",
type=int,
default=3,
help="Max concurrent in-flight requests (default: 3).",
)
group.add_argument(
"--ldqa-max-output-length",
type=int,
default=_LDQA_MAX_OUTPUT_LENGTH_DEFAULT,
help=(
f"Max tokens to generate per benchmark query "
f"(default: {_LDQA_MAX_OUTPUT_LENGTH_DEFAULT}). Combine with "
"--ignore-eos for a reproducible decode phase."
),
)
# --- Multi-round-chat workload args ---
mrc_group = parser.add_argument_group(
"multi-round-chat workload options",
)
mrc_group.add_argument(
"--mrc-shared-prompt-length",
type=int,
default=2000,
help="System prompt token length (default: 2000).",
)
mrc_group.add_argument(
"--mrc-chat-history-length",
type=int,
default=10000,
help="Pre-filled chat history token length (default: 10000).",
)
mrc_group.add_argument(
"--mrc-user-input-length",
type=int,
default=50,
help="Tokens per user query (default: 50).",
)
mrc_group.add_argument(
"--mrc-output-length",
type=int,
default=200,
help="Max tokens to generate per response (default: 200).",
)
mrc_group.add_argument(
"--mrc-qps",
type=float,
default=1.0,
help="Queries per second (default: 1.0).",
)
mrc_group.add_argument(
"--mrc-duration",
type=float,
default=60.0,
help="Benchmark duration in seconds (default: 60).",
)
# --- Prefix-suffix-tuner workload args ---
psf_group = parser.add_argument_group(
"prefix-suffix-tuner workload options",
)
psf_group.add_argument(
"--psf-context-length",
type=int,
default=8000,
help="Total tokens per request (prefix + breaker + suffix) (default: 8000).",
)
psf_group.add_argument(
"--psf-prefix-ratio",
type=float,
default=0.8,
help="Fraction of context-length used by the prefix (default: 0.8). "
"Must be in (0.0, 1.0). The remainder (minus a 32-token breaker) is "
"the shared suffix.",
)
psf_group.add_argument(
"--psf-thrash",
type=float,
default=20.0,
help="Size in GB of the KV-cache tier to overflow (default: 20.0). "
"The workload sizes its prefix pool to slightly more than this, "
"so every pass-2 request misses that tier and falls through to "
"the next one. Use the L0 (HBM) size for vanilla vLLM baselines, "
"or the L1 (LMCache DRAM) size for tiered baselines.",
)
# --- Random-prefill workload args ---
rp_group = parser.add_argument_group(
"random-prefill workload options",
)
rp_group.add_argument(
"--rp-request-length",
type=int,
default=10000,
help="Token length per request (default: 10000).",
)
rp_group.add_argument(
"--rp-num-requests",
type=int,
default=50,
help="Number of requests to send (default: 50).",
)
# ---------------------------------------------------------------------------
# Argument resolution helpers
# ---------------------------------------------------------------------------
def _get_missing_args(args: argparse.Namespace) -> list[str]:
"""Return list of missing required CLI flags."""
missing: list[str] = []
if args.engine_url is None:
missing.append("--engine-url")
if args.workload is None:
missing.append("--workload")
if (
args.tokens_per_gb_kvcache is None
and getattr(args, "lmcache_url", None) is None
):
missing.append("--tokens-per-gb-kvcache or --lmcache-url")
return missing
def _needs_interactive(args: argparse.Namespace) -> bool:
"""Check whether interactive mode should be triggered."""
if getattr(args, "config", None):
return False
return len(_get_missing_args(args)) > 0
def _resolve_args(args: argparse.Namespace) -> argparse.Namespace:
"""Resolve args via config file, interactive mode, or pass through."""
# Case 1: --config file
config_path = getattr(args, "config", None)
if config_path:
state = InteractiveState.load_json(config_path)
state.merge_cli_args(args)
resolved = state.to_namespace()
# Carry over output flags from CLI
for attr in (
"output_dir",
"seed",
"no_csv",
"json",
"quiet",
"format",
"output",
):
cli_val = getattr(args, attr, None)
if cli_val is not None:
setattr(resolved, attr, cli_val)
return resolved
# Case 2: --no-interactive or --export-config — error if missing
no_interactive = getattr(args, "no_interactive", False)
export_config = getattr(args, "export_config", None)
if no_interactive or export_config:
missing = _get_missing_args(args)
if missing:
flag = "--export-config" if export_config else "--no-interactive"
raise SystemExit(
"Missing required arguments: "
+ ", ".join(missing)
+ f". Provide them or remove {flag} "
"for guided setup."
)
return args
# Case 3: Interactive mode
if _needs_interactive(args):
return run_interactive(args)
# Case 4: All required args present — run directly
return args
def _export_config(
config: EngineBenchConfig,
args: argparse.Namespace,
path: str,
) -> None:
"""Export resolved config to JSON and exit.
Builds a standalone config dict from the resolved
``EngineBenchConfig`` and workload-specific CLI args.
Environment-specific keys (``engine_url``, ``lmcache_url``)
are excluded by ``InteractiveState.to_json()`` so the exported
config is portable.
"""
# Standard
import json as json_mod
state = InteractiveState()
state.set("engine_url", config.engine_url)
state.set("model", config.model)
state.set("workload", config.workload)
state.set("kv_cache_volume", config.kv_cache_volume_gb)
state.set("tokens_per_gb_kvcache", config.tokens_per_gb_kvcache)
state.set("ignore_eos", config.ignore_eos)
# Workload-specific args from namespace
for item in state.get_workload_items():
value = getattr(args, item.key, item.default)
if value is not None:
state.set(item.key, value)
# to_json() handles filtering out engine_url, lmcache_url, etc.
data = state.to_json()
with open(path, "w") as f:
json_mod.dump(data, f, indent=2)
f.write("\n")
print(f"Configuration exported to {path}")
print(
f"\033[1mReplay with:\033[0m \033[96mlmcache bench engine "
f"--engine-url <URL> --config {path}\033[0m"
)
# ---------------------------------------------------------------------------
# Final metrics emission
# ---------------------------------------------------------------------------
def _emit_final_metrics(
command: "BaseCommand",
config: EngineBenchConfig,
final: FinalStats,
args: argparse.Namespace,
) -> None:
"""Emit final benchmark summary using the CLI metrics system."""
title = f"Engine Benchmark Result ({config.workload})"
metrics = command.create_metrics(title, args, width=56)
cfg_section = metrics.add_section("config", "Configuration")
cfg_section.add("engine_url", "Engine URL", config.engine_url)
cfg_section.add("model", "Model", config.model)
cfg_section.add("workload", "Workload", config.workload)
results = metrics.add_section("results", "Results")
results.add(
"successful",
"Successful requests",
final.successful_requests,
)
results.add("failed", "Failed requests", final.failed_requests)
results.add(
"duration",
"Benchmark duration (s)",
round(final.elapsed_time, 2),
)
results.add(
"input_tokens",
"Total input tokens",
final.total_input_tokens,
)
results.add(
"output_tokens",
"Total output tokens",
final.total_output_tokens,
)
results.add(
"input_tput",
"Input throughput (tok/s)",
round(final.input_throughput, 2),
)
results.add(
"output_tput",
"Output throughput (tok/s)",
round(final.output_throughput, 2),
)
ttft = metrics.add_section("ttft", "Time to First Token")
ttft.add("mean", "Mean TTFT (ms)", round(final.mean_ttft_ms, 2))
ttft.add("p50", "P50 TTFT (ms)", round(final.p50_ttft_ms, 2))
ttft.add("p90", "P90 TTFT (ms)", round(final.p90_ttft_ms, 2))
ttft.add("p99", "P99 TTFT (ms)", round(final.p99_ttft_ms, 2))
decode = metrics.add_section("decode", "Decoding Speed")
decode.add(
"mean",
"Mean decode (tok/s)",
round(final.mean_decode_speed, 2),
)
decode.add(
"p99",
"P99 decode (tok/s)",
round(final.p99_decode_speed, 2),
)
metrics.emit()
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
def run_engine_bench(command: "BaseCommand", args: argparse.Namespace) -> None:
"""Centralized orchestrator: create all modules and run the engine bench.
Args:
command: The outer ``BenchCommand`` instance, used for
``create_metrics`` (inherited from ``BaseCommand``).
args: Parsed CLI arguments for ``lmcache bench engine``.
"""
# 0. Resolve args (config file / interactive / pass-through)
args = _resolve_args(args)
# 1. Parse config
config = parse_args_to_config(args)
# 1a. A max output length can only be set for workloads that have a
# max-output-length parameter; reject it for any other workload.
if args.ldqa_max_output_length != _LDQA_MAX_OUTPUT_LENGTH_DEFAULT:
validate_max_output_length_supported(config.workload)
# 1b. --export-config: save resolved config and exit
export_path = getattr(args, "export_config", None)
if export_path:
_export_config(config, args, export_path)
return
logger.info(
"Benchmark config: workload=%s, model=%s, kv_cache=%.1f GB, tokens_per_gb=%d",
config.workload,
config.model,
config.kv_cache_volume_gb,
config.tokens_per_gb_kvcache,
)
# 2. Create shared modules
stats_collector = StatsCollector()
progress_monitor = ProgressMonitor(
stats_collector,
quiet=config.quiet,
)
# 3. Create request sender (callbacks wired after workload creation)
request_sender = RequestSender(
config.engine_url,
config.model,
ignore_eos=config.ignore_eos,
)
# 4. Create workload
workload = create_workload(
config,
args,
request_sender,
stats_collector,
progress_monitor,
)
# 5. Wire callbacks on sender
request_sender.add_on_finished_callback(
lambda result, _text: stats_collector.on_request_finished(result),
)
request_sender.add_on_finished_callback(
lambda result, _text: progress_monitor.on_request_finished(
result.request_id,
result.successful,
),
)
request_sender.add_on_finished_callback(workload.request_finished)
# 6. Log config and run benchmark
workload.log_config()
progress_monitor.start()
try:
workload.run()
finally:
progress_monitor.stop()
# 7. Final metrics
final = stats_collector.get_final_stats()
_emit_final_metrics(command, config, final, args)
# 8. Export
if config.export_csv:
csv_path = os.path.join(config.output_dir, "bench_results.csv")
stats_collector.export_csv(csv_path)
logger.info("CSV results written to %s", csv_path)
if config.export_json:
json_path = os.path.join(
config.output_dir,
"bench_summary.json",
)
stats_collector.export_json(json_path, config)
logger.info("JSON summary written to %s", json_path)
# 9. Exit code
if final.failed_requests > 0:
sys.exit(1)