[Benchmark] Add per-case kernel profiles to vllm bench forward (#61692)
Signed-off-by: Woosuk Kwon <woosuk@inferact.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: vllm-agent <claw@inferact.ai>
This commit is contained in:
co-authored by
Claude Opus 5.5
vllm-agent
parent
c61a1e73c6
commit
c0b6f74bee
@@ -51,6 +51,46 @@ Key flags (plus all `EngineArgs`):
|
|||||||
|
|
||||||
Read `median_us` (each replay is already the max over ranks).
|
Read `median_us` (each replay is already the max over ranks).
|
||||||
|
|
||||||
|
## Kernel breakdown
|
||||||
|
|
||||||
|
Add `--profile-dir DIR` to see where the time goes. After timing, each case is
|
||||||
|
replayed 3 more times under `torch.profiler`; every rank writes
|
||||||
|
`DIR/<case>.rank<r>.kernels.json` (GPU time per kernel per forward, sorted)
|
||||||
|
and `DIR/<case>.rank<r>.trace.json.gz` (open in Perfetto). Rank 0's top
|
||||||
|
kernels are printed and included in `--output-json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m vllm.benchmarks.forward.profile DIR/decode.rank0.kernels.json
|
||||||
|
# Before/after a change, same case: per-kernel deltas, largest first.
|
||||||
|
python -m vllm.benchmarks.forward.profile base/decode.rank0.kernels.json new/decode.rank0.kernels.json
|
||||||
|
```
|
||||||
|
|
||||||
|
- Kernels are grouped by name without template or function arguments; the
|
||||||
|
JSON keeps one full name per group in `example`.
|
||||||
|
- Only the timed forward is included: the in-graph KV restore and rank
|
||||||
|
rendezvous are excluded (`pre_forward_us`). `span_us` (first to last
|
||||||
|
forward kernel) should be close to `median_us`, a bit higher from profiler
|
||||||
|
overhead; `busy_us` can exceed it when streams overlap.
|
||||||
|
- Compare the same rank across runs: under EP, ranks differ (e.g. collective
|
||||||
|
wait time shows up inside communication kernels).
|
||||||
|
|
||||||
|
For Nsight Systems (NVTX, NCCL, CPU side, GPU metrics), skip `--profile-dir`:
|
||||||
|
the two can't share CUPTI. Each case's 3 profiled replays run between
|
||||||
|
`cudaProfilerStart`/`Stop` in an NVTX range `forward/<case>`, so a capture range
|
||||||
|
records only them, one report per case (and rank):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nsys profile --capture-range=cudaProfilerApi --capture-range-end=repeat \
|
||||||
|
--cuda-graph-trace=node -o prof vllm bench forward ... # prof.1, prof.2, ...
|
||||||
|
torchrun --nproc-per-node=4 --no-python nsys profile ... -o prof_rank%q{RANK} \
|
||||||
|
python -m vllm.entrypoints.cli.main bench forward ...
|
||||||
|
nsys stats --report cuda_gpu_kern_sum --format csv -o kern prof.1.nsys-rep
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `--capture-range-end=repeat`: nsys's default stops after the first case
|
||||||
|
and leaves the bench hanging. `nsys stats` also counts the KV restore copies
|
||||||
|
that the JSON summary excludes.
|
||||||
|
|
||||||
## Caveats
|
## Caveats
|
||||||
|
|
||||||
- **Dummy weights lie for data-dependent kernels.** Dummy gates route every
|
- **Dummy weights lie for data-dependent kernels.** Dummy gates route every
|
||||||
|
|||||||
+1
-1
@@ -136,7 +136,7 @@ vllm bench {forward, latency, serve, throughput}
|
|||||||
|
|
||||||
### forward
|
### 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.
|
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. Add `--profile-dir DIR` to write a Perfetto trace and a per-kernel GPU time summary of each case; `python -m vllm.benchmarks.forward.profile A.kernels.json [B.kernels.json]` prints a summary or compares two. Under Nsight Systems, `nsys profile --capture-range=cudaProfilerApi --capture-range-end=repeat --cuda-graph-trace=node` records just those replays, one report per case.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vllm bench forward \
|
vllm bench forward \
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -10,6 +11,7 @@ import pytest
|
|||||||
|
|
||||||
from vllm.benchmarks.forward.batch import build_batch
|
from vllm.benchmarks.forward.batch import build_batch
|
||||||
from vllm.benchmarks.forward.main import run_case
|
from vllm.benchmarks.forward.main import run_case
|
||||||
|
from vllm.benchmarks.forward.profile import summarize
|
||||||
from vllm.benchmarks.forward.shapes import jitter_kv_lens, parse_case
|
from vllm.benchmarks.forward.shapes import jitter_kv_lens, parse_case
|
||||||
from vllm.platforms import current_platform
|
from vllm.platforms import current_platform
|
||||||
|
|
||||||
@@ -97,6 +99,35 @@ def test_bench_forward_rejects_oversized_verify(capacity):
|
|||||||
run_case(runner, case, SimpleNamespace())
|
run_case(runner, case, SimpleNamespace())
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_summary():
|
||||||
|
"""Each replay's leading restore/rendezvous activities are left out, and
|
||||||
|
kernels are grouped by name without template or function arguments."""
|
||||||
|
|
||||||
|
def act(name, ts, dur, cat="kernel"):
|
||||||
|
return {"cat": cat, "name": name, "ts": ts, "dur": dur}
|
||||||
|
|
||||||
|
replay = [
|
||||||
|
("Memcpy DtoD (Device -> Device)", "gpu_memcpy", 5),
|
||||||
|
("void ns::gemm<128>(Params)", "kernel", 10),
|
||||||
|
("void ns::gemm<64>(Params)", "kernel", 20),
|
||||||
|
("std::enable_if<(1>0), void>::type ns::norm<float>(float*)", "kernel", 4),
|
||||||
|
]
|
||||||
|
events = [{"cat": "cpu_op", "name": "x", "ts": 0, "dur": 1}]
|
||||||
|
for r in range(2):
|
||||||
|
for i, (name, cat, dur) in enumerate(replay):
|
||||||
|
events.append(act(name, 1000 * r + 100 * i, dur, cat))
|
||||||
|
summary = summarize(events, replays=2, skip=1)
|
||||||
|
assert summary["pre_forward_us"] == 5
|
||||||
|
assert summary["busy_us"] == 34
|
||||||
|
assert summary["span_us"] == 204
|
||||||
|
assert [(k["name"], k["us"], k["calls"]) for k in summary["kernels"]] == [
|
||||||
|
("ns::gemm", 30, 2),
|
||||||
|
("ns::norm", 4, 1),
|
||||||
|
]
|
||||||
|
with pytest.raises(ValueError, match="replays"):
|
||||||
|
summarize(events[:-1], replays=2, skip=1)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
@pytest.mark.benchmark
|
||||||
# TODO(woosuk): Support ROCm and re-enable this test there.
|
# TODO(woosuk): Support ROCm and re-enable this test there.
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.skipif(
|
||||||
@@ -126,6 +157,8 @@ def test_bench_forward(tmp_path):
|
|||||||
"2",
|
"2",
|
||||||
"--output-json",
|
"--output-json",
|
||||||
str(output),
|
str(output),
|
||||||
|
"--profile-dir",
|
||||||
|
str(tmp_path / "profile"),
|
||||||
]
|
]
|
||||||
result = subprocess.run(command, capture_output=True, text=True)
|
result = subprocess.run(command, capture_output=True, text=True)
|
||||||
print(result.stdout)
|
print(result.stdout)
|
||||||
@@ -139,3 +172,7 @@ def test_bench_forward(tmp_path):
|
|||||||
assert len(rank0["batch_order"]) == len(row["requests"])
|
assert len(rank0["batch_order"]) == len(row["requests"])
|
||||||
assert rank0["finite"] and rank0["graph_deterministic"]
|
assert rank0["finite"] and rank0["graph_deterministic"]
|
||||||
assert row["median_us"] > 0
|
assert row["median_us"] > 0
|
||||||
|
profile = rank0["profile"]
|
||||||
|
assert profile["top_kernels"] and profile["span_us"] > 0
|
||||||
|
summary = json.loads(Path(profile["summary"]).read_text())
|
||||||
|
assert Path(summary["trace"]).exists()
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ class ForwardBench:
|
|||||||
iters: int,
|
iters: int,
|
||||||
samples: int,
|
samples: int,
|
||||||
state_views: list[torch.Tensor],
|
state_views: list[torch.Tensor],
|
||||||
|
name: str,
|
||||||
|
profile_prefix: str | None = None,
|
||||||
):
|
):
|
||||||
self.runner = runner
|
self.runner = runner
|
||||||
self.batch = batch
|
self.batch = batch
|
||||||
@@ -62,6 +64,8 @@ class ForwardBench:
|
|||||||
self.iters = iters
|
self.iters = iters
|
||||||
self.samples = samples
|
self.samples = samples
|
||||||
self.state_views = state_views
|
self.state_views = state_views
|
||||||
|
self.name = name
|
||||||
|
self.profile_prefix = profile_prefix
|
||||||
self.phase = "warmup"
|
self.phase = "warmup"
|
||||||
self.result: dict[str, Any] = {}
|
self.result: dict[str, Any] = {}
|
||||||
|
|
||||||
@@ -193,6 +197,20 @@ class ForwardBench:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.result["latency_us"] = self._time(graph)
|
self.result["latency_us"] = self._time(graph)
|
||||||
|
from vllm.benchmarks.forward.profile import profile_graph
|
||||||
|
|
||||||
|
# Each replay restores the state views, then (multi-rank) rendezvouses,
|
||||||
|
# before the timed forward.
|
||||||
|
skip = len(self.state_views) + (world.world_size > 1)
|
||||||
|
summary = profile_graph(graph, self.name, self.profile_prefix, skip)
|
||||||
|
if summary is not None:
|
||||||
|
self.result["profile"] = {
|
||||||
|
"summary": f"{self.profile_prefix}.kernels.json",
|
||||||
|
"span_us": summary["span_us"],
|
||||||
|
"busy_us": summary["busy_us"],
|
||||||
|
"activities": summary["activities"],
|
||||||
|
"top_kernels": summary["kernels"][:10],
|
||||||
|
}
|
||||||
del graph
|
del graph
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ def add_cli_args(parser: FlexibleArgumentParser) -> None:
|
|||||||
parser.add_argument("--iters", type=int, default=20, help="Replays per sample.")
|
parser.add_argument("--iters", type=int, default=20, help="Replays per sample.")
|
||||||
parser.add_argument("--samples", type=int, default=5)
|
parser.add_argument("--samples", type=int, default=5)
|
||||||
parser.add_argument("--output-json", help="Write results to this file.")
|
parser.add_argument("--output-json", help="Write results to this file.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--profile-dir",
|
||||||
|
help="After timing, trace a few replays of each case and write "
|
||||||
|
"`<case>.rank<r>.trace.json.gz` (Perfetto) and `<case>.rank<r>.kernels.json` "
|
||||||
|
"(GPU time per kernel per forward) here. Summarize or compare with "
|
||||||
|
"`python -m vllm.benchmarks.forward.profile A.kernels.json [B.kernels.json]`.",
|
||||||
|
)
|
||||||
EngineArgs.add_cli_args(parser)
|
EngineArgs.add_cli_args(parser)
|
||||||
parser.set_defaults(load_format="dummy", enable_prefix_caching=False)
|
parser.set_defaults(load_format="dummy", enable_prefix_caching=False)
|
||||||
|
|
||||||
@@ -146,6 +153,13 @@ def run_case(runner, case: BenchCase, args: argparse.Namespace) -> dict[str, Any
|
|||||||
dcp_size,
|
dcp_size,
|
||||||
)
|
)
|
||||||
zero_kv_blocks(context, kv_config.kv_cache_groups, batch, kv_config.num_blocks)
|
zero_kv_blocks(context, kv_config.kv_cache_groups, batch, kv_config.num_blocks)
|
||||||
|
profile_prefix = None
|
||||||
|
if args.profile_dir:
|
||||||
|
from vllm.distributed.parallel_state import get_world_group
|
||||||
|
|
||||||
|
os.makedirs(args.profile_dir, exist_ok=True)
|
||||||
|
rank = get_world_group().rank
|
||||||
|
profile_prefix = os.path.join(args.profile_dir, f"{case.name}.rank{rank}")
|
||||||
bench = ForwardBench(
|
bench = ForwardBench(
|
||||||
runner,
|
runner,
|
||||||
batch,
|
batch,
|
||||||
@@ -154,6 +168,8 @@ def run_case(runner, case: BenchCase, args: argparse.Namespace) -> dict[str, Any
|
|||||||
touched_kv_views(
|
touched_kv_views(
|
||||||
context, kv_config.kv_cache_groups, batch, kv_config.num_blocks, dcp_size
|
context, kv_config.kv_cache_groups, batch, kv_config.num_blocks, dcp_size
|
||||||
),
|
),
|
||||||
|
case.name,
|
||||||
|
profile_prefix,
|
||||||
)
|
)
|
||||||
routing, balanced = install_routing(
|
routing, balanced = install_routing(
|
||||||
runner, args.routing, args.load_format == "dummy", batch.num_tokens, args.seed
|
runner, args.routing, args.load_format == "dummy", batch.num_tokens, args.seed
|
||||||
@@ -221,6 +237,14 @@ def main(args: argparse.Namespace) -> None:
|
|||||||
f"eager rel rms {rank0['graph_vs_eager']['relative_rms']:.1e}",
|
f"eager rel rms {rank0['graph_vs_eager']['relative_rms']:.1e}",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
|
if profile := rank0.get("profile"):
|
||||||
|
from vllm.benchmarks.forward.profile import format_summary
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[forward] {case.name} rank 0 kernels ({profile['summary']}):\n"
|
||||||
|
+ format_summary({**profile, "kernels": profile["top_kernels"]}),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
if latency["sample_spread_pct"] > 0.5:
|
if latency["sample_spread_pct"] > 0.5:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"%s: unstable timing (sample medians spread %.2f%%).",
|
"%s: unstable timing (sample medians spread %.2f%%).",
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||||
|
"""GPU kernel profiles of `vllm bench forward` cases.
|
||||||
|
|
||||||
|
With `--profile-dir`, each case's graph is replayed a few more times under
|
||||||
|
`torch.profiler` after timing. Each rank writes a Perfetto trace and a kernel
|
||||||
|
summary (`<case>.rank<r>.kernels.json`): per-kernel time per forward, sorted.
|
||||||
|
|
||||||
|
Summarize one file or compare two (e.g. before/after a change):
|
||||||
|
`python -m vllm.benchmarks.forward.profile A.kernels.json [B.kernels.json]`.
|
||||||
|
|
||||||
|
The same replays run between `cudaProfilerStart`/`Stop` in an NVTX range per
|
||||||
|
case (with or without `--profile-dir`), so Nsight Systems can record just them:
|
||||||
|
`nsys profile --capture-range=cudaProfilerApi --capture-range-end=repeat
|
||||||
|
--cuda-graph-trace=node vllm bench forward ...`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import statistics
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import regex as re
|
||||||
|
import torch
|
||||||
|
|
||||||
|
_GPU_CATEGORIES = ("kernel", "gpu_memcpy", "gpu_memset")
|
||||||
|
# Kernels named after a generic launcher are identified by their first
|
||||||
|
# template argument instead.
|
||||||
|
_GENERIC = re.compile(r"(^|::)(device_kernel|Kernel|kernel|KernelWrapper)$")
|
||||||
|
# CuTe DSL kernel names embed per-shape layouts after these markers.
|
||||||
|
_CUTE_DSL_SUFFIX = re.compile(r"_object_at_|_tensorptr")
|
||||||
|
|
||||||
|
|
||||||
|
def _match_back(text: str, end: int, open_ch: str, close_ch: str) -> int:
|
||||||
|
"""Index of the bracket opening the one closing at `end`, skipping
|
||||||
|
brackets of the other kind nested inside parentheses."""
|
||||||
|
depth, parens = 0, 0
|
||||||
|
for i in range(end, -1, -1):
|
||||||
|
ch = text[i]
|
||||||
|
if open_ch == "<" and ch in "()":
|
||||||
|
parens += 1 if ch == ")" else -1
|
||||||
|
continue
|
||||||
|
if parens:
|
||||||
|
continue
|
||||||
|
if ch == close_ch:
|
||||||
|
depth += 1
|
||||||
|
elif ch == open_ch:
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def short_name(name: str) -> str:
|
||||||
|
"""`void ns::foo<T, 4>(args)` -> `ns::foo`: return type, template and
|
||||||
|
function arguments dropped. Generic launchers (e.g.
|
||||||
|
`cutlass::device_kernel<Op<...>>`) keep their first template argument."""
|
||||||
|
if name.startswith("kernel_cutlass_"):
|
||||||
|
return _CUTE_DSL_SUFFIX.split(name, maxsplit=1)[0]
|
||||||
|
head = name.strip()
|
||||||
|
if head.endswith(")"):
|
||||||
|
head = head[: max(_match_back(head, len(head) - 1, "(", ")"), 0)].rstrip()
|
||||||
|
template = ""
|
||||||
|
if head.endswith(">"):
|
||||||
|
start = _match_back(head, len(head) - 1, "<", ">")
|
||||||
|
if start > 0:
|
||||||
|
head, template = head[:start], head[start + 1 : -1]
|
||||||
|
base = head.split()[-1] if head.split() else name
|
||||||
|
if _GENERIC.search(base) and template:
|
||||||
|
arg = re.split(r"[<>(,]", template, maxsplit=1)[0].strip()
|
||||||
|
if arg:
|
||||||
|
return f"{base}<{arg.removeprefix('void ')}>"
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(events: list[dict], replays: int, skip: int) -> dict[str, Any]:
|
||||||
|
"""Per-forward kernel times from the GPU activities of `replays` replays.
|
||||||
|
|
||||||
|
Each replay first runs `skip` activities that aren't part of the timed
|
||||||
|
forward (the KV state restore and the rank rendezvous); they are reported
|
||||||
|
separately as `pre_forward_us`.
|
||||||
|
"""
|
||||||
|
acts = sorted(
|
||||||
|
(e for e in events if e.get("cat") in _GPU_CATEGORIES and "dur" in e),
|
||||||
|
key=lambda e: e["ts"],
|
||||||
|
)
|
||||||
|
if not acts:
|
||||||
|
raise ValueError(
|
||||||
|
"No GPU activity recorded. Another profiler (e.g. nsys) may hold "
|
||||||
|
"CUPTI; don't combine --profile-dir with it."
|
||||||
|
)
|
||||||
|
if len(acts) % replays:
|
||||||
|
raise ValueError(
|
||||||
|
f"{len(acts)} GPU activities don't split into {replays} replays"
|
||||||
|
)
|
||||||
|
per_replay = len(acts) // replays
|
||||||
|
if per_replay <= skip:
|
||||||
|
raise ValueError(f"{per_replay} activities per replay, {skip} to skip")
|
||||||
|
kernels: dict[str, dict[str, Any]] = defaultdict(
|
||||||
|
lambda: {"total_us": 0.0, "calls": 0, "example": ""}
|
||||||
|
)
|
||||||
|
spans, busy, pre = [], [], []
|
||||||
|
for r in range(replays):
|
||||||
|
chunk = acts[r * per_replay : (r + 1) * per_replay]
|
||||||
|
pre.append(sum(e["dur"] for e in chunk[:skip]))
|
||||||
|
forward = chunk[skip:]
|
||||||
|
spans.append(
|
||||||
|
max(e["ts"] + e["dur"] for e in forward) - min(e["ts"] for e in forward)
|
||||||
|
)
|
||||||
|
busy.append(sum(e["dur"] for e in forward))
|
||||||
|
for e in forward:
|
||||||
|
name = short_name(e["name"]) if e["cat"] == "kernel" else e["name"]
|
||||||
|
k = kernels[name]
|
||||||
|
k["total_us"] += e["dur"]
|
||||||
|
k["calls"] += 1
|
||||||
|
k["example"] = k["example"] or e["name"][:300]
|
||||||
|
total = sum(k["total_us"] for k in kernels.values())
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"us": round(k["total_us"] / replays, 2),
|
||||||
|
"pct": round(100 * k["total_us"] / total, 2),
|
||||||
|
"calls": k["calls"] // replays,
|
||||||
|
"example": k["example"],
|
||||||
|
}
|
||||||
|
for name, k in kernels.items()
|
||||||
|
]
|
||||||
|
rows.sort(key=lambda row: -row["us"])
|
||||||
|
return {
|
||||||
|
"replays": replays,
|
||||||
|
# First to last forward activity; busy can exceed it when streams overlap.
|
||||||
|
"span_us": round(statistics.median(spans), 2),
|
||||||
|
"busy_us": round(statistics.median(busy), 2),
|
||||||
|
"pre_forward_us": round(statistics.median(pre), 2),
|
||||||
|
"activities": per_replay - skip,
|
||||||
|
"kernels": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def profile_graph(
|
||||||
|
graph: torch.cuda.CUDAGraph,
|
||||||
|
name: str,
|
||||||
|
prefix: str | None,
|
||||||
|
skip: int,
|
||||||
|
replays: int = 3,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Replay `replays` times between `cudaProfilerStart`/`Stop`, in an NVTX
|
||||||
|
range named after the case, for `nsys --capture-range=cudaProfilerApi`.
|
||||||
|
|
||||||
|
With `prefix`, also trace them with `torch.profiler` and write
|
||||||
|
`prefix.trace.json.gz` and `prefix.kernels.json`. Every rank must call
|
||||||
|
this (replays may contain collectives).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
torch.cuda.profiler.start()
|
||||||
|
with torch.cuda.nvtx.range(f"forward/{name}"):
|
||||||
|
for _ in range(replays):
|
||||||
|
graph.replay()
|
||||||
|
torch.accelerator.synchronize()
|
||||||
|
torch.cuda.profiler.stop()
|
||||||
|
|
||||||
|
if prefix is None:
|
||||||
|
run()
|
||||||
|
return None
|
||||||
|
activities = [torch.profiler.ProfilerActivity.CUDA]
|
||||||
|
with torch.profiler.profile(activities=activities) as prof:
|
||||||
|
run()
|
||||||
|
trace = f"{prefix}.trace.json.gz"
|
||||||
|
prof.export_chrome_trace(trace)
|
||||||
|
with gzip.open(trace, "rt") as f:
|
||||||
|
events = json.load(f)["traceEvents"]
|
||||||
|
summary = {"trace": trace, **summarize(events, replays, skip)}
|
||||||
|
with open(f"{prefix}.kernels.json", "w") as f:
|
||||||
|
json.dump(summary, f, indent=1)
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def format_summary(summary: dict[str, Any], top: int = 20) -> str:
|
||||||
|
lines = [
|
||||||
|
f"span {summary['span_us']:.1f} us, busy {summary['busy_us']:.1f} us, "
|
||||||
|
f"{summary['activities']} activities/forward",
|
||||||
|
f"{'us':>10} {'%':>6} {'calls':>6} kernel",
|
||||||
|
]
|
||||||
|
for row in summary["kernels"][:top]:
|
||||||
|
lines.append(
|
||||||
|
f"{row['us']:10.1f} {row['pct']:6.2f} {row['calls']:6d} {row['name']}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_diff(a: dict[str, Any], b: dict[str, Any], top: int = 20) -> str:
|
||||||
|
rows_a = {row["name"]: row for row in a["kernels"]}
|
||||||
|
rows_b = {row["name"]: row for row in b["kernels"]}
|
||||||
|
deltas = []
|
||||||
|
for name in rows_a.keys() | rows_b.keys():
|
||||||
|
ra, rb = rows_a.get(name), rows_b.get(name)
|
||||||
|
us_a, us_b = ra["us"] if ra else 0.0, rb["us"] if rb else 0.0
|
||||||
|
calls_a, calls_b = ra["calls"] if ra else 0, rb["calls"] if rb else 0
|
||||||
|
deltas.append((us_b - us_a, us_a, us_b, calls_a, calls_b, name))
|
||||||
|
deltas.sort(key=lambda d: -abs(d[0]))
|
||||||
|
lines = [
|
||||||
|
f"span {a['span_us']:.1f} -> {b['span_us']:.1f} us "
|
||||||
|
f"({b['span_us'] - a['span_us']:+.1f}), busy {a['busy_us']:.1f} -> "
|
||||||
|
f"{b['busy_us']:.1f} us ({b['busy_us'] - a['busy_us']:+.1f})",
|
||||||
|
f"{'delta us':>10} {'A us':>10} {'B us':>10} {'A n':>5} {'B n':>5} kernel",
|
||||||
|
]
|
||||||
|
for delta, us_a, us_b, calls_a, calls_b, name in deltas[:top]:
|
||||||
|
lines.append(
|
||||||
|
f"{delta:+10.1f} {us_a:10.1f} {us_b:10.1f} {calls_a:5d} {calls_b:5d} "
|
||||||
|
f"{name}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument("summaries", nargs="+", help="One or two *.kernels.json.")
|
||||||
|
parser.add_argument("--top", type=int, default=20)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if len(args.summaries) > 2:
|
||||||
|
parser.error("Pass one summary, or two to compare.")
|
||||||
|
loaded = []
|
||||||
|
for path in args.summaries:
|
||||||
|
with open(path) as f:
|
||||||
|
loaded.append(json.load(f))
|
||||||
|
if len(loaded) == 1:
|
||||||
|
print(format_summary(loaded[0], args.top))
|
||||||
|
else:
|
||||||
|
print(format_diff(loaded[0], loaded[1], args.top))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user