main
py 1,440 lines 55.3 KB
Raw
1 #!/usr/bin/env python3
2 # SPDX-License-Identifier: Apache-2.0
3 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
4
5 """Universal vLLM Attention Benchmark.
6
7 Benchmark any attention backend with the extended grammar.
8 Supports standard attention (Flash/Triton/FlashInfer) and MLA backends.
9
10 Examples:
11 # Standard attention
12 python benchmark.py --backends flash flashinfer --batch-specs "q2k" "8q1s1k"
13
14 # MLA backends
15 python benchmark.py --backends cutlass_mla flashinfer_mla --batch-specs "64q1s1k"
16
17 # Parameter sweep (CLI)
18 python benchmark.py --backend cutlass_mla \
19 --batch-specs "64q1s1k" \
20 --sweep-param num_kv_splits \
21 --sweep-values 1 4 8 16
22
23 # Parameter sweep (YAML config - recommended)
24 python benchmark.py --config configs/cutlass_numsplits.yaml
25
26 """
27
28 import argparse
29 import os
30 import shutil
31 import subprocess
32 import sys
33 from dataclasses import replace
34 from pathlib import Path
35
36 import yaml
37 from rich.console import Console
38 from tqdm import tqdm
39
40 sys.path.insert(0, str(Path(__file__).parent.parent.parent))
41
42 from batch_spec import parse_batch_spec
43 from common import (
44 BenchmarkConfig,
45 BenchmarkResult,
46 ModelParameterSweep,
47 ParameterSweep,
48 ResultsFormatter,
49 batch_spec_sort_key,
50 is_mla_backend,
51 )
52
53 from vllm.v1.worker.workspace import init_workspace_manager
54
55
56 def _str2bool(v) -> bool:
57 if isinstance(v, bool):
58 return v
59 if v.lower() in ("true", "1", "yes", "t"):
60 return True
61 if v.lower() in ("false", "0", "no", "f"):
62 return False
63 raise argparse.ArgumentTypeError(f"expected a boolean, got {v!r}")
64
65
66 def run_standard_attention_benchmark(config: BenchmarkConfig) -> BenchmarkResult:
67 """Run standard attention benchmark (Flash/Triton/FlashInfer)."""
68 from runner import run_attention_benchmark
69
70 return run_attention_benchmark(config)
71
72
73 def run_mla_benchmark(config: BenchmarkConfig, **kwargs) -> BenchmarkResult:
74 """Run MLA benchmark with appropriate backend."""
75 from mla_runner import run_mla_benchmark as run_mla
76
77 return run_mla(
78 config.backend,
79 config,
80 prefill_backend=config.prefill_backend,
81 sparse_mla_force_mqa=config.sparse_mla_force_mqa,
82 **kwargs,
83 )
84
85
86 def run_benchmark(config: BenchmarkConfig, **kwargs) -> BenchmarkResult:
87 """Run a single benchmark with proper backend selection.
88
89 Args:
90 config: BenchmarkConfig with backend, batch_spec, and model params
91 **kwargs: Additional arguments passed to MLA benchmarks
92
93 Returns:
94 BenchmarkResult (may have error field set on failure)
95
96 """
97 try:
98 if is_mla_backend(config.backend):
99 return run_mla_benchmark(config, **kwargs)
100 else:
101 return run_standard_attention_benchmark(config)
102 except Exception as e:
103 error_msg = str(e) or repr(e)
104 return BenchmarkResult(
105 config=config,
106 mean_time=float("inf"),
107 median_time=float("inf"),
108 std_time=0,
109 min_time=float("inf"),
110 max_time=float("inf"),
111 error=error_msg,
112 )
113
114
115 def run_model_parameter_sweep(
116 backends: list[str],
117 batch_specs: list[str],
118 base_config_args: dict,
119 sweep: ModelParameterSweep,
120 console: Console,
121 ) -> list[BenchmarkResult]:
122 """Run model parameter sweep for given backends and batch specs.
123
124 Args:
125 backends: List of backend names
126 batch_specs: List of batch specifications
127 base_config_args: Base configuration arguments (num_layers, head_dim, etc.)
128 sweep: ModelParameterSweep configuration
129 console: Rich console for output
130
131 Returns:
132 List of BenchmarkResult objects
133
134 """
135 all_results = []
136
137 sweep_desc = (
138 f"{sweep.param_name} = {sweep.values}"
139 if sweep.param_name
140 else f"{len(sweep.values)} configurations"
141 )
142 console.print(f"[yellow]Model sweep mode: testing {sweep_desc}[/]")
143
144 total = len(backends) * len(batch_specs) * len(sweep.values)
145
146 with tqdm(total=total, desc="Benchmarking") as pbar:
147 for backend in backends:
148 for spec in batch_specs:
149 for value in sweep.values:
150 # Create config with modified model parameter(s)
151 config_args = base_config_args.copy()
152 sweep.apply(config_args, value)
153
154 # Create config with original backend for running
155 clean_config = BenchmarkConfig(
156 backend=backend, batch_spec=spec, **config_args
157 )
158
159 # Run benchmark
160 result = run_benchmark(clean_config)
161
162 # Replace backend with labeled version for display
163 backend_label = sweep.get_label(backend, value)
164 labeled_config = replace(result.config, backend=backend_label)
165 result = replace(result, config=labeled_config)
166 all_results.append(result)
167
168 if not result.success:
169 err_label = (
170 f"{sweep.param_name}={value}"
171 if sweep.param_name
172 else f"{value}"
173 )
174 console.print(
175 f"[red]Error {backend} {spec} {err_label}"
176 f": {result.error}[/]"
177 )
178
179 pbar.update(1)
180
181 if base_config_args.get("ncu_profile"):
182 return all_results
183
184 # Display sweep results - create separate table for each parameter value
185 console.print("\n[bold green]Model Parameter Sweep Results:[/]")
186 formatter = ResultsFormatter(console)
187
188 # Group results by parameter value and extract backend mapping
189 by_param_value = {}
190 backend_mapping = {} # Maps labeled backend -> original backend
191
192 for r in all_results:
193 # Extract original backend and param value from labeled backend
194 # The label format is: {backend}_{param_name}_{value}
195 # We need to reverse engineer this
196 labeled_backend = r.config.backend
197
198 # Try each backend to find which one this result belongs to
199 for backend in backends:
200 for value in sweep.values:
201 expected_label = sweep.get_label(backend, value)
202 if labeled_backend == expected_label:
203 backend_mapping[labeled_backend] = backend
204 param_value = str(value)
205
206 if param_value not in by_param_value:
207 by_param_value[param_value] = []
208 by_param_value[param_value].append(r)
209 break
210
211 # Create a table for each parameter value
212 sorted_param_values = sorted(
213 by_param_value.keys(), key=lambda x: int(x) if x.isdigit() else x
214 )
215
216 for param_value in sorted_param_values:
217 label = (
218 f"{sweep.param_name} = {param_value}" if sweep.param_name else param_value
219 )
220 console.print(f"\n[bold cyan]{label}[/]")
221 param_results = by_param_value[param_value]
222
223 # Create modified results with original backend names
224 modified_results = []
225 for r in param_results:
226 # Get the original backend name from our mapping
227 original_backend = backend_mapping[r.config.backend]
228 modified_config = replace(r.config, backend=original_backend)
229 modified_result = replace(r, config=modified_config)
230 modified_results.append(modified_result)
231
232 # Print table with original backend names
233 formatter.print_table(modified_results, backends, compare_to_fastest=True)
234
235 # Show optimal backend for each (param_value, batch_spec) combination
236 sweep_name = sweep.param_name or "config"
237 console.print(
238 f"\n[bold cyan]Optimal backend for each ({sweep_name}, batch_spec):[/]"
239 )
240
241 # Group by (param_value, batch_spec)
242 by_param_and_spec = {}
243 for r in all_results:
244 if r.success:
245 # Find which (backend, value) this result corresponds to
246 labeled_backend = r.config.backend
247 for backend in backends:
248 for value in sweep.values:
249 expected_label = sweep.get_label(backend, value)
250 if labeled_backend == expected_label:
251 param_value = str(value)
252 spec = r.config.batch_spec
253 key = (param_value, spec)
254
255 if key not in by_param_and_spec:
256 by_param_and_spec[key] = []
257 by_param_and_spec[key].append(r)
258 break
259
260 # Sort by param value then spec (batch_size, q_len, kv_len)
261 sorted_keys = sorted(
262 by_param_and_spec.keys(),
263 key=lambda x: (
264 int(x[0]) if x[0].isdigit() else x[0],
265 batch_spec_sort_key(x[1]),
266 ),
267 )
268
269 current_param_value = None
270 for param_value, spec in sorted_keys:
271 # Print header when param value changes
272 if param_value != current_param_value:
273 header = (
274 f"{sweep.param_name}={param_value}" if sweep.param_name else param_value
275 )
276 console.print(f"\n [bold]{header}:[/]")
277 current_param_value = param_value
278
279 results = by_param_and_spec[(param_value, spec)]
280 best = min(results, key=lambda r: r.mean_time)
281
282 # Extract original backend name using the mapping
283 backend_name = backend_mapping[best.config.backend]
284
285 # Show all backends' times for comparison
286 times_str = " | ".join(
287 [
288 f"{backend_mapping[r.config.backend]}: {r.mean_time:.6f}s"
289 for r in sorted(results, key=lambda r: r.mean_time)
290 ]
291 )
292
293 console.print(
294 f" {spec:12s} -> [bold green]{backend_name:15s}[/] ({times_str})"
295 )
296
297 return all_results
298
299
300 def run_parameter_sweep(
301 backends: list[str],
302 batch_specs: list[str],
303 base_config_args: dict,
304 sweep: ParameterSweep,
305 console: Console,
306 ) -> list[BenchmarkResult]:
307 """Run parameter sweep for given backends and batch specs.
308
309 Args:
310 backends: List of backend names
311 batch_specs: List of batch specifications
312 base_config_args: Base configuration arguments (num_layers, head_dim, etc.)
313 sweep: ParameterSweep configuration
314 console: Rich console for output
315
316 Returns:
317 List of BenchmarkResult objects
318
319 """
320 all_results = []
321
322 # Build list of values to sweep (including auto if requested)
323 sweep_values = list(sweep.values)
324 if sweep.include_auto:
325 sweep_values.append("auto")
326
327 console.print(f"[yellow]Sweep mode: testing {sweep.param_name} = {sweep_values}[/]")
328
329 total = len(backends) * len(batch_specs) * len(sweep_values)
330
331 with tqdm(total=total, desc="Benchmarking") as pbar:
332 for backend in backends:
333 for spec in batch_specs:
334 for value in sweep_values:
335 # Create config with original backend for running
336 config = BenchmarkConfig(
337 backend=backend, batch_spec=spec, **base_config_args
338 )
339
340 # Prepare kwargs for benchmark runner
341 kwargs = {}
342 if value != "auto":
343 kwargs[sweep.param_name] = value
344
345 # Run benchmark
346 result = run_benchmark(config, **kwargs)
347
348 # Replace backend with labeled version for display
349 backend_label = sweep.get_label(backend, value)
350 labeled_config = replace(result.config, backend=backend_label)
351 result = replace(result, config=labeled_config)
352 all_results.append(result)
353
354 if not result.success:
355 console.print(
356 f"[red]Error {backend} {spec} {sweep.param_name}="
357 f"{value}: {result.error}[/]"
358 )
359
360 pbar.update(1)
361
362 if base_config_args.get("ncu_profile"):
363 return all_results
364
365 # Display sweep results
366 console.print("\n[bold green]Sweep Results:[/]")
367 backend_labels = [sweep.get_label(b, v) for b in backends for v in sweep_values]
368 formatter = ResultsFormatter(console)
369 formatter.print_table(all_results, backend_labels)
370
371 # Show optimal values
372 console.print(f"\n[bold cyan]Optimal {sweep.param_name} per batch spec:[/]")
373 by_spec = {}
374 for r in all_results:
375 if r.success:
376 spec = r.config.batch_spec
377 if spec not in by_spec:
378 by_spec[spec] = []
379 by_spec[spec].append(r)
380
381 for spec in sorted(by_spec.keys(), key=batch_spec_sort_key):
382 results = by_spec[spec]
383 best = min(results, key=lambda r: r.mean_time)
384 console.print(
385 f" {spec}: [bold green]{best.config.backend}[/] ({best.mean_time:.6f}s)"
386 )
387
388 return all_results
389
390
391 def load_config_from_yaml(config_path: str) -> dict:
392 """Load configuration from YAML file."""
393 with open(config_path) as f:
394 return yaml.safe_load(f)
395
396
397 def generate_batch_specs_from_ranges(ranges: list[dict]) -> list[str]:
398 """Generate batch specs from range specifications.
399
400 Args:
401 ranges: List of range specifications, each containing:
402 - template: Batch spec template (e.g., "q{q_len}kv1k")
403 - q_len: Dict with start, stop, step, end_inclusive (optional)
404 - Other parameters can also be ranges
405
406 Returns:
407 List of generated batch spec strings
408
409 Example:
410 ranges = [
411 {
412 "template": "q{q_len}kv1k",
413 "q_len": {
414 "start": 1,
415 "stop": 16,
416 "step": 1,
417 "end_inclusive": true # Optional, defaults to true
418 }
419 }
420 ]
421 Returns: ["q1kv1k", "q2kv1k", ..., "q16kv1k"]
422
423 """
424 all_specs = []
425
426 for range_spec in ranges:
427 template = range_spec.get("template")
428 if not template:
429 raise ValueError("Range specification must include 'template'")
430
431 # Extract all range parameters from the spec
432 range_params = {}
433 for key, value in range_spec.items():
434 if key == "template":
435 continue
436 if isinstance(value, dict) and "start" in value:
437 # This is a range specification
438 start = value["start"]
439 stop = value["stop"]
440 step = value.get("step", 1)
441 # Check if end should be inclusive (default: True)
442 end_inclusive = value.get("end_inclusive", True)
443
444 # Adjust stop based on end_inclusive
445 if end_inclusive:
446 range_params[key] = list(range(start, stop + 1, step))
447 else:
448 range_params[key] = list(range(start, stop, step))
449 else:
450 # This is a fixed value
451 range_params[key] = [value]
452
453 # Generate all combinations (Cartesian product)
454 if range_params:
455 import itertools
456
457 param_names = list(range_params.keys())
458 param_values = [range_params[name] for name in param_names]
459
460 for values in itertools.product(*param_values):
461 params = dict(zip(param_names, values))
462 spec = template.format(**params)
463 all_specs.append(spec)
464 else:
465 # No parameters, just use template as-is
466 all_specs.append(template)
467
468 return all_specs
469
470
471 def main():
472 parser = argparse.ArgumentParser(
473 description="Universal vLLM attention benchmark",
474 formatter_class=argparse.RawDescriptionHelpFormatter,
475 epilog=__doc__,
476 )
477
478 # Config file
479 parser.add_argument(
480 "--config",
481 help="Path to YAML config file (overrides other args)",
482 )
483
484 # Backend selection
485 parser.add_argument(
486 "--backends",
487 "--decode-backends",
488 nargs="+",
489 help="Decode backends to benchmark (flash, triton, flashinfer, cutlass_mla, "
490 "flashinfer_mla, flashattn_mla, flashmla)",
491 )
492 parser.add_argument(
493 "--backend",
494 help="Single backend (alternative to --backends)",
495 )
496 parser.add_argument(
497 "--prefill-backends",
498 nargs="+",
499 help="Prefill backends to compare (fa2, fa3, fa4). "
500 "Uses the first decode backend for impl construction.",
501 )
502 parser.add_argument(
503 "--fp8-output-scale",
504 type=float,
505 help="Static per-tensor scale enabling the MLA prefill FP8-output "
506 "comparison on FA4 (fused write vs standalone post-quant).",
507 )
508 parser.add_argument(
509 "--fuse-quant-op",
510 nargs="+",
511 type=_str2bool,
512 help="FP8-output write path(s) to run: false = bf16 attention + "
513 "standalone static-FP8 quant, true = FA4 writes FP8 directly. "
514 "Default: both.",
515 )
516
517 # Batch specifications
518 parser.add_argument(
519 "--batch-specs",
520 nargs="+",
521 default=None,
522 help="Batch specifications using extended grammar",
523 )
524
525 # Model config
526 parser.add_argument("--num-layers", type=int, default=10, help="Number of layers")
527 parser.add_argument("--head-dim", type=int, default=128, help="Head dimension")
528 parser.add_argument("--num-q-heads", type=int, default=32, help="Query heads")
529 parser.add_argument("--num-kv-heads", type=int, default=8, help="KV heads")
530 parser.add_argument("--block-size", type=int, default=16, help="Block size")
531 parser.add_argument(
532 "--v-head-dim",
533 type=int,
534 default=None,
535 help="Value head dimension (defaults to --head-dim if unset)",
536 )
537
538 # MLA-specific model dimensions
539 parser.add_argument(
540 "--kv-lora-rank", type=int, default=None, help="MLA KV LoRA rank"
541 )
542 parser.add_argument(
543 "--qk-nope-head-dim", type=int, default=None, help="MLA non-RoPE QK head dim"
544 )
545 parser.add_argument(
546 "--qk-rope-head-dim", type=int, default=None, help="MLA RoPE QK head dim"
547 )
548
549 # Benchmark settings
550 parser.add_argument("--device", default="cuda:0", help="Device")
551 parser.add_argument(
552 "--warmup-ms",
553 type=int,
554 default=None,
555 help=(
556 "Warmup window in ms for triton's do_bench (default: triton's own). "
557 "Has no effect with CUDA graphs; pass --no-cuda-graphs to use it."
558 ),
559 )
560 parser.add_argument("--profile-memory", action="store_true", help="Profile memory")
561 parser.add_argument(
562 "--kv-cache-dtype",
563 default="auto",
564 choices=["auto", "fp8"],
565 help="KV cache dtype: auto or fp8",
566 )
567 parser.add_argument(
568 "--cuda-graphs",
569 action=argparse.BooleanOptionalAction,
570 default=True,
571 help=(
572 "Use triton do_bench_cudagraph (True) or do_bench (False) "
573 "for timing. CUDA graphs eliminate CPU launch overhead "
574 "(default: True)"
575 ),
576 )
577 parser.add_argument(
578 "--num-splits",
579 type=int,
580 default=None,
581 help="FlashAttention split-K factor (0=auto heuristic, 1=disabled, >1=force N)",
582 )
583 parser.add_argument(
584 "--ncu-profile",
585 action="store_true",
586 default=False,
587 help=(
588 "Enable Nsight Compute profiling mode. Automatically wraps the "
589 "script with ncu, capturing a profile with source correlation. "
590 "Use --ncu-output to set the output file name."
591 ),
592 )
593 parser.add_argument(
594 "--ncu-output",
595 type=str,
596 default="profile",
597 help="Output file name for ncu profile (default: 'profile').",
598 )
599 parser.add_argument(
600 "--torch-profile",
601 action="store_true",
602 default=False,
603 help="Collect a PyTorch profiler Chrome trace for each benchmark run.",
604 )
605 parser.add_argument(
606 "--torch-profile-dir",
607 default=None,
608 help="Directory for PyTorch profiler traces.",
609 )
610 parser.add_argument(
611 "--torch-profile-iters",
612 type=int,
613 default=3,
614 help="Number of forward passes to record per PyTorch profiler trace.",
615 )
616 parser.add_argument(
617 "--sparse-mla-mha-variants",
618 nargs="+",
619 default=None,
620 choices=["dense_mha", "masked_mha", "mqa"],
621 help="Sparse MLA variants to run in mha_vs_mqa mode. Defaults to both.",
622 )
623
624 # Parameter sweep (use YAML config for advanced sweeps)
625 parser.add_argument(
626 "--sweep-param",
627 help="Parameter name to sweep (e.g., num_kv_splits, reorder_batch_threshold)",
628 )
629 parser.add_argument(
630 "--sweep-values",
631 type=int,
632 nargs="+",
633 help="Values to sweep for the parameter",
634 )
635
636 # Output
637 parser.add_argument("--output-csv", help="Save to CSV")
638 parser.add_argument("--output-json", help="Save to JSON")
639
640 args = parser.parse_args()
641
642 console = Console()
643 console.print("[bold cyan]vLLM Attention Benchmark[/]")
644
645 # Load config from YAML if provided
646 if args.config:
647 console.print(f"[yellow]Loading config from: {args.config}[/]")
648 yaml_config = load_config_from_yaml(args.config)
649
650 # Show description if available
651 if "description" in yaml_config:
652 console.print(f"[dim]{yaml_config['description']}[/]")
653
654 # Override args with YAML values, but CLI args take precedence
655 # Check if CLI provided backends (they would be non-None and not default)
656 cli_backends_provided = args.backend is not None or args.backends is not None
657
658 # Backend(s) - only use YAML if CLI didn't specify
659 if not cli_backends_provided:
660 if "backend" in yaml_config:
661 args.backend = yaml_config["backend"]
662 args.backends = None
663 elif "backends" in yaml_config:
664 args.backends = yaml_config["backends"]
665 args.backend = None
666 elif "decode_backends" in yaml_config:
667 args.backends = yaml_config["decode_backends"]
668 args.backend = None
669
670 # Prefill backends (e.g., ["fa3", "fa4"])
671 args.prefill_backends = yaml_config.get("prefill_backends", None)
672 args.prefill_backend = yaml_config.get("prefill_backend", None)
673
674 # FP8 output benchmark knobs; CLI wins.
675 if args.fp8_output_scale is None:
676 args.fp8_output_scale = yaml_config.get("fp8_output_scale", None)
677 if args.fuse_quant_op is None:
678 args.fuse_quant_op = yaml_config.get("fuse_quant_op", None)
679
680 # Check for special modes
681 args.mode = yaml_config.get("mode", None)
682
683 # Batch specs and sizes
684 # Support both explicit batch_specs and generated batch_spec_ranges
685 # CLI --batch-specs takes precedence over YAML when provided.
686 cli_batch_specs_provided = args.batch_specs is not None
687 if not cli_batch_specs_provided:
688 if "batch_spec_ranges" in yaml_config:
689 # Generate batch specs from ranges
690 generated_specs = generate_batch_specs_from_ranges(
691 yaml_config["batch_spec_ranges"]
692 )
693 # Combine with any explicit batch_specs
694 if "batch_specs" in yaml_config:
695 args.batch_specs = yaml_config["batch_specs"] + generated_specs
696 else:
697 args.batch_specs = generated_specs
698 console.print(
699 f"[dim]Generated {len(generated_specs)} batch specs from ranges[/]"
700 )
701 elif "batch_specs" in yaml_config:
702 args.batch_specs = yaml_config["batch_specs"]
703
704 args.batch_sizes = yaml_config.get("batch_sizes", None)
705
706 # Model config
707 if "model" in yaml_config:
708 model = yaml_config["model"]
709 args.num_layers = model.get("num_layers", args.num_layers)
710 args.head_dim = model.get("head_dim", args.head_dim)
711 args.v_head_dim = model.get("v_head_dim", args.v_head_dim)
712 args.num_q_heads = model.get("num_q_heads", args.num_q_heads)
713 args.num_kv_heads = model.get("num_kv_heads", args.num_kv_heads)
714 args.block_size = model.get("block_size", args.block_size)
715 args.max_model_len = model.get(
716 "max_model_len", getattr(args, "max_model_len", None)
717 )
718 # MLA-specific dimensions
719 args.kv_lora_rank = model.get("kv_lora_rank", args.kv_lora_rank)
720 args.qk_nope_head_dim = model.get("qk_nope_head_dim", args.qk_nope_head_dim)
721 args.qk_rope_head_dim = model.get("qk_rope_head_dim", args.qk_rope_head_dim)
722
723 # Benchmark settings (top-level keys)
724 if "device" in yaml_config:
725 args.device = yaml_config["device"]
726 if "warmup_ms" in yaml_config:
727 args.warmup_ms = yaml_config["warmup_ms"]
728 if "profile_memory" in yaml_config:
729 args.profile_memory = yaml_config["profile_memory"]
730 if "kv_cache_dtype" in yaml_config:
731 args.kv_cache_dtype = yaml_config["kv_cache_dtype"]
732 if "cuda_graphs" in yaml_config:
733 args.cuda_graphs = yaml_config["cuda_graphs"]
734 if "ncu_profile" in yaml_config:
735 args.ncu_profile = yaml_config["ncu_profile"]
736 if "torch_profile" in yaml_config:
737 args.torch_profile = yaml_config["torch_profile"]
738 if "torch_profile_dir" in yaml_config:
739 args.torch_profile_dir = yaml_config["torch_profile_dir"]
740 if "torch_profile_iters" in yaml_config:
741 args.torch_profile_iters = yaml_config["torch_profile_iters"]
742 args.sparse_mla_topk_pattern = yaml_config.get(
743 "sparse_mla_topk_pattern", "random"
744 )
745 args.sparse_mla_dense_mha_max_seq_len = yaml_config.get(
746 "sparse_mla_dense_mha_max_seq_len", None
747 )
748 args.sparse_mla_mha_variants = yaml_config.get(
749 "sparse_mla_mha_variants", args.sparse_mla_mha_variants
750 )
751 args.sparse_mla_masked_mha_max_seq_len = yaml_config.get(
752 "sparse_mla_masked_mha_max_seq_len", None
753 )
754
755 # Parameter sweep configuration
756 if "parameter_sweep" in yaml_config:
757 sweep_config = yaml_config["parameter_sweep"]
758 args.parameter_sweep = ParameterSweep(
759 param_name=sweep_config["param_name"],
760 values=sweep_config["values"],
761 include_auto=sweep_config.get("include_auto", False),
762 label_format=sweep_config.get(
763 "label_format", "{backend}_{param_name}_{value}"
764 ),
765 )
766 else:
767 args.parameter_sweep = None
768
769 # Model parameter sweep configuration
770 if "model_parameter_sweep" in yaml_config:
771 sweep_config = yaml_config["model_parameter_sweep"]
772 args.model_parameter_sweep = ModelParameterSweep(
773 param_name=sweep_config.get("param_name"),
774 values=sweep_config["values"],
775 label_format=sweep_config.get(
776 "label_format", "{backend}_{param_name}_{value}"
777 ),
778 )
779 else:
780 args.model_parameter_sweep = None
781
782 # Output
783 if "output" in yaml_config:
784 output = yaml_config["output"]
785 if "csv" in output and not args.output_csv:
786 args.output_csv = output["csv"]
787 if "json" in output and not args.output_json:
788 args.output_json = output["json"]
789
790 console.print()
791
792 # Re-exec under ncu if --ncu-profile and not already inside ncu. This runs
793 # after YAML processing so ncu_profile set via config file is honored.
794 if args.ncu_profile and "_NCU_INNER" not in os.environ:
795 ncu = shutil.which("ncu")
796 if ncu is None:
797 print("Error: 'ncu' not found in PATH", file=sys.stderr)
798 sys.exit(1)
799 cmd = [
800 ncu,
801 "--profile-from-start",
802 "off",
803 "--set",
804 "full",
805 "--import-source",
806 "yes",
807 "-o",
808 args.ncu_output,
809 sys.executable,
810 *sys.argv,
811 ]
812 env = os.environ.copy()
813 env["CUTE_DSL_LINEINFO"] = "1"
814 env["_NCU_INNER"] = "1"
815 print(f"Launching: {' '.join(cmd)}")
816 sys.exit(subprocess.call(cmd, env=env))
817
818 # Handle CLI-based parameter sweep (if not from YAML)
819 if (
820 (not hasattr(args, "parameter_sweep") or args.parameter_sweep is None)
821 and args.sweep_param
822 and args.sweep_values
823 ):
824 args.parameter_sweep = ParameterSweep(
825 param_name=args.sweep_param,
826 values=args.sweep_values,
827 include_auto=False,
828 label_format="{backend}_{param_name}_{value}",
829 )
830
831 # Determine backends
832 backends = args.backends or ([args.backend] if args.backend else ["flash"])
833 prefill_backends = getattr(args, "prefill_backends", None)
834 if not args.batch_specs:
835 args.batch_specs = ["q2k", "8q1s1k"]
836 console.print(f"Backends: {', '.join(backends)}")
837 if prefill_backends:
838 console.print(f"Prefill backends: {', '.join(prefill_backends)}")
839 console.print(f"Batch specs: {', '.join(args.batch_specs)}")
840 console.print(f"KV cache dtype: {args.kv_cache_dtype}")
841 console.print(f"CUDA graphs: {args.cuda_graphs}")
842 if args.warmup_ms is not None and args.cuda_graphs:
843 console.print(
844 "[yellow]Warning: --warmup-ms is ignored with CUDA graphs "
845 "(do_bench_cudagraph warms up internally). Pass --no-cuda-graphs "
846 "to use it.[/]"
847 )
848 if args.num_splits == 0 and args.cuda_graphs:
849 console.print(
850 "[yellow]Warning: --num-splits 0 (FA3 heuristic) is not CUDA-graph "
851 "compatible and may fail or fall back. Pass --no-cuda-graphs or use "
852 "--num-splits >=1.[/]"
853 )
854 console.print()
855
856 init_workspace_manager(args.device)
857
858 # Run benchmarks
859 all_results = []
860
861 # Under ncu profiling the kernels run only to be captured by the profiler;
862 # timings are placeholder zeros, so the result tables and saved metrics are
863 # skipped. The Nsight Compute report (--ncu-output) holds the real data.
864 if args.ncu_profile:
865 console.print(
866 "[dim]ncu profiling enabled: result tables and saved metrics are "
867 "skipped (timings are placeholder zeros).[/]"
868 )
869
870 # FA4 fused FP8 output vs standalone post-quant, on the same fa4 kernel:
871 # the delta is the post-quant kernel the fused path removes.
872 fp8_output_scale = getattr(args, "fp8_output_scale", None)
873 if fp8_output_scale is not None:
874 decode_backend = backends[0]
875 fuse_variants = args.fuse_quant_op or [False, True]
876 label_of = {False: "post_quant", True: "fused"}
877 console.print(
878 f"[yellow]FP8 output comparison @ scale={fp8_output_scale} "
879 f"(prefill=fa4, decode impl={decode_backend})[/]"
880 )
881 fp8_results = []
882 total = len(fuse_variants) * len(args.batch_specs)
883 with tqdm(total=total, desc="FP8 output benchmarking") as pbar:
884 for spec in args.batch_specs:
885 for fuse in fuse_variants:
886 config = BenchmarkConfig(
887 backend=decode_backend,
888 batch_spec=spec,
889 num_layers=args.num_layers,
890 head_dim=args.head_dim,
891 num_q_heads=args.num_q_heads,
892 num_kv_heads=args.num_kv_heads,
893 block_size=args.block_size,
894 device=args.device,
895 profile_memory=args.profile_memory,
896 kv_cache_dtype=args.kv_cache_dtype,
897 use_cuda_graphs=args.cuda_graphs,
898 prefill_backend="fa4",
899 )
900 result = run_benchmark(
901 config, output_scale=fp8_output_scale, fuse_quant_op=fuse
902 )
903 label = label_of[fuse]
904 labeled_config = replace(result.config, backend=label)
905 result = replace(result, config=labeled_config)
906 fp8_results.append(result)
907
908 if not result.success:
909 console.print(f"[red]Error {label} {spec}: {result.error}[/]")
910
911 pbar.update(1)
912
913 console.print("\n[bold green]FP8 Output Results:[/]")
914 formatter = ResultsFormatter(console)
915 labels = [label_of[f] for f in fuse_variants]
916 formatter.print_table(fp8_results, labels, compare_to_fastest=True)
917 all_results = fp8_results
918
919 # Handle special mode: decode_vs_prefill comparison
920 elif hasattr(args, "mode") and args.mode == "decode_vs_prefill":
921 console.print("[yellow]Mode: Decode vs Prefill pipeline comparison[/]")
922 console.print(
923 "[dim]For each query length, testing both decode and prefill pipelines[/]"
924 )
925 console.print("[dim]Using batched execution for optimal performance[/]")
926
927 # Extract batch sizes from config
928 batch_sizes = getattr(args, "batch_sizes", [1])
929 backend = backends[0] # Use first backend (should only be one)
930
931 # Calculate total benchmarks
932 total = len(batch_sizes)
933
934 with tqdm(total=total, desc="Benchmarking") as pbar:
935 for batch_size in batch_sizes:
936 # Prepare all configs for this batch size
937 configs_with_thresholds = []
938
939 for spec in args.batch_specs:
940 # Parse the batch spec to get query length
941 requests = parse_batch_spec(spec)
942 if not requests:
943 console.print(
944 f"[red]Error: Could not parse batch spec '{spec}'[/]"
945 )
946 continue
947
948 # Get query length from first request
949 query_length = requests[0].q_len
950
951 # Create batch spec for this batch size
952 # For batch_size > 1, we need to prepend the count
953 batch_spec = f"{batch_size}{spec}" if batch_size > 1 else spec
954
955 # Create base config (without backend name)
956 base_config = BenchmarkConfig(
957 backend=backend, # Will be overridden later
958 batch_spec=batch_spec,
959 num_layers=args.num_layers,
960 head_dim=args.head_dim,
961 num_q_heads=args.num_q_heads,
962 num_kv_heads=args.num_kv_heads,
963 block_size=args.block_size,
964 device=args.device,
965 profile_memory=args.profile_memory,
966 kv_cache_dtype=args.kv_cache_dtype,
967 use_cuda_graphs=args.cuda_graphs,
968 ncu_profile=args.ncu_profile,
969 warmup_ms=args.warmup_ms,
970 )
971
972 # Add decode pipeline config
973 decode_threshold = query_length
974 config_decode = replace(
975 base_config,
976 backend=f"{backend}_decode_qlen{query_length}_bs{batch_size}",
977 )
978 configs_with_thresholds.append((config_decode, decode_threshold))
979
980 # Add prefill pipeline config if query_length > 1
981 if query_length > 1:
982 prefill_threshold = query_length - 1
983 config_prefill = replace(
984 base_config,
985 backend=f"{backend}_prefill_qlen{query_length}"
986 f"_bs{batch_size}",
987 )
988 configs_with_thresholds.append(
989 (config_prefill, prefill_threshold)
990 )
991
992 # Run all benchmarks for this batch size in one go (batched mode)
993 try:
994 from mla_runner import run_mla_benchmark as run_mla
995
996 # Use batched API: pass list of (config, threshold) tuples
997 timing_results = run_mla(backend, configs_with_thresholds)
998
999 # Create BenchmarkResult objects from timing results
1000 for (config, _), timing in zip(
1001 configs_with_thresholds, timing_results
1002 ):
1003 result = BenchmarkResult(
1004 config=config,
1005 mean_time=timing["mean"],
1006 median_time=timing.get("median", timing["mean"]),
1007 std_time=timing["std"],
1008 min_time=timing["min"],
1009 max_time=timing["max"],
1010 throughput_tokens_per_sec=timing.get("throughput", None),
1011 )
1012 all_results.append(result)
1013
1014 except Exception as e:
1015 import traceback
1016
1017 console.print(
1018 f"[red]Error running batched benchmarks for "
1019 f"batch_size={batch_size}: {e}[/]"
1020 )
1021 console.print("[red]Traceback:[/]")
1022 traceback.print_exc()
1023 # Add error results for all configs
1024 for config, _ in configs_with_thresholds:
1025 result = BenchmarkResult(
1026 config=config,
1027 mean_time=float("inf"),
1028 median_time=float("inf"),
1029 std_time=0,
1030 min_time=float("inf"),
1031 max_time=float("inf"),
1032 error=str(e),
1033 )
1034 all_results.append(result)
1035
1036 pbar.update(1)
1037
1038 if args.ncu_profile:
1039 return
1040
1041 # Display decode vs prefill results
1042 console.print("\n[bold green]Decode vs Prefill Results:[/]")
1043
1044 # Group by batch size
1045 by_batch_size = {}
1046 for r in all_results:
1047 if r.success:
1048 # Extract batch size from backend name
1049 parts = r.config.backend.split("_")
1050 bs_part = [p for p in parts if p.startswith("bs")]
1051 if bs_part:
1052 bs = int(bs_part[0][2:])
1053 if bs not in by_batch_size:
1054 by_batch_size[bs] = []
1055 by_batch_size[bs].append(r)
1056
1057 # For each batch size, analyze crossover point
1058 for bs in sorted(by_batch_size.keys()):
1059 console.print(f"\n[bold cyan]Batch size: {bs}[/]")
1060 results = by_batch_size[bs]
1061
1062 # Group by query length
1063 by_qlen = {}
1064 for r in results:
1065 parts = r.config.backend.split("_")
1066 qlen_part = [p for p in parts if p.startswith("qlen")]
1067 if qlen_part:
1068 qlen = int(qlen_part[0][4:])
1069 if qlen not in by_qlen:
1070 by_qlen[qlen] = {}
1071
1072 pipeline = "decode" if "decode" in r.config.backend else "prefill"
1073 by_qlen[qlen][pipeline] = r
1074
1075 # Find crossover point
1076 last_decode_faster = None
1077 for qlen in sorted(by_qlen.keys()):
1078 pipelines = by_qlen[qlen]
1079 if "decode" in pipelines and "prefill" in pipelines:
1080 decode_time = pipelines["decode"].mean_time
1081 prefill_time = pipelines["prefill"].mean_time
1082 faster = "decode" if decode_time < prefill_time else "prefill"
1083
1084 speedup = (
1085 prefill_time / decode_time
1086 if decode_time < prefill_time
1087 else decode_time / prefill_time
1088 )
1089
1090 console.print(
1091 f" qlen={qlen:3d}: decode={decode_time:.6f}s, "
1092 f"prefill={prefill_time:.6f}s -> "
1093 f"[bold]{faster}[/] ({speedup:.2f}x)"
1094 )
1095
1096 if faster == "decode":
1097 last_decode_faster = qlen
1098
1099 if last_decode_faster is not None:
1100 optimal_threshold = last_decode_faster
1101 console.print(
1102 f"\n [bold green]Optimal threshold for batch_size={bs}: "
1103 f"{optimal_threshold}[/]"
1104 )
1105 console.print(
1106 f" [dim](Use decode pipeline for query_length <= "
1107 f"{optimal_threshold})[/]"
1108 )
1109 else:
1110 console.print(
1111 f"\n [yellow]Prefill always faster for batch_size={bs}[/]"
1112 )
1113
1114 # Handle MHA vs MQA comparison mode for sparse MLA
1115 elif hasattr(args, "mode") and args.mode == "mha_vs_mqa":
1116 console.print("[yellow]Mode: MHA vs MQA comparison for sparse MLA[/]")
1117
1118 sparse_mla_topk_pattern = getattr(args, "sparse_mla_topk_pattern", "random")
1119 dense_mha_max_seq_len = getattr(args, "sparse_mla_dense_mha_max_seq_len", None)
1120 prefill_backend = getattr(args, "prefill_backend", None)
1121 if prefill_backend:
1122 console.print(f"Prefill backend: {prefill_backend}")
1123 available_variants = [
1124 ("dense_mha", False, "dense"),
1125 ("masked_mha", False, "masked"),
1126 ("mqa", True, "auto"),
1127 ]
1128 requested_variants = getattr(args, "sparse_mla_mha_variants", None)
1129 if requested_variants is not None:
1130 valid_variants = {label for label, _, _ in available_variants}
1131 invalid_variants = sorted(set(requested_variants) - valid_variants)
1132 if invalid_variants:
1133 raise ValueError(
1134 "Invalid sparse_mla_mha_variants entries: "
1135 f"{invalid_variants}. Valid variants are: "
1136 f"{sorted(valid_variants)}"
1137 )
1138 requested_variant_set = set(requested_variants)
1139 variants = [
1140 variant
1141 for variant in available_variants
1142 if variant[0] in requested_variant_set
1143 ]
1144 else:
1145 variants = available_variants
1146 model_sweep = getattr(args, "model_parameter_sweep", None)
1147 if model_sweep is None:
1148 num_q_heads_values = [args.num_q_heads]
1149 elif model_sweep.param_name == "num_q_heads":
1150 num_q_heads_values = model_sweep.values
1151 else:
1152 raise ValueError(
1153 "mha_vs_mqa mode only supports a num_q_heads model parameter sweep"
1154 )
1155 benchmark_specs = [
1156 (spec, num_q_heads)
1157 for spec in args.batch_specs
1158 for num_q_heads in num_q_heads_values
1159 ]
1160 masked_mha_max_seq_len = getattr(
1161 args, "sparse_mla_masked_mha_max_seq_len", None
1162 )
1163 formatter = ResultsFormatter(console)
1164 total = 0
1165 for spec, _ in benchmark_specs:
1166 q_len = max(request.q_len for request in parse_batch_spec(spec))
1167 for variant_label, _, _ in variants:
1168 if (
1169 variant_label == "dense_mha"
1170 and dense_mha_max_seq_len is not None
1171 and q_len > dense_mha_max_seq_len
1172 ) or (
1173 variant_label == "masked_mha"
1174 and masked_mha_max_seq_len is not None
1175 and q_len > masked_mha_max_seq_len
1176 ):
1177 continue
1178 total += len(backends)
1179
1180 with tqdm(total=total, desc="Benchmarking") as pbar:
1181 for spec, num_q_heads in benchmark_specs:
1182 q_len = max(request.q_len for request in parse_batch_spec(spec))
1183 for backend in backends:
1184 for variant_label, force_mqa, mha_mode in variants:
1185 if (
1186 variant_label == "dense_mha"
1187 and dense_mha_max_seq_len is not None
1188 and q_len > dense_mha_max_seq_len
1189 ) or (
1190 variant_label == "masked_mha"
1191 and masked_mha_max_seq_len is not None
1192 and q_len > masked_mha_max_seq_len
1193 ):
1194 continue
1195 result_backend = f"{backend}_{variant_label}"
1196 if model_sweep is not None:
1197 result_backend = model_sweep.get_label(
1198 result_backend, num_q_heads
1199 )
1200 config = BenchmarkConfig(
1201 backend=result_backend,
1202 batch_spec=spec,
1203 num_layers=args.num_layers,
1204 head_dim=args.head_dim,
1205 num_q_heads=num_q_heads,
1206 num_kv_heads=args.num_kv_heads,
1207 block_size=args.block_size,
1208 device=args.device,
1209 max_model_len=getattr(args, "max_model_len", None),
1210 kv_cache_dtype=args.kv_cache_dtype,
1211 profile_memory=args.profile_memory,
1212 use_cuda_graphs=args.cuda_graphs,
1213 ncu_profile=args.ncu_profile,
1214 torch_profile=args.torch_profile,
1215 torch_profile_dir=args.torch_profile_dir,
1216 torch_profile_iters=args.torch_profile_iters,
1217 warmup_ms=args.warmup_ms,
1218 kv_lora_rank=getattr(args, "kv_lora_rank", None),
1219 qk_nope_head_dim=getattr(args, "qk_nope_head_dim", None),
1220 qk_rope_head_dim=getattr(args, "qk_rope_head_dim", None),
1221 v_head_dim=getattr(args, "v_head_dim", None),
1222 sparse_mla_force_mqa=force_mqa,
1223 sparse_mla_mha_mode=mha_mode,
1224 sparse_mla_dense_mha_max_seq_len=dense_mha_max_seq_len,
1225 sparse_mla_topk_pattern=sparse_mla_topk_pattern,
1226 prefill_backend=prefill_backend,
1227 sparse_mla_masked_mha_max_seq_len=masked_mha_max_seq_len,
1228 )
1229
1230 # run_mla_benchmark needs the real backend name
1231 from mla_runner import run_mla_benchmark as run_mla
1232
1233 run_label = f"{backend}_{variant_label} {num_q_heads}h {spec}"
1234 pbar.set_postfix_str(run_label)
1235
1236 try:
1237 result = run_mla(
1238 backend,
1239 config,
1240 prefill_backend=prefill_backend,
1241 sparse_mla_force_mqa=force_mqa,
1242 )
1243 except Exception as e:
1244 result = BenchmarkResult(
1245 config=config,
1246 mean_time=float("inf"),
1247 median_time=float("inf"),
1248 std_time=0,
1249 min_time=float("inf"),
1250 max_time=float("inf"),
1251 error=str(e),
1252 )
1253
1254 all_results.append(result)
1255 if args.output_csv:
1256 formatter.save_csv(all_results, args.output_csv)
1257 if args.output_json:
1258 formatter.save_json(all_results, args.output_json)
1259
1260 if not result.success:
1261 console.print(
1262 f"[red]Error {backend}_{variant_label} "
1263 f"{spec}: {result.error}[/]"
1264 )
1265
1266 pbar.update(1)
1267
1268 # Display results with variant labels as separate "backends"
1269 console.print("\n[bold green]MHA vs MQA Results:[/]")
1270 variant_backends = []
1271 for num_q_heads in num_q_heads_values:
1272 for backend in backends:
1273 for variant, _, _ in variants:
1274 label = f"{backend}_{variant}"
1275 if model_sweep is not None:
1276 label = model_sweep.get_label(label, num_q_heads)
1277 variant_backends.append(label)
1278 formatter.print_table(all_results, variant_backends)
1279
1280 # Handle model parameter sweep mode
1281 elif hasattr(args, "model_parameter_sweep") and args.model_parameter_sweep:
1282 # Model parameter sweep
1283 base_config_args = {
1284 "num_layers": args.num_layers,
1285 "head_dim": args.head_dim,
1286 "v_head_dim": args.v_head_dim,
1287 "num_q_heads": args.num_q_heads,
1288 "num_kv_heads": args.num_kv_heads,
1289 "block_size": args.block_size,
1290 "device": args.device,
1291 "profile_memory": args.profile_memory,
1292 "kv_cache_dtype": args.kv_cache_dtype,
1293 "use_cuda_graphs": args.cuda_graphs,
1294 "ncu_profile": args.ncu_profile,
1295 "warmup_ms": args.warmup_ms,
1296 "num_splits": args.num_splits,
1297 "kv_lora_rank": args.kv_lora_rank,
1298 "qk_nope_head_dim": args.qk_nope_head_dim,
1299 "qk_rope_head_dim": args.qk_rope_head_dim,
1300 }
1301 all_results = run_model_parameter_sweep(
1302 backends,
1303 args.batch_specs,
1304 base_config_args,
1305 args.model_parameter_sweep,
1306 console,
1307 )
1308
1309 # Handle parameter sweep mode (unified)
1310 elif hasattr(args, "parameter_sweep") and args.parameter_sweep:
1311 # Unified parameter sweep
1312 base_config_args = {
1313 "num_layers": args.num_layers,
1314 "head_dim": args.head_dim,
1315 "v_head_dim": args.v_head_dim,
1316 "num_q_heads": args.num_q_heads,
1317 "num_kv_heads": args.num_kv_heads,
1318 "block_size": args.block_size,
1319 "device": args.device,
1320 "profile_memory": args.profile_memory,
1321 "kv_cache_dtype": args.kv_cache_dtype,
1322 "use_cuda_graphs": args.cuda_graphs,
1323 "ncu_profile": args.ncu_profile,
1324 "warmup_ms": args.warmup_ms,
1325 "num_splits": args.num_splits,
1326 }
1327 all_results = run_parameter_sweep(
1328 backends, args.batch_specs, base_config_args, args.parameter_sweep, console
1329 )
1330
1331 else:
1332 # Normal mode: compare backends
1333 decode_results = []
1334 prefill_results = []
1335
1336 # Run decode backend comparison
1337 if not prefill_backends:
1338 # No prefill backends specified: compare decode backends as before
1339 total = len(backends) * len(args.batch_specs)
1340
1341 with tqdm(total=total, desc="Benchmarking") as pbar:
1342 for spec in args.batch_specs:
1343 for backend in backends:
1344 config = BenchmarkConfig(
1345 backend=backend,
1346 batch_spec=spec,
1347 num_layers=args.num_layers,
1348 head_dim=args.head_dim,
1349 v_head_dim=getattr(args, "v_head_dim", None),
1350 num_q_heads=args.num_q_heads,
1351 num_kv_heads=args.num_kv_heads,
1352 block_size=args.block_size,
1353 device=args.device,
1354 profile_memory=args.profile_memory,
1355 kv_cache_dtype=args.kv_cache_dtype,
1356 use_cuda_graphs=args.cuda_graphs,
1357 ncu_profile=args.ncu_profile,
1358 warmup_ms=args.warmup_ms,
1359 num_splits=args.num_splits,
1360 )
1361
1362 result = run_benchmark(config)
1363 decode_results.append(result)
1364
1365 if not result.success:
1366 console.print(
1367 f"[red]Error {backend} {spec}: {result.error}[/]"
1368 )
1369
1370 pbar.update(1)
1371
1372 if not args.ncu_profile:
1373 console.print("\n[bold green]Results:[/]")
1374 formatter = ResultsFormatter(console)
1375 formatter.print_table(decode_results, backends)
1376
1377 # Run prefill backend comparison
1378 if prefill_backends:
1379 # Use first decode backend for impl construction
1380 decode_backend = backends[0]
1381 total = len(prefill_backends) * len(args.batch_specs)
1382
1383 console.print(
1384 f"[yellow]Prefill comparison mode: "
1385 f"using {decode_backend} for decode impl[/]"
1386 )
1387
1388 with tqdm(total=total, desc="Prefill benchmarking") as pbar:
1389 for spec in args.batch_specs:
1390 for pb in prefill_backends:
1391 config = BenchmarkConfig(
1392 backend=decode_backend,
1393 batch_spec=spec,
1394 num_layers=args.num_layers,
1395 head_dim=args.head_dim,
1396 num_q_heads=args.num_q_heads,
1397 num_kv_heads=args.num_kv_heads,
1398 block_size=args.block_size,
1399 device=args.device,
1400 profile_memory=args.profile_memory,
1401 warmup_ms=args.warmup_ms,
1402 prefill_backend=pb,
1403 kv_lora_rank=args.kv_lora_rank,
1404 qk_nope_head_dim=args.qk_nope_head_dim,
1405 qk_rope_head_dim=args.qk_rope_head_dim,
1406 v_head_dim=args.v_head_dim,
1407 )
1408
1409 result = run_benchmark(config)
1410
1411 # Label result with prefill backend name for display
1412 labeled_config = replace(result.config, backend=pb)
1413 result = replace(result, config=labeled_config)
1414 prefill_results.append(result)
1415
1416 if not result.success:
1417 console.print(f"[red]Error {pb} {spec}: {result.error}[/]")
1418
1419 pbar.update(1)
1420
1421 if not args.ncu_profile:
1422 console.print("\n[bold green]Prefill Backend Results:[/]")
1423 formatter = ResultsFormatter(console)
1424 formatter.print_table(
1425 prefill_results, prefill_backends, compare_to_fastest=True
1426 )
1427
1428 all_results = decode_results + prefill_results
1429
1430 # Save results (skip ncu profiling runs: timings are placeholder zeros)
1431 if all_results and not args.ncu_profile:
1432 formatter = ResultsFormatter(console)
1433 if args.output_csv:
1434 formatter.save_csv(all_results, args.output_csv)
1435 if args.output_json:
1436 formatter.save_json(all_results, args.output_json)
1437
1438
1439 if __name__ == "__main__":
1440 main()