main
py 561 lines 18.9 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 """Common utilities for attention benchmarking."""
5
6 import csv
7 import gc
8 import json
9 import math
10 from collections.abc import Sequence
11 from dataclasses import asdict, dataclass
12 from pathlib import Path
13 from typing import Any
14
15 import torch
16 from batch_spec import get_batch_type, parse_batch_spec
17 from rich.console import Console
18 from rich.table import Table
19
20 from vllm.triton_utils import triton
21
22
23 def batch_spec_sort_key(spec: str) -> tuple[int, int, int]:
24 """Extract sorting key from batch spec: (batch_size, max_q_len, max_kv_len).
25
26 This ensures results are sorted by batch size first, then query length,
27 then sequence length, rather than alphabetically.
28 """
29 try:
30 requests = parse_batch_spec(spec)
31 batch_size = len(requests)
32 max_q_len = max(r.q_len for r in requests) if requests else 0
33 max_kv_len = max(r.kv_len for r in requests) if requests else 0
34 return (batch_size, max_q_len, max_kv_len)
35 except Exception:
36 # Fallback for unparsable specs
37 return (0, 0, 0)
38
39
40 def run_do_bench(
41 benchmark_fn,
42 use_cuda_graphs: bool,
43 warmup_ms: int | None = None,
44 ) -> list[float]:
45 kwargs: dict[str, Any] = {"return_mode": "all"}
46 if use_cuda_graphs:
47 result = triton.testing.do_bench_cudagraph(benchmark_fn, **kwargs)
48 gc.collect()
49 torch.accelerator.empty_cache()
50 else:
51 if warmup_ms is not None:
52 kwargs["warmup"] = warmup_ms
53 result = triton.testing.do_bench(benchmark_fn, **kwargs)
54 torch.accelerator.synchronize()
55 return result
56
57
58 def run_ncu_profile(benchmark_fn) -> None:
59 benchmark_fn()
60 torch.accelerator.synchronize()
61 torch.cuda.cudart().cudaProfilerStart()
62 benchmark_fn()
63 torch.accelerator.synchronize()
64 torch.cuda.cudart().cudaProfilerStop()
65
66
67 # Mock classes for vLLM attention infrastructure
68
69
70 class MockHfConfig:
71 """Mock HuggingFace config that satisfies vLLM's requirements."""
72
73 def __init__(self, mla_dims: dict, index_topk: int | None = None):
74 self.num_attention_heads = mla_dims["num_q_heads"]
75 self.num_key_value_heads = mla_dims["num_kv_heads"]
76 self.hidden_size = mla_dims["head_dim"] * mla_dims["num_q_heads"]
77 self.model_type = "deepseek_v2"
78 self.is_encoder_decoder = False
79 self.kv_lora_rank = mla_dims["kv_lora_rank"]
80 self.qk_nope_head_dim = mla_dims["qk_nope_head_dim"]
81 self.qk_rope_head_dim = mla_dims["qk_rope_head_dim"]
82 self.v_head_dim = mla_dims["v_head_dim"]
83 self.qk_head_dim = mla_dims["qk_nope_head_dim"] + mla_dims["qk_rope_head_dim"]
84 if index_topk is not None:
85 self.index_topk = index_topk
86
87 def get_text_config(self):
88 return self
89
90
91 # Import AttentionLayerBase at module level to avoid circular dependencies
92 try:
93 from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
94 except ImportError:
95 AttentionLayerBase = object # Fallback
96
97
98 class MockIndexer:
99 """Mock Indexer for sparse MLA backends.
100
101 Provides topk_indices_buffer that sparse MLA backends use to determine
102 which KV cache slots to attend to for each token.
103 """
104
105 def __init__(
106 self,
107 max_num_tokens: int,
108 topk_tokens: int,
109 device: torch.device,
110 ):
111 self.topk_tokens = topk_tokens
112 self.topk_indices_buffer = torch.zeros(
113 (max_num_tokens, topk_tokens),
114 dtype=torch.int32,
115 device=device,
116 )
117
118 def fill_random_indices(self, num_tokens: int, max_kv_len: int):
119 """Fill topk_indices_buffer with random valid indices for benchmarking."""
120 indices = torch.randint(
121 0,
122 max_kv_len,
123 (num_tokens, self.topk_tokens),
124 dtype=torch.int32,
125 device=self.topk_indices_buffer.device,
126 )
127 self.topk_indices_buffer[:num_tokens] = indices
128
129 def fill_indices(
130 self,
131 num_tokens: int,
132 max_kv_len: int,
133 pattern: str = "random",
134 requests: Sequence[Any] | None = None,
135 ):
136 if pattern == "random":
137 self.fill_random_indices(num_tokens, max_kv_len)
138 return
139 if pattern == "prefix":
140 indices = torch.arange(
141 self.topk_tokens,
142 dtype=torch.int32,
143 device=self.topk_indices_buffer.device,
144 )
145 indices = (indices % max_kv_len).expand(num_tokens, -1)
146 self.topk_indices_buffer[:num_tokens] = indices
147 return
148 if pattern == "sliding_window":
149 if requests is None:
150 start = max(max_kv_len - self.topk_tokens, 0)
151 indices = torch.arange(
152 start,
153 start + self.topk_tokens,
154 dtype=torch.int32,
155 device=self.topk_indices_buffer.device,
156 )
157 indices = indices.clamp(max=max_kv_len - 1).expand(num_tokens, -1)
158 self.topk_indices_buffer[:num_tokens] = indices
159 return
160
161 rows = []
162 offsets = torch.arange(
163 self.topk_tokens,
164 dtype=torch.int32,
165 device=self.topk_indices_buffer.device,
166 ) - (self.topk_tokens - 1)
167 for request in requests:
168 q_len = request.q_len
169 kv_len = request.kv_len
170 context_len = kv_len - q_len
171 positions = torch.arange(
172 context_len,
173 kv_len,
174 dtype=torch.int32,
175 device=self.topk_indices_buffer.device,
176 )
177 row_indices = positions[:, None] + offsets[None, :]
178 rows.append(row_indices.clamp(min=0, max=kv_len - 1))
179 self.topk_indices_buffer[:num_tokens] = torch.cat(rows, dim=0)
180 return
181 raise ValueError(f"Unknown sparse MLA topk pattern: {pattern}")
182
183
184 class MockLayer(AttentionLayerBase):
185 """Mock attention layer with scale parameters and impl.
186
187 Inherits from AttentionLayerBase so it passes isinstance checks
188 in get_layers_from_vllm_config when FlashInfer prefill is enabled.
189 """
190
191 def __init__(self, device: torch.device, impl=None, kv_cache_spec=None):
192 # Don't call super().__init__() as AttentionLayerBase doesn't have __init__
193 self._k_scale = torch.tensor(1.0, device=device)
194 self._v_scale = torch.tensor(1.0, device=device)
195 self._q_scale = torch.tensor(1.0, device=device)
196 # Scalar floats for kernels that need them
197 self._k_scale_float = float(self._k_scale.item())
198 self._v_scale_float = float(self._v_scale.item())
199 self._q_scale_float = float(self._q_scale.item())
200 # AttentionImpl for metadata builders to query
201 self.impl = impl
202 # KV cache spec for get_kv_cache_spec
203 self._kv_cache_spec = kv_cache_spec
204
205 def get_attn_backend(self):
206 """Get the attention backend class (required by AttentionLayerBase)."""
207 # Return None as this is just a mock layer for benchmarking
208 return None
209
210 def get_kv_cache_spec(self):
211 """Get the KV cache spec (required by AttentionLayerBase)."""
212 return self._kv_cache_spec
213
214
215 @dataclass
216 class ParameterSweep:
217 """Configuration for sweeping a backend parameter."""
218
219 param_name: str # Name of the backend parameter to sweep
220 values: list[Any] # List of values to test
221 include_auto: bool = False # Also test with param unset (auto mode)
222 label_format: str = "{backend}_{param_name}_{value}" # Result label template
223
224 def get_label(self, backend: str, value: Any) -> str:
225 """Generate a label for a specific parameter value."""
226 return self.label_format.format(
227 backend=backend, param_name=self.param_name, value=value
228 )
229
230
231 @dataclass
232 class ModelParameterSweep:
233 """Configuration for sweeping model configuration parameter(s).
234
235 Supports two modes:
236 - Single param: param_name="head_dim", values=[128, 256, 512]
237 - Multi param: values=[{head_dim: 192, v_head_dim: 128}, {head_dim: 256}]
238 When values are dicts, each dict's keys are applied as config overrides.
239 """
240
241 param_name: str | None = None
242 values: list[Any] | None = None
243 label_format: str = "{backend}_{param_name}_{value}"
244
245 def get_label(self, backend: str, value: Any) -> str:
246 """Generate a label for a specific parameter value."""
247 if isinstance(value, dict):
248 return self.label_format.format(
249 backend=backend, param_name=self.param_name, value=value, **value
250 )
251 return self.label_format.format(
252 backend=backend, param_name=self.param_name, value=value
253 )
254
255 def apply(self, config_args: dict, value: Any) -> None:
256 """Apply a sweep value to config args."""
257 if isinstance(value, dict):
258 config_args.update(value)
259 elif self.param_name is not None:
260 config_args[self.param_name] = value
261 else:
262 raise ValueError("param_name must be set if sweep values are not dicts")
263
264
265 @dataclass
266 class BenchmarkConfig:
267 """Configuration for a single benchmark run."""
268
269 backend: str
270 batch_spec: str
271 num_layers: int
272 head_dim: int
273 num_q_heads: int
274 num_kv_heads: int
275 block_size: int
276 device: str
277 max_model_len: int | None = None
278 dtype: torch.dtype = torch.float16
279 profile_memory: bool = False
280 use_cuda_graphs: bool = True
281 ncu_profile: bool = False
282 torch_profile: bool = False
283 torch_profile_dir: str | None = None
284 torch_profile_iters: int = 3
285 warmup_ms: int | None = None
286
287 # "auto" or "fp8"
288 kv_cache_dtype: str = "auto"
289
290 # MLA-specific
291 prefill_backend: str | None = None
292 kv_lora_rank: int | None = None
293 qk_nope_head_dim: int | None = None
294 qk_rope_head_dim: int | None = None
295 v_head_dim: int | None = None
296
297 # Backend-specific tuning
298 num_kv_splits: int | None = None # CUTLASS MLA
299 reorder_batch_threshold: int | None = None # FlashAttn MLA, FlashMLA
300 sparse_mla_force_mqa: bool = False # Force MQA path for sparse MLA
301 sparse_mla_mha_mode: str = "auto" # "auto", "dense", or "masked"
302 sparse_mla_dense_mha_max_seq_len: int | None = None
303 sparse_mla_masked_mha_max_seq_len: int | None = None
304 sparse_mla_topk_pattern: str = "random" # "random", "prefix", "sliding_window"
305 num_splits: int | None = None # FlashAttention split-K (0=auto, 1=disabled)
306
307
308 @dataclass
309 class BenchmarkResult:
310 """Results from a single benchmark run."""
311
312 config: BenchmarkConfig
313 mean_time: float # seconds
314 median_time: float # seconds
315 std_time: float # seconds
316 min_time: float # seconds
317 max_time: float # seconds
318 throughput_tokens_per_sec: float | None = None
319 memory_allocated_mb: float | None = None
320 memory_reserved_mb: float | None = None
321 error: str | None = None
322
323 @property
324 def success(self) -> bool:
325 """Whether benchmark completed successfully."""
326 return self.error is None
327
328 def to_dict(self) -> dict[str, Any]:
329 """Convert to dictionary for serialization."""
330 return {
331 "config": asdict(self.config),
332 "mean_time": self.mean_time,
333 "median_time": self.median_time,
334 "std_time": self.std_time,
335 "min_time": self.min_time,
336 "max_time": self.max_time,
337 "throughput_tokens_per_sec": self.throughput_tokens_per_sec,
338 "memory_allocated_mb": self.memory_allocated_mb,
339 "memory_reserved_mb": self.memory_reserved_mb,
340 "error": self.error,
341 }
342
343
344 class ResultsFormatter:
345 """Format and display benchmark results."""
346
347 def __init__(self, console: Console | None = None):
348 self.console = console or Console()
349
350 def print_table(
351 self,
352 results: list[BenchmarkResult],
353 backends: list[str],
354 compare_to_fastest: bool = True,
355 ):
356 """Print results as a rich table.
357
358 Args:
359 results: List of BenchmarkResult
360 backends: List of backend names being compared
361 compare_to_fastest: Show percentage comparison to fastest
362
363 """
364 # Group by batch spec, preserving first-occurrence order
365 by_spec = {}
366 specs_order = []
367 for r in results:
368 spec = r.config.batch_spec
369 if spec not in by_spec:
370 by_spec[spec] = {}
371 specs_order.append(spec)
372 by_spec[spec][r.config.backend] = r
373
374 # Sort specs by (batch_size, q_len, kv_len) instead of alphabetically
375 specs_order = sorted(by_spec.keys(), key=batch_spec_sort_key)
376
377 # Create shortened backend names for display
378 def shorten_backend_name(name: str) -> str:
379 """Shorten long backend names for table display."""
380 # Remove common prefixes
381 name = name.replace("flashattn_mla", "famla")
382 name = name.replace("flashinfer_mla", "fimla")
383 name = name.replace("flashmla", "fmla")
384 name = name.replace("cutlass_mla", "cmla")
385 name = name.replace("numsplits", "ns")
386 return name
387
388 table = Table(title="Attention Benchmark Results")
389 table.add_column("Batch\nSpec", no_wrap=True)
390 table.add_column("Type", no_wrap=True)
391 table.add_column("Batch\nSize", justify="right", no_wrap=True)
392
393 multi = len(backends) > 1
394 for backend in backends:
395 short_name = shorten_backend_name(backend)
396 # Time column
397 col_time = f"{short_name}\nTime (s)"
398 table.add_column(col_time, justify="right", no_wrap=False)
399 if multi and compare_to_fastest:
400 # Relative performance column
401 col_rel = f"{short_name}\nvs Best"
402 table.add_column(col_rel, justify="right", no_wrap=False)
403
404 # Add rows
405 for spec in specs_order:
406 spec_results = by_spec[spec]
407 times = {b: r.mean_time for b, r in spec_results.items() if r.success}
408 best_time = min(times.values()) if times else 0.0
409
410 batch_type = get_batch_type(spec)
411 batch_size = len(parse_batch_spec(spec))
412 row = [spec, batch_type, str(batch_size)]
413 for backend in backends:
414 if backend in spec_results:
415 r = spec_results[backend]
416 if r.success:
417 row.append(f"{r.mean_time:.6f}")
418 if multi and compare_to_fastest:
419 pct = (
420 (r.mean_time / best_time * 100) if best_time > 0 else 0
421 )
422 pct_str = f"{pct:.1f}%"
423 if r.mean_time == best_time:
424 pct_str = f"[bold green]{pct_str}[/]"
425 row.append(pct_str)
426 else:
427 row.append("[red]ERROR[/]")
428 if multi and compare_to_fastest:
429 row.append("-")
430 else:
431 row.append("-")
432 if multi and compare_to_fastest:
433 row.append("-")
434
435 table.add_row(*row)
436
437 self.console.print(table)
438
439 def save_csv(self, results: list[BenchmarkResult], path: str):
440 """Save results to CSV file."""
441 if not results:
442 return
443
444 path_obj = Path(path)
445 path_obj.parent.mkdir(parents=True, exist_ok=True)
446
447 with open(path, "w", newline="") as f:
448 writer = csv.DictWriter(
449 f,
450 fieldnames=[
451 "backend",
452 "batch_spec",
453 "num_layers",
454 "kv_cache_dtype",
455 "mean_time",
456 "std_time",
457 "throughput",
458 "memory_mb",
459 ],
460 )
461 writer.writeheader()
462 for r in results:
463 writer.writerow(
464 {
465 "backend": r.config.backend,
466 "batch_spec": r.config.batch_spec,
467 "num_layers": r.config.num_layers,
468 "kv_cache_dtype": r.config.kv_cache_dtype,
469 "mean_time": r.mean_time,
470 "std_time": r.std_time,
471 "throughput": r.throughput_tokens_per_sec or 0,
472 "memory_mb": r.memory_allocated_mb or 0,
473 }
474 )
475
476 self.console.print(f"[green]Saved CSV results to {path}[/]")
477
478 def save_json(self, results: list[BenchmarkResult], path: str):
479 """Save results to JSON file."""
480 path_obj = Path(path)
481 path_obj.parent.mkdir(parents=True, exist_ok=True)
482
483 data = [r.to_dict() for r in results]
484 with open(path, "w") as f:
485 json.dump(data, f, indent=2, default=str)
486
487 self.console.print(f"[green]Saved JSON results to {path}[/]")
488
489
490 def setup_mla_dims(model_name: str = "deepseek-v3") -> dict:
491 """Get MLA dimensions for known models.
492
493 Args:
494 model_name: Model identifier
495
496 Returns:
497 Dict with MLA dimension configuration
498
499 """
500 configs = {
501 "deepseek-v2": {
502 "kv_lora_rank": 512,
503 "qk_nope_head_dim": 128,
504 "qk_rope_head_dim": 64,
505 "v_head_dim": 128,
506 "num_q_heads": 128,
507 "num_kv_heads": 1,
508 "head_dim": 576,
509 },
510 "deepseek-v3": {
511 "kv_lora_rank": 512,
512 "qk_nope_head_dim": 128,
513 "qk_rope_head_dim": 64,
514 "v_head_dim": 128,
515 "num_q_heads": 128,
516 "num_kv_heads": 1,
517 "head_dim": 576,
518 },
519 "deepseek-v2-lite": {
520 "kv_lora_rank": 512,
521 "qk_nope_head_dim": 128,
522 "qk_rope_head_dim": 64,
523 "v_head_dim": 128,
524 "num_q_heads": 16,
525 "num_kv_heads": 1,
526 "head_dim": 576,
527 },
528 }
529
530 if model_name not in configs:
531 raise ValueError(
532 f"Unknown model '{model_name}'. Known models: {list(configs.keys())}"
533 )
534
535 return configs[model_name]
536
537
538 def get_attention_scale(head_dim: int) -> float:
539 """Compute attention scale factor (1/sqrt(d))."""
540 return 1.0 / math.sqrt(head_dim)
541
542
543 def is_mla_backend(backend: str) -> bool:
544 """Check if backend is an MLA backend using the AttentionBackendEnum.
545
546 Args:
547 backend: Backend name matching AttentionBackendEnum exactly
548 (e.g., "FLASHMLA_SPARSE")
549
550 Returns:
551 True if the backend is an MLA backend, False otherwise
552
553 """
554 from vllm.v1.attention.backends.registry import AttentionBackendEnum
555
556 try:
557 backend_enum = AttentionBackendEnum[backend]
558 backend_class = backend_enum.get_class()
559 return backend_class.is_mla()
560 except (KeyError, ValueError, ImportError, AttributeError):
561 return False