[Agents] Add kernel microbenchmark skill (#53688)

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg> Co-authored-by: Codex <codex@openai.com>

Thien Tran committed Aug 25, 2026 at 15:25 UTC 5e379a361e3ea8bb82b7efd768c36f39a0cf32fd
5 files changed +484
.agents/skills/kernel-microbenchmark/SKILL.md new
+92
@@ -0,0 +1,92 @@
1 +---
2 +name: kernel-microbenchmark
3 +description: Build, debug, and interpret vLLM GPU kernel microbenchmarks for CUDA, Triton, and CuteDSL, including CUPTI timing, correctness checks, generated-code inspection, multi-GPU measurements, and SOL sanity checks.
4 +---
5 +
6 +# Kernel Microbenchmark
7 +
8 +## Workflow
9 +
10 +1. Create an isolated repro or benchmark when the existing harness is noisy.
11 +2. Check correctness before timing. Keep tolerances explicit.
12 +3. Time only the operation under study. Exclude allocation, compilation, random
13 + input generation, logging, and host-device transfers unless those are the
14 + target.
15 +4. Compare against a baseline and report enough metadata to reproduce the
16 + result: GPU, dtype, shape, command, branch/commit, and relevant env vars.
17 +5. Treat explanations as hypotheses until backed by an artifact: ablation,
18 + generated PTX/SASS, profiler output, or controlled benchmark.
19 +6. If the result changes the conclusion, preserve the compact lesson in a note,
20 + comment, benchmark table, or final summary. For experiments, a short
21 + `Question / Change / Correctness / Result / Observation / Next` note is
22 + usually enough.
23 +
24 +## Benchmark Defaults
25 +
26 +- Use FlashInfer CUPTI timing by default, with CUDA graph and cold L2 cache:
27 + `from flashinfer.testing import bench_gpu_time_with_cupti`.
28 +- For compute-heavy kernels, report TFLOPS with the FLOP formula in the
29 + benchmark. For memory-heavy kernels, report estimated bytes moved and GB/s.
30 + For mixed kernels, report the most honest metric available and call out the
31 + caveats. TFLOPS and memory bandwidth should be computed from the theoretical
32 + best for the operation, not from a particular kernel implementation. For
33 + example, memory bandwidth should assume all data is read exactly once from
34 + global memory.
35 +- When comparing across shapes, prefer throughput metrics such as TFLOPS or
36 + GB/s as the primary table columns; keep latency for absolute cost.
37 +- If a result exceeds expected peak/SOL, first inspect units, FLOP/byte
38 + formulas, skipped work, sparsity, caching, and whether the baseline is doing
39 + the same operation.
40 +- Force compilation/autotuning before measuring compiled kernels.
41 +- Seed inputs when correctness comparisons matter.
42 +- Keep metadata setup, plan construction, allocation, random input generation,
43 + and logging outside the timed region unless that overhead is the experiment.
44 +
45 +## Multi-GPU Benchmarks
46 +
47 +- State whether the run is local or multi-node and report the GPU topology,
48 + world size, GPUs per node, collective backend, and relevant library versions.
49 +- Compare like-for-like TP configurations. Report both per-rank and global
50 + dimensions, and do not compare results from different TP sizes without an
51 + explicit normalization or scaling question.
52 +- Define the timed operation boundary before benchmarking. If the production
53 + wrapper performs input staging, flag resets, generation barriers, padding,
54 + or output copies, keep them in the timed region for an end-to-end comparison.
55 + Use a separate, clearly labeled ablation for kernel-only timing.
56 +- Check distributed correctness before timing. Seed each rank deliberately,
57 + form the reference with the same collective semantics, and synchronize before
58 + reading or comparing outputs.
59 +- Use a device-side barrier immediately before each measured replay. Keep this
60 + common synchronization outside the timed interval, but keep barriers required
61 + by the candidate implementation inside it.
62 +- With CUDA graphs, warm up before capture, coordinate capture across ranks,
63 + rotate pointer-distinct graphs when cache reuse matters, and ensure every
64 + collective is issued in the same order on every rank.
65 +- Measure every rank and reduce each sample with `MAX`; report the median of
66 + those per-sample maxima. A rank-local event time is not a distributed latency.
67 +- Reset reusable symmetric-memory flags before each invocation and establish a
68 + device-side generation barrier before peers may signal them. Otherwise a fast
69 + rank can signal before a slow rank resets its flags, causing a lost arrival
70 + and intermittent deadlock.
71 +- Preserve symmetric-memory handles, CUDA graphs, streams, and graph outputs for
72 + the full measurement lifetime. Allocate and rendezvous symmetric buffers in
73 + identical order and with identical shapes on all ranks.
74 +- Treat hangs and isolated millisecond outliers as synchronization bugs or rank
75 + skew until disproven. Add stage markers, bounded synchronization checks, and
76 + per-rank diagnostics before blaming compilation or kernel performance.
77 +- For multi-node runs, record the scheduler allocation and verify the fabric
78 + supports the required multicast or NVLink-domain assumptions. Do not describe
79 + a two-node TP run as equivalent to a local NVLink-domain run without checking.
80 +- Stabilize GPU clocks or run enough untimed work to reach a steady state.
81 + Alternate candidate order so clock, thermal, and rank-skew effects are shared.
82 +
83 +## Included Examples
84 +
85 +- Use [benchmarks/cupti_microbenchmark.py](benchmarks/cupti_microbenchmark.py)
86 + as a minimal single-GPU FlashInfer CUPTI timing pattern.
87 +- Use
88 + [benchmarks/multi_gpu_gemm_rs.py](benchmarks/multi_gpu_gemm_rs.py) as a
89 + minimal distributed CUDA-graph timing pattern.
90 +
91 +Adapt the operation, cases, work formula, and correctness tolerances rather
92 +than copying either example unchanged.
.agents/skills/kernel-microbenchmark/agents/openai.yaml new
+4
@@ -0,0 +1,4 @@
1 +interface:
2 + display_name: "Kernel Microbenchmark"
3 + short_description: "Build and debug reliable GPU kernel microbenchmarks."
4 + default_prompt: "Use the kernel microbenchmark workflow to build or debug a vLLM GPU kernel benchmark."
.agents/skills/kernel-microbenchmark/benchmarks/cupti_microbenchmark.py new
+63
@@ -0,0 +1,63 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +"""FlashInfer CUPTI microbenchmark template with throughput metrics."""
5 +
6 +import statistics
7 +
8 +import pandas as pd
9 +import torch
10 +from flashinfer.testing import bench_gpu_time_with_cupti
11 +
12 +WARMUP = 25
13 +MATMUL_CASES = [
14 + ("compute-bound", 4096, 4096, 4096),
15 + ("small-M", 16, 16384, 8192),
16 +]
17 +
18 +
19 +def bench_us(fn):
20 + for _ in range(WARMUP):
21 + fn()
22 + torch.accelerator.synchronize()
23 + return statistics.median(bench_gpu_time_with_cupti(fn)) * 1e3
24 +
25 +
26 +def main() -> None:
27 + if not torch.accelerator.is_available() or torch.version.cuda is None:
28 + raise RuntimeError("CUDA is required for CUPTI kernel timing.")
29 +
30 + torch.set_default_device("cuda")
31 + torch.manual_seed(0)
32 +
33 + rows = []
34 + for name, m, n, k in MATMUL_CASES:
35 + a = torch.randn(m, k, dtype=torch.bfloat16)
36 + b = torch.randn(k, n, dtype=torch.bfloat16)
37 + out = torch.empty(m, n, dtype=torch.bfloat16)
38 +
39 + def run_matmul(a=a, b=b, out=out):
40 + torch.mm(a, b, out=out)
41 +
42 + run_matmul()
43 + ref = torch.mm(a, b)
44 + torch.accelerator.synchronize()
45 + torch.testing.assert_close(out, ref, atol=1e-1, rtol=1e-1)
46 +
47 + us = bench_us(run_matmul)
48 + rows.append(
49 + {
50 + "case": name,
51 + "shape": f"{m}x{n}x{k}",
52 + "us": us,
53 + "tflops": 2 * m * n * k / (us * 1e6),
54 + "gbps": 2 * (m * k + k * n + m * n) / (us * 1e3),
55 + }
56 + )
57 +
58 + df = pd.DataFrame(rows)
59 + print(df.to_string(index=False, float_format=lambda x: f"{x:.3f}"))
60 +
61 +
62 +if __name__ == "__main__":
63 + main()
.agents/skills/kernel-microbenchmark/benchmarks/multi_gpu_gemm_rs.py new
+324
@@ -0,0 +1,324 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +"""Minimal multi-GPU GEMM + reduce-scatter microbenchmark.
5 +
6 +Run on one node with, for example:
7 +
8 + torchrun --standalone --nproc-per-node=8 \
9 + .agents/skills/kernel-microbenchmark/benchmarks/multi_gpu_gemm_rs.py
10 +"""
11 +
12 +import argparse
13 +import os
14 +import statistics
15 +from collections.abc import Callable
16 +
17 +import pandas as pd
18 +import torch
19 +import torch.distributed as dist
20 +import torch.distributed._symmetric_memory as symm_mem
21 +
22 +from vllm.config import VllmConfig, set_current_vllm_config
23 +from vllm.distributed import cleanup_dist_env_and_memory
24 +from vllm.distributed.parallel_state import (
25 + get_tp_group,
26 + init_distributed_environment,
27 + initialize_model_parallel,
28 +)
29 +
30 +
31 +def parse_args() -> argparse.Namespace:
32 + parser = argparse.ArgumentParser()
33 + parser.add_argument("--m", type=int, nargs="+", default=[128, 512, 2048])
34 + parser.add_argument("--n", type=int, default=4096)
35 + parser.add_argument(
36 + "--k",
37 + type=int,
38 + nargs="+",
39 + default=[4096],
40 + help="Per-rank K values",
41 + )
42 + parser.add_argument("--num-workspaces", type=int, default=10)
43 + parser.add_argument("--warmup-replays", type=int, default=5)
44 + parser.add_argument("--samples", type=int, default=20)
45 + return parser.parse_args()
46 +
47 +
48 +def make_gemm_rs(
49 + x: torch.Tensor,
50 + weight: torch.Tensor,
51 + partial: torch.Tensor,
52 + output: torch.Tensor,
53 + rows: int,
54 + device_group: dist.ProcessGroup,
55 +) -> Callable[[], None]:
56 + def run() -> None:
57 + torch.mm(x, weight.T, out=partial[:rows])
58 + dist.reduce_scatter_single(output, partial, group=device_group)
59 +
60 + return run
61 +
62 +
63 +def check_correctness(
64 + runs: dict[str, Callable[[], None]],
65 + outputs: dict[str, torch.Tensor],
66 + x: torch.Tensor,
67 + weight: torch.Tensor,
68 + padded_rows: int,
69 + rank: int,
70 + device_group: dist.ProcessGroup,
71 +) -> None:
72 + expected_full = torch.zeros(
73 + (padded_rows, weight.shape[0]),
74 + dtype=x.dtype,
75 + device=x.device,
76 + )
77 + torch.mm(x, weight.T, out=expected_full[: x.shape[0]])
78 + dist.all_reduce(expected_full, group=device_group)
79 + expected = expected_full.chunk(dist.get_world_size(device_group))[rank]
80 + for name, run in runs.items():
81 + run()
82 + torch.accelerator.synchronize()
83 + torch.testing.assert_close(
84 + outputs[name],
85 + expected,
86 + rtol=5e-2,
87 + atol=4.0,
88 + )
89 +
90 +
91 +def capture_graph(
92 + run: Callable[[], None],
93 + cpu_group: dist.ProcessGroup,
94 +) -> tuple[torch.cuda.CUDAGraph, torch.cuda.Stream]:
95 + stream = torch.cuda.Stream()
96 + stream.wait_stream(torch.cuda.current_stream())
97 + dist.barrier(group=cpu_group)
98 + with torch.cuda.stream(stream):
99 + for _ in range(3):
100 + run()
101 + stream.synchronize()
102 + dist.barrier(group=cpu_group)
103 +
104 + graph = torch.cuda.CUDAGraph()
105 + with torch.cuda.graph(graph, stream=stream):
106 + run()
107 + torch.cuda.current_stream().wait_stream(stream)
108 + dist.barrier(group=cpu_group)
109 + return graph, stream
110 +
111 +
112 +def benchmark_graphs(
113 + candidate_graphs: dict[str, list[torch.cuda.CUDAGraph]],
114 + warmup_replays: int,
115 + samples: int,
116 + device_group: dist.ProcessGroup,
117 + device_barrier: Callable[[], None],
118 +) -> dict[str, float]:
119 + candidate_names = list(candidate_graphs)
120 + for round_index in range(warmup_replays):
121 + for candidate_index in range(len(candidate_names)):
122 + candidate_id = (round_index + candidate_index) % len(candidate_names)
123 + name = candidate_names[candidate_id]
124 + graphs = candidate_graphs[name]
125 + device_barrier()
126 + graphs[round_index % len(graphs)].replay()
127 + torch.accelerator.synchronize()
128 +
129 + timings: dict[str, list[float]] = {name: [] for name in candidate_names}
130 + start = torch.cuda.Event(enable_timing=True)
131 + end = torch.cuda.Event(enable_timing=True)
132 + for sample_index in range(samples):
133 + for candidate_index in range(len(candidate_names)):
134 + candidate_id = (sample_index + candidate_index) % len(candidate_names)
135 + name = candidate_names[candidate_id]
136 + graphs = candidate_graphs[name]
137 + device_barrier()
138 + start.record()
139 + graphs[sample_index % len(graphs)].replay()
140 + end.record()
141 + end.synchronize()
142 +
143 + elapsed_us = torch.tensor(
144 + start.elapsed_time(end) * 1000,
145 + dtype=torch.float64,
146 + device=torch.accelerator.current_device_index(),
147 + )
148 + dist.all_reduce(elapsed_us, op=dist.ReduceOp.MAX, group=device_group)
149 + timings[name].append(elapsed_us.item())
150 + return {name: statistics.median(values) for name, values in timings.items()}
151 +
152 +
153 +def benchmark_shape(
154 + m: int,
155 + n: int,
156 + k: int,
157 + num_workspaces: int,
158 + warmup_replays: int,
159 + samples: int,
160 + device: torch.device,
161 + rank: int,
162 + world_size: int,
163 + device_group: dist.ProcessGroup,
164 + cpu_group: dist.ProcessGroup,
165 + device_barrier: Callable[[], None],
166 +) -> dict[str, float | int]:
167 + padded_m = (m + world_size - 1) // world_size * world_size
168 + local_m = padded_m // world_size
169 +
170 + torch.manual_seed(1000 + rank * 10 + m + k)
171 + inputs = []
172 + weights = []
173 + for _ in range(num_workspaces):
174 + inputs.append(torch.randn(m, k, dtype=torch.bfloat16, device=device))
175 + weights.append(torch.randn(n, k, dtype=torch.bfloat16, device=device))
176 +
177 + ring_partial = torch.empty(padded_m, n, dtype=torch.bfloat16, device=device)
178 + ldmc_partial = symm_mem.empty(
179 + (padded_m, n),
180 + dtype=torch.bfloat16,
181 + device=device,
182 + )
183 + ldmc_handle = symm_mem.rendezvous(ldmc_partial, device_group)
184 + ring_output = torch.empty(local_m, n, dtype=torch.bfloat16, device=device)
185 + ldmc_output = torch.empty_like(ring_output)
186 + if padded_m > m:
187 + ring_partial[m:].zero_()
188 + ldmc_partial[m:].zero_()
189 +
190 + candidate_runs = {
191 + "ring_ll_us": [
192 + make_gemm_rs(
193 + x,
194 + weight,
195 + ring_partial,
196 + ring_output,
197 + m,
198 + device_group,
199 + )
200 + for x, weight in zip(inputs, weights)
201 + ],
202 + "ldmc_us": [
203 + make_gemm_rs(
204 + x,
205 + weight,
206 + ldmc_partial,
207 + ldmc_output,
208 + m,
209 + device_group,
210 + )
211 + for x, weight in zip(inputs, weights)
212 + ],
213 + }
214 + x = inputs[0]
215 + weight = weights[0]
216 + check_correctness(
217 + {name: runs[0] for name, runs in candidate_runs.items()},
218 + {"ring_ll_us": ring_output, "ldmc_us": ldmc_output},
219 + x,
220 + weight,
221 + padded_m,
222 + rank,
223 + device_group,
224 + )
225 +
226 + candidate_graphs = {}
227 + graph_keepalive: list[object] = [ldmc_handle]
228 + for name, runs in candidate_runs.items():
229 + bundles = [capture_graph(run, cpu_group) for run in runs]
230 + candidate_graphs[name] = [graph for graph, _ in bundles]
231 + graph_keepalive.extend(bundles)
232 +
233 + times = benchmark_graphs(
234 + candidate_graphs,
235 + warmup_replays,
236 + samples,
237 + device_group,
238 + device_barrier,
239 + )
240 + global_flops = 2 * m * n * k * world_size
241 + return {
242 + "M": m,
243 + "N": n,
244 + "K_per_rank": k,
245 + "K_global": k * world_size,
246 + **times,
247 + "ring_ll_tflops": global_flops / (times["ring_ll_us"] * 1e6),
248 + "ldmc_tflops": global_flops / (times["ldmc_us"] * 1e6),
249 + "ldmc_speedup": times["ring_ll_us"] / times["ldmc_us"],
250 + }
251 +
252 +
253 +def main() -> None:
254 + args = parse_args()
255 + assert args.m and min(args.m) > 0
256 + assert args.k and min(args.k) > 0
257 + assert min(args.n, args.num_workspaces, args.samples) > 0
258 + assert args.warmup_replays >= 0
259 + local_rank = int(os.environ["LOCAL_RANK"])
260 + local_world_size = int(os.environ["LOCAL_WORLD_SIZE"])
261 + torch.accelerator.set_device_index(local_rank)
262 + init_distributed_environment()
263 + world_size = dist.get_world_size()
264 + os.environ["VLLM_ALLREDUCE_USE_SYMM_MEM"] = "0"
265 + symm_mem.set_backend("NCCL")
266 + with set_current_vllm_config(VllmConfig()):
267 + initialize_model_parallel(tensor_model_parallel_size=world_size)
268 +
269 + tp_group = get_tp_group()
270 + device_group = tp_group.device_group
271 + cpu_group = tp_group.cpu_group
272 + rank = tp_group.rank_in_group
273 + device = torch.device("cuda", local_rank)
274 + group_warmup = torch.zeros(1, device=device)
275 + dist.all_reduce(group_warmup, group=device_group)
276 + pynccl_comm = tp_group.device_communicator.pynccl_comm
277 + assert pynccl_comm is not None
278 + sync_input = torch.zeros(1, device=device)
279 + sync_output = torch.empty_like(sync_input)
280 +
281 + def device_barrier() -> None:
282 + # Order the timed launch after a device-side rank rendezvous without
283 + # including the rendezvous itself in the measured event interval.
284 + pynccl_comm.all_reduce(sync_input, sync_output)
285 +
286 + results = [
287 + benchmark_shape(
288 + m,
289 + args.n,
290 + k,
291 + args.num_workspaces,
292 + args.warmup_replays,
293 + args.samples,
294 + device,
295 + rank,
296 + world_size,
297 + device_group,
298 + cpu_group,
299 + device_barrier,
300 + )
301 + for k in args.k
302 + for m in args.m
303 + ]
304 +
305 + if rank == 0:
306 + metadata = {
307 + "world_size": world_size,
308 + "local_world_size": local_world_size,
309 + "num_nodes": world_size // local_world_size,
310 + "backend": dist.get_backend(device_group),
311 + "gpu": torch.cuda.get_device_name(local_rank),
312 + "torch": torch.__version__,
313 + "cuda": torch.version.cuda,
314 + }
315 + print(pd.Series(metadata, name="value").to_string())
316 + df = pd.DataFrame(results)
317 + print(df.to_string(index=False, float_format=lambda x: f"{x:.3f}"))
318 +
319 + dist.barrier(group=cpu_group)
320 + cleanup_dist_env_and_memory()
321 +
322 +
323 +if __name__ == "__main__":
324 + main()
.claude/skills/kernel-microbenchmark new
+1
@@ -0,0 +1 @@
1 +../../.agents/skills/kernel-microbenchmark
\ No newline at end of file