[V1][Metrics] Support Sliding Window Attention (SWA) and hybrid layers in MFU/MBU estimation (#55624)
Signed-off-by: Thillai Chithambaram <thillaichithambaram.a@gmail.com> Co-authored-by: Mark McLoughlin <markmc@redhat.com>
Thillai Chithambaram committed
Sep 14, 2026 at 12:04 UTC
435c96f9dbdd29258cb8e0f433c5b54a00cf6b16
2 files changed
+640
-48
tests/v1/metrics/test_perf_metrics.py
+372
-2
@@ -8,6 +8,7 @@ import types
8
from types import SimpleNamespace
9
10
import pytest
11
+from transformers import Gemma2Config, MistralConfig
12
from transformers.models.deepseek_v3.configuration_deepseek_v3 import DeepseekV3Config
13
from transformers.models.llama4.configuration_llama4 import (
14
Llama4Config,
@@ -31,6 +32,7 @@ from vllm.v1.metrics.perf import (
32
MLAAttentionMetrics,
33
ModelMetrics,
34
ParsedArgs,
35
+ SlidingWindowAttentionParser,
36
UnembedMetrics,
37
)
38
@@ -38,7 +40,8 @@ from vllm.v1.metrics.perf import (
40
class MockModelConfig:
41
"""Mock ModelConfig that implements the getter methods used by parsers."""
42
41
- def __init__(self, hf_config, dtype):
43
+ def __init__(self, hf_config, dtype, disable_sliding_window=False):
44
+ """Initialize MockModelConfig with HF config and options."""
45
self.hf_config = hf_config
46
self.hf_text_config = get_hf_text_config(hf_config)
47
convertor_cls = MODEL_ARCH_CONFIG_CONVERTORS.get(
@@ -49,8 +52,10 @@ class MockModelConfig:
52
).convert()
53
self.dtype = dtype
54
self.is_attention_free = False
55
+ self.disable_sliding_window = disable_sliding_window
56
57
def __getattr__(self, name):
58
+ """Delegate attribute lookups to ModelConfig."""
59
# 1. Check if ModelConfig actually has this attribute
60
if not hasattr(ModelConfig, name):
61
raise AttributeError(
@@ -86,9 +91,13 @@ def create_mock_vllm_config(
91
tensor_parallel_size=1,
92
pipeline_parallel_size=1,
93
enable_expert_parallel=False,
94
+ disable_sliding_window=False,
95
) -> SimpleNamespace:
96
+ """Create a mock VllmConfig object for unit tests."""
97
vllm_config = SimpleNamespace()
91
- vllm_config.model_config = MockModelConfig(hf_config, model_dtype)
98
+ vllm_config.model_config = MockModelConfig(
99
+ hf_config, model_dtype, disable_sliding_window=disable_sliding_window
100
+ )
101
102
vllm_config.cache_config = SimpleNamespace()
103
vllm_config.cache_config.cache_dtype = cache_dtype
@@ -1336,3 +1345,364 @@ def test_mla_attention_scaling_with_layers():
1345
assert double_metrics.get_num_flops(ctx) == 2 * base_metrics.get_num_flops(ctx)
1346
assert double_metrics.get_read_bytes(ctx) == 2 * base_metrics.get_read_bytes(ctx)
1347
assert double_metrics.get_write_bytes(ctx) == 2 * base_metrics.get_write_bytes(ctx)
1348
+
1349
+
1350
+#### Sliding Window Attention Tests ####
1351
+
1352
+
1353
+def test_sliding_window_parser_no_swa():
1354
+ """Test parser defaults to no SWA when sliding_window is unset."""
1355
+ hf_config = Qwen3Config(
1356
+ hidden_size=2048,
1357
+ num_attention_heads=16,
1358
+ num_hidden_layers=12,
1359
+ )
1360
+ vllm_config = create_mock_vllm_config(hf_config)
1361
+ parser = SlidingWindowAttentionParser()
1362
+ args = ParsedArgs()
1363
+ args.num_hidden_layers = 12
1364
+ result = parser.parse(args, vllm_config)
1365
+
1366
+ assert result.sliding_window is None
1367
+ assert result.num_swa_layers == 0
1368
+
1369
+
1370
+def test_sliding_window_parser_uniform_swa():
1371
+ """Test SlidingWindowAttentionParser for full SWA model (e.g. Mistral)."""
1372
+ hf_config = MistralConfig(
1373
+ hidden_size=4096,
1374
+ num_attention_heads=32,
1375
+ num_key_value_heads=8,
1376
+ num_hidden_layers=32,
1377
+ sliding_window=4096,
1378
+ )
1379
+ vllm_config = create_mock_vllm_config(hf_config)
1380
+ parser = SlidingWindowAttentionParser()
1381
+ args = ParsedArgs()
1382
+ args.num_hidden_layers = 32
1383
+ result = parser.parse(args, vllm_config)
1384
+
1385
+ assert result.sliding_window == 4096
1386
+ assert result.num_swa_layers == 32
1387
+
1388
+
1389
+def test_sliding_window_parser_explicit_layer_types():
1390
+ """Test SlidingWindowAttentionParser with explicit layer_types list."""
1391
+ hf_config = Qwen3Config(
1392
+ hidden_size=2048,
1393
+ num_attention_heads=16,
1394
+ num_hidden_layers=6,
1395
+ )
1396
+ hf_config.sliding_window = 2048
1397
+ hf_config.layer_types = [
1398
+ "sliding_attention",
1399
+ "full_attention",
1400
+ "sliding_attention",
1401
+ "full_attention",
1402
+ "sliding_attention",
1403
+ "full_attention",
1404
+ ]
1405
+ vllm_config = create_mock_vllm_config(hf_config)
1406
+ parser = SlidingWindowAttentionParser()
1407
+ args = ParsedArgs()
1408
+ args.num_hidden_layers = 6
1409
+ result = parser.parse(args, vllm_config)
1410
+
1411
+ assert result.sliding_window == 2048
1412
+ assert result.num_swa_layers == 3
1413
+
1414
+
1415
+def test_sliding_window_parser_gemma2():
1416
+ """Test SlidingWindowAttentionParser detects alternating SWA for Gemma 2."""
1417
+ hf_config = Gemma2Config(
1418
+ hidden_size=2304,
1419
+ num_attention_heads=8,
1420
+ num_key_value_heads=4,
1421
+ num_hidden_layers=26,
1422
+ sliding_window=4096,
1423
+ )
1424
+ vllm_config = create_mock_vllm_config(hf_config)
1425
+ parser = SlidingWindowAttentionParser()
1426
+ args = ParsedArgs()
1427
+ args.num_hidden_layers = 26
1428
+ result = parser.parse(args, vllm_config)
1429
+
1430
+ assert result.sliding_window == 4096
1431
+ # Gemma 2 alternates: even layers are sliding window (26 layers -> 13 SWA)
1432
+ assert result.num_swa_layers == 13
1433
+
1434
+
1435
+def test_sliding_window_parser_disabled():
1436
+ """Test SlidingWindowAttentionParser respects disable_sliding_window."""
1437
+ hf_config = MistralConfig(
1438
+ hidden_size=4096,
1439
+ num_attention_heads=32,
1440
+ num_key_value_heads=8,
1441
+ num_hidden_layers=32,
1442
+ sliding_window=4096,
1443
+ )
1444
+ vllm_config = create_mock_vllm_config(hf_config, disable_sliding_window=True)
1445
+ parser = SlidingWindowAttentionParser()
1446
+ args = ParsedArgs()
1447
+ args.num_hidden_layers = 32
1448
+ result = parser.parse(args, vllm_config)
1449
+
1450
+ assert result.sliding_window is None
1451
+ assert result.num_swa_layers == 0
1452
+
1453
+
1454
+def test_attention_metrics_sliding_window_flops_bounds():
1455
+ """Test that SWA clamps attention core FLOPs when context_len > sliding_window."""
1456
+ sw = 1024
1457
+ dense_config = MistralConfig(
1458
+ hidden_size=2048,
1459
+ num_attention_heads=16,
1460
+ num_key_value_heads=16,
1461
+ num_hidden_layers=8,
1462
+ sliding_window=None,
1463
+ )
1464
+ swa_config = MistralConfig(
1465
+ hidden_size=2048,
1466
+ num_attention_heads=16,
1467
+ num_key_value_heads=16,
1468
+ num_hidden_layers=8,
1469
+ sliding_window=sw,
1470
+ )
1471
+
1472
+ dense_vllm = create_mock_vllm_config(dense_config)
1473
+ swa_vllm = create_mock_vllm_config(swa_config)
1474
+
1475
+ dense_metrics = AttentionMetrics.from_vllm_config(dense_vllm)
1476
+ swa_metrics = AttentionMetrics.from_vllm_config(swa_vllm)
1477
+
1478
+ # 1. When context <= sliding_window: FLOPs should be identical
1479
+ short_ctx = ExecutionContext.from_single_request(
1480
+ num_tokens=1, context_len=512, is_prefill=False
1481
+ )
1482
+ assert swa_metrics.get_num_flops(short_ctx) == dense_metrics.get_num_flops(
1483
+ short_ctx
1484
+ )
1485
+
1486
+ # 2. When context > sliding_window: FLOPs for SWA must be strictly smaller
1487
+ long_ctx = ExecutionContext.from_single_request(
1488
+ num_tokens=1, context_len=8192, is_prefill=False
1489
+ )
1490
+ dense_flops = dense_metrics.get_num_flops_breakdown(long_ctx)
1491
+ swa_flops = swa_metrics.get_num_flops_breakdown(long_ctx)
1492
+
1493
+ # Projections (qkv_proj, out_proj) are unaffected by sliding window
1494
+ assert swa_flops["qkv_proj"] == dense_flops["qkv_proj"]
1495
+ assert swa_flops["out_proj"] == dense_flops["out_proj"]
1496
+
1497
+ # Attention core FLOPs are clamped: 8192 vs 1024 (8x difference)
1498
+ assert swa_flops["attn_qk"] < dense_flops["attn_qk"]
1499
+ assert swa_flops["attn_av"] < dense_flops["attn_av"]
1500
+ assert swa_flops["attn_qk"] * 8 == dense_flops["attn_qk"]
1501
+ assert swa_flops["attn_av"] * 8 == dense_flops["attn_av"]
1502
+
1503
+
1504
+def test_attention_metrics_sliding_window_read_bytes():
1505
+ """Test that SWA bounds decode KV-cache read bytes to sliding_window."""
1506
+ sw = 2048
1507
+ dense_config = MistralConfig(
1508
+ hidden_size=2048,
1509
+ num_attention_heads=16,
1510
+ num_key_value_heads=4,
1511
+ num_hidden_layers=4,
1512
+ sliding_window=None,
1513
+ )
1514
+ swa_config = MistralConfig(
1515
+ hidden_size=2048,
1516
+ num_attention_heads=16,
1517
+ num_key_value_heads=4,
1518
+ num_hidden_layers=4,
1519
+ sliding_window=sw,
1520
+ )
1521
+
1522
+ dense_vllm = create_mock_vllm_config(dense_config)
1523
+ swa_vllm = create_mock_vllm_config(swa_config)
1524
+
1525
+ dense_metrics = AttentionMetrics.from_vllm_config(dense_vllm)
1526
+ swa_metrics = AttentionMetrics.from_vllm_config(swa_vllm)
1527
+
1528
+ # Long decode context: 8192 tokens
1529
+ ctx = ExecutionContext.from_single_request(
1530
+ num_tokens=1, context_len=8192, is_prefill=False
1531
+ )
1532
+ dense_reads = dense_metrics.get_read_bytes_breakdown(ctx)
1533
+ swa_reads = swa_metrics.get_read_bytes_breakdown(ctx)
1534
+
1535
+ # Attention input read traffic is lower for SWA
1536
+ assert swa_reads["attn_input"] < dense_reads["attn_input"]
1537
+
1538
+ # Weight reads and projection inputs are identical
1539
+ assert swa_reads["qkv_weight"] == dense_reads["qkv_weight"]
1540
+ assert swa_reads["out_weight"] == dense_reads["out_weight"]
1541
+ assert swa_reads["qkv_input"] == dense_reads["qkv_input"]
1542
+
1543
+
1544
+def test_attention_metrics_hybrid_alternating_layers():
1545
+ """Test hybrid model where half layers are full attention and half are SWA."""
1546
+ sw = 1024
1547
+ hybrid_config = Qwen3Config(
1548
+ hidden_size=2048,
1549
+ num_attention_heads=16,
1550
+ num_key_value_heads=16,
1551
+ num_hidden_layers=4,
1552
+ )
1553
+ hybrid_config.sliding_window = sw
1554
+ hybrid_config.layer_types = [
1555
+ "sliding_attention",
1556
+ "full_attention",
1557
+ "sliding_attention",
1558
+ "full_attention",
1559
+ ]
1560
+
1561
+ vllm_config = create_mock_vllm_config(hybrid_config)
1562
+ metrics = AttentionMetrics.from_vllm_config(vllm_config)
1563
+
1564
+ assert metrics.num_swa_layers == 2
1565
+ assert metrics.sliding_window == sw
1566
+
1567
+ ctx = ExecutionContext.from_single_request(
1568
+ num_tokens=1, context_len=4096, is_prefill=False
1569
+ )
1570
+ flops = metrics.get_num_flops_breakdown(ctx)
1571
+
1572
+ # 2 full layers attend to 4096; 2 SWA layers attend to 1024
1573
+ # Expected TC total = 2 * 4096 + 2 * 1024 = 8192 + 2048 = 10240
1574
+ # Formula: 2 * q * d * (L_full * 4096 + L_swa * 1024)
1575
+ q = metrics.num_attention_heads
1576
+ d = metrics.head_dim
1577
+ expected_core_flops = 2 * q * d * (2 * 4096 + 2 * 1024)
1578
+ assert flops["attn_qk"] == expected_core_flops
1579
+ assert flops["attn_av"] == expected_core_flops
1580
+
1581
+
1582
+def test_attention_metrics_sliding_window_per_gpu():
1583
+ """Test that SWA metrics scale properly with tensor and pipeline parallelism."""
1584
+ sw = 1024
1585
+ hf_config = MistralConfig(
1586
+ hidden_size=4096,
1587
+ num_attention_heads=32,
1588
+ num_key_value_heads=8,
1589
+ num_hidden_layers=16,
1590
+ sliding_window=sw,
1591
+ )
1592
+ single_vllm = create_mock_vllm_config(hf_config)
1593
+ tp_vllm = create_mock_vllm_config(hf_config, tensor_parallel_size=2)
1594
+ pp_vllm = create_mock_vllm_config(hf_config, pipeline_parallel_size=2)
1595
+
1596
+ single_metrics = AttentionMetrics.from_vllm_config(single_vllm)
1597
+ tp_metrics = AttentionMetrics.from_vllm_config(tp_vllm)
1598
+ pp_metrics = AttentionMetrics.from_vllm_config(pp_vllm)
1599
+
1600
+ ctx = ExecutionContext.from_single_request(
1601
+ num_tokens=1, context_len=4096, is_prefill=False
1602
+ )
1603
+
1604
+ # Both TP=2 and PP=2 should halve the per-GPU attention core FLOPs
1605
+ single_flops = single_metrics.get_num_flops_breakdown(ctx, per_gpu=True)
1606
+ tp_flops = tp_metrics.get_num_flops_breakdown(ctx, per_gpu=True)
1607
+ pp_flops = pp_metrics.get_num_flops_breakdown(ctx, per_gpu=True)
1608
+
1609
+ assert tp_flops["attn_qk"] == single_flops["attn_qk"] // 2
1610
+ assert pp_flops["attn_qk"] == single_flops["attn_qk"] // 2
1611
+
1612
+
1613
+def test_attention_metrics_hybrid_sliding_window_per_gpu():
1614
+ """Test that hybrid SWA models preserve L_full + L_swa == L under PP."""
1615
+ sw = 1024
1616
+ hybrid_config = Gemma2Config(
1617
+ hidden_size=2048,
1618
+ num_attention_heads=16,
1619
+ num_key_value_heads=16,
1620
+ num_hidden_layers=30,
1621
+ sliding_window=sw,
1622
+ )
1623
+ pp_vllm = create_mock_vllm_config(hybrid_config, pipeline_parallel_size=2)
1624
+ pp_metrics = AttentionMetrics.from_vllm_config(pp_vllm)
1625
+
1626
+ ctx = ExecutionContext.from_single_request(
1627
+ num_tokens=1, context_len=4096, is_prefill=False
1628
+ )
1629
+ pp_flops = pp_metrics.get_num_flops_breakdown(ctx, per_gpu=True)
1630
+ pp_read_bytes = pp_metrics.get_read_bytes_breakdown(ctx, per_gpu=True)
1631
+
1632
+ # Under PP=2, each global count is halved: 30 layers -> 15 per GPU, and the
1633
+ # 15 SWA / 15 full split becomes 7.5 each. Gemma 2 alternates, so an even
1634
+ # split is the correct average stage; deriving it after reducing L instead
1635
+ # produced a lopsided 8/7.
1636
+ q = pp_metrics.num_attention_heads
1637
+ d = pp_metrics.head_dim
1638
+ expected_pp_attn = int(2 * q * d * (7.5 * 4096 + 7.5 * sw))
1639
+ assert pp_flops["attn_qk"] == expected_pp_attn
1640
+ assert pp_flops["attn_av"] == expected_pp_attn
1641
+ assert "attn_input" in pp_read_bytes
1642
+
1643
+
1644
+def test_attention_metrics_minority_swa_survives_pipeline_parallelism():
1645
+ """A minority SWA sublayer must not round away under PP.
1646
+
1647
+ Regression test for a 6-layer model with a single sliding-window layer at
1648
+ pp_size=2: deriving the split after reducing L gave
1649
+ round(3 * 1 / 6) == 0, billing all three per-GPU layers as full attention
1650
+ and dropping the sliding-window contribution entirely.
1651
+ """
1652
+ sw = 512
1653
+ context_len = 4096
1654
+ hf_config = Qwen3Config(
1655
+ hidden_size=2048,
1656
+ num_attention_heads=16,
1657
+ num_key_value_heads=16,
1658
+ num_hidden_layers=6,
1659
+ )
1660
+ hf_config.sliding_window = sw
1661
+ hf_config.layer_types = ["sliding_attention"] + ["full_attention"] * 5
1662
+
1663
+ pp_vllm = create_mock_vllm_config(hf_config, pipeline_parallel_size=2)
1664
+ pp_metrics = AttentionMetrics.from_vllm_config(pp_vllm)
1665
+ assert pp_metrics.num_swa_layers == 1
1666
+
1667
+ ctx = ExecutionContext.from_single_request(
1668
+ num_tokens=1, context_len=context_len, is_prefill=False
1669
+ )
1670
+ pp_flops = pp_metrics.get_num_flops_breakdown(ctx, per_gpu=True)
1671
+
1672
+ # 5 full and 1 SWA layer, each halved by PP=2: 2.5 full and 0.5 SWA.
1673
+ q = pp_metrics.num_attention_heads
1674
+ d = pp_metrics.head_dim
1675
+ expected = int(2 * q * d * (2.5 * context_len + 0.5 * sw))
1676
+ assert pp_flops["attn_qk"] == expected
1677
+ assert pp_flops["attn_av"] == expected
1678
+
1679
+ # The sliding-window layer must change the result, i.e. it is not being
1680
+ # billed at the full context length.
1681
+ all_full = int(2 * q * d * 3 * context_len)
1682
+ assert pp_flops["attn_qk"] < all_full
1683
+
1684
+
1685
+def test_attention_metrics_per_gpu_layers_sum_to_whole_model():
1686
+ """Per-GPU counts times pp_size must account for every layer.
1687
+
1688
+ Flooring the layer count per stage silently drops the remainder, so a model
1689
+ whose layers do not divide evenly across stages under-reports its FLOPs.
1690
+ """
1691
+ hf_config = Qwen3Config(
1692
+ hidden_size=2048,
1693
+ num_attention_heads=16,
1694
+ num_key_value_heads=16,
1695
+ num_hidden_layers=7,
1696
+ )
1697
+ pp_size = 2
1698
+ vllm_config = create_mock_vllm_config(hf_config, pipeline_parallel_size=pp_size)
1699
+ metrics = AttentionMetrics.from_vllm_config(vllm_config)
1700
+
1701
+ ctx = ExecutionContext.from_single_request(
1702
+ num_tokens=8, context_len=1024, is_prefill=True
1703
+ )
1704
+ per_gpu = metrics.get_num_flops_breakdown(ctx, per_gpu=True)
1705
+ whole = metrics.get_num_flops_breakdown(ctx, per_gpu=False)
1706
+
1707
+ for key, value in whole.items():
1708
+ assert per_gpu[key] * pp_size == pytest.approx(value, rel=1e-9)
vllm/v1/metrics/perf.py
+268
-46
@@ -10,7 +10,7 @@ import json
10
import time
11
from abc import ABC, abstractmethod
12
from collections.abc import Iterable
13
-from dataclasses import asdict, dataclass
13
+from dataclasses import asdict, dataclass, field
14
from typing import Any, Protocol
15
16
import prometheus_client
@@ -124,8 +124,12 @@ class ExecutionContext:
124
decode_context_len: int = 0 # sum of context_len for decode requests
125
decode_token_context_product: int = 0 # sum of (num_tokens * context_len)
126
127
+ # Per-request records: (num_tokens, context_len, is_prefill)
128
+ requests: list[tuple[int, int, bool]] = field(default_factory=list)
129
+
130
def add(self, num_tokens: int, context_len: int, is_prefill: bool) -> None:
131
"""Add a single request's statistics to this batch context."""
132
+ self.requests.append((num_tokens, context_len, is_prefill))
133
if is_prefill:
134
self.num_prefill_requests += 1
135
self.prefill_num_tokens += num_tokens
@@ -141,9 +145,40 @@ class ExecutionContext:
145
"""Total number of tokens across all requests in the batch."""
146
return self.prefill_num_tokens + self.decode_num_tokens
147
144
- def total_token_context_product(self) -> int:
145
- """Total sum of (num_tokens * context_len) across all requests."""
146
- return self.prefill_token_context_product + self.decode_token_context_product
148
+ def total_token_context_product(self, sliding_window: int | None = None) -> int:
149
+ """Total sum of (num_tokens * context_len) across all requests.
150
+
151
+ If sliding_window is provided, context length per request is clamped to
152
+ sliding_window.
153
+ """
154
+ if sliding_window is None:
155
+ return (
156
+ self.prefill_token_context_product + self.decode_token_context_product
157
+ )
158
+ return sum(
159
+ num_tokens * min(context_len, sliding_window)
160
+ for num_tokens, context_len, _ in self.requests
161
+ )
162
+
163
+ def prefill_context_len_for_window(self, sliding_window: int | None = None) -> int:
164
+ """Sum of context_len for prefill requests, clamped to sliding_window if set."""
165
+ if sliding_window is None:
166
+ return self.prefill_context_len
167
+ return sum(
168
+ min(context_len, sliding_window)
169
+ for _, context_len, is_prefill in self.requests
170
+ if is_prefill
171
+ )
172
+
173
+ def decode_context_len_for_window(self, sliding_window: int | None = None) -> int:
174
+ """Sum of context_len for decode requests, clamped to sliding_window if set."""
175
+ if sliding_window is None:
176
+ return self.decode_context_len
177
+ return sum(
178
+ min(context_len, sliding_window)
179
+ for _, context_len, is_prefill in self.requests
180
+ if not is_prefill
181
+ )
182
183
def num_logits_tokens(self) -> int:
184
"""Number of tokens that require logits computation (unembedding).
@@ -214,6 +249,7 @@ class ParserChain:
249
self.parsers.append(parser)
250
251
def parse(self, vllm_config: VllmConfig) -> ParsedArgs:
252
+ """Apply all parsers in sequence and return the accumulated ParsedArgs."""
253
args = ParsedArgs()
254
for parser in self.parsers:
255
args = parser.parse(args, vllm_config)
@@ -234,7 +270,9 @@ class ComponentMetrics(BaseModel, ABC):
270
271
@classmethod
272
@abstractmethod
237
- def component_type(cls) -> str: ...
273
+ def component_type(cls) -> str:
274
+ """Return the unique string key identifying this component in the registry."""
275
+ ...
276
277
@classmethod
278
@abstractmethod
@@ -273,17 +311,23 @@ class ComponentMetrics(BaseModel, ABC):
311
@abstractmethod
312
def get_num_flops_breakdown(
313
self, ctx: ExecutionContext, per_gpu: bool = True
276
- ) -> dict[str, int]: ...
314
+ ) -> dict[str, int]:
315
+ """Return per-operation FLOPs breakdown for this component."""
316
+ ...
317
318
@abstractmethod
319
def get_read_bytes_breakdown(
320
self, ctx: ExecutionContext, per_gpu: bool = True
281
- ) -> dict[str, int]: ...
321
+ ) -> dict[str, int]:
322
+ """Return per-operation read-memory-traffic breakdown for this component."""
323
+ ...
324
325
@abstractmethod
326
def get_write_bytes_breakdown(
327
self, ctx: ExecutionContext, per_gpu: bool = True
286
- ) -> dict[str, int]: ...
328
+ ) -> dict[str, int]:
329
+ """Return per-operation write-memory-traffic breakdown for this component."""
330
+ ...
331
332
def get_num_flops(self, ctx: ExecutionContext, per_gpu: bool = True) -> int:
333
return sum(self.get_num_flops_breakdown(ctx, per_gpu).values())
@@ -306,6 +350,7 @@ class BaseConfigParser(Parser):
350
"""
351
352
def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
353
+ """Parse base model dimensions, dtype byte sizes, and parallelism config."""
354
model_config = vllm_config.model_config
355
356
args.vocab_size = model_config.get_vocab_size()
@@ -357,6 +402,7 @@ class BaseAttentionConfigParser(Parser):
402
"""
403
404
def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
405
+ """Parse KV head count, head dimension, and KV-cache dtype byte size."""
406
model_config = vllm_config.model_config
407
408
args.num_key_value_heads = model_config.get_total_num_kv_heads()
@@ -371,6 +417,54 @@ class BaseAttentionConfigParser(Parser):
417
return args
418
419
420
+class SlidingWindowAttentionParser(Parser):
421
+ """
422
+ Parses sliding window attention configuration and layer breakdown.
423
+ Provides: sliding_window, num_swa_layers
424
+ """
425
+
426
+ def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
427
+ """Determine sliding_window size and count of SWA layers.
428
+
429
+ Detects per-layer type lists (e.g. Llama-4), Gemma 2 alternating
430
+ pattern, and uniform SWA models (Mistral, Qwen2.5).
431
+ """
432
+ model_config = vllm_config.model_config
433
+
434
+ sliding_window: int | None = None
435
+ if not getattr(model_config, "disable_sliding_window", False):
436
+ sliding_window = model_config.get_sliding_window()
437
+ if sliding_window is None and hasattr(vllm_config, "cache_config"):
438
+ sliding_window = getattr(
439
+ vllm_config.cache_config, "sliding_window", None
440
+ )
441
+
442
+ args.sliding_window = sliding_window
443
+
444
+ num_layers = args.num_hidden_layers
445
+ if sliding_window is not None and sliding_window > 0:
446
+ hf_text_config = getattr(model_config, "hf_text_config", None)
447
+ layer_types = getattr(hf_text_config, "layer_types", None)
448
+ if layer_types is not None and isinstance(layer_types, list):
449
+ num_swa = sum(
450
+ 1 for lt in layer_types[:num_layers] if "sliding" in lt.lower()
451
+ )
452
+ args.num_swa_layers = num_swa
453
+ elif (
454
+ getattr(getattr(model_config, "hf_config", None), "model_type", None)
455
+ == "gemma2"
456
+ ):
457
+ # Gemma 2 alternates sliding window: even layers (0, 2, ...) are sliding
458
+ args.num_swa_layers = (num_layers + 1) // 2
459
+ else:
460
+ # Full SWA model (e.g. Mistral, Qwen2.5 with sliding_window)
461
+ args.num_swa_layers = num_layers
462
+ else:
463
+ args.num_swa_layers = 0
464
+
465
+ return args
466
+
467
+
468
class AttentionQuantizationConfigParser(Parser):
469
"""
470
Parses quantization configuration for attention layers.
@@ -378,6 +472,7 @@ class AttentionQuantizationConfigParser(Parser):
472
"""
473
474
def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
475
+ """Override weight_byte_size based on the active quantization method."""
476
cfg = vllm_config.quant_config
477
478
if cfg is None:
@@ -401,6 +496,7 @@ class AttentionDetectionParser(Parser):
496
"""
497
498
def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
499
+ """Raise InvalidComponent for MLA models so AttentionMetrics is skipped."""
500
if vllm_config.model_config.is_deepseek_mla:
501
raise InvalidComponent(
502
"Model uses MLA attention; use MLAAttentionMetrics instead"
@@ -425,53 +521,129 @@ class AttentionMetrics(ComponentMetrics):
521
# From BaseConfig Parser, overridden by AttentionQuantizationConfigParser
522
weight_byte_size: int | float = Field(..., gt=0)
523
428
- # TODO: discern cases where we have mixture of different attention layer types
429
- # such as SWA, MLA, etc.
524
+ # From SlidingWindowAttentionParser
525
+ sliding_window: int | None = Field(None)
526
+ num_swa_layers: int = Field(0, ge=0)
527
528
@classmethod
529
def component_type(cls) -> str:
530
+ """Return the component registry key for standard (non-MLA) attention."""
531
return "attn"
532
533
@classmethod
534
def get_parser(cls) -> ParserChain:
535
+ """Return the parser chain for AttentionMetrics.
536
+
537
+ Parsers run in order: MLA detection guard, base model config,
538
+ attention-specific config, sliding-window breakdown, quantization
539
+ weight-size override.
540
+ """
541
return ParserChain(
542
AttentionDetectionParser(),
543
BaseConfigParser(),
544
BaseAttentionConfigParser(),
545
+ SlidingWindowAttentionParser(),
546
AttentionQuantizationConfigParser(),
547
)
548
549
+ def _layer_counts(self, per_gpu: bool) -> tuple[float, float, float]:
550
+ """Return ``(L, L_full, L_swa)``, the layer counts to bill for.
551
+
552
+ Per-GPU counts divide each global count by ``pp_size`` independently.
553
+ Deriving the sublayer split after reducing ``L`` instead rounds a
554
+ minority sublayer away: a 6-layer model with 1 sliding-window layer at
555
+ ``pp_size=2`` yields ``round(3 * 1 / 6) == 0``, billing all three layers
556
+ as full attention. Dividing each count separately keeps
557
+ ``L_full + L_swa == L`` and keeps the per-GPU values summing back to the
558
+ whole model across stages.
559
+
560
+ The counts are fractional because these metrics are built from config
561
+ alone (see ``from_vllm_config``); no pipeline rank is available, so a
562
+ per-GPU figure is the average stage rather than a specific one. A model
563
+ whose layers do not divide evenly across stages has no single integer
564
+ answer here.
565
+ """
566
+ L = float(self.num_hidden_layers)
567
+ L_swa = float(self.num_swa_layers)
568
+ L_full = L - L_swa
569
+
570
+ if per_gpu:
571
+ L /= self.pp_size
572
+ L_swa /= self.pp_size
573
+ L_full /= self.pp_size
574
+
575
+ return L, L_full, L_swa
576
+
577
def get_num_flops_breakdown(
578
self, ctx: ExecutionContext, per_gpu: bool = True
579
) -> dict[str, int]:
447
- L, D, q, kv, d = (
448
- self.num_hidden_layers,
580
+ """Compute FLOPs breakdown for attention layers.
581
+
582
+ Accounts for hybrid models by splitting total layers into full-attention
583
+ (L_full) and sliding-window (L_swa) sublayers. See ``_layer_counts`` for
584
+ how those are scaled under pipeline parallelism.
585
+
586
+ Args:
587
+ ctx: Execution context describing the current batch.
588
+ per_gpu: If True, scale counts down by tensor/pipeline parallelism.
589
+
590
+ Returns:
591
+ Dict with keys: qkv_proj, out_proj, attn_qk, attn_av.
592
+ """
593
+ D, q, kv, d = (
594
self.hidden_size,
595
self.num_attention_heads,
596
self.num_key_value_heads,
597
self.head_dim,
598
)
599
T = ctx.total_num_tokens()
455
- TC = ctx.total_token_context_product()
600
+ TC_full = ctx.total_token_context_product()
601
+
602
+ L, L_full, L_swa = self._layer_counts(per_gpu)
603
604
if per_gpu:
458
- L //= self.pp_size
605
# tensor parallel along heads
606
q = max(1, q // self.tp_size)
607
kv = max(1, kv // self.tp_size)
608
463
- return {
464
- "qkv_proj": 2 * T * D * (q + 2 * kv) * d * L,
465
- "attn_qk": 2 * q * TC * d * L,
466
- "attn_av": 2 * q * TC * d * L,
467
- "out_proj": 2 * T * D * q * d * L,
609
+ flops = {
610
+ "qkv_proj": int(2 * T * D * (q + 2 * kv) * d * L),
611
+ "out_proj": int(2 * T * D * q * d * L),
612
}
613
614
+ attn_qk = 0.0
615
+ attn_av = 0.0
616
+ if L_full > 0:
617
+ attn_qk += 2 * q * TC_full * d * L_full
618
+ attn_av += 2 * q * TC_full * d * L_full
619
+
620
+ if L_swa > 0 and self.sliding_window is not None:
621
+ TC_swa = ctx.total_token_context_product(self.sliding_window)
622
+ attn_qk += 2 * q * TC_swa * d * L_swa
623
+ attn_av += 2 * q * TC_swa * d * L_swa
624
+
625
+ flops["attn_qk"] = int(attn_qk)
626
+ flops["attn_av"] = int(attn_av)
627
+
628
+ return flops
629
+
630
def get_read_bytes_breakdown(
631
self, ctx: ExecutionContext, per_gpu: bool = True
632
) -> dict[str, int]:
473
- L, D, q, kv, d = (
474
- self.num_hidden_layers,
633
+ """Compute read-memory-traffic breakdown for attention layers.
634
+
635
+ Splits layers into full-attention and sliding-window sublayers using the
636
+ same counts as get_num_flops_breakdown; see ``_layer_counts``.
637
+
638
+ Args:
639
+ ctx: Execution context describing the current batch.
640
+ per_gpu: If True, scale counts down by tensor/pipeline parallelism.
641
+
642
+ Returns:
643
+ Dict with keys: qkv_input, qkv_weight, attn_input (conditional),
644
+ out_input, out_weight.
645
+ """
646
+ D, q, kv, d = (
647
self.hidden_size,
648
self.num_attention_heads,
649
self.num_key_value_heads,
@@ -479,35 +651,63 @@ class AttentionMetrics(ComponentMetrics):
651
)
652
T = ctx.total_num_tokens()
653
654
+ L, L_full, L_swa = self._layer_counts(per_gpu)
655
+
656
if per_gpu:
483
- L //= self.pp_size
657
# tensor parallel along heads
658
q = max(1, q // self.tp_size)
659
kv = max(1, kv // self.tp_size)
660
661
read_bytes = {}
662
490
- read_bytes["qkv_input"] = T * D * self.activation_byte_size * L
663
+ read_bytes["qkv_input"] = int(T * D * self.activation_byte_size * L)
664
read_bytes["qkv_weight"] = int(D * (q + 2 * kv) * d * self.weight_byte_size * L)
665
493
- # Attention input reads differ between prefill and decode
494
- # Prefill: read Q, K, V activations (all in activation_byte_size)
495
- if ctx.prefill_num_tokens > 0:
496
- read_bytes["attn_input"] = (
497
- (ctx.prefill_num_tokens * q + 2 * ctx.prefill_context_len * kv)
498
- * d
499
- * self.activation_byte_size
500
- * L
501
- )
666
+ attn_input = 0.0
667
503
- # Decode: read Q activations + read K, V from cache (in cache_byte_size)
504
- if ctx.decode_num_tokens > 0:
505
- read_bytes["attn_input"] = read_bytes.get("attn_input", 0) + (
506
- ctx.decode_num_tokens * q * d * self.activation_byte_size * L
507
- + 2 * ctx.decode_context_len * kv * d * self.cache_byte_size * L
508
- )
668
+ # Full attention layers
669
+ if L_full > 0:
670
+ if ctx.prefill_num_tokens > 0:
671
+ attn_input += (
672
+ (ctx.prefill_num_tokens * q + 2 * ctx.prefill_context_len * kv)
673
+ * d
674
+ * self.activation_byte_size
675
+ * L_full
676
+ )
677
+ if ctx.decode_num_tokens > 0:
678
+ attn_input += (
679
+ ctx.decode_num_tokens * q * d * self.activation_byte_size * L_full
680
+ + 2
681
+ * ctx.decode_context_len
682
+ * kv
683
+ * d
684
+ * self.cache_byte_size
685
+ * L_full
686
+ )
687
+
688
+ # Sliding window attention layers
689
+ if L_swa > 0:
690
+ sw = self.sliding_window
691
+ prefill_sw_len = ctx.prefill_context_len_for_window(sw)
692
+ decode_sw_len = ctx.decode_context_len_for_window(sw)
693
+
694
+ if ctx.prefill_num_tokens > 0:
695
+ attn_input += (
696
+ (ctx.prefill_num_tokens * q + 2 * prefill_sw_len * kv)
697
+ * d
698
+ * self.activation_byte_size
699
+ * L_swa
700
+ )
701
+ if ctx.decode_num_tokens > 0:
702
+ attn_input += (
703
+ ctx.decode_num_tokens * q * d * self.activation_byte_size * L_swa
704
+ + 2 * decode_sw_len * kv * d * self.cache_byte_size * L_swa
705
+ )
706
+
707
+ if ctx.prefill_num_tokens > 0 or ctx.decode_num_tokens > 0:
708
+ read_bytes["attn_input"] = int(attn_input)
709
510
- read_bytes["out_input"] = T * q * d * self.activation_byte_size * L
710
+ read_bytes["out_input"] = int(T * q * d * self.activation_byte_size * L)
711
read_bytes["out_weight"] = int(q * d * D * self.weight_byte_size * L)
712
713
return read_bytes
@@ -515,9 +715,13 @@ class AttentionMetrics(ComponentMetrics):
715
def get_write_bytes_breakdown(
716
self, ctx: ExecutionContext, per_gpu: bool = True
717
) -> dict[str, int]:
518
- """Calculate write memory traffic for attention layers."""
519
- L, D, q, kv, d = (
520
- self.num_hidden_layers,
718
+ """Calculate write memory traffic for attention layers.
719
+
720
+ Writes do not depend on the attention window, so only the total layer
721
+ count is needed; it comes from ``_layer_counts`` so all three breakdowns
722
+ bill the same number of layers.
723
+ """
724
+ D, q, kv, d = (
725
self.hidden_size,
726
self.num_attention_heads,
727
self.num_key_value_heads,
@@ -525,16 +729,17 @@ class AttentionMetrics(ComponentMetrics):
729
)
730
T = ctx.total_num_tokens()
731
732
+ L, _, _ = self._layer_counts(per_gpu)
733
+
734
if per_gpu:
529
- L //= self.pp_size
735
# tensor parallel along heads
736
q = max(1, q // self.tp_size)
737
kv = max(1, kv // self.tp_size)
738
739
return {
535
- "qkv_output": T * (q + 2 * kv) * d * self.activation_byte_size * L,
536
- "kv_cache": 2 * T * kv * d * self.cache_byte_size * L,
537
- "out_output": T * D * self.activation_byte_size * L,
740
+ "qkv_output": int(T * (q + 2 * kv) * d * self.activation_byte_size * L),
741
+ "kv_cache": int(2 * T * kv * d * self.cache_byte_size * L),
742
+ "out_output": int(T * D * self.activation_byte_size * L),
743
}
744
745
@@ -549,6 +754,7 @@ class MLADetectionParser(Parser):
754
"""
755
756
def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
757
+ """Raise InvalidComponent if the model does not use MLA attention."""
758
if not vllm_config.model_config.is_deepseek_mla:
759
raise InvalidComponent("Model does not use MLA attention")
760
return args
@@ -562,6 +768,7 @@ class MLAConfigParser(Parser):
768
"""
769
770
def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
771
+ """Parse MLA-specific compression dimensions and KV-cache dtype byte size."""
772
model_config = vllm_config.model_config
773
cfg = model_config.hf_text_config
774
@@ -616,10 +823,16 @@ class MLAAttentionMetrics(ComponentMetrics):
823
824
@classmethod
825
def component_type(cls) -> str:
826
+ """Return the component registry key for MLA attention."""
827
return "mla_attn"
828
829
@classmethod
830
def get_parser(cls) -> ParserChain:
831
+ """Return the parser chain for MLAAttentionMetrics.
832
+
833
+ Parsers run in order: MLA detection guard, base model config,
834
+ MLA-specific config, quantization weight-size override.
835
+ """
836
return ParserChain(
837
MLADetectionParser(),
838
BaseConfigParser(),
@@ -923,6 +1136,7 @@ class FfnQuantizationConfigParser(Parser):
1136
"""
1137
1138
def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
1139
+ """Override weight_byte_size based on the active FFN quantization method."""
1140
cfg = vllm_config.quant_config
1141
1142
if cfg is None:
@@ -980,10 +1194,16 @@ class FfnMetrics(ComponentMetrics):
1194
1195
@classmethod
1196
def component_type(cls) -> str:
1197
+ """Return the component registry key for FFN layers."""
1198
return "ffn"
1199
1200
@classmethod
1201
def get_parser(cls) -> ParserChain:
1202
+ """Return the parser chain for FfnMetrics.
1203
+
1204
+ Parsers run in order: base model config, FFN parallelism, base FFN
1205
+ config, MoE interleave step, MoE layer frequency, quantization override.
1206
+ """
1207
return ParserChain(
1208
BaseConfigParser(),
1209
FfnParallelParser(),
@@ -1207,10 +1427,12 @@ class UnembedMetrics(ComponentMetrics):
1427
1428
@classmethod
1429
def component_type(cls) -> str:
1430
+ """Return the component registry key for the unembedding (LM head) layer."""
1431
return "unembed"
1432
1433
@classmethod
1434
def get_parser(cls) -> ParserChain:
1435
+ """Return the parser chain for UnembedMetrics (base model config only)."""
1436
return ParserChain(
1437
BaseConfigParser(),
1438
)