[Benchmark] Add vllm bench forward: exact-shape forward timing (#61339)
Signed-off-by: Woosuk Kwon <woosuk@inferact.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2c74fd1873
commit
9d3dfa2886
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: model-microbenchmark
|
||||||
|
description: Time a vLLM model's forward pass on exact batch shapes (decode, prefill, chunked prefill, prefix hit, spec-decode verify, or mixes) with `vllm bench forward`, single-GPU or DP/TP/EP across nodes. Use for per-step model latency, layout/parallelism comparisons, and checking a model-level optimization. Not for single kernels (use kernel-microbenchmark) or end-to-end serving (use `vllm bench serve`).
|
||||||
|
---
|
||||||
|
|
||||||
|
# Model Microbenchmark (`vllm bench forward`)
|
||||||
|
|
||||||
|
Builds the batch without the scheduler, runs it through MRV2's real
|
||||||
|
`execute_model`, and times the forward as a one-shot CUDA graph (prefill
|
||||||
|
included). Sampling, the speculator, and CPU step overhead are excluded, so
|
||||||
|
expect results somewhat below a serving step.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vllm bench forward --model deepseek-ai/DeepSeek-V4-Flash \
|
||||||
|
--case decode=64*1@8192 --case prefill=8192 --case mix=4096,32*1@8192
|
||||||
|
|
||||||
|
# DP/TP/EP: one process per GPU (multi-node: add --nnodes/--node-rank/--master-addr)
|
||||||
|
torchrun --nproc-per-node=4 -m vllm.entrypoints.cli.main bench forward \
|
||||||
|
--model ... -dp 4 --enable-expert-parallel --case decode=128*1@100000
|
||||||
|
```
|
||||||
|
|
||||||
|
Requests: `[N*]Q[@K][c][:D]`, comma-separated, per DP rank.
|
||||||
|
|
||||||
|
| Form | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `8192` | fresh prefill, 8192 tokens |
|
||||||
|
| `32*1@8192` | 32 decodes, KV length 8192 (KV length **includes** the query) |
|
||||||
|
| `4@4096:3` | spec-decode verify, 3 draft tokens |
|
||||||
|
| `2048@10240` | prefill after an 8192-token prefix-cache hit |
|
||||||
|
| `2048@10240c` | chunked prefill: the prefix came from earlier chunks |
|
||||||
|
|
||||||
|
Hit vs chunk only differs on models with SWA bounded replay (DSV4.1): a hit
|
||||||
|
also recomputes the last window (128 tokens) of the prefix.
|
||||||
|
|
||||||
|
Or `--shapes file.json|yaml`:
|
||||||
|
`{"cases": [{"name": ..., "requests": [{"query_len", "kv_len", "num_draft_tokens", "chunked", "count"}]}]}`.
|
||||||
|
|
||||||
|
Key flags (plus all `EngineArgs`):
|
||||||
|
|
||||||
|
- `--kv-len-jitter 0.5` (default): prefixes drawn within ±50%, same on every
|
||||||
|
DP rank, capped at `max_model_len`. Use `0` for exact lengths.
|
||||||
|
- `--routing auto|real|balanced`: `auto` = balanced with dummy weights.
|
||||||
|
- `--iters 20 --samples 5`, `--output-json out.json` (per-rank latency stats,
|
||||||
|
`samples_us`, graph-vs-eager check).
|
||||||
|
- Defaults: `--load-format dummy`, prefix caching off, no torch.compile.
|
||||||
|
|
||||||
|
Read `median_us` (each replay is already the max over ranks).
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
- **Dummy weights lie for data-dependent kernels.** Dummy gates route every
|
||||||
|
token to the same experts (hence balanced routing). Dummy norm weights make
|
||||||
|
DSV4 indexer logits degenerate, so `topKPerRowPrefill` runs ~2.6x slow
|
||||||
|
until #61323 lands. Cross-check suspicious kernels with real weights.
|
||||||
|
- **torch.compile models (e.g. Qwen3/3.5) look slow**: no compile here. Use
|
||||||
|
them for correctness only; trust perf for models under `vllm/models/`.
|
||||||
|
- `sample spread > 0.5%` warns of unstable timing. DSV4.1 DEP prefill is
|
||||||
|
inherently ~0.5–1% (MegaMoE cross-rank sync, host engram reads).
|
||||||
|
- `graph_vs_eager` may be nonzero for small batches where models branch on
|
||||||
|
`is_current_stream_capturing()`; that's expected, not a bug.
|
||||||
|
- KV cache is zero-initialized (`--kv-init zeros`); random KV was within 3%.
|
||||||
|
- Uniform DP only: every rank gets the same case; no idle ranks.
|
||||||
|
- CUDA only: it hangs on ROCm (untested cause: likely the in-graph timing
|
||||||
|
events).
|
||||||
|
- Large prefills/prefix hits need room: raise `--max-num-batched-tokens` /
|
||||||
|
`--max-model-len` / `--gpu-memory-utilization` if build fails on KV blocks.
|
||||||
|
- Startup cost is JIT/autotune, not the bench: install FlashInfer jit-cache
|
||||||
|
and cubin; DeepGEMM warmup is skipped (each case's eager pass compiles its
|
||||||
|
own shapes).
|
||||||
|
- To compare against serving decode: `vllm bench latency` with 65- vs
|
||||||
|
1-token outputs, divided by 64, with a FULL decode graph.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/model-microbenchmark
|
||||||
+14
-1
@@ -131,9 +131,22 @@ To use benchmark commands, please install with extra dependencies using `pip ins
|
|||||||
Available Commands:
|
Available Commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vllm bench {latency, serve, throughput}
|
vllm bench {forward, latency, serve, throughput}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### forward
|
||||||
|
|
||||||
|
Benchmark the model forward of exact batches: each case lists its requests as `[N*]Q[@K][c][:D]` (query length Q, KV length K including the query, D draft tokens). A prefill's prefix is a prefix-cache hit unless `c` marks a chunked prefill; they differ only on models with SWA bounded replay, which recompute the last window of a hit. Each request's prefix length varies by ±50% by default (`--kv-len-jitter`), identically on every DP rank. Each forward is captured as a one-shot CUDA graph; sampling and CPU overhead aren't measured. Use `torchrun`, one process per GPU, for DP/TP/EP. CUDA only for now.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vllm bench forward \
|
||||||
|
--model meta-llama/Llama-3.2-1B-Instruct \
|
||||||
|
--case decode=32*1@4096 \
|
||||||
|
--case mix=2048,32*1@4096
|
||||||
|
```
|
||||||
|
|
||||||
|
See [vllm bench forward](./bench/forward.md) for the full reference of all available arguments.
|
||||||
|
|
||||||
### latency
|
### latency
|
||||||
|
|
||||||
Benchmark the latency of a single batch of requests.
|
Benchmark the latency of a single batch of requests.
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ def auto_mock(module_name: str, attr: str, max_mocks: int = 100):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
bench_forward = auto_mock("vllm.benchmarks.forward", "main")
|
||||||
bench_latency = auto_mock("vllm.benchmarks", "latency")
|
bench_latency = auto_mock("vllm.benchmarks", "latency")
|
||||||
bench_serve = auto_mock("vllm.benchmarks", "serve")
|
bench_serve = auto_mock("vllm.benchmarks", "serve")
|
||||||
bench_startup = auto_mock("vllm.benchmarks", "startup")
|
bench_startup = auto_mock("vllm.benchmarks", "startup")
|
||||||
@@ -340,6 +341,7 @@ pages = {
|
|||||||
False,
|
False,
|
||||||
),
|
),
|
||||||
"cli/launch/render.md": (create_parser(RenderSubcommand.add_cli_args), True),
|
"cli/launch/render.md": (create_parser(RenderSubcommand.add_cli_args), True),
|
||||||
|
"cli/bench/forward.md": (create_parser(bench_forward.add_cli_args), True),
|
||||||
"cli/bench/latency.md": (create_parser(bench_latency.add_cli_args), True),
|
"cli/bench/latency.md": (create_parser(bench_latency.add_cli_args), True),
|
||||||
# URL kept as `mm_processor` for back-compat; command name is `mm-processor`
|
# URL kept as `mm_processor` for back-compat; command name is `mm-processor`
|
||||||
"cli/bench/mm_processor.md": (
|
"cli/bench/mm_processor.md": (
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import replace
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from vllm.benchmarks.forward.batch import build_batch
|
||||||
|
from vllm.benchmarks.forward.shapes import jitter_kv_lens, parse_case
|
||||||
|
from vllm.platforms import current_platform
|
||||||
|
|
||||||
|
MODEL_NAME = "meta-llama/Llama-3.2-1B-Instruct"
|
||||||
|
|
||||||
|
|
||||||
|
def _group(block_size: int, replay: int = 0, dcp_sharded: bool = False):
|
||||||
|
return SimpleNamespace(
|
||||||
|
kv_cache_spec=SimpleNamespace(
|
||||||
|
block_size=block_size,
|
||||||
|
prefix_replay_tokens=replay,
|
||||||
|
dcp_sharded=dcp_sharded,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_batch_layout():
|
||||||
|
"""The runner sees each request kind as the scheduler would schedule it."""
|
||||||
|
case = parse_case("c=2*1@101,4@64:3,32,8@48c,16@316")
|
||||||
|
groups = [_group(16), _group(32, replay=128)]
|
||||||
|
batch = build_batch(case, groups, 1000, 1000, np.random.default_rng(0))
|
||||||
|
|
||||||
|
# (scheduled, computed, is_decode) per request: decode, verify, prefill,
|
||||||
|
# chunked prefill, then a prefix hit replaying the last 128 hit tokens.
|
||||||
|
assert list(batch.expected.values()) == [
|
||||||
|
(1, 100, True),
|
||||||
|
(1, 100, True),
|
||||||
|
(4, 60, True),
|
||||||
|
(32, 0, False),
|
||||||
|
(8, 40, False),
|
||||||
|
(16 + 128, 300 - 128, False),
|
||||||
|
]
|
||||||
|
output = batch.scheduler_output
|
||||||
|
assert output.total_num_scheduled_tokens == 1 + 1 + 4 + 32 + 8 + 144
|
||||||
|
assert len(output.scheduled_spec_decode_tokens["bench-2"]) == 3
|
||||||
|
assert output.scheduled_new_reqs[-1].replay_start == 300 - 128
|
||||||
|
for req_id, (scheduled, _, _) in batch.expected.items():
|
||||||
|
assert len(batch.query_token_ids[req_id]) == scheduled
|
||||||
|
# Groups share one block pool: block IDs must not overlap.
|
||||||
|
ranges = sorted(r for group in batch.group_block_ranges for r in group)
|
||||||
|
assert ranges[0][0] == 1
|
||||||
|
assert all(a[1] == b[0] for a, b in zip(ranges, ranges[1:]))
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="KV blocks"):
|
||||||
|
build_batch(case, groups, 10, 1000, np.random.default_rng(0))
|
||||||
|
|
||||||
|
# DCP spreads each block's tokens over the DCP ranks, like the scheduler.
|
||||||
|
groups = [_group(16, dcp_sharded=True), _group(16)]
|
||||||
|
batch = build_batch(
|
||||||
|
parse_case("c=1@640"), groups, 1000, 1000, np.random.default_rng(0), 4
|
||||||
|
)
|
||||||
|
assert batch.group_block_ranges == [[(1, 11)], [(11, 51)]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_jitter_kv_lens():
|
||||||
|
"""Prefixes vary, capped at the max length; queries and fresh prefills don't.
|
||||||
|
A case's draw depends only on the seed and its name, so adding or reordering
|
||||||
|
other cases doesn't change it."""
|
||||||
|
case = parse_case("c=1000*1@1001,4@64:3,32")
|
||||||
|
jittered = jitter_kv_lens(case, 0.5, 1400, 0)
|
||||||
|
assert jitter_kv_lens(case, 0.5, 1400, 0) == jittered
|
||||||
|
other = jitter_kv_lens(replace(case, name="d"), 0.5, 1400, 0)
|
||||||
|
assert other.requests != jittered.requests
|
||||||
|
prefixes = np.array([r.prefix_len for r in jittered.requests[:1000]])
|
||||||
|
assert prefixes.min() >= 500 and max(r.kv_len for r in jittered.requests) == 1400
|
||||||
|
assert prefixes.std() > 200
|
||||||
|
assert [r.query_len for r in jittered.requests] == [
|
||||||
|
r.query_len for r in case.requests
|
||||||
|
]
|
||||||
|
assert jittered.requests[-1] == case.requests[-1]
|
||||||
|
assert jitter_kv_lens(case, 0, 2048, 0) == case
|
||||||
|
with pytest.raises(ValueError, match="KV length"):
|
||||||
|
jitter_kv_lens(case, 0, 1000, 0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.benchmark
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
current_platform.is_rocm(), reason="Hangs on ROCm; the bench is CUDA-only."
|
||||||
|
)
|
||||||
|
def test_bench_forward(tmp_path):
|
||||||
|
output = tmp_path / "forward.json"
|
||||||
|
command = [
|
||||||
|
"vllm",
|
||||||
|
"bench",
|
||||||
|
"forward",
|
||||||
|
"--model",
|
||||||
|
MODEL_NAME,
|
||||||
|
"--max-model-len",
|
||||||
|
"2048",
|
||||||
|
"--gpu-memory-utilization",
|
||||||
|
"0.5",
|
||||||
|
"--case",
|
||||||
|
"decode=8*1@512",
|
||||||
|
"--case",
|
||||||
|
"prefill=256",
|
||||||
|
"--case",
|
||||||
|
"mix=128@256,4*1@512",
|
||||||
|
"--iters",
|
||||||
|
"3",
|
||||||
|
"--samples",
|
||||||
|
"2",
|
||||||
|
"--output-json",
|
||||||
|
str(output),
|
||||||
|
]
|
||||||
|
result = subprocess.run(command, capture_output=True, text=True)
|
||||||
|
print(result.stdout)
|
||||||
|
print(result.stderr)
|
||||||
|
assert result.returncode == 0, f"Benchmark failed: {result.stderr}"
|
||||||
|
|
||||||
|
results = {row["case"]: row for row in json.loads(output.read_text())}
|
||||||
|
assert list(results) == ["decode", "prefill", "mix"]
|
||||||
|
for row in results.values():
|
||||||
|
rank0 = row["ranks"][0]
|
||||||
|
assert len(rank0["batch_order"]) == len(row["requests"])
|
||||||
|
assert rank0["finite"] and rank0["graph_deterministic"]
|
||||||
|
assert row["median_us"] > 0
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
"""Build a `SchedulerOutput` for an exact batch, without the scheduler."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from vllm.benchmarks.forward.shapes import BenchCase
|
||||||
|
from vllm.sampling_params import SamplingParams
|
||||||
|
from vllm.v1.core.sched.output import NewRequestData, SchedulerOutput
|
||||||
|
from vllm.v1.kv_cache_interface import (
|
||||||
|
CircularBufferSpec,
|
||||||
|
KVCacheSpec,
|
||||||
|
MambaSpec,
|
||||||
|
SlidingWindowSpec,
|
||||||
|
UniformTypeKVCacheSpecs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BenchBatch:
|
||||||
|
scheduler_output: SchedulerOutput
|
||||||
|
# req_id -> token IDs of the query tokens, in order.
|
||||||
|
query_token_ids: dict[str, np.ndarray]
|
||||||
|
# Per KV cache group: allocated (begin, end) block ranges.
|
||||||
|
group_block_ranges: list[list[tuple[int, int]]]
|
||||||
|
num_blocks: int
|
||||||
|
# req_id -> the runner's expected (scheduled, computed, is_decode).
|
||||||
|
expected: dict[str, tuple[int, int, bool]]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def num_tokens(self) -> int:
|
||||||
|
return self.scheduler_output.total_num_scheduled_tokens
|
||||||
|
|
||||||
|
@property
|
||||||
|
def req_ids(self) -> list[str]:
|
||||||
|
return list(self.scheduler_output.num_scheduled_tokens)
|
||||||
|
|
||||||
|
def finish(self) -> SchedulerOutput:
|
||||||
|
"""A step that only removes this batch's requests."""
|
||||||
|
output = SchedulerOutput.make_empty()
|
||||||
|
output.finished_req_ids = set(self.req_ids)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def kv_block_size(spec: KVCacheSpec, dcp_size: int) -> int:
|
||||||
|
"""Tokens per block as the scheduler counts them: DCP spreads each
|
||||||
|
block's tokens over the DCP ranks."""
|
||||||
|
return spec.block_size * (dcp_size if spec.dcp_sharded else 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _num_blocks(
|
||||||
|
spec: KVCacheSpec, computed: int, kv_len: int, dcp_size: int
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""Return (leading null blocks, live blocks) for one request."""
|
||||||
|
block_size = kv_block_size(spec, dcp_size)
|
||||||
|
if isinstance(spec, UniformTypeKVCacheSpecs):
|
||||||
|
spec = spec.first_spec
|
||||||
|
if isinstance(spec, (CircularBufferSpec, MambaSpec)):
|
||||||
|
return 0, 1
|
||||||
|
total = -(-kv_len // block_size)
|
||||||
|
skipped = 0
|
||||||
|
if isinstance(spec, SlidingWindowSpec):
|
||||||
|
# Blocks wholly before the window are freed to the null block.
|
||||||
|
skipped = max(0, computed - spec.sliding_window + 1) // block_size
|
||||||
|
return skipped, total - skipped
|
||||||
|
|
||||||
|
|
||||||
|
def build_batch(
|
||||||
|
case: BenchCase,
|
||||||
|
kv_cache_groups,
|
||||||
|
num_kv_blocks: int,
|
||||||
|
vocab_size: int,
|
||||||
|
rng: np.random.Generator,
|
||||||
|
dcp_size: int = 1,
|
||||||
|
) -> BenchBatch:
|
||||||
|
"""Allocate disjoint blocks and build `NewRequestData` for every request.
|
||||||
|
|
||||||
|
Groups share one block pool, so block IDs are unique across groups. Block
|
||||||
|
0 is the null block.
|
||||||
|
|
||||||
|
With prefix P = K - Q (KV length K, query length Q):
|
||||||
|
|
||||||
|
| Kind | prompt tokens | num_computed_tokens | spec tokens |
|
||||||
|
| ------------------ | ------------- | ------------------- | ----------- |
|
||||||
|
| Prefill | K | P | - |
|
||||||
|
| Decode | P | P | - |
|
||||||
|
| Verify (D drafts) | P | P | D |
|
||||||
|
|
||||||
|
Unless chunked, a prefill's prefix is a prefix hit, which replays its last
|
||||||
|
R = min(window, P) tokens on models with SWA bounded replay (window 0
|
||||||
|
otherwise), like the scheduler: it schedules R + Q tokens from
|
||||||
|
replay_start = num_computed_tokens = P - R.
|
||||||
|
"""
|
||||||
|
replay_window = max(
|
||||||
|
(group.kv_cache_spec.prefix_replay_tokens for group in kv_cache_groups),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
expected: dict[str, tuple[int, int, bool]] = {}
|
||||||
|
cursor = 1
|
||||||
|
new_reqs = []
|
||||||
|
query_token_ids = {}
|
||||||
|
group_block_ranges: list[list[tuple[int, int]]] = [[] for _ in kv_cache_groups]
|
||||||
|
output = SchedulerOutput.make_empty()
|
||||||
|
for i, req in enumerate(case.requests):
|
||||||
|
req_id = f"bench-{i}"
|
||||||
|
replay = min(replay_window, req.prefix_len) if req.prefix_hit else 0
|
||||||
|
computed = req.prefix_len - replay
|
||||||
|
block_ids = []
|
||||||
|
for group, ranges in zip(kv_cache_groups, group_block_ranges):
|
||||||
|
skipped, live = _num_blocks(
|
||||||
|
group.kv_cache_spec, computed, req.kv_len, dcp_size
|
||||||
|
)
|
||||||
|
block_ids.append([0] * skipped + list(range(cursor, cursor + live)))
|
||||||
|
ranges.append((cursor, cursor + live))
|
||||||
|
cursor += live
|
||||||
|
tokens = rng.integers(0, vocab_size, req.kv_len, dtype=np.int64)
|
||||||
|
prompt = tokens[: req.prefix_len] if req.is_decode else tokens
|
||||||
|
query_token_ids[req_id] = tokens[computed:]
|
||||||
|
expected[req_id] = (req.query_len + replay, computed, req.is_decode)
|
||||||
|
new_reqs.append(
|
||||||
|
NewRequestData(
|
||||||
|
req_id=req_id,
|
||||||
|
prompt_token_ids=prompt.tolist(),
|
||||||
|
prefill_token_ids=prompt.tolist(),
|
||||||
|
mm_features=[],
|
||||||
|
sampling_params=SamplingParams(max_tokens=16, temperature=0.0),
|
||||||
|
pooling_params=None,
|
||||||
|
block_ids=tuple(block_ids),
|
||||||
|
num_computed_tokens=computed,
|
||||||
|
lora_request=None,
|
||||||
|
replay_start=computed if replay else 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
output.num_scheduled_tokens[req_id] = req.query_len + replay
|
||||||
|
if req.num_draft_tokens:
|
||||||
|
output.scheduled_spec_decode_tokens[req_id] = tokens[
|
||||||
|
req.prefix_len + 1 :
|
||||||
|
].tolist()
|
||||||
|
if cursor > num_kv_blocks:
|
||||||
|
raise ValueError(
|
||||||
|
f"Case {case.name!r} needs {cursor} KV blocks, but only "
|
||||||
|
f"{num_kv_blocks} exist. Increase the KV cache size."
|
||||||
|
)
|
||||||
|
output.scheduled_new_reqs = new_reqs
|
||||||
|
output.total_num_scheduled_tokens = sum(output.num_scheduled_tokens.values())
|
||||||
|
return BenchBatch(output, query_token_ids, group_block_ranges, cursor, expected)
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
"""Capture the forward of one exact batch as a one-shot CUDA graph and time it.
|
||||||
|
|
||||||
|
The graph is captured for a single batch and only replays that batch, so the
|
||||||
|
host-side decisions baked in at capture (attention plans, kernel choices by
|
||||||
|
query length, chunk splits) are correct by construction. This deliberately
|
||||||
|
bypasses vLLM's CUDA graph manager: no capture sizes, padding or dummy
|
||||||
|
metadata. Each replay first restores the KV blocks the step writes, so state the
|
||||||
|
forward mutates in place (e.g. compressor state) is the same on every replay.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from vllm.benchmarks.forward.batch import BenchBatch
|
||||||
|
from vllm.config.compilation import CUDAGraphMode
|
||||||
|
from vllm.distributed.parallel_state import get_world_group, graph_capture
|
||||||
|
from vllm.forward_context import get_forward_context
|
||||||
|
from vllm.v1.worker.gpu.input_batch import InputBatch
|
||||||
|
|
||||||
|
|
||||||
|
def _first_tensor(output: Any) -> torch.Tensor:
|
||||||
|
if isinstance(output, tuple):
|
||||||
|
output = output[0]
|
||||||
|
assert isinstance(output, torch.Tensor), type(output)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _compare(actual: torch.Tensor, expected: torch.Tensor) -> dict[str, float]:
|
||||||
|
delta = actual.float() - expected.float()
|
||||||
|
scale = expected.float().square().mean().sqrt().clamp(min=1e-12)
|
||||||
|
return {
|
||||||
|
"max_abs": delta.abs().max().item(),
|
||||||
|
"relative_rms": (delta.square().mean().sqrt() / scale).item(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ForwardBench:
|
||||||
|
"""Runs one batch through the runner's `execute_model`, timing its forward.
|
||||||
|
|
||||||
|
`execute_model` prepares the inputs, attention metadata and forward context
|
||||||
|
as in serving; the bench takes over the eager model call. Two steps: the
|
||||||
|
first ("warmup") runs the model eagerly, which triggers JIT, autotuning and
|
||||||
|
lazy init. The second ("capture") captures, validates and times the graph.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
runner: Any,
|
||||||
|
batch: BenchBatch,
|
||||||
|
iters: int,
|
||||||
|
samples: int,
|
||||||
|
state_views: list[torch.Tensor],
|
||||||
|
):
|
||||||
|
self.runner = runner
|
||||||
|
self.batch = batch
|
||||||
|
self.device = runner.device
|
||||||
|
self.iters = iters
|
||||||
|
self.samples = samples
|
||||||
|
self.state_views = state_views
|
||||||
|
self.phase = "warmup"
|
||||||
|
self.result: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def run(self) -> dict[str, Any]:
|
||||||
|
runner, model = self.runner, self.runner.model
|
||||||
|
# The runner's eager branch calls `model(**inputs)`.
|
||||||
|
assert runner.compilation_config.cudagraph_mode == CUDAGraphMode.NONE
|
||||||
|
assert runner.ubatch_runner is None
|
||||||
|
prepare_inputs = runner.prepare_inputs
|
||||||
|
self._model_forward = model.forward
|
||||||
|
|
||||||
|
def prepare_and_check(*args, **kwargs) -> InputBatch:
|
||||||
|
self._input_batch = prepare_inputs(*args, **kwargs)
|
||||||
|
self._check_batch(self._input_batch)
|
||||||
|
return self._input_batch
|
||||||
|
|
||||||
|
# Instance attributes shadow the methods for these two steps only.
|
||||||
|
runner.prepare_inputs = prepare_and_check
|
||||||
|
model.forward = self._forward
|
||||||
|
try:
|
||||||
|
for self.phase in ("warmup", "capture"):
|
||||||
|
runner.execute_model(self.batch.scheduler_output)
|
||||||
|
finally:
|
||||||
|
del runner.prepare_inputs, model.forward
|
||||||
|
# No sample_tokens follows.
|
||||||
|
runner.execute_model_state = None
|
||||||
|
return self.result
|
||||||
|
|
||||||
|
def _forward(self, **inputs) -> Any:
|
||||||
|
self._write_input_ids(self._input_batch, inputs["input_ids"])
|
||||||
|
# Models may update the forward context in place (e.g. sequence
|
||||||
|
# parallelism shards is_padding); every call must start from the
|
||||||
|
# context the runner built.
|
||||||
|
context = get_forward_context()
|
||||||
|
saved = dict(vars(context))
|
||||||
|
|
||||||
|
def run_model() -> Any:
|
||||||
|
for name, value in saved.items():
|
||||||
|
setattr(context, name, value)
|
||||||
|
return self._model_forward(**inputs)
|
||||||
|
|
||||||
|
self._run_model = run_model
|
||||||
|
if self.phase == "warmup":
|
||||||
|
return run_model()
|
||||||
|
return self._capture_and_time()
|
||||||
|
|
||||||
|
def _check_batch(self, input_batch: InputBatch) -> None:
|
||||||
|
"""Fail unless the runner built exactly the requested batch."""
|
||||||
|
expected = self.batch.expected
|
||||||
|
assert sorted(input_batch.req_ids) == sorted(expected), input_batch.req_ids
|
||||||
|
for i, req_id in enumerate(input_batch.req_ids):
|
||||||
|
actual = (
|
||||||
|
int(input_batch.num_scheduled_tokens[i]),
|
||||||
|
int(input_batch.num_computed_tokens_np[i]),
|
||||||
|
not bool(input_batch.is_prefilling_np[i]),
|
||||||
|
)
|
||||||
|
assert actual == expected[req_id], (
|
||||||
|
f"{req_id}: got (scheduled, computed, decode) = {actual}, "
|
||||||
|
f"wanted {expected[req_id]}"
|
||||||
|
)
|
||||||
|
assert input_batch.num_tokens == self.batch.num_tokens
|
||||||
|
self.result["num_tokens_after_padding"] = input_batch.num_tokens_after_padding
|
||||||
|
self.result["batch_order"] = input_batch.req_ids
|
||||||
|
|
||||||
|
def _write_input_ids(
|
||||||
|
self, input_batch: InputBatch, input_ids: torch.Tensor | None
|
||||||
|
) -> None:
|
||||||
|
"""Decode/verify inputs otherwise come from sampler state."""
|
||||||
|
if input_ids is None:
|
||||||
|
return
|
||||||
|
ids = np.concatenate(
|
||||||
|
[self.batch.query_token_ids[req_id] for req_id in input_batch.req_ids]
|
||||||
|
)
|
||||||
|
input_ids[: len(ids)].copy_(torch.from_numpy(ids))
|
||||||
|
|
||||||
|
def _capture_and_time(self) -> Any:
|
||||||
|
world = get_world_group()
|
||||||
|
# The forward mutates state it reads (e.g. compressor state), so
|
||||||
|
# every run starts by restoring the KV blocks the step writes.
|
||||||
|
snapshots = [(view, view.clone()) for view in self.state_views]
|
||||||
|
|
||||||
|
def restore() -> None:
|
||||||
|
for view, saved in snapshots:
|
||||||
|
view.copy_(saved)
|
||||||
|
|
||||||
|
# A rendezvous and the timing events live inside the graph, after the
|
||||||
|
# restore: neither rank skew, the restore nor host launch gaps reach
|
||||||
|
# the timed interval.
|
||||||
|
flag = torch.zeros(1, device=self.device)
|
||||||
|
self._events = (
|
||||||
|
torch.cuda.Event(enable_timing=True, external=True),
|
||||||
|
torch.cuda.Event(enable_timing=True, external=True),
|
||||||
|
)
|
||||||
|
if world.world_size > 1:
|
||||||
|
# Initialize the communicator outside capture.
|
||||||
|
torch.distributed.all_reduce(flag, group=world.device_group)
|
||||||
|
with graph_capture(self.device) as capture_context:
|
||||||
|
stream = capture_context.stream
|
||||||
|
# Some kernels set up per-stream state on first use, outside capture.
|
||||||
|
eager = _first_tensor(self._run_model()).clone()
|
||||||
|
restore()
|
||||||
|
reference = _first_tensor(self._run_model()).clone()
|
||||||
|
stream.synchronize()
|
||||||
|
graph = torch.cuda.CUDAGraph()
|
||||||
|
with torch.cuda.graph(graph, stream=stream):
|
||||||
|
restore()
|
||||||
|
if world.world_size > 1:
|
||||||
|
torch.distributed.all_reduce(flag, group=world.device_group)
|
||||||
|
self._events[0].record()
|
||||||
|
output = self._run_model()
|
||||||
|
self._events[1].record()
|
||||||
|
hidden = _first_tensor(output)
|
||||||
|
|
||||||
|
graph.replay()
|
||||||
|
torch.accelerator.synchronize()
|
||||||
|
first = hidden.clone()
|
||||||
|
graph.replay()
|
||||||
|
torch.accelerator.synchronize()
|
||||||
|
self.result.update(
|
||||||
|
# Same step from the same restored state: should match exactly.
|
||||||
|
eager_repeat=_compare(reference, eager),
|
||||||
|
graph_vs_eager=_compare(first, reference),
|
||||||
|
graph_deterministic=bool(torch.equal(first, hidden)),
|
||||||
|
finite=bool(hidden.isfinite().all()),
|
||||||
|
state_reset_bytes=sum(
|
||||||
|
v.numel() * v.element_size() for v in self.state_views
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.result["latency_us"] = self._time(graph)
|
||||||
|
del graph
|
||||||
|
return output
|
||||||
|
|
||||||
|
def _time(self, graph: torch.cuda.CUDAGraph) -> dict[str, Any]:
|
||||||
|
"""Per-replay forward time between in-graph events, MAX across ranks."""
|
||||||
|
world = get_world_group()
|
||||||
|
start, end = self._events
|
||||||
|
self._warmup(graph)
|
||||||
|
times = []
|
||||||
|
for _ in range(self.iters * self.samples):
|
||||||
|
graph.replay()
|
||||||
|
end.synchronize()
|
||||||
|
times.append(start.elapsed_time(end) * 1000)
|
||||||
|
arr = torch.tensor(times, device=self.device)
|
||||||
|
if world.world_size > 1:
|
||||||
|
torch.distributed.all_reduce(
|
||||||
|
arr, op=torch.distributed.ReduceOp.MAX, group=world.device_group
|
||||||
|
)
|
||||||
|
arr = arr.cpu().numpy()
|
||||||
|
sample_medians = np.median(arr.reshape(self.samples, -1), axis=1)
|
||||||
|
median = float(np.median(arr))
|
||||||
|
return {
|
||||||
|
"timer": "graph_events" + ("_rank_max" if world.world_size > 1 else ""),
|
||||||
|
"median": median,
|
||||||
|
# Stability: spread of per-sample medians relative to the median.
|
||||||
|
"sample_spread_pct": float(
|
||||||
|
100 * (sample_medians.max() - sample_medians.min()) / median
|
||||||
|
),
|
||||||
|
"mean": float(arr.mean()),
|
||||||
|
"std": float(arr.std()),
|
||||||
|
"min": float(arr.min()),
|
||||||
|
"p90": float(np.percentile(arr, 90)),
|
||||||
|
"count": len(arr),
|
||||||
|
"samples_us": [round(float(t), 2) for t in arr],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _warmup(self, graph: torch.cuda.CUDAGraph, seconds: float = 1.0) -> None:
|
||||||
|
"""Replay until clocks and caches reach a steady state."""
|
||||||
|
world = get_world_group()
|
||||||
|
graph.replay()
|
||||||
|
torch.accelerator.synchronize()
|
||||||
|
begin = time.perf_counter()
|
||||||
|
graph.replay()
|
||||||
|
torch.accelerator.synchronize()
|
||||||
|
per_replay = max(time.perf_counter() - begin, 1e-6)
|
||||||
|
# Same count on every rank: the graph may contain collectives.
|
||||||
|
count = torch.tensor([max(self.iters, int(seconds / per_replay))])
|
||||||
|
torch.distributed.all_reduce(
|
||||||
|
count, op=torch.distributed.ReduceOp.MAX, group=world.cpu_group
|
||||||
|
)
|
||||||
|
for _ in range(int(count.item())):
|
||||||
|
graph.replay()
|
||||||
|
torch.accelerator.synchronize()
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
"""KV blocks of a benchmark case: initialization and the blocks a step writes.
|
||||||
|
|
||||||
|
Only the case's blocks are touched, group by group: groups can alias one
|
||||||
|
allocation with different layouts, and the batch builder keeps their block IDs
|
||||||
|
disjoint. Zero KV times within a few percent of random KV, even for sparse
|
||||||
|
attention (DeepSeek-V4.1, ~100K context), so zeros are the only init for now.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from vllm.benchmarks.forward.batch import BenchBatch, kv_block_size
|
||||||
|
|
||||||
|
|
||||||
|
def _tensors(kv_cache) -> Iterator[torch.Tensor]:
|
||||||
|
if isinstance(kv_cache, torch.Tensor):
|
||||||
|
if kv_cache.numel():
|
||||||
|
yield kv_cache
|
||||||
|
elif isinstance(kv_cache, (list, tuple)):
|
||||||
|
for item in kv_cache:
|
||||||
|
yield from _tensors(item)
|
||||||
|
|
||||||
|
|
||||||
|
def _block_dim(tensor: torch.Tensor, num_blocks: int) -> tuple[int, int]:
|
||||||
|
"""Return (block dim, kernel blocks per allocator block)."""
|
||||||
|
for dim in range(min(2, tensor.dim())):
|
||||||
|
if tensor.shape[dim] >= num_blocks and tensor.shape[dim] % num_blocks == 0:
|
||||||
|
return dim, tensor.shape[dim] // num_blocks
|
||||||
|
raise ValueError(f"No block dim of size {num_blocks} in {tuple(tensor.shape)}")
|
||||||
|
|
||||||
|
|
||||||
|
@torch.inference_mode()
|
||||||
|
def zero_kv_blocks(
|
||||||
|
static_forward_context: dict, kv_cache_groups, batch: BenchBatch, num_blocks: int
|
||||||
|
) -> None:
|
||||||
|
for group, ranges in zip(kv_cache_groups, batch.group_block_ranges):
|
||||||
|
for name in group.layer_names:
|
||||||
|
layer = static_forward_context[name]
|
||||||
|
for tensor in _tensors(getattr(layer, "kv_cache", None)):
|
||||||
|
dim, factor = _block_dim(tensor, num_blocks)
|
||||||
|
for begin, end in ranges:
|
||||||
|
tensor.narrow(dim, begin * factor, (end - begin) * factor).zero_()
|
||||||
|
|
||||||
|
|
||||||
|
def touched_kv_views(
|
||||||
|
static_forward_context: dict,
|
||||||
|
kv_cache_groups,
|
||||||
|
batch: BenchBatch,
|
||||||
|
num_blocks: int,
|
||||||
|
dcp_size: int,
|
||||||
|
) -> list[torch.Tensor]:
|
||||||
|
"""Views of the KV blocks a step may write: each request's last
|
||||||
|
ceil(scheduled / block_size) + 2 blocks, in every group."""
|
||||||
|
scheduled_tokens = [batch.expected[req_id][0] for req_id in batch.req_ids]
|
||||||
|
views = []
|
||||||
|
for group, ranges in zip(kv_cache_groups, batch.group_block_ranges):
|
||||||
|
block_size = kv_block_size(group.kv_cache_spec, dcp_size)
|
||||||
|
for name in group.layer_names:
|
||||||
|
layer = static_forward_context[name]
|
||||||
|
for tensor in _tensors(getattr(layer, "kv_cache", None)):
|
||||||
|
dim, factor = _block_dim(tensor, num_blocks)
|
||||||
|
for (begin, end), tokens in zip(ranges, scheduled_tokens):
|
||||||
|
first = max(begin, end - (-(-tokens // block_size) + 2))
|
||||||
|
views.append(
|
||||||
|
tensor.narrow(dim, first * factor, (end - first) * factor)
|
||||||
|
)
|
||||||
|
return views
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
"""Benchmark the model forward of exact batches as one-shot CUDA graphs.
|
||||||
|
|
||||||
|
Each case is a list of requests `(query_len, kv_len, num_draft_tokens)`
|
||||||
|
per DP rank. The bench builds the batch without the scheduler, runs it through
|
||||||
|
the real `execute_model` (input prep, attention metadata, DP sync), and times
|
||||||
|
the target-model forward captured as a CUDA graph. Sampling, the speculator
|
||||||
|
and CPU step overhead aren't measured.
|
||||||
|
|
||||||
|
Single GPU: `vllm bench forward --model M --case decode=32*1@8192`.
|
||||||
|
Multi-GPU: `torchrun --nproc-per-node=N -m vllm.entrypoints.cli.main bench
|
||||||
|
forward ...`, one process per GPU.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from dataclasses import asdict
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from vllm.benchmarks.forward.shapes import (
|
||||||
|
BenchCase,
|
||||||
|
jitter_kv_lens,
|
||||||
|
load_cases,
|
||||||
|
parse_case,
|
||||||
|
)
|
||||||
|
from vllm.engine.arg_utils import EngineArgs
|
||||||
|
from vllm.logger import init_logger
|
||||||
|
from vllm.utils.argparse_utils import FlexibleArgumentParser
|
||||||
|
|
||||||
|
logger = init_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def add_cli_args(parser: FlexibleArgumentParser) -> None:
|
||||||
|
parser.add_argument(
|
||||||
|
"--case",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="`NAME=REQS`, where REQS is a comma list of `[N*]Q[@K][c][:D]` "
|
||||||
|
"(query length Q, KV length K including the query (default Q), D draft "
|
||||||
|
"tokens, N requests). A prefill's prefix is a prefix-cache hit unless c "
|
||||||
|
"marks a chunked prefill. E.g. `decode=32*1@8192`, "
|
||||||
|
"`verify=16*4@4096:3`, `prefill=8192`, `hit=2048@10240`, "
|
||||||
|
"`mix=8192,32*1@8192`. Repeatable.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--shapes", help="JSON or YAML file with cases.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--kv-len-jitter",
|
||||||
|
type=float,
|
||||||
|
default=0.5,
|
||||||
|
help="Draw each request's prefix (KV length minus query length) "
|
||||||
|
"uniformly within +-this fraction of its value, like the varied lengths "
|
||||||
|
"of a real batch, capped at the max model length. Seeded by --seed only, "
|
||||||
|
"so every DP rank gets the same lengths. 0 keeps the exact lengths.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--kv-init",
|
||||||
|
choices=["zeros"],
|
||||||
|
default="zeros",
|
||||||
|
help="Initial KV cache contents of each case's blocks.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--routing",
|
||||||
|
choices=["auto", "real", "balanced"],
|
||||||
|
default="auto",
|
||||||
|
help="MoE routing. balanced: fixed routing with equal load per EP rank. "
|
||||||
|
"auto: balanced with dummy weights, else real.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--iters", type=int, default=20, help="Replays per sample.")
|
||||||
|
parser.add_argument("--samples", type=int, default=5)
|
||||||
|
parser.add_argument("--output-json", help="Write results to this file.")
|
||||||
|
EngineArgs.add_cli_args(parser)
|
||||||
|
parser.set_defaults(load_format="dummy", enable_prefix_caching=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_env() -> None:
|
||||||
|
os.environ["VLLM_USE_V2_MODEL_RUNNER"] = "1"
|
||||||
|
os.environ["VLLM_ENABLE_V1_MULTIPROCESSING"] = "0"
|
||||||
|
# Each case's eager pass compiles exactly the shapes it needs.
|
||||||
|
os.environ.setdefault("VLLM_DEEP_GEMM_WARMUP", "skip")
|
||||||
|
if "RANK" in os.environ:
|
||||||
|
return # Launched by torchrun.
|
||||||
|
with socket.socket() as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
port = s.getsockname()[1]
|
||||||
|
os.environ.update(
|
||||||
|
RANK="0",
|
||||||
|
LOCAL_RANK="0",
|
||||||
|
WORLD_SIZE="1",
|
||||||
|
MASTER_ADDR="127.0.0.1",
|
||||||
|
MASTER_PORT=str(port),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _register_dummy_loader() -> str:
|
||||||
|
"""Dummy loader that also runs load-time model finalization.
|
||||||
|
|
||||||
|
TODO(woosuk): Remove once #60096 lands. DeepSeek-V4.1 fuses its MegaMoE
|
||||||
|
shared experts at the end of `load_weights`, before the generic linear
|
||||||
|
post-processing repacks their scales. Dummy loading never calls
|
||||||
|
`load_weights`, so the fusion would fail and the bench would time unfused
|
||||||
|
shared experts.
|
||||||
|
"""
|
||||||
|
from vllm.model_executor.model_loader import register_model_loader
|
||||||
|
from vllm.model_executor.model_loader.dummy_loader import DummyModelLoader
|
||||||
|
|
||||||
|
name = "forward_bench_dummy"
|
||||||
|
|
||||||
|
@register_model_loader(name)
|
||||||
|
class ForwardBenchDummyLoader(DummyModelLoader):
|
||||||
|
def load_weights(self, model, model_config) -> None:
|
||||||
|
super().load_weights(model, model_config)
|
||||||
|
if any(hasattr(m, "finalize_mega_moe_weights") for m in model.modules()):
|
||||||
|
model.process_weights_after_loading()
|
||||||
|
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engine_args(args: argparse.Namespace, cases: list[BenchCase]):
|
||||||
|
from vllm.config.compilation import CompilationMode, CUDAGraphMode
|
||||||
|
|
||||||
|
engine_args = EngineArgs.from_cli_args(args)
|
||||||
|
if engine_args.load_format == "dummy":
|
||||||
|
engine_args.load_format = _register_dummy_loader()
|
||||||
|
engine_args.distributed_executor_backend = "external_launcher"
|
||||||
|
engine_args.enable_prefix_caching = False
|
||||||
|
engine_args.compilation_config.mode = CompilationMode.NONE
|
||||||
|
engine_args.compilation_config.cudagraph_mode = CUDAGraphMode.NONE
|
||||||
|
if engine_args.max_num_seqs is None:
|
||||||
|
engine_args.max_num_seqs = max(len(c.requests) for c in cases)
|
||||||
|
if engine_args.max_num_batched_tokens is None:
|
||||||
|
# Prefix hits may replay up to a window more per request.
|
||||||
|
engine_args.max_num_batched_tokens = max(
|
||||||
|
8192,
|
||||||
|
*(
|
||||||
|
c.num_tokens + 1024 * sum(r.prefix_hit for r in c.requests)
|
||||||
|
for c in cases
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return engine_args
|
||||||
|
|
||||||
|
|
||||||
|
def run_case(runner, case: BenchCase, args: argparse.Namespace) -> dict[str, Any]:
|
||||||
|
from vllm.benchmarks.forward.batch import build_batch
|
||||||
|
from vllm.benchmarks.forward.capture import ForwardBench
|
||||||
|
from vllm.benchmarks.forward.kv_init import touched_kv_views, zero_kv_blocks
|
||||||
|
from vllm.benchmarks.forward.routing import install_routing
|
||||||
|
|
||||||
|
context = runner.vllm_config.compilation_config.static_forward_context
|
||||||
|
kv_config = runner.kv_cache_config
|
||||||
|
dcp_size = runner.parallel_config.decode_context_parallel_size
|
||||||
|
rng = np.random.default_rng([args.seed, runner.dp_rank])
|
||||||
|
batch = build_batch(
|
||||||
|
case,
|
||||||
|
kv_config.kv_cache_groups,
|
||||||
|
kv_config.num_blocks,
|
||||||
|
runner.model_config.get_vocab_size(),
|
||||||
|
rng,
|
||||||
|
dcp_size,
|
||||||
|
)
|
||||||
|
zero_kv_blocks(context, kv_config.kv_cache_groups, batch, kv_config.num_blocks)
|
||||||
|
bench = ForwardBench(
|
||||||
|
runner,
|
||||||
|
batch,
|
||||||
|
args.iters,
|
||||||
|
args.samples,
|
||||||
|
touched_kv_views(
|
||||||
|
context, kv_config.kv_cache_groups, batch, kv_config.num_blocks, dcp_size
|
||||||
|
),
|
||||||
|
)
|
||||||
|
routing, balanced = install_routing(
|
||||||
|
runner, args.routing, args.load_format == "dummy", batch.num_tokens, args.seed
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = bench.run()
|
||||||
|
if balanced is not None:
|
||||||
|
balanced.check_applied()
|
||||||
|
finally:
|
||||||
|
runner.execute_model(batch.finish())
|
||||||
|
if balanced is not None:
|
||||||
|
balanced.remove()
|
||||||
|
return {
|
||||||
|
"num_tokens": batch.num_tokens,
|
||||||
|
"kv_blocks": batch.num_blocks,
|
||||||
|
"routing": routing,
|
||||||
|
**result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(args: argparse.Namespace) -> None:
|
||||||
|
cases = [parse_case(c) for c in args.case]
|
||||||
|
if args.shapes:
|
||||||
|
cases += load_cases(args.shapes)
|
||||||
|
if not cases:
|
||||||
|
raise ValueError("Pass at least one --case or --shapes.")
|
||||||
|
if not 0 <= args.kv_len_jitter < 1:
|
||||||
|
raise ValueError("--kv-len-jitter must be in [0, 1).")
|
||||||
|
_setup_env()
|
||||||
|
|
||||||
|
from vllm import LLM
|
||||||
|
from vllm.config import set_current_vllm_config
|
||||||
|
from vllm.distributed.parallel_state import get_world_group
|
||||||
|
|
||||||
|
llm = LLM.from_engine_args(_make_engine_args(args, cases))
|
||||||
|
runner = llm.llm_engine.model_executor.driver_worker.worker.model_runner
|
||||||
|
# Same draw on every rank: shapes are balanced across DP ranks.
|
||||||
|
max_kv_len = runner.model_config.max_model_len
|
||||||
|
cases = [
|
||||||
|
jitter_kv_lens(c, args.kv_len_jitter, max_kv_len, args.seed) for c in cases
|
||||||
|
]
|
||||||
|
world = get_world_group()
|
||||||
|
results = []
|
||||||
|
with torch.inference_mode(), set_current_vllm_config(runner.vllm_config):
|
||||||
|
for case in cases:
|
||||||
|
result = run_case(runner, case, args)
|
||||||
|
gathered: list[Any] = [None] * world.world_size
|
||||||
|
torch.distributed.all_gather_object(gathered, result, group=world.cpu_group)
|
||||||
|
if world.rank != 0:
|
||||||
|
continue
|
||||||
|
rank0 = gathered[0]
|
||||||
|
latency = rank0["latency_us"] # Each replay is the max over ranks.
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"case": case.name,
|
||||||
|
"requests": [asdict(r) for r in case.requests],
|
||||||
|
"median_us": latency["median"],
|
||||||
|
"ranks": gathered,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"[forward] {case.name}: {len(case.requests)} reqs, "
|
||||||
|
f"{rank0['num_tokens']} tokens, median {latency['median']:.1f} us, "
|
||||||
|
f"sample spread {latency['sample_spread_pct']:.2f}%, graph vs "
|
||||||
|
f"eager rel rms {rank0['graph_vs_eager']['relative_rms']:.1e}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
if latency["sample_spread_pct"] > 0.5:
|
||||||
|
logger.warning(
|
||||||
|
"%s: unstable timing (sample medians spread %.2f%%).",
|
||||||
|
case.name,
|
||||||
|
latency["sample_spread_pct"],
|
||||||
|
)
|
||||||
|
if world.rank == 0 and args.output_json:
|
||||||
|
with open(args.output_json, "w") as f:
|
||||||
|
json.dump(results, f, indent=2)
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
"""Balanced MoE routing for benchmarks with dummy weights.
|
||||||
|
|
||||||
|
Dummy gate weights give degenerate routing, which skews MoE and all-to-all
|
||||||
|
cost. The routing still runs; its result is replaced by the fixture at
|
||||||
|
whichever boundary the layer exposes:
|
||||||
|
|
||||||
|
- Gate output: fixed logits whose top-k is the fixture. Covers every backend
|
||||||
|
that routes from logits, including monolithic kernels that run top-k
|
||||||
|
internally (e.g. FlashInfer TRTLLM).
|
||||||
|
- Experts input: models that compute top-k themselves and pass
|
||||||
|
`topk_ids` to their experts (e.g. MegaMoE with a fused gate) get the
|
||||||
|
fixture's IDs. The routing weights are kept; they don't affect cost.
|
||||||
|
|
||||||
|
Hash-routed layers ignore logits and keep their tables unless their experts
|
||||||
|
take `topk_ids`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from vllm.logger import init_logger
|
||||||
|
from vllm.model_executor.layers.fused_moe.router.gate_linear import GateLinear
|
||||||
|
|
||||||
|
logger = init_logger(__name__)
|
||||||
|
|
||||||
|
# Fixture logits: chosen experts score far above the rest under any monotonic
|
||||||
|
# scoring function, so top-k picks exactly them.
|
||||||
|
_CHOSEN_LOGIT = 4.0
|
||||||
|
_OTHER_LOGIT = -4.0
|
||||||
|
|
||||||
|
|
||||||
|
def balanced_topk_ids(
|
||||||
|
num_tokens: int, num_experts: int, top_k: int, ep_size: int, rng
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Expert IDs [num_tokens, top_k] giving every EP rank equal load.
|
||||||
|
|
||||||
|
Each token's slots go round-robin over EP ranks, to distinct experts.
|
||||||
|
"""
|
||||||
|
assert num_experts % ep_size == 0
|
||||||
|
local = num_experts // ep_size
|
||||||
|
slots = np.arange(num_tokens)[:, None] * top_k + np.arange(top_k)
|
||||||
|
ranks = slots % ep_size
|
||||||
|
# Slots of one token that land on the same rank take consecutive experts.
|
||||||
|
repeat = np.zeros_like(ranks)
|
||||||
|
for j in range(1, top_k):
|
||||||
|
repeat[:, j] = (ranks[:, :j] == ranks[:, j : j + 1]).sum(axis=1)
|
||||||
|
assert repeat.max() < local
|
||||||
|
base = rng.integers(local, size=(num_tokens, ep_size))
|
||||||
|
offset = np.take_along_axis(base, ranks, axis=1)
|
||||||
|
return ranks * local + (offset + repeat) % local
|
||||||
|
|
||||||
|
|
||||||
|
def _moe_layers(model: torch.nn.Module) -> list[tuple[torch.nn.Module, GateLinear]]:
|
||||||
|
"""(MoE module, gate) per layer, in model order."""
|
||||||
|
layers: dict[GateLinear, torch.nn.Module] = {}
|
||||||
|
for module in model.modules():
|
||||||
|
gate = getattr(module, "gate", None)
|
||||||
|
if not isinstance(gate, GateLinear) or gate in layers:
|
||||||
|
continue
|
||||||
|
if _top_k(module) is not None:
|
||||||
|
layers[gate] = module
|
||||||
|
return [(module, gate) for gate, module in layers.items()]
|
||||||
|
|
||||||
|
|
||||||
|
def _top_k(module: torch.nn.Module) -> int | None:
|
||||||
|
"""The MoE layer's top-k, from the module or its experts submodules."""
|
||||||
|
for sub in module.modules():
|
||||||
|
for name in ("top_k", "n_activated_experts"):
|
||||||
|
top_k = getattr(sub, name, None)
|
||||||
|
if isinstance(top_k, int):
|
||||||
|
return top_k
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class BalancedRouting:
|
||||||
|
"""Installs balanced routing for one case; `remove()` restores the model."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
model: torch.nn.Module,
|
||||||
|
num_tokens: int,
|
||||||
|
dp_rank: int,
|
||||||
|
dp_size: int,
|
||||||
|
ep_size: int,
|
||||||
|
seed: int,
|
||||||
|
device: torch.device,
|
||||||
|
):
|
||||||
|
layers = _moe_layers(model)
|
||||||
|
if not layers:
|
||||||
|
raise ValueError("No MoE gates found; use --routing real.")
|
||||||
|
self._hooks = []
|
||||||
|
self._biases: list[tuple[torch.Tensor, torch.Tensor]] = []
|
||||||
|
self._fired = [0] * len(layers)
|
||||||
|
self._layers = layers
|
||||||
|
# Per-rank rows, with headroom for padding; every rank generates the
|
||||||
|
# same global fixture and takes its slice.
|
||||||
|
rows = -(-num_tokens // 256) * 256 + 256
|
||||||
|
for layer, (module, gate) in enumerate(layers):
|
||||||
|
if (getattr(module, "num_expert_group", None) or 1) > 1:
|
||||||
|
raise NotImplementedError("Balanced routing with grouped top-k.")
|
||||||
|
num_experts = gate.weight.shape[0]
|
||||||
|
top_k = _top_k(module)
|
||||||
|
assert top_k is not None
|
||||||
|
rng = np.random.default_rng([seed, layer])
|
||||||
|
ids = balanced_topk_ids(rows * dp_size, num_experts, top_k, ep_size, rng)[
|
||||||
|
dp_rank * rows : (dp_rank + 1) * rows
|
||||||
|
]
|
||||||
|
logits = torch.full((rows, num_experts), _OTHER_LOGIT, dtype=torch.float32)
|
||||||
|
logits.scatter_(1, torch.from_numpy(ids), _CHOSEN_LOGIT)
|
||||||
|
# Distinct values: no ties for any top-k implementation.
|
||||||
|
logits += torch.from_numpy(rng.uniform(0, 0.5, logits.shape)).float()
|
||||||
|
self._hooks.append(
|
||||||
|
gate.register_forward_hook(
|
||||||
|
self._substitute(logits.to(device), self._fired, layer)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
bias = getattr(gate, "e_score_correction_bias", None)
|
||||||
|
if bias is None:
|
||||||
|
bias = getattr(module, "e_score_correction_bias", None)
|
||||||
|
if isinstance(bias, torch.Tensor):
|
||||||
|
self._biases.append((bias, bias.detach().clone()))
|
||||||
|
bias.data.zero_()
|
||||||
|
experts = getattr(module, "experts", None)
|
||||||
|
if isinstance(experts, torch.nn.Module):
|
||||||
|
params = list(inspect.signature(experts.forward).parameters)
|
||||||
|
if "topk_ids" in params:
|
||||||
|
self._hooks.append(
|
||||||
|
experts.register_forward_pre_hook(
|
||||||
|
self._substitute_ids(
|
||||||
|
torch.from_numpy(ids).to(device),
|
||||||
|
params.index("topk_ids"),
|
||||||
|
self._fired,
|
||||||
|
layer,
|
||||||
|
),
|
||||||
|
with_kwargs=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _substitute(fixture: torch.Tensor, fired: list[int], layer: int):
|
||||||
|
cast = [fixture]
|
||||||
|
|
||||||
|
def hook(module, inputs, output):
|
||||||
|
fired[layer] += 1
|
||||||
|
logits = output[0] if isinstance(output, tuple) else output
|
||||||
|
if cast[0].dtype != logits.dtype:
|
||||||
|
cast[0] = fixture.to(logits.dtype) # Once, at warmup.
|
||||||
|
replaced = cast[0][: logits.shape[0]]
|
||||||
|
return (replaced, *output[1:]) if isinstance(output, tuple) else replaced
|
||||||
|
|
||||||
|
return hook
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _substitute_ids(fixture: torch.Tensor, index: int, fired, layer: int):
|
||||||
|
cast = [fixture]
|
||||||
|
|
||||||
|
def hook(module, args, kwargs):
|
||||||
|
fired[layer] += 1
|
||||||
|
ids = kwargs["topk_ids"] if "topk_ids" in kwargs else args[index]
|
||||||
|
if cast[0].dtype != ids.dtype:
|
||||||
|
cast[0] = fixture.to(ids.dtype) # Once, at warmup.
|
||||||
|
replaced = cast[0][: ids.shape[0]]
|
||||||
|
if "topk_ids" in kwargs:
|
||||||
|
return args, {**kwargs, "topk_ids": replaced}
|
||||||
|
return (*args[:index], replaced, *args[index + 1 :]), kwargs
|
||||||
|
|
||||||
|
return hook
|
||||||
|
|
||||||
|
def check_applied(self) -> None:
|
||||||
|
"""Fail if a layer bypassed both the gate and the experts hooks."""
|
||||||
|
missed = [
|
||||||
|
type(module).__name__
|
||||||
|
for (module, _), fired in zip(self._layers, self._fired)
|
||||||
|
if not fired
|
||||||
|
]
|
||||||
|
if missed:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Balanced routing didn't reach {len(missed)} MoE layers "
|
||||||
|
f"({missed[0]}): their gate is fused away. Use --routing real."
|
||||||
|
)
|
||||||
|
|
||||||
|
def remove(self) -> None:
|
||||||
|
for hook in self._hooks:
|
||||||
|
hook.remove()
|
||||||
|
for bias, saved in self._biases:
|
||||||
|
bias.data.copy_(saved)
|
||||||
|
|
||||||
|
|
||||||
|
def install_routing(
|
||||||
|
runner, requested: str, dummy_weights: bool, num_tokens: int, seed: int
|
||||||
|
) -> tuple[str, BalancedRouting | None]:
|
||||||
|
"""Resolve `--routing` and install balanced routing if selected.
|
||||||
|
|
||||||
|
`auto` is balanced with dummy weights and real otherwise; it falls back to
|
||||||
|
real routing when the model doesn't support the fixture.
|
||||||
|
"""
|
||||||
|
mode = requested
|
||||||
|
if mode == "auto":
|
||||||
|
mode = "balanced" if dummy_weights else "real"
|
||||||
|
if mode == "real":
|
||||||
|
return mode, None
|
||||||
|
parallel = runner.parallel_config
|
||||||
|
ep_size = (
|
||||||
|
parallel.data_parallel_size * parallel.tensor_parallel_size
|
||||||
|
if parallel.enable_expert_parallel
|
||||||
|
else 1
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
routing = BalancedRouting(
|
||||||
|
runner.model,
|
||||||
|
num_tokens,
|
||||||
|
runner.dp_rank,
|
||||||
|
runner.dp_size,
|
||||||
|
ep_size,
|
||||||
|
seed,
|
||||||
|
runner.device,
|
||||||
|
)
|
||||||
|
except (ValueError, NotImplementedError) as e:
|
||||||
|
if requested == "balanced":
|
||||||
|
raise
|
||||||
|
logger.warning("Using real routing: %s", e)
|
||||||
|
return "real", None
|
||||||
|
return mode, routing
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
"""Exact batch shapes for `vllm bench forward`."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import zlib
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import regex as re
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BenchRequest:
|
||||||
|
query_len: int
|
||||||
|
# KV length after the step: the prefix already in the KV cache plus the
|
||||||
|
# query tokens.
|
||||||
|
kv_len: int
|
||||||
|
# Spec-decode verify: query_len == 1 + num_draft_tokens.
|
||||||
|
num_draft_tokens: int = 0
|
||||||
|
# The prefix was computed by this request's earlier chunks. Otherwise a
|
||||||
|
# prefill's prefix is a prefix-cache hit, of which models with SWA bounded
|
||||||
|
# replay (DeepSeek-V4.1) recompute the last window.
|
||||||
|
chunked: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if (
|
||||||
|
self.query_len < 1
|
||||||
|
or self.kv_len < self.query_len
|
||||||
|
or self.num_draft_tokens < 0
|
||||||
|
):
|
||||||
|
raise ValueError(f"Invalid request: {self}")
|
||||||
|
if self.num_draft_tokens and not self.is_decode:
|
||||||
|
raise ValueError(
|
||||||
|
f"Verify needs query_len == 1 + num_draft_tokens and a prefix: {self}"
|
||||||
|
)
|
||||||
|
if self.chunked and (self.is_decode or not self.prefix_len):
|
||||||
|
raise ValueError(f"A chunk needs a prefill with a prefix: {self}")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prefix_len(self) -> int:
|
||||||
|
return self.kv_len - self.query_len
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_decode(self) -> bool:
|
||||||
|
"""Decode or verify; otherwise a prefill."""
|
||||||
|
return self.prefix_len > 0 and self.query_len == 1 + self.num_draft_tokens
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prefix_hit(self) -> bool:
|
||||||
|
return self.prefix_len > 0 and not self.is_decode and not self.chunked
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BenchCase:
|
||||||
|
name: str
|
||||||
|
requests: tuple[BenchRequest, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def num_tokens(self) -> int:
|
||||||
|
return sum(r.query_len for r in self.requests)
|
||||||
|
|
||||||
|
|
||||||
|
def jitter_kv_lens(
|
||||||
|
case: BenchCase, jitter: float, max_kv_len: int, seed: int
|
||||||
|
) -> BenchCase:
|
||||||
|
"""Draw each request's prefix uniformly within +-jitter of its value.
|
||||||
|
|
||||||
|
Query lengths are kept, so a fresh prefill stays one and a non-empty
|
||||||
|
prefix stays non-empty. KV lengths are capped at `max_kv_len`. The draw
|
||||||
|
depends only on the seed and the case's name, not on the other cases.
|
||||||
|
"""
|
||||||
|
rng = np.random.default_rng([seed, zlib.crc32(case.name.encode())])
|
||||||
|
requests = []
|
||||||
|
for req in case.requests:
|
||||||
|
if req.kv_len > max_kv_len:
|
||||||
|
raise ValueError(f"{case.name}: KV length {req.kv_len} > {max_kv_len}")
|
||||||
|
prefix = req.prefix_len
|
||||||
|
if prefix and jitter:
|
||||||
|
prefix = max(1, round(prefix * rng.uniform(1 - jitter, 1 + jitter)))
|
||||||
|
kv_len = min(req.query_len + prefix, max_kv_len)
|
||||||
|
requests.append(replace(req, kv_len=kv_len))
|
||||||
|
return replace(case, requests=tuple(requests))
|
||||||
|
|
||||||
|
|
||||||
|
_REQ_RE = re.compile(r"^(?:(\d+)\*)?(\d+)(?:@(\d+))?(c)?(?::(\d+))?$")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_case(text: str) -> BenchCase:
|
||||||
|
"""Parse `NAME=REQS`, where REQS is a comma list of `[N*]Q[@K][c][:D]`.
|
||||||
|
|
||||||
|
Q is the query length, K the KV length including the query (default Q, a
|
||||||
|
fresh prefill), D the number of draft tokens (verify) and N the number of
|
||||||
|
such requests. A prefill's prefix is a prefix-cache hit unless `c` marks
|
||||||
|
it as a chunked prefill. For example, `decode=32*1@8192`,
|
||||||
|
`verify=16*4@4096:3`, `prefill=8192`, `hit=8192@40960`,
|
||||||
|
`chunk=8192@40960c` or `mix=8192,32*1@8192`.
|
||||||
|
"""
|
||||||
|
name, sep, body = text.partition("=")
|
||||||
|
if not sep:
|
||||||
|
name, body = text, text
|
||||||
|
requests: list[BenchRequest] = []
|
||||||
|
for item in body.split(","):
|
||||||
|
match = _REQ_RE.match(item.strip())
|
||||||
|
if match is None:
|
||||||
|
raise ValueError(f"Invalid request {item!r} in case {text!r}")
|
||||||
|
count, query, kv, chunked, drafts = match.groups()
|
||||||
|
request = BenchRequest(
|
||||||
|
int(query), int(kv or query), int(drafts or 0), chunked=bool(chunked)
|
||||||
|
)
|
||||||
|
requests.extend([request] * int(count or 1))
|
||||||
|
return BenchCase(name, tuple(requests))
|
||||||
|
|
||||||
|
|
||||||
|
def load_cases(path: str) -> list[BenchCase]:
|
||||||
|
"""Load cases from a JSON or YAML file.
|
||||||
|
|
||||||
|
Format: `{"cases": [{"name": ..., "requests": [{"query_len": ...,
|
||||||
|
"kv_len": ..., "num_draft_tokens": ..., "chunked": ..., "count": ...}]}]}`.
|
||||||
|
"""
|
||||||
|
text = Path(path).read_text()
|
||||||
|
if path.endswith((".yaml", ".yml")):
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
data = yaml.safe_load(text)
|
||||||
|
else:
|
||||||
|
data = json.loads(text)
|
||||||
|
cases = []
|
||||||
|
for case in data["cases"]:
|
||||||
|
requests: list[BenchRequest] = []
|
||||||
|
for req in case["requests"]:
|
||||||
|
req = dict(req)
|
||||||
|
count = req.pop("count", 1)
|
||||||
|
requests.extend([BenchRequest(**req)] * count)
|
||||||
|
cases.append(BenchCase(case["name"], tuple(requests)))
|
||||||
|
return cases
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from vllm.benchmarks.forward.main import add_cli_args, main
|
||||||
|
from vllm.entrypoints.cli.benchmark.base import BenchmarkSubcommandBase
|
||||||
|
from vllm.utils.argparse_utils import FlexibleArgumentParser
|
||||||
|
|
||||||
|
|
||||||
|
class BenchmarkForwardSubcommand(BenchmarkSubcommandBase):
|
||||||
|
"""The `forward` subcommand for `vllm bench`."""
|
||||||
|
|
||||||
|
name = "forward"
|
||||||
|
help = "Benchmark the model forward of exact batches as CUDA graphs."
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_cli_args(cls, parser: FlexibleArgumentParser) -> None:
|
||||||
|
add_cli_args(parser)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cmd(args: argparse.Namespace) -> None:
|
||||||
|
main(args)
|
||||||
@@ -38,6 +38,7 @@ def maybe_exec_rust_bench() -> None:
|
|||||||
def _import_bench_subcommand_modules() -> None:
|
def _import_bench_subcommand_modules() -> None:
|
||||||
# Imported lazily so `BenchmarkSubcommandBase` subclasses register only
|
# Imported lazily so `BenchmarkSubcommandBase` subclasses register only
|
||||||
# when `vllm bench` is actually invoked.
|
# when `vllm bench` is actually invoked.
|
||||||
|
import vllm.entrypoints.cli.benchmark.forward # noqa: F401
|
||||||
import vllm.entrypoints.cli.benchmark.latency # noqa: F401
|
import vllm.entrypoints.cli.benchmark.latency # noqa: F401
|
||||||
import vllm.entrypoints.cli.benchmark.mm_processor # noqa: F401
|
import vllm.entrypoints.cli.benchmark.mm_processor # noqa: F401
|
||||||
import vllm.entrypoints.cli.benchmark.serve # noqa: F401
|
import vllm.entrypoints.cli.benchmark.serve # noqa: F401
|
||||||
|
|||||||
Reference in New Issue
Block a user