main
py 1,828 lines 63.7 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 """Analytic flops/memory estimation module for transformer components,
5 to help derive MFU (Model Flops Utilization) stats for a running model.
6 """
7
8 import json
9 import time
10 from abc import ABC, abstractmethod
11 from collections.abc import Iterable
12 from dataclasses import asdict, dataclass, field
13 from typing import Any, Protocol
14
15 import prometheus_client
16 import torch
17 from pydantic import BaseModel, Field, ValidationError, model_validator
18 from typing_extensions import Self
19
20 import vllm.envs as envs
21 from vllm.config import VllmConfig
22 from vllm.logger import init_logger
23 from vllm.utils.torch_utils import (
24 STR_DTYPE_TO_TORCH_DTYPE,
25 get_dtype_size,
26 get_kv_cache_torch_dtype,
27 )
28 from vllm.v1.core.sched.output import SchedulerOutput
29 from vllm.v1.metrics.utils import create_metric_per_engine
30
31 logger = init_logger(__name__)
32
33
34 class InvalidComponent(Exception):
35 """Custom exception to indicate that a certain ComponentMetric is not
36 applicable to the given VllmConfig.
37 """
38
39 pass
40
41
42 # Mapping from quantization method name to effective weight byte size.
43 # Used by both AttentionQuantizationConfigParser and
44 # FfnQuantizationConfigParser to determine the weight_byte_size for
45 # flops/memory estimation.
46 #
47 # NOTE: Methods like GPTQ support variable bit-widths
48 # (e.g., 4-bit and 8-bit). We default to 4-bit (0.5 bytes) since this
49 # is by far the most common configuration.
50 _QUANT_WEIGHT_BYTE_SIZE: dict[str, float] = {
51 # FP8 methods (1 byte per weight)
52 "fp8": 1,
53 "fbgemm_fp8": 1,
54 "ptpc_fp8": 1,
55 "fp_quant": 1,
56 "modelopt": 1,
57 "modelopt_mxfp8": 1,
58 # FP4 / INT4 methods (0.5 bytes per weight)
59 "mxfp4": 0.5,
60 "awq": 0.5,
61 "awq_marlin": 0.5,
62 "gptq": 0.5,
63 "gptq_marlin": 0.5,
64 "modelopt_fp4": 0.5,
65 "petit_nvfp4": 0.5,
66 "compressed-tensors": 0.5,
67 "torchao": 0.5,
68 "quark": 0.5,
69 "moe_wna16": 0.5,
70 "inc": 0.5,
71 "experts_int8": 1,
72 }
73
74
75 #### Basic Data Types ####
76
77
78 @dataclass
79 class DebugPerfStats:
80 ## Stats for debugging the metrics calculation
81 calc_duration: float = 0.0 # time spent calculating these stats
82 num_prefill_requests: int = 0
83 num_decode_requests: int = 0
84 context_breakdown: dict[str, int] | None = None
85 num_flops_per_gpu_breakdown: dict[str, int] | None = None
86 num_read_bytes_per_gpu_breakdown: dict[str, int] | None = None
87 num_write_bytes_per_gpu_breakdown: dict[str, int] | None = None
88
89
90 @dataclass
91 class PerfStats:
92 num_flops_per_gpu: int = 0
93 num_read_bytes_per_gpu: int = 0
94 num_write_bytes_per_gpu: int = 0
95 debug_stats: DebugPerfStats | None = None
96
97
98 @dataclass
99 class ExecutionContext:
100 """Represents an execution context for a batch of requests.
101
102 This class aggregates statistics across multiple requests in a batch,
103 separately tracking prefill and decode phases.
104
105 Example)
106 - Batch with one full prefill (2048 tokens) and one decode (1 token, 8192 context):
107 ctx = ExecutionContext()
108 ctx.add(2048, 2048, is_prefill=True)
109 ctx.add(1, 8192, is_prefill=False)
110 """
111
112 # Prefill phase statistics
113 num_prefill_requests: int = 0
114 prefill_num_tokens: int = 0 # sum of num_tokens for prefill requests
115 prefill_context_len: int = 0 # sum of context_len for prefill requests
116 prefill_token_context_product: int = 0 # sum of (num_tokens * context_len)
117
118 # Decode phase statistics
119 num_decode_requests: int = 0
120 decode_num_tokens: int = 0 # sum of num_tokens for decode requests
121 decode_context_len: int = 0 # sum of context_len for decode requests
122 decode_token_context_product: int = 0 # sum of (num_tokens * context_len)
123
124 # Per-request records: (num_tokens, context_len, is_prefill)
125 requests: list[tuple[int, int, bool]] = field(default_factory=list)
126
127 def add(self, num_tokens: int, context_len: int, is_prefill: bool) -> None:
128 """Add a single request's statistics to this batch context."""
129 self.requests.append((num_tokens, context_len, is_prefill))
130 if is_prefill:
131 self.num_prefill_requests += 1
132 self.prefill_num_tokens += num_tokens
133 self.prefill_context_len += context_len
134 self.prefill_token_context_product += num_tokens * context_len
135 else:
136 self.num_decode_requests += 1
137 self.decode_num_tokens += num_tokens
138 self.decode_context_len += context_len
139 self.decode_token_context_product += num_tokens * context_len
140
141 def total_num_tokens(self) -> int:
142 """Total number of tokens across all requests in the batch."""
143 return self.prefill_num_tokens + self.decode_num_tokens
144
145 def total_token_context_product(self, sliding_window: int | None = None) -> int:
146 """Total sum of (num_tokens * context_len) across all requests.
147
148 If sliding_window is provided, context length per request is clamped to
149 sliding_window.
150 """
151 if sliding_window is None:
152 return (
153 self.prefill_token_context_product + self.decode_token_context_product
154 )
155 return sum(
156 num_tokens * min(context_len, sliding_window)
157 for num_tokens, context_len, _ in self.requests
158 )
159
160 def prefill_context_len_for_window(self, sliding_window: int | None = None) -> int:
161 """Sum of context_len for prefill requests, clamped to sliding_window if set."""
162 if sliding_window is None:
163 return self.prefill_context_len
164 return sum(
165 min(context_len, sliding_window)
166 for _, context_len, is_prefill in self.requests
167 if is_prefill
168 )
169
170 def decode_context_len_for_window(self, sliding_window: int | None = None) -> int:
171 """Sum of context_len for decode requests, clamped to sliding_window if set."""
172 if sliding_window is None:
173 return self.decode_context_len
174 return sum(
175 min(context_len, sliding_window)
176 for _, context_len, is_prefill in self.requests
177 if not is_prefill
178 )
179
180 def num_logits_tokens(self) -> int:
181 """Number of tokens that require logits computation (unembedding).
182
183 For prefill, only the last token per request needs logits.
184 For decode, all tokens need logits.
185 """
186 return self.num_prefill_requests + self.decode_num_tokens
187
188 @classmethod
189 def from_single_request(
190 cls, num_tokens: int, context_len: int, is_prefill: bool
191 ) -> "ExecutionContext":
192 """Create an ExecutionContext from a single request.
193
194 This is a convenience method primarily for testing.
195 """
196 ctx = cls()
197 ctx.add(num_tokens, context_len, is_prefill)
198 return ctx
199
200
201 class ParsedArgs:
202 """Syntactic sugar so that Parsers can use dot notations
203 to access/update the parsed arguments.
204
205 e.g.)
206 args = ParsedArgs()
207 args.x = 3
208 args.y = args.x + 1
209 """
210
211 def __getattr__(self, name: str) -> Any:
212 raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
213
214 def __setattr__(self, name: str, value: Any) -> None:
215 object.__setattr__(self, name, value)
216
217 def model_dump(self) -> dict[str, Any]:
218 return vars(self).copy()
219
220
221 #### Abstract ####
222
223
224 class Parser(Protocol):
225 def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
226 """Parse the vllm config and update the current ParsedArgs and pass it on.
227 If the parser isn't applicable to the vllm_config, it will do nothing.
228 """
229 ...
230
231
232 class ParserChain:
233 """Applies chain of parser in a sequential order.
234 Later parsers might overwrite results from previous parsers,
235 so parsers should be chained in the appropriate order if they
236 are not mutually exclusive.
237 """
238
239 def __init__(self, *parsers: Parser) -> None:
240 self.parsers = list(parsers)
241
242 def add_parser(self, parser: Parser) -> None:
243 self.parsers.append(parser)
244
245 def parse(self, vllm_config: VllmConfig) -> ParsedArgs:
246 """Apply all parsers in sequence and return the accumulated ParsedArgs."""
247 args = ParsedArgs()
248 for parser in self.parsers:
249 args = parser.parse(args, vllm_config)
250 return args
251
252
253 _COMPONENT_METRICS_REGISTRY: dict[str, type["ComponentMetrics"]] = {}
254
255
256 class ComponentMetrics(BaseModel, ABC):
257 """Each concrete ComponentMetrics class is associated with:
258 - fields that are required for metric derivation
259 (fields are specified/validated through pydantic model)
260 - parser to parse VllmConfig into fields
261 - metric methods that derive flops/bytes for a given execution context
262 """
263
264 @classmethod
265 @abstractmethod
266 def component_type(cls) -> str:
267 """Return the unique string key identifying this component in the registry."""
268 ...
269
270 @classmethod
271 @abstractmethod
272 def get_parser(cls) -> ParserChain:
273 """Return a ParserChain that provides values for all required fields.
274 The returned parser chain must populate ParsedArgs with values for every
275 field defined on this ComponentMetrics class. Missing fields will cause
276 a ValidationError when from_vllm_config() is called.
277 See individual Parser docstrings for which args they provide, and field
278 comments on ComponentMetrics subclasses for which parser provides each field.
279 """
280 ...
281
282 def __init_subclass__(cls):
283 _COMPONENT_METRICS_REGISTRY[cls.component_type()] = cls
284
285 @classmethod
286 def from_vllm_config(cls, vllm_config: VllmConfig) -> Self:
287 """Instantiate this class from VllmConfig.
288 Raises ValidationError if parsing fails.
289 """
290 parser = cls.get_parser()
291 parsed_args = parser.parse(vllm_config)
292 try:
293 return cls.model_validate(parsed_args.model_dump())
294 except ValidationError as e:
295 raise InvalidComponent(f"Invalid {cls.component_type()} config: {e}") from e
296
297 @classmethod
298 def registered_metrics(cls) -> Iterable[type["ComponentMetrics"]]:
299 return iter(_COMPONENT_METRICS_REGISTRY.values())
300
301 @abstractmethod
302 def get_num_flops_breakdown(
303 self, ctx: ExecutionContext, per_gpu: bool = True
304 ) -> dict[str, int]:
305 """Return per-operation FLOPs breakdown for this component."""
306 ...
307
308 @abstractmethod
309 def get_read_bytes_breakdown(
310 self, ctx: ExecutionContext, per_gpu: bool = True
311 ) -> dict[str, int]:
312 """Return per-operation read-memory-traffic breakdown for this component."""
313 ...
314
315 @abstractmethod
316 def get_write_bytes_breakdown(
317 self, ctx: ExecutionContext, per_gpu: bool = True
318 ) -> dict[str, int]:
319 """Return per-operation write-memory-traffic breakdown for this component."""
320 ...
321
322 def get_num_flops(self, ctx: ExecutionContext, per_gpu: bool = True) -> int:
323 return sum(self.get_num_flops_breakdown(ctx, per_gpu).values())
324
325 def get_read_bytes(self, ctx: ExecutionContext, per_gpu: bool = True) -> int:
326 return sum(self.get_read_bytes_breakdown(ctx, per_gpu).values())
327
328 def get_write_bytes(self, ctx: ExecutionContext, per_gpu: bool = True) -> int:
329 return sum(self.get_write_bytes_breakdown(ctx, per_gpu).values())
330
331
332 #### parsers ####
333
334
335 class BaseConfigParser(Parser):
336 """Parses base model configuration.
337 Provides: vocab_size, hidden_size, num_attention_heads, num_hidden_layers,
338 weight_byte_size, activation_byte_size, dp_size, tp_size, pp_size, enable_ep
339 """
340
341 def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
342 """Parse base model dimensions, dtype byte sizes, and parallelism config."""
343 model_config = vllm_config.model_config
344
345 args.vocab_size = model_config.get_vocab_size()
346 args.hidden_size = model_config.get_hidden_size()
347 # NOTE: model_config.get_attention_heads() divide by TP
348 # so we access field manually here to get total num_heads
349 args.num_attention_heads = get_required(
350 model_config.hf_text_config, "num_attention_heads"
351 )
352 args.num_hidden_layers = get_required(
353 model_config.hf_text_config, "num_hidden_layers"
354 )
355
356 model_dtype = vllm_config.model_config.dtype
357
358 if isinstance(model_dtype, torch.dtype):
359 torch_dtype = model_dtype
360 elif isinstance(model_dtype, str) and model_dtype in STR_DTYPE_TO_TORCH_DTYPE:
361 torch_dtype = STR_DTYPE_TO_TORCH_DTYPE[model_dtype]
362 else:
363 # FIXME: handle this better
364 logger.warning(
365 "Unknown model_dtype %s, defaulting to bfloat16",
366 model_dtype,
367 )
368 torch_dtype = torch.bfloat16
369
370 args.weight_byte_size = get_dtype_size(torch_dtype)
371
372 # Activations are produced in the model's compute dtype. Quantization
373 # overrides weight_byte_size below but leaves activations alone, so
374 # this stays keyed on the model dtype.
375 args.activation_byte_size = get_dtype_size(torch_dtype)
376
377 args.dp_size = vllm_config.parallel_config.data_parallel_size
378 args.tp_size = vllm_config.parallel_config.tensor_parallel_size
379 args.pp_size = vllm_config.parallel_config.pipeline_parallel_size
380 args.enable_ep = vllm_config.parallel_config.enable_expert_parallel
381
382 return args
383
384
385 #### Attention ####
386
387
388 class BaseAttentionConfigParser(Parser):
389 """Parses attention-specific configuration.
390 Provides: num_key_value_heads, head_dim, cache_byte_size
391 """
392
393 def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
394 """Parse KV head count, head dimension, and KV-cache dtype byte size."""
395 model_config = vllm_config.model_config
396
397 args.num_key_value_heads = model_config.get_total_num_kv_heads()
398 args.head_dim = model_config.get_head_size()
399
400 model_dtype = vllm_config.model_config.dtype
401 cache_dtype = vllm_config.cache_config.cache_dtype
402
403 kv_cache_torch_dtype = get_kv_cache_torch_dtype(cache_dtype, model_dtype)
404 args.cache_byte_size = get_dtype_size(kv_cache_torch_dtype)
405
406 return args
407
408
409 class SlidingWindowAttentionParser(Parser):
410 """Parses sliding window attention configuration and layer breakdown.
411 Provides: sliding_window, num_swa_layers
412 """
413
414 def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
415 """Determine sliding_window size and count of SWA layers.
416
417 Detects per-layer type lists (e.g. Llama-4), Gemma 2 alternating
418 pattern, and uniform SWA models (Mistral, Qwen2.5).
419 """
420 model_config = vllm_config.model_config
421
422 sliding_window: int | None = None
423 if not getattr(model_config, "disable_sliding_window", False):
424 sliding_window = model_config.get_sliding_window()
425 if sliding_window is None and hasattr(vllm_config, "cache_config"):
426 sliding_window = getattr(
427 vllm_config.cache_config, "sliding_window", None
428 )
429
430 args.sliding_window = sliding_window
431
432 num_layers = args.num_hidden_layers
433 if sliding_window is not None and sliding_window > 0:
434 hf_text_config = getattr(model_config, "hf_text_config", None)
435 layer_types = getattr(hf_text_config, "layer_types", None)
436 if layer_types is not None and isinstance(layer_types, list):
437 num_swa = sum(
438 1 for lt in layer_types[:num_layers] if "sliding" in lt.lower()
439 )
440 args.num_swa_layers = num_swa
441 elif (
442 getattr(getattr(model_config, "hf_config", None), "model_type", None)
443 == "gemma2"
444 ):
445 # Gemma 2 alternates sliding window: even layers (0, 2, ...) are sliding
446 args.num_swa_layers = (num_layers + 1) // 2
447 else:
448 # Full SWA model (e.g. Mistral, Qwen2.5 with sliding_window)
449 args.num_swa_layers = num_layers
450 else:
451 args.num_swa_layers = 0
452
453 return args
454
455
456 class AttentionQuantizationConfigParser(Parser):
457 """Parses quantization configuration for attention layers.
458 Overrides: weight_byte_size
459 """
460
461 def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
462 """Override weight_byte_size based on the active quantization method."""
463 cfg = vllm_config.quant_config
464
465 if cfg is None:
466 return args
467
468 quant_method = cfg.get_name()
469 if quant_method in _QUANT_WEIGHT_BYTE_SIZE:
470 args.weight_byte_size = _QUANT_WEIGHT_BYTE_SIZE[quant_method]
471 else:
472 raise InvalidComponent(
473 f"Unsupported quantization method for attention metrics: {quant_method}"
474 )
475
476 return args
477
478
479 class AttentionDetectionParser(Parser):
480 """Prevents standard AttentionMetrics from being instantiated for MLA models.
481 MLA models should use MLAAttentionMetrics instead.
482 """
483
484 def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
485 """Raise InvalidComponent for MLA models so AttentionMetrics is skipped."""
486 if vllm_config.model_config.is_deepseek_mla:
487 raise InvalidComponent(
488 "Model uses MLA attention; use MLAAttentionMetrics instead"
489 )
490 return args
491
492
493 class AttentionMetrics(ComponentMetrics):
494 # From BaseConfigParser
495 num_hidden_layers: int = Field(..., gt=0)
496 hidden_size: int = Field(..., gt=0)
497 num_attention_heads: int = Field(..., gt=0)
498 activation_byte_size: int = Field(..., gt=0)
499 tp_size: int = Field(..., gt=0)
500 pp_size: int = Field(..., gt=0)
501
502 # From BaseAttentionConfigParser
503 num_key_value_heads: int = Field(..., gt=0)
504 head_dim: int = Field(..., gt=0)
505 cache_byte_size: int = Field(..., gt=0)
506
507 # From BaseConfig Parser, overridden by AttentionQuantizationConfigParser
508 weight_byte_size: int | float = Field(..., gt=0)
509
510 # From SlidingWindowAttentionParser
511 sliding_window: int | None = Field(None)
512 num_swa_layers: int = Field(0, ge=0)
513
514 @classmethod
515 def component_type(cls) -> str:
516 """Return the component registry key for standard (non-MLA) attention."""
517 return "attn"
518
519 @classmethod
520 def get_parser(cls) -> ParserChain:
521 """Return the parser chain for AttentionMetrics.
522
523 Parsers run in order: MLA detection guard, base model config,
524 attention-specific config, sliding-window breakdown, quantization
525 weight-size override.
526 """
527 return ParserChain(
528 AttentionDetectionParser(),
529 BaseConfigParser(),
530 BaseAttentionConfigParser(),
531 SlidingWindowAttentionParser(),
532 AttentionQuantizationConfigParser(),
533 )
534
535 def _layer_counts(self, per_gpu: bool) -> tuple[float, float, float]:
536 """Return ``(L, L_full, L_swa)``, the layer counts to bill for.
537
538 Per-GPU counts divide each global count by ``pp_size`` independently.
539 Deriving the sublayer split after reducing ``L`` instead rounds a
540 minority sublayer away: a 6-layer model with 1 sliding-window layer at
541 ``pp_size=2`` yields ``round(3 * 1 / 6) == 0``, billing all three layers
542 as full attention. Dividing each count separately keeps
543 ``L_full + L_swa == L`` and keeps the per-GPU values summing back to the
544 whole model across stages.
545
546 The counts are fractional because these metrics are built from config
547 alone (see ``from_vllm_config``); no pipeline rank is available, so a
548 per-GPU figure is the average stage rather than a specific one. A model
549 whose layers do not divide evenly across stages has no single integer
550 answer here.
551 """
552 L = float(self.num_hidden_layers)
553 L_swa = float(self.num_swa_layers)
554 L_full = L - L_swa
555
556 if per_gpu:
557 L /= self.pp_size
558 L_swa /= self.pp_size
559 L_full /= self.pp_size
560
561 return L, L_full, L_swa
562
563 def get_num_flops_breakdown(
564 self, ctx: ExecutionContext, per_gpu: bool = True
565 ) -> dict[str, int]:
566 """Compute FLOPs breakdown for attention layers.
567
568 Accounts for hybrid models by splitting total layers into full-attention
569 (L_full) and sliding-window (L_swa) sublayers. See ``_layer_counts`` for
570 how those are scaled under pipeline parallelism.
571
572 Args:
573 ctx: Execution context describing the current batch.
574 per_gpu: If True, scale counts down by tensor/pipeline parallelism.
575
576 Returns:
577 Dict with keys: qkv_proj, out_proj, attn_qk, attn_av.
578
579 """
580 D, q, kv, d = (
581 self.hidden_size,
582 self.num_attention_heads,
583 self.num_key_value_heads,
584 self.head_dim,
585 )
586 T = ctx.total_num_tokens()
587 TC_full = ctx.total_token_context_product()
588
589 L, L_full, L_swa = self._layer_counts(per_gpu)
590
591 if per_gpu:
592 # tensor parallel along heads
593 q = max(1, q // self.tp_size)
594 kv = max(1, kv // self.tp_size)
595
596 flops = {
597 "qkv_proj": int(2 * T * D * (q + 2 * kv) * d * L),
598 "out_proj": int(2 * T * D * q * d * L),
599 }
600
601 attn_qk = 0.0
602 attn_av = 0.0
603 if L_full > 0:
604 attn_qk += 2 * q * TC_full * d * L_full
605 attn_av += 2 * q * TC_full * d * L_full
606
607 if L_swa > 0 and self.sliding_window is not None:
608 TC_swa = ctx.total_token_context_product(self.sliding_window)
609 attn_qk += 2 * q * TC_swa * d * L_swa
610 attn_av += 2 * q * TC_swa * d * L_swa
611
612 flops["attn_qk"] = int(attn_qk)
613 flops["attn_av"] = int(attn_av)
614
615 return flops
616
617 def get_read_bytes_breakdown(
618 self, ctx: ExecutionContext, per_gpu: bool = True
619 ) -> dict[str, int]:
620 """Compute read-memory-traffic breakdown for attention layers.
621
622 Splits layers into full-attention and sliding-window sublayers using the
623 same counts as get_num_flops_breakdown; see ``_layer_counts``.
624
625 Args:
626 ctx: Execution context describing the current batch.
627 per_gpu: If True, scale counts down by tensor/pipeline parallelism.
628
629 Returns:
630 Dict with keys: qkv_input, qkv_weight, attn_input (conditional),
631 out_input, out_weight.
632
633 """
634 D, q, kv, d = (
635 self.hidden_size,
636 self.num_attention_heads,
637 self.num_key_value_heads,
638 self.head_dim,
639 )
640 T = ctx.total_num_tokens()
641
642 L, L_full, L_swa = self._layer_counts(per_gpu)
643
644 if per_gpu:
645 # tensor parallel along heads
646 q = max(1, q // self.tp_size)
647 kv = max(1, kv // self.tp_size)
648
649 read_bytes = {}
650
651 read_bytes["qkv_input"] = int(T * D * self.activation_byte_size * L)
652 read_bytes["qkv_weight"] = int(D * (q + 2 * kv) * d * self.weight_byte_size * L)
653
654 attn_input = 0.0
655
656 # Full attention layers
657 if L_full > 0:
658 if ctx.prefill_num_tokens > 0:
659 attn_input += (
660 (ctx.prefill_num_tokens * q + 2 * ctx.prefill_context_len * kv)
661 * d
662 * self.activation_byte_size
663 * L_full
664 )
665 if ctx.decode_num_tokens > 0:
666 attn_input += (
667 ctx.decode_num_tokens * q * d * self.activation_byte_size * L_full
668 + 2
669 * ctx.decode_context_len
670 * kv
671 * d
672 * self.cache_byte_size
673 * L_full
674 )
675
676 # Sliding window attention layers
677 if L_swa > 0:
678 sw = self.sliding_window
679 prefill_sw_len = ctx.prefill_context_len_for_window(sw)
680 decode_sw_len = ctx.decode_context_len_for_window(sw)
681
682 if ctx.prefill_num_tokens > 0:
683 attn_input += (
684 (ctx.prefill_num_tokens * q + 2 * prefill_sw_len * kv)
685 * d
686 * self.activation_byte_size
687 * L_swa
688 )
689 if ctx.decode_num_tokens > 0:
690 attn_input += (
691 ctx.decode_num_tokens * q * d * self.activation_byte_size * L_swa
692 + 2 * decode_sw_len * kv * d * self.cache_byte_size * L_swa
693 )
694
695 if ctx.prefill_num_tokens > 0 or ctx.decode_num_tokens > 0:
696 read_bytes["attn_input"] = int(attn_input)
697
698 read_bytes["out_input"] = int(T * q * d * self.activation_byte_size * L)
699 read_bytes["out_weight"] = int(q * d * D * self.weight_byte_size * L)
700
701 return read_bytes
702
703 def get_write_bytes_breakdown(
704 self, ctx: ExecutionContext, per_gpu: bool = True
705 ) -> dict[str, int]:
706 """Calculate write memory traffic for attention layers.
707
708 Writes do not depend on the attention window, so only the total layer
709 count is needed; it comes from ``_layer_counts`` so all three breakdowns
710 bill the same number of layers.
711 """
712 D, q, kv, d = (
713 self.hidden_size,
714 self.num_attention_heads,
715 self.num_key_value_heads,
716 self.head_dim,
717 )
718 T = ctx.total_num_tokens()
719
720 L, _, _ = self._layer_counts(per_gpu)
721
722 if per_gpu:
723 # tensor parallel along heads
724 q = max(1, q // self.tp_size)
725 kv = max(1, kv // self.tp_size)
726
727 return {
728 "qkv_output": int(T * (q + 2 * kv) * d * self.activation_byte_size * L),
729 "kv_cache": int(2 * T * kv * d * self.cache_byte_size * L),
730 "out_output": int(T * D * self.activation_byte_size * L),
731 }
732
733
734 #### MLA Attention ####
735
736
737 class MLADetectionParser(Parser):
738 """Validates that the model uses MLA attention.
739 Raises InvalidComponent if the model does not use MLA,
740 so MLAAttentionMetrics is silently skipped for non-MLA models.
741 """
742
743 def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
744 """Raise InvalidComponent if the model does not use MLA attention."""
745 if not vllm_config.model_config.is_deepseek_mla:
746 raise InvalidComponent("Model does not use MLA attention")
747 return args
748
749
750 class MLAConfigParser(Parser):
751 """Parses MLA-specific configuration fields.
752 Provides: kv_lora_rank, qk_nope_head_dim, qk_rope_head_dim,
753 v_head_dim, q_lora_rank
754 """
755
756 def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
757 """Parse MLA-specific compression dimensions and KV-cache dtype byte size."""
758 model_config = vllm_config.model_config
759 cfg = model_config.hf_text_config
760
761 args.kv_lora_rank = get_required(cfg, "kv_lora_rank")
762 args.qk_nope_head_dim = get_required(cfg, "qk_nope_head_dim")
763 args.qk_rope_head_dim = get_required(cfg, "qk_rope_head_dim")
764 args.v_head_dim = get_required(cfg, "v_head_dim")
765 args.q_lora_rank = getattr(cfg, "q_lora_rank", None)
766
767 model_dtype = vllm_config.model_config.dtype
768 cache_dtype = vllm_config.cache_config.cache_dtype
769 kv_cache_torch_dtype = get_kv_cache_torch_dtype(cache_dtype, model_dtype)
770 args.cache_byte_size = get_dtype_size(kv_cache_torch_dtype)
771
772 return args
773
774
775 class MLAAttentionMetrics(ComponentMetrics):
776 """Performance metrics for Multi-Latent Attention (MLA) layers.
777
778 MLA uses a compressed latent representation for KV cache:
779 - KV cache stores a single compressed vector of size
780 (kv_lora_rank + qk_rope_head_dim) per token per layer,
781 instead of 2 * num_kv_heads * head_dim as in standard MHA/GQA.
782 - Q path uses optional low-rank compression:
783 h -> q_lora_rank -> num_heads * qk_head_dim
784 - KV path: h -> (kv_lora_rank + qk_rope_head_dim),
785 then kv_lora_rank -> num_heads * (qk_nope_head_dim + v_head_dim)
786
787 Used by DeepSeek-V2, DeepSeek-V3, DeepSeek-R1, and similar models.
788 """
789
790 # From BaseConfigParser
791 num_hidden_layers: int = Field(..., gt=0)
792 hidden_size: int = Field(..., gt=0)
793 num_attention_heads: int = Field(..., gt=0)
794 activation_byte_size: int = Field(..., gt=0)
795 tp_size: int = Field(..., gt=0)
796 pp_size: int = Field(..., gt=0)
797
798 # From BaseConfigParser, can be overridden by AttentionQuantizationConfigParser
799 weight_byte_size: int | float = Field(..., gt=0)
800
801 # From MLAConfigParser
802 kv_lora_rank: int = Field(..., gt=0)
803 qk_nope_head_dim: int = Field(..., gt=0)
804 qk_rope_head_dim: int = Field(..., gt=0)
805 v_head_dim: int = Field(..., gt=0)
806 q_lora_rank: int | None = Field(None)
807 cache_byte_size: int = Field(..., gt=0)
808
809 @classmethod
810 def component_type(cls) -> str:
811 """Return the component registry key for MLA attention."""
812 return "mla_attn"
813
814 @classmethod
815 def get_parser(cls) -> ParserChain:
816 """Return the parser chain for MLAAttentionMetrics.
817
818 Parsers run in order: MLA detection guard, base model config,
819 MLA-specific config, quantization weight-size override.
820 """
821 return ParserChain(
822 MLADetectionParser(),
823 BaseConfigParser(),
824 MLAConfigParser(),
825 AttentionQuantizationConfigParser(),
826 )
827
828 def get_num_flops_breakdown(
829 self, ctx: ExecutionContext, per_gpu: bool = True
830 ) -> dict[str, int]:
831 """Calculate flops breakdown for MLA attention layers.
832
833 MLA projection structure:
834 - Q path: h -> q_lora_rank -> num_heads * qk_head_dim
835 (or h -> num_heads * qk_head_dim if q_lora_rank is None)
836 - KV path: h -> (kv_lora_rank + qk_rope_head_dim),
837 then kv_lora_rank -> num_heads * (qk_nope_head_dim + v_head_dim)
838 - Attention: Q @ K^T and attn @ V
839 - Output: num_heads * v_head_dim -> h
840 """
841 L = self.num_hidden_layers
842 D = self.hidden_size
843 q = self.num_attention_heads
844 qk_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
845 v_d = self.v_head_dim
846 c = self.kv_lora_rank
847 r = self.qk_rope_head_dim
848 q_rank = self.q_lora_rank
849
850 T = ctx.total_num_tokens()
851 TC = ctx.total_token_context_product()
852
853 if per_gpu:
854 L //= self.pp_size
855 q = max(1, q // self.tp_size)
856
857 flops: dict[str, int] = {}
858
859 # Q projection
860 if q_rank is not None:
861 # Two-stage: h -> q_lora_rank -> num_heads * qk_head_dim
862 flops["q_a_proj"] = 2 * T * D * q_rank * L
863 flops["q_b_proj"] = 2 * T * q_rank * q * qk_head_dim * L
864 else:
865 # Direct: h -> num_heads * qk_head_dim
866 flops["q_proj"] = 2 * T * D * q * qk_head_dim * L
867
868 # KV projection (always compressed, shared across heads)
869 # kv_a: h -> (kv_lora_rank + qk_rope_head_dim) [replicated]
870 flops["kv_a_proj"] = 2 * T * D * (c + r) * L
871 # kv_b: kv_lora_rank -> num_heads * (qk_nope + v_head_dim)
872 flops["kv_b_proj"] = 2 * T * c * q * (self.qk_nope_head_dim + v_d) * L
873
874 # Attention core
875 flops["attn_qk"] = 2 * q * TC * qk_head_dim * L
876 flops["attn_av"] = 2 * q * TC * v_d * L
877
878 # Output projection: num_heads * v_head_dim -> h
879 flops["out_proj"] = 2 * T * q * v_d * D * L
880
881 return flops
882
883 def get_read_bytes_breakdown(
884 self, ctx: ExecutionContext, per_gpu: bool = True
885 ) -> dict[str, int]:
886 """Calculate read memory traffic for MLA attention layers."""
887 L = self.num_hidden_layers
888 D = self.hidden_size
889 q = self.num_attention_heads
890 qk_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
891 v_d = self.v_head_dim
892 c = self.kv_lora_rank
893 r = self.qk_rope_head_dim
894 q_rank = self.q_lora_rank
895
896 T = ctx.total_num_tokens()
897 # Compressed KV cache size per token
898 kv_compressed_dim = c + r
899
900 if per_gpu:
901 L //= self.pp_size
902 q = max(1, q // self.tp_size)
903
904 read_bytes: dict[str, int] = {}
905
906 # Q projection weight + input reads
907 if q_rank is not None:
908 read_bytes["q_a_input"] = T * D * self.activation_byte_size * L
909 read_bytes["q_a_weight"] = int(D * q_rank * self.weight_byte_size * L)
910 read_bytes["q_b_input"] = T * q_rank * self.activation_byte_size * L
911 read_bytes["q_b_weight"] = int(
912 q_rank * q * qk_head_dim * self.weight_byte_size * L
913 )
914 else:
915 read_bytes["q_input"] = T * D * self.activation_byte_size * L
916 read_bytes["q_weight"] = int(
917 D * q * qk_head_dim * self.weight_byte_size * L
918 )
919
920 # KV projection weight + input reads
921 # kv_a is replicated (not TP-sharded)
922 read_bytes["kv_a_input"] = T * D * self.activation_byte_size * L
923 read_bytes["kv_a_weight"] = int(
924 D * kv_compressed_dim * self.weight_byte_size * L
925 )
926 # kv_b is TP-sharded along heads
927 read_bytes["kv_b_input"] = T * c * self.activation_byte_size * L
928 read_bytes["kv_b_weight"] = int(
929 c * q * (self.qk_nope_head_dim + v_d) * self.weight_byte_size * L
930 )
931
932 # Attention input reads
933 # Prefill: read Q activations + K,V from kv_b_proj output
934 if ctx.prefill_num_tokens > 0:
935 read_bytes["attn_input"] = (
936 ctx.prefill_num_tokens * q * qk_head_dim * self.activation_byte_size * L
937 + ctx.prefill_context_len
938 * q
939 * (qk_head_dim + v_d)
940 * self.activation_byte_size
941 * L
942 )
943
944 # Decode: read Q activations + read compressed KV from cache
945 if ctx.decode_num_tokens > 0:
946 read_bytes["attn_input"] = read_bytes.get("attn_input", 0) + (
947 ctx.decode_num_tokens * q * qk_head_dim * self.activation_byte_size * L
948 + ctx.decode_context_len * kv_compressed_dim * self.cache_byte_size * L
949 )
950
951 # Output projection reads
952 read_bytes["out_input"] = T * q * v_d * self.activation_byte_size * L
953 read_bytes["out_weight"] = int(q * v_d * D * self.weight_byte_size * L)
954
955 return read_bytes
956
957 def get_write_bytes_breakdown(
958 self, ctx: ExecutionContext, per_gpu: bool = True
959 ) -> dict[str, int]:
960 """Calculate write memory traffic for MLA attention layers."""
961 L = self.num_hidden_layers
962 D = self.hidden_size
963 q = self.num_attention_heads
964 qk_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
965 v_d = self.v_head_dim
966 c = self.kv_lora_rank
967 r = self.qk_rope_head_dim
968 q_rank = self.q_lora_rank
969
970 T = ctx.total_num_tokens()
971 kv_compressed_dim = c + r
972
973 if per_gpu:
974 L //= self.pp_size
975 q = max(1, q // self.tp_size)
976
977 write_bytes: dict[str, int] = {}
978
979 # Q projection outputs
980 if q_rank is not None:
981 write_bytes["q_a_output"] = T * q_rank * self.activation_byte_size * L
982 write_bytes["q_b_output"] = (
983 T * q * qk_head_dim * self.activation_byte_size * L
984 )
985 else:
986 write_bytes["q_output"] = (
987 T * q * qk_head_dim * self.activation_byte_size * L
988 )
989
990 # KV projection outputs
991 write_bytes["kv_a_output"] = (
992 T * kv_compressed_dim * self.activation_byte_size * L
993 )
994 write_bytes["kv_b_output"] = (
995 T * q * (self.qk_nope_head_dim + v_d) * self.activation_byte_size * L
996 )
997
998 # KV cache write: one compressed vector per token
999 # (kv_lora_rank + qk_rope_head_dim) instead of
1000 # 2 * num_kv_heads * head_dim in standard MHA
1001 write_bytes["kv_cache"] = T * kv_compressed_dim * self.cache_byte_size * L
1002
1003 # Output projection
1004 write_bytes["out_output"] = T * D * self.activation_byte_size * L
1005
1006 return write_bytes
1007
1008
1009 #### Ffn ####
1010
1011
1012 class BaseFfnConfigParser(Parser):
1013 """Parses FFN and MoE configuration.
1014 Provides: intermediate_size, num_experts, num_experts_per_tok,
1015 moe_intermediate_size, num_shared_experts, num_moe_layers
1016 """
1017
1018 def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
1019 cfg = vllm_config.model_config.hf_config
1020 if hasattr(cfg, "text_config") and cfg.text_config is not None:
1021 cfg = cfg.text_config
1022
1023 args.intermediate_size = getattr(cfg, "intermediate_size", args.hidden_size * 4)
1024
1025 # Try different naming conventions.
1026 args.num_experts = vllm_config.model_config.get_num_experts()
1027 args.num_experts_per_tok = vllm_config.model_config.get_num_experts_per_tok()
1028 args.moe_intermediate_size = getattr_from_list(
1029 cfg, ["moe_intermediate_size", "intermediate_size"], 0
1030 )
1031 args.num_shared_experts = getattr_from_list(
1032 cfg, ["n_shared_experts", "num_shared_experts"], 0
1033 )
1034
1035 is_moe = args.num_experts != 0
1036 # Assume all MoE layers by default
1037 args.num_moe_layers = args.num_hidden_layers if is_moe else 0
1038
1039 return args
1040
1041
1042 class FfnParallelParser(Parser):
1043 """Parses FFN parallelism configuration.
1044
1045 Provides: ffn_tp_size, ffn_ep_size
1046 """
1047
1048 def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
1049 # NOTE: ffn tp_size does not equal the tp_size parameter directly.
1050 # e.g.) If we use DP2TP4, ffn will use TP8 (or EP8 if EP is enabled.)
1051 if args.enable_ep:
1052 ffn_tp_size, ffn_ep_size = 1, args.dp_size * args.tp_size
1053 else:
1054 ffn_tp_size, ffn_ep_size = args.dp_size * args.tp_size, 1
1055
1056 args.ffn_tp_size = ffn_tp_size
1057 args.ffn_ep_size = ffn_ep_size
1058
1059 return args
1060
1061
1062 class InterleaveMoeLayerStepParser(Parser):
1063 """Parses interleave_moe_layer_step field for models like Llama4.
1064
1065 Overrides: num_moe_layers
1066 """
1067
1068 def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
1069 cfg = vllm_config.model_config.hf_config
1070 if hasattr(cfg, "text_config") and cfg.text_config is not None:
1071 cfg = cfg.text_config
1072
1073 if (
1074 hasattr(cfg, "interleave_moe_layer_step")
1075 and cfg.interleave_moe_layer_step > 0
1076 ):
1077 args.num_moe_layers = len(
1078 [
1079 layer
1080 for layer in range(args.num_hidden_layers)
1081 if (layer + 1) % cfg.interleave_moe_layer_step == 0
1082 ]
1083 )
1084
1085 return args
1086
1087
1088 class MoeLayerFreqParser(Parser):
1089 """Parses moe_layer_freq and first_k_dense_replace fields for models like Deepseek.
1090
1091 Overrides: num_moe_layers
1092 """
1093
1094 def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
1095 cfg = vllm_config.model_config.hf_config
1096 if hasattr(cfg, "text_config") and cfg.text_config is not None:
1097 cfg = cfg.text_config
1098
1099 if hasattr(cfg, "moe_layer_freq") and hasattr(cfg, "first_k_dense_replace"):
1100 args.num_moe_layers = len(
1101 [
1102 layer
1103 for layer in range(args.num_hidden_layers)
1104 if layer >= cfg.first_k_dense_replace
1105 and layer % cfg.moe_layer_freq == 0
1106 ]
1107 )
1108
1109 return args
1110
1111
1112 class FfnQuantizationConfigParser(Parser):
1113 """Parses quantization configuration for FFN layers.
1114
1115 Overrides: weight_byte_size
1116 """
1117
1118 def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
1119 """Override weight_byte_size based on the active FFN quantization method."""
1120 cfg = vllm_config.quant_config
1121
1122 if cfg is None:
1123 return args
1124
1125 quant_method = cfg.get_name()
1126 if quant_method in _QUANT_WEIGHT_BYTE_SIZE:
1127 args.weight_byte_size = _QUANT_WEIGHT_BYTE_SIZE[quant_method]
1128 else:
1129 raise InvalidComponent(
1130 f"Unsupported quantization method for FFN metrics: {quant_method}"
1131 )
1132
1133 return args
1134
1135
1136 class FfnMetrics(ComponentMetrics):
1137 # From BaseConfigParser
1138 num_hidden_layers: int = Field(..., gt=0)
1139 hidden_size: int = Field(..., gt=0)
1140 activation_byte_size: int = Field(..., gt=0)
1141 pp_size: int = Field(..., gt=0)
1142
1143 # From FfnParallelParser
1144 ffn_tp_size: int = Field(..., gt=0)
1145 ffn_ep_size: int = Field(..., gt=0)
1146
1147 # From BaseFfnConfigParser
1148 intermediate_size: int = Field(..., gt=0)
1149 num_experts: int = Field(0)
1150 num_experts_per_tok: int = Field(1)
1151 moe_intermediate_size: int = Field(0)
1152 num_shared_experts: int = Field(0)
1153
1154 # From BaseConfigParser, can be overridden InterleaveMoeLayerStep or MoeLayerFreq
1155 num_moe_layers: int = Field(..., ge=0)
1156
1157 # FIXME: might have to make this more granular
1158 # (i.e. dense_weight_byte_size, moe_routed_weight_byte_size,
1159 # moe_shared_weight_byte_size)
1160 # since it can differ from byte size of other components (e.g. attn)
1161 # and can differ even from each other.
1162
1163 # From BaseConfigParser, can be overridden by FfnQuantizationConfigParser
1164 weight_byte_size: int | float = Field(..., gt=0)
1165
1166 @model_validator(mode="after")
1167 def validate_moe_fields(self) -> Self:
1168 """Validate that MoE-related fields are properly set when num_moe_layers > 0."""
1169 if self.num_moe_layers > 0:
1170 assert self.num_experts, f"{self.num_experts=}"
1171 assert self.num_experts_per_tok, f"{self.num_experts_per_tok=}"
1172 assert self.moe_intermediate_size, f"{self.moe_intermediate_size=}"
1173 return self
1174
1175 @classmethod
1176 def component_type(cls) -> str:
1177 """Return the component registry key for FFN layers."""
1178 return "ffn"
1179
1180 @classmethod
1181 def get_parser(cls) -> ParserChain:
1182 """Return the parser chain for FfnMetrics.
1183
1184 Parsers run in order: base model config, FFN parallelism, base FFN
1185 config, MoE interleave step, MoE layer frequency, quantization override.
1186 """
1187 return ParserChain(
1188 BaseConfigParser(),
1189 FfnParallelParser(),
1190 BaseFfnConfigParser(),
1191 InterleaveMoeLayerStepParser(),
1192 MoeLayerFreqParser(),
1193 FfnQuantizationConfigParser(),
1194 )
1195
1196 def get_num_flops_breakdown(
1197 self, ctx: ExecutionContext, per_gpu: bool = True
1198 ) -> dict[str, int]:
1199 """Calculate flops breakdown for FFN layers."""
1200 L, D, DI = self.num_hidden_layers, self.hidden_size, self.intermediate_size
1201 Lm, E, MI, S = (
1202 self.num_moe_layers,
1203 self.num_experts_per_tok,
1204 self.moe_intermediate_size,
1205 self.num_shared_experts,
1206 )
1207 T = ctx.total_num_tokens()
1208
1209 Ld = L - Lm
1210
1211 num_activated_tokens = T * E if E else 0
1212
1213 if per_gpu:
1214 Ld //= self.pp_size
1215 Lm //= self.pp_size
1216
1217 DI //= self.ffn_tp_size
1218 if MI is not None:
1219 MI //= self.ffn_tp_size
1220 if E:
1221 num_activated_tokens //= self.ffn_ep_size
1222
1223 flops = {}
1224
1225 # Dense FFN layers (SwiGLU: 3 linear layers: up, gate, down)
1226 if Ld:
1227 flops["dense_ffn"] = 2 * D * 3 * DI * T * Ld
1228
1229 # MoE routed experts (each token activates E experts)
1230 if Lm and E:
1231 flops["routed_ffn"] = 2 * D * 3 * MI * num_activated_tokens * Lm
1232
1233 # MoE shared experts (all S shared experts run for every token)
1234 if Lm and S:
1235 flops["shared_ffn"] = 2 * D * 3 * MI * S * T * Lm
1236
1237 return flops
1238
1239 def get_read_bytes_breakdown(
1240 self, ctx: ExecutionContext, per_gpu: bool = True
1241 ) -> dict[str, int]:
1242 """Calculate read memory traffic for FFN layers."""
1243 L, D, DI = self.num_hidden_layers, self.hidden_size, self.intermediate_size
1244 Lm, E, MI, S = (
1245 self.num_moe_layers,
1246 self.num_experts_per_tok,
1247 self.moe_intermediate_size,
1248 self.num_shared_experts,
1249 )
1250 T = ctx.total_num_tokens()
1251 num_experts = self.num_experts
1252
1253 Ld = L - Lm
1254
1255 num_activated_tokens = T * E if E else 0
1256
1257 if per_gpu:
1258 Ld //= self.pp_size
1259 Lm //= self.pp_size
1260
1261 DI //= self.ffn_tp_size
1262 if MI is not None:
1263 MI //= self.ffn_tp_size
1264 if E:
1265 num_activated_tokens //= self.ffn_ep_size
1266 if num_experts is not None:
1267 num_experts //= self.ffn_ep_size
1268
1269 read_bytes = {}
1270
1271 # Dense FFN layers (3 GEMMs: up, gate, down projections + SiLU activation)
1272 if Ld:
1273 read_bytes["dense_up_gate_input"] = int(
1274 T * D * self.activation_byte_size * Ld
1275 )
1276 read_bytes["dense_up_gate_weights"] = int(
1277 2 * D * DI * self.weight_byte_size * Ld
1278 )
1279 read_bytes["dense_silu_input"] = int(
1280 2 * T * DI * self.activation_byte_size * Ld
1281 )
1282 read_bytes["dense_down_input"] = int(
1283 T * DI * self.activation_byte_size * Ld
1284 )
1285 read_bytes["dense_down_weights"] = int(D * DI * self.weight_byte_size * Ld)
1286
1287 if Lm:
1288 # MoE routed expert reads
1289 if E:
1290 # FIXME: Assume perfect load balancing for now.
1291 num_activated_experts = min(num_activated_tokens, num_experts)
1292
1293 read_bytes["routed_up_gate_input"] = int(
1294 num_activated_tokens * D * self.activation_byte_size * Lm
1295 )
1296 read_bytes["routed_up_gate_weights"] = int(
1297 2 * D * MI * num_activated_experts * self.weight_byte_size * Lm
1298 )
1299 read_bytes["routed_silu_input"] = int(
1300 2 * num_activated_tokens * MI * self.activation_byte_size * Lm
1301 )
1302 read_bytes["routed_down_input"] = int(
1303 num_activated_tokens * MI * self.activation_byte_size * Lm
1304 )
1305 read_bytes["routed_down_weights"] = int(
1306 D * MI * num_activated_experts * self.weight_byte_size * Lm
1307 )
1308
1309 # MoE shared expert reads
1310 if S:
1311 read_bytes["shared_up_gate_input"] = int(
1312 T * D * self.activation_byte_size * Lm
1313 )
1314 read_bytes["shared_up_gate_weights"] = int(
1315 2 * D * MI * S * self.weight_byte_size * Lm
1316 )
1317 read_bytes["shared_silu_input"] = int(
1318 2 * T * MI * S * self.activation_byte_size * Lm
1319 )
1320 read_bytes["shared_down_input"] = int(
1321 T * MI * self.activation_byte_size * Lm
1322 )
1323 read_bytes["shared_down_weights"] = int(
1324 D * MI * S * self.weight_byte_size * Lm
1325 )
1326
1327 return read_bytes
1328
1329 def get_write_bytes_breakdown(
1330 self, ctx: ExecutionContext, per_gpu: bool = True
1331 ) -> dict[str, int]:
1332 """Calculate write memory traffic for FFN layers."""
1333 L, D, DI = self.num_hidden_layers, self.hidden_size, self.intermediate_size
1334 Lm, E, MI, S = (
1335 self.num_moe_layers,
1336 self.num_experts_per_tok,
1337 self.moe_intermediate_size,
1338 self.num_shared_experts,
1339 )
1340 T = ctx.total_num_tokens()
1341
1342 Ld = L - Lm
1343
1344 num_activated_tokens = T * E if E else 0
1345
1346 if per_gpu:
1347 Ld //= self.pp_size
1348 Lm //= self.pp_size
1349
1350 DI //= self.ffn_tp_size
1351 if MI is not None:
1352 MI //= self.ffn_tp_size
1353 if E:
1354 num_activated_tokens //= self.ffn_ep_size
1355
1356 write_bytes = {}
1357
1358 # Dense FFN layers
1359 if Ld:
1360 write_bytes["dense_up_gate_output"] = int(
1361 2 * T * DI * self.activation_byte_size * Ld
1362 )
1363 write_bytes["dense_silu_output"] = int(
1364 T * DI * self.activation_byte_size * Ld
1365 )
1366 write_bytes["dense_down_output"] = int(
1367 T * D * self.activation_byte_size * Ld
1368 )
1369
1370 # MoE outputs
1371 if Lm:
1372 if E:
1373 write_bytes["routed_up_gate_output"] = int(
1374 2 * num_activated_tokens * MI * self.activation_byte_size * Lm
1375 )
1376 write_bytes["routed_silu_output"] = int(
1377 num_activated_tokens * MI * self.activation_byte_size * Lm
1378 )
1379 write_bytes["routed_down_output"] = int(
1380 num_activated_tokens * D * self.activation_byte_size * Lm
1381 )
1382 if S:
1383 write_bytes["shared_up_gate_output"] = int(
1384 2 * T * S * MI * self.activation_byte_size * Lm
1385 )
1386 write_bytes["shared_silu_output"] = int(
1387 T * S * MI * self.activation_byte_size * Lm
1388 )
1389 write_bytes["shared_down_output"] = int(
1390 T * S * D * self.activation_byte_size * Lm
1391 )
1392
1393 return write_bytes
1394
1395
1396 #### Unembed ####
1397
1398
1399 class UnembedMetrics(ComponentMetrics):
1400 # From BaseConfigParser
1401 hidden_size: int = Field(..., gt=0)
1402 vocab_size: int = Field(..., gt=0)
1403 weight_byte_size: int = Field(..., gt=0)
1404 activation_byte_size: int = Field(..., gt=0)
1405
1406 tp_size: int
1407
1408 @classmethod
1409 def component_type(cls) -> str:
1410 """Return the component registry key for the unembedding (LM head) layer."""
1411 return "unembed"
1412
1413 @classmethod
1414 def get_parser(cls) -> ParserChain:
1415 """Return the parser chain for UnembedMetrics (base model config only)."""
1416 return ParserChain(
1417 BaseConfigParser(),
1418 )
1419
1420 def get_num_flops_breakdown(
1421 self, ctx: ExecutionContext, per_gpu: bool = True
1422 ) -> dict[str, int]:
1423 """Calculate flops breakdown for unembedding layer."""
1424 D, V = self.hidden_size, self.vocab_size
1425 T = ctx.num_logits_tokens()
1426
1427 if per_gpu:
1428 V //= self.tp_size
1429
1430 return {
1431 "unembed": 2 * T * D * V,
1432 }
1433
1434 def get_read_bytes_breakdown(
1435 self, ctx: ExecutionContext, per_gpu: bool = True
1436 ) -> dict[str, int]:
1437 """Calculate read memory traffic for unembedding layer."""
1438 D, V = self.hidden_size, self.vocab_size
1439 T = ctx.num_logits_tokens()
1440
1441 if per_gpu:
1442 V //= self.tp_size
1443
1444 return {
1445 "input": T * D * self.activation_byte_size,
1446 "weight": D * V * self.weight_byte_size,
1447 }
1448
1449 def get_write_bytes_breakdown(
1450 self, ctx: ExecutionContext, per_gpu: bool = True
1451 ) -> dict[str, int]:
1452 """Calculate write memory traffic for unembedding layer."""
1453 V = self.vocab_size
1454 T = ctx.num_logits_tokens()
1455
1456 if per_gpu:
1457 V //= self.tp_size
1458
1459 return {
1460 "output": T * V * self.activation_byte_size,
1461 }
1462
1463
1464 #### ModelMetrics ####
1465
1466
1467 class ModelMetrics:
1468 def __init__(self, vllm_config: VllmConfig) -> None:
1469 """Parse vllm_config to instantiate metrics for each component.
1470 is_enabled() will return False if no component metrics could be instantiated.
1471 """
1472 self.vllm_config = vllm_config
1473
1474 self.metrics: list[ComponentMetrics] = []
1475 for metric_cls in ComponentMetrics.registered_metrics():
1476 try:
1477 metric = metric_cls.from_vllm_config(vllm_config)
1478 self.metrics.append(metric)
1479 logger.info(
1480 "Instantiated ComponentMetrics [%s] with (%s)",
1481 metric.component_type(),
1482 str(metric),
1483 )
1484 except InvalidComponent as e:
1485 logger.debug(
1486 "Failed to instantiate %s from %s",
1487 metric_cls.component_type(),
1488 str(e),
1489 )
1490
1491 def is_enabled(self) -> bool:
1492 return len(self.metrics) > 0
1493
1494 def get_num_flops(self, ctx: ExecutionContext, per_gpu: bool = True) -> int:
1495 return sum(metric.get_num_flops(ctx, per_gpu) for metric in self.metrics)
1496
1497 def get_read_bytes(self, ctx: ExecutionContext, per_gpu: bool = True) -> int:
1498 return sum(metric.get_read_bytes(ctx, per_gpu) for metric in self.metrics)
1499
1500 def get_write_bytes(self, ctx: ExecutionContext, per_gpu: bool = True) -> int:
1501 return sum(metric.get_write_bytes(ctx, per_gpu) for metric in self.metrics)
1502
1503 def get_num_flops_breakdown(
1504 self, ctx: ExecutionContext, per_gpu: bool = True
1505 ) -> dict[str, int]:
1506 total = {}
1507 for metric in self.metrics:
1508 breakdown = metric.get_num_flops_breakdown(ctx, per_gpu)
1509 component = metric.component_type()
1510 prefixed = {f"{component}.{key}": val for key, val in breakdown.items()}
1511 total.update(prefixed)
1512 return total
1513
1514 def get_read_bytes_breakdown(
1515 self, ctx: ExecutionContext, per_gpu: bool = True
1516 ) -> dict[str, int]:
1517 total = {}
1518 for metric in self.metrics:
1519 breakdown = metric.get_read_bytes_breakdown(ctx, per_gpu)
1520 component = metric.component_type()
1521 prefixed = {f"{component}.{key}": val for key, val in breakdown.items()}
1522 total.update(prefixed)
1523 return total
1524
1525 def get_write_bytes_breakdown(
1526 self, ctx: ExecutionContext, per_gpu: bool = True
1527 ) -> dict[str, int]:
1528 total = {}
1529 for metric in self.metrics:
1530 breakdown = metric.get_write_bytes_breakdown(ctx, per_gpu)
1531 component = metric.component_type()
1532 prefixed = {f"{component}.{key}": val for key, val in breakdown.items()}
1533 total.update(prefixed)
1534 return total
1535
1536 def get_step_perf_stats_per_gpu(
1537 self, scheduler_output: SchedulerOutput
1538 ) -> PerfStats:
1539 """Calculate perf stats for the current step based on scheduled tokens."""
1540 t0 = time.monotonic()
1541
1542 # Build a single batch context
1543 ctx = ExecutionContext()
1544
1545 # Process new requests (these are in prefill phase)
1546 for new_req in scheduler_output.scheduled_new_reqs:
1547 req_id = new_req.req_id
1548 num_tokens = scheduler_output.num_scheduled_tokens.get(req_id, 0)
1549 if num_tokens == 0:
1550 continue
1551
1552 # For new requests, context_len = num_computed_tokens + num_tokens
1553 # num_computed_tokens represents previously computed tokens in the sequence
1554 context_len = new_req.num_computed_tokens + num_tokens
1555 ctx.add(num_tokens, context_len, is_prefill=True)
1556
1557 # Process cached requests (continuing requests)
1558 cached_reqs = scheduler_output.scheduled_cached_reqs
1559 for i, req_id in enumerate(cached_reqs.req_ids):
1560 num_tokens = scheduler_output.num_scheduled_tokens.get(req_id, 0)
1561 if num_tokens == 0:
1562 continue
1563
1564 # For cached requests, we have the current num_computed_tokens
1565 num_computed_tokens = cached_reqs.num_computed_tokens[i]
1566 context_len = num_computed_tokens + num_tokens
1567
1568 # Cached requests are typically in decode phase (num_tokens == 1)
1569 # unless they're doing chunked prefill (num_tokens > 1)
1570 is_prefill = num_tokens > 1
1571 ctx.add(num_tokens, context_len, is_prefill)
1572
1573 num_flops_breakdown = self.get_num_flops_breakdown(ctx, True)
1574 read_bytes_breakdown = self.get_read_bytes_breakdown(ctx, True)
1575 write_bytes_breakdown = self.get_write_bytes_breakdown(ctx, True)
1576 perf_stats = PerfStats(
1577 sum(num_flops_breakdown.values()),
1578 sum(read_bytes_breakdown.values()),
1579 sum(write_bytes_breakdown.values()),
1580 )
1581
1582 if envs.VLLM_DEBUG_MFU_METRICS:
1583 perf_stats.debug_stats = DebugPerfStats(
1584 time.monotonic() - t0,
1585 ctx.num_prefill_requests,
1586 ctx.num_decode_requests,
1587 asdict(ctx),
1588 num_flops_breakdown,
1589 read_bytes_breakdown,
1590 write_bytes_breakdown,
1591 )
1592
1593 return perf_stats
1594
1595
1596 #### Logging ####
1597
1598
1599 class PerfMetricsDebugLogging:
1600 def __init__(self):
1601 self.reset()
1602
1603 def reset(self):
1604 self.total_calc_duration: float = 0.0
1605 self.total_num_prefill_requests: int = 0
1606 self.total_num_decode_requests: int = 0
1607 self.total_num_batches: int = 0
1608 self.total_context_breakdown: dict[str, int] = {}
1609 self.total_num_flops_per_gpu_breakdown: dict[str, int] = {}
1610 self.total_read_bytes_per_gpu_breakdown: dict[str, int] = {}
1611 self.total_write_bytes_per_gpu_breakdown: dict[str, int] = {}
1612
1613 def observe(self, debug_stats: DebugPerfStats) -> None:
1614 self.total_calc_duration += debug_stats.calc_duration
1615 self.total_num_prefill_requests += debug_stats.num_prefill_requests
1616 self.total_num_decode_requests += debug_stats.num_decode_requests
1617 self.total_num_batches += 1
1618
1619 for dst, src in zip(
1620 [
1621 self.total_context_breakdown,
1622 self.total_num_flops_per_gpu_breakdown,
1623 self.total_read_bytes_per_gpu_breakdown,
1624 self.total_write_bytes_per_gpu_breakdown,
1625 ],
1626 [
1627 debug_stats.context_breakdown,
1628 debug_stats.num_flops_per_gpu_breakdown,
1629 debug_stats.num_read_bytes_per_gpu_breakdown,
1630 debug_stats.num_write_bytes_per_gpu_breakdown,
1631 ],
1632 ):
1633 assert isinstance(src, dict)
1634 for key, val in src.items():
1635 dst[key] = dst.get(key, 0) + val
1636
1637 def log(self, log_fn, log_prefix: str, delta_time: float):
1638 # pretty print breakdowns
1639 total_num_flops_per_gpu_breakdown = {
1640 k: f"{v / 1e12:.1f}TF"
1641 for k, v in self.total_num_flops_per_gpu_breakdown.items()
1642 }
1643 total_read_bytes_per_gpu_breakdown = {
1644 k: f"{v / 1e9:.1f}GB"
1645 for k, v in self.total_read_bytes_per_gpu_breakdown.items()
1646 }
1647 total_write_bytes_per_gpu_breakdown = {
1648 k: f"{v / 1e9:.1f}GB"
1649 for k, v in self.total_write_bytes_per_gpu_breakdown.items()
1650 }
1651
1652 logger.debug(
1653 "%sMFU details: %s",
1654 log_prefix,
1655 json.dumps(
1656 {
1657 "prefill_reqs": self.total_num_prefill_requests,
1658 "decode_reqs": self.total_num_decode_requests,
1659 "num_batches": self.total_num_batches,
1660 "context_breakdown": self.total_context_breakdown,
1661 "flops_breakdown": total_num_flops_per_gpu_breakdown,
1662 "num_read_bytes_breakdown": total_read_bytes_per_gpu_breakdown,
1663 "num_write_bytes_breakdown": (total_write_bytes_per_gpu_breakdown),
1664 "duration": f"{delta_time:.1f}s",
1665 "mfu_calc_overhead": (
1666 f"{self.total_calc_duration / delta_time:.1%}"
1667 ),
1668 },
1669 indent=2,
1670 ),
1671 )
1672
1673
1674 class PerfMetricsLogging:
1675 def __init__(self, vllm_config: VllmConfig):
1676 self.vllm_config = vllm_config
1677 self.pp_size = vllm_config.parallel_config.pipeline_parallel_size
1678
1679 self.debug_logging: PerfMetricsDebugLogging | None = None
1680 if envs.VLLM_DEBUG_MFU_METRICS:
1681 self.debug_logging = PerfMetricsDebugLogging()
1682
1683 self.reset()
1684
1685 def reset(self):
1686 self.last_log_time = time.monotonic()
1687
1688 self.total_num_flops_per_gpu: int = 0
1689 self.total_read_bytes_per_gpu: int = 0
1690 self.total_write_bytes_per_gpu: int = 0
1691
1692 if self.debug_logging:
1693 self.debug_logging.reset()
1694
1695 def observe(self, perf_stats: PerfStats) -> None:
1696 self.total_num_flops_per_gpu += perf_stats.num_flops_per_gpu
1697 self.total_read_bytes_per_gpu += perf_stats.num_read_bytes_per_gpu
1698 self.total_write_bytes_per_gpu += perf_stats.num_write_bytes_per_gpu
1699
1700 if self.debug_logging:
1701 assert perf_stats.debug_stats is not None
1702 self.debug_logging.observe(perf_stats.debug_stats)
1703
1704 def log(self, log_fn=logger.info, log_prefix: str = "") -> None:
1705 if not (
1706 self.total_num_flops_per_gpu
1707 or self.total_read_bytes_per_gpu
1708 or self.total_write_bytes_per_gpu
1709 ):
1710 return
1711
1712 now = time.monotonic()
1713 delta_time = now - self.last_log_time
1714
1715 if delta_time <= 0.0:
1716 avg_tflops_per_gpu = 0.0
1717 avg_gbps_per_gpu = 0.0
1718 else:
1719 avg_tflops_per_gpu = self.total_num_flops_per_gpu / delta_time / 1e12
1720 avg_gbps_per_gpu = (
1721 (self.total_read_bytes_per_gpu + self.total_write_bytes_per_gpu)
1722 / delta_time
1723 / 1e9
1724 )
1725
1726 log_fn(
1727 "%sMFU: %.1f TF/s/device %.1f GB/s/device",
1728 log_prefix,
1729 avg_tflops_per_gpu,
1730 avg_gbps_per_gpu,
1731 )
1732
1733 if self.debug_logging:
1734 self.debug_logging.log(log_fn, log_prefix, delta_time)
1735
1736 self.reset()
1737
1738
1739 #### Prometheus Integration ####
1740
1741
1742 class PerfMetricsProm:
1743 """Record performance metrics in Prometheus.
1744
1745 Average TFLOPS (tera floating-point operations per second) can be
1746 calculated using a PromQL query:
1747
1748 rate(vllm:estimated_flops_per_gpu_total[1m]) / 1e12
1749
1750 Average memory bandwidth in GB/s can be calculated using:
1751
1752 (rate(vllm:estimated_read_bytes_per_gpu_total[1m]) +
1753 rate(vllm:estimated_write_bytes_per_gpu_total[1m])) / 1e9
1754 """
1755
1756 _counter_cls = prometheus_client.Counter
1757
1758 def __init__(
1759 self,
1760 vllm_config: VllmConfig,
1761 labelnames: list[str],
1762 per_engine_labelvalues: dict[int, list[object]],
1763 ):
1764 counter_flops = self._counter_cls(
1765 name="vllm:estimated_flops_per_gpu_total",
1766 documentation=(
1767 "Estimated number of floating point operations per GPU "
1768 "(for Model Flops Utilization calculations)."
1769 ),
1770 labelnames=labelnames,
1771 )
1772 self.counter_flops = create_metric_per_engine(
1773 counter_flops, per_engine_labelvalues
1774 )
1775
1776 counter_read_bytes = self._counter_cls(
1777 name="vllm:estimated_read_bytes_per_gpu_total",
1778 documentation=(
1779 "Estimated number of bytes read from memory per GPU "
1780 "(for Model Flops Utilization calculations)."
1781 ),
1782 labelnames=labelnames,
1783 )
1784 self.counter_read_bytes = create_metric_per_engine(
1785 counter_read_bytes, per_engine_labelvalues
1786 )
1787
1788 counter_write_bytes = self._counter_cls(
1789 name="vllm:estimated_write_bytes_per_gpu_total",
1790 documentation=(
1791 "Estimated number of bytes written to memory per GPU "
1792 "(for Model Flops Utilization calculations)."
1793 ),
1794 labelnames=labelnames,
1795 )
1796 self.counter_write_bytes = create_metric_per_engine(
1797 counter_write_bytes, per_engine_labelvalues
1798 )
1799
1800 def observe(self, perf_stats: PerfStats, engine_idx: int = 0):
1801 if not (
1802 perf_stats.num_flops_per_gpu
1803 or perf_stats.num_read_bytes_per_gpu
1804 or perf_stats.num_write_bytes_per_gpu
1805 ):
1806 return
1807 self.counter_flops[engine_idx].inc(perf_stats.num_flops_per_gpu)
1808 self.counter_read_bytes[engine_idx].inc(perf_stats.num_read_bytes_per_gpu)
1809 self.counter_write_bytes[engine_idx].inc(perf_stats.num_write_bytes_per_gpu)
1810
1811
1812 ## util functions
1813
1814
1815 def get_required(obj: object, attr: str):
1816 """Get an attr from an object, or throw a InvalidComponentError if it's not set."""
1817 if not hasattr(obj, attr):
1818 raise InvalidComponent(f"Missing required attr {attr} in config")
1819 return getattr(obj, attr)
1820
1821
1822 def getattr_from_list(obj: object, attrs: list[str], default: object = None):
1823 """Try to get the first attr that exists in the object
1824 from a list of attrs. Otherwise return None."""
1825 for attr in attrs:
1826 if hasattr(obj, attr):
1827 return getattr(obj, attr)
1828 return default