A high-throughput and memory-efficient inference and serving engine for LLMs
[V1][Metrics] Add MLA attention metrics for DeepSeek MFU estimation (#39457)
Signed-off-by: Thillai Chithambaram <thillaichithambaram.a@gmail.com> Co-authored-by: Mark McLoughlin <markmc@redhat.com>
Thillai Chithambaram committed
Jun 12, 2026 at 09:28 UTC
4171ae406cdcec1c9952ed6fc00cd9ac91e3e342
2 files changed
+600
tests/v1/metrics/test_perf_metrics.py
+315
@@ -28,6 +28,7 @@ from vllm.v1.metrics.perf import (
28
ExecutionContext,
29
FfnMetrics,
30
InvalidComponent,
31
+ MLAAttentionMetrics,
32
ModelMetrics,
33
ParsedArgs,
34
UnembedMetrics,
@@ -1021,3 +1022,317 @@ def test_quantized_model_metrics_aggregation():
1022
1023
assert total_flops > 0
1024
assert total_flops == sum(breakdown.values())
1025
+
1026
+
1027
+#### MLA Attention Tests ####
1028
+
1029
+
1030
+def test_mla_config_parser():
1031
+ """Test MLAConfigParser extracts MLA-specific fields from DeepseekV3Config."""
1032
+ hf_config = DeepseekV3Config(
1033
+ hidden_size=7168,
1034
+ num_attention_heads=128,
1035
+ num_hidden_layers=61,
1036
+ kv_lora_rank=512,
1037
+ qk_nope_head_dim=128,
1038
+ qk_rope_head_dim=64,
1039
+ v_head_dim=128,
1040
+ q_lora_rank=1536,
1041
+ )
1042
+ vllm_config = create_mock_vllm_config(hf_config)
1043
+
1044
+ parser_chain = MLAAttentionMetrics.get_parser()
1045
+ result = parser_chain.parse(vllm_config)
1046
+
1047
+ assert result.kv_lora_rank == 512
1048
+ assert result.qk_nope_head_dim == 128
1049
+ assert result.qk_rope_head_dim == 64
1050
+ assert result.v_head_dim == 128
1051
+ assert result.q_lora_rank == 1536
1052
+ assert result.num_attention_heads == 128
1053
+ assert result.hidden_size == 7168
1054
+
1055
+
1056
+def test_mla_attention_metrics_decode():
1057
+ """Test MLA decode metrics use compressed KV cache, not standard head_dim."""
1058
+ hf_config = DeepseekV3Config(
1059
+ hidden_size=7168,
1060
+ num_attention_heads=128,
1061
+ num_hidden_layers=1,
1062
+ kv_lora_rank=512,
1063
+ qk_nope_head_dim=128,
1064
+ qk_rope_head_dim=64,
1065
+ v_head_dim=128,
1066
+ q_lora_rank=1536,
1067
+ )
1068
+ vllm_config = create_mock_vllm_config(hf_config)
1069
+ metrics = MLAAttentionMetrics.from_vllm_config(vllm_config)
1070
+
1071
+ # Single decode token with 1024 context
1072
+ ctx = ExecutionContext.from_single_request(
1073
+ num_tokens=1, context_len=1024, is_prefill=False
1074
+ )
1075
+
1076
+ write_breakdown = metrics.get_write_bytes_breakdown(ctx, per_gpu=False)
1077
+
1078
+ # KV cache write should be 1 * (512 + 64) * cache_byte_size * 1 layer
1079
+ # = 576 * 2 = 1152 bytes (for bfloat16 cache)
1080
+ kv_compressed_dim = 512 + 64 # kv_lora_rank + qk_rope_head_dim
1081
+ expected_kv_cache_write = 1 * kv_compressed_dim * 2 * 1 # T * dim * bytes * L
1082
+ assert write_breakdown["kv_cache"] == expected_kv_cache_write
1083
+
1084
+ # Verify read bytes include compressed KV cache reads for context
1085
+ read_breakdown = metrics.get_read_bytes_breakdown(ctx, per_gpu=False)
1086
+ assert "attn_input" in read_breakdown
1087
+ assert read_breakdown["attn_input"] > 0
1088
+
1089
+
1090
+def test_mla_attention_metrics_prefill():
1091
+ """Test MLA prefill metrics account for low-rank Q and KV projections."""
1092
+ hf_config = DeepseekV3Config(
1093
+ hidden_size=7168,
1094
+ num_attention_heads=128,
1095
+ num_hidden_layers=1,
1096
+ kv_lora_rank=512,
1097
+ qk_nope_head_dim=128,
1098
+ qk_rope_head_dim=64,
1099
+ v_head_dim=128,
1100
+ q_lora_rank=1536,
1101
+ )
1102
+ vllm_config = create_mock_vllm_config(hf_config)
1103
+ metrics = MLAAttentionMetrics.from_vllm_config(vllm_config)
1104
+
1105
+ ctx = ExecutionContext.from_single_request(
1106
+ num_tokens=2048, context_len=2048, is_prefill=True
1107
+ )
1108
+
1109
+ flops_breakdown = metrics.get_num_flops_breakdown(ctx, per_gpu=False)
1110
+
1111
+ # Should have two-stage Q projection (q_a and q_b)
1112
+ assert "q_a_proj" in flops_breakdown
1113
+ assert "q_b_proj" in flops_breakdown
1114
+ assert "q_proj" not in flops_breakdown # Since q_lora_rank is not None
1115
+
1116
+ # Should have KV projections
1117
+ assert "kv_a_proj" in flops_breakdown
1118
+ assert "kv_b_proj" in flops_breakdown
1119
+
1120
+ # Should have attention and output
1121
+ assert "attn_qk" in flops_breakdown
1122
+ assert "attn_av" in flops_breakdown
1123
+ assert "out_proj" in flops_breakdown
1124
+
1125
+ # Verify q_a_proj: 2 * T * D * q_lora_rank * L
1126
+ expected_q_a = 2 * 2048 * 7168 * 1536 * 1
1127
+ assert flops_breakdown["q_a_proj"] == expected_q_a
1128
+
1129
+ # Verify kv_a_proj: 2 * T * D * (kv_lora_rank + qk_rope_head_dim) * L
1130
+ expected_kv_a = 2 * 2048 * 7168 * (512 + 64) * 1
1131
+ assert flops_breakdown["kv_a_proj"] == expected_kv_a
1132
+
1133
+
1134
+def test_mla_kv_cache_vs_standard_attention():
1135
+ """Test MLA KV cache writes are dramatically smaller than standard MHA."""
1136
+ # MLA config (DeepSeek-V3 style)
1137
+ mla_config = DeepseekV3Config(
1138
+ hidden_size=7168,
1139
+ num_attention_heads=128,
1140
+ num_hidden_layers=1,
1141
+ kv_lora_rank=512,
1142
+ qk_nope_head_dim=128,
1143
+ qk_rope_head_dim=64,
1144
+ v_head_dim=128,
1145
+ )
1146
+ mla_vllm_config = create_mock_vllm_config(mla_config)
1147
+ mla_metrics = MLAAttentionMetrics.from_vllm_config(mla_vllm_config)
1148
+
1149
+ # Standard MHA config with same num_heads and head_dim
1150
+ standard_config = Qwen3Config(
1151
+ hidden_size=7168,
1152
+ num_attention_heads=128,
1153
+ num_key_value_heads=128, # MHA: same as num_heads
1154
+ num_hidden_layers=1,
1155
+ head_dim=128,
1156
+ )
1157
+ standard_vllm_config = create_mock_vllm_config(standard_config)
1158
+ standard_metrics = AttentionMetrics.from_vllm_config(standard_vllm_config)
1159
+
1160
+ # Compare KV cache write for 100 tokens
1161
+ ctx = ExecutionContext.from_single_request(
1162
+ num_tokens=100, context_len=100, is_prefill=True
1163
+ )
1164
+
1165
+ mla_write = mla_metrics.get_write_bytes_breakdown(ctx, per_gpu=False)
1166
+ standard_write = standard_metrics.get_write_bytes_breakdown(ctx, per_gpu=False)
1167
+
1168
+ # MLA: T * (kv_lora_rank + qk_rope_head_dim) * cache_bytes * L
1169
+ # = 100 * 576 * 2 * 1 = 115,200
1170
+ mla_kv_cache = mla_write["kv_cache"]
1171
+
1172
+ # Standard: 2 * T * num_kv_heads * head_dim * cache_bytes * L
1173
+ # = 2 * 100 * 128 * 128 * 2 * 1 = 6,553,600
1174
+ standard_kv_cache = standard_write["kv_cache"]
1175
+
1176
+ # MLA KV cache should be dramatically smaller (about 57x)
1177
+ assert mla_kv_cache < standard_kv_cache
1178
+ ratio = standard_kv_cache / mla_kv_cache
1179
+ assert ratio > 50 # Should be ~56.9x
1180
+
1181
+
1182
+def test_mla_per_gpu_with_tensor_parallelism():
1183
+ """Test MLA metrics with tensor parallelism."""
1184
+ hf_config = DeepseekV3Config(
1185
+ hidden_size=7168,
1186
+ num_attention_heads=128,
1187
+ num_hidden_layers=8,
1188
+ kv_lora_rank=512,
1189
+ qk_nope_head_dim=128,
1190
+ qk_rope_head_dim=64,
1191
+ v_head_dim=128,
1192
+ q_lora_rank=1536,
1193
+ )
1194
+
1195
+ # Test with TP=8
1196
+ vllm_config = create_mock_vllm_config(hf_config, tensor_parallel_size=8)
1197
+ metrics = MLAAttentionMetrics.from_vllm_config(vllm_config)
1198
+
1199
+ ctx = ExecutionContext.from_single_request(
1200
+ num_tokens=64, context_len=1024, is_prefill=True
1201
+ )
1202
+
1203
+ global_flops = metrics.get_num_flops(ctx, per_gpu=False)
1204
+ per_gpu_flops = metrics.get_num_flops(ctx, per_gpu=True)
1205
+
1206
+ # Both should be positive
1207
+ assert global_flops > 0
1208
+ assert per_gpu_flops > 0
1209
+ # Global should exceed per-GPU
1210
+ assert global_flops > per_gpu_flops
1211
+
1212
+
1213
+def test_mla_per_gpu_with_pipeline_parallelism():
1214
+ """Test MLA metrics with pipeline parallelism."""
1215
+ hf_config = DeepseekV3Config(
1216
+ hidden_size=7168,
1217
+ num_attention_heads=128,
1218
+ num_hidden_layers=16, # Divisible by PP
1219
+ kv_lora_rank=512,
1220
+ qk_nope_head_dim=128,
1221
+ qk_rope_head_dim=64,
1222
+ v_head_dim=128,
1223
+ q_lora_rank=1536,
1224
+ )
1225
+
1226
+ vllm_config = create_mock_vllm_config(hf_config, pipeline_parallel_size=4)
1227
+ metrics = MLAAttentionMetrics.from_vllm_config(vllm_config)
1228
+
1229
+ ctx = ExecutionContext.from_single_request(
1230
+ num_tokens=1, context_len=512, is_prefill=False
1231
+ )
1232
+
1233
+ global_flops = metrics.get_num_flops(ctx, per_gpu=False)
1234
+ per_gpu_flops = metrics.get_num_flops(ctx, per_gpu=True)
1235
+
1236
+ # With PP=4, layers are divided by 4
1237
+ assert global_flops == 4 * per_gpu_flops
1238
+
1239
+
1240
+def test_mla_model_metrics_excludes_standard_attention():
1241
+ """Test that ModelMetrics uses MLAAttentionMetrics, not AttentionMetrics,
1242
+ for DeepSeek MLA models."""
1243
+ hf_config = DeepseekV3Config(
1244
+ hidden_size=7168,
1245
+ num_attention_heads=128,
1246
+ num_hidden_layers=4,
1247
+ kv_lora_rank=512,
1248
+ qk_nope_head_dim=128,
1249
+ qk_rope_head_dim=64,
1250
+ v_head_dim=128,
1251
+ q_lora_rank=1536,
1252
+ )
1253
+ vllm_config = create_mock_vllm_config(hf_config)
1254
+ model_metrics = ModelMetrics(vllm_config)
1255
+
1256
+ # Should have MLAAttentionMetrics but NOT standard AttentionMetrics
1257
+ component_types = [m.component_type() for m in model_metrics.metrics]
1258
+ assert "mla_attn" in component_types
1259
+ assert "attn" not in component_types
1260
+
1261
+ # Should still have FFN and unembed
1262
+ assert "ffn" in component_types
1263
+ assert "unembed" in component_types
1264
+
1265
+ # Breakdowns should work end-to-end
1266
+ ctx = ExecutionContext.from_single_request(
1267
+ num_tokens=100, context_len=512, is_prefill=True
1268
+ )
1269
+ total_flops = model_metrics.get_num_flops(ctx)
1270
+ breakdown = model_metrics.get_num_flops_breakdown(ctx)
1271
+ assert total_flops == sum(breakdown.values())
1272
+ assert total_flops > 0
1273
+
1274
+ # Verify MLA-specific keys in breakdown
1275
+ assert any(k.startswith("mla_attn.") for k in breakdown)
1276
+ assert not any(k.startswith("attn.") for k in breakdown)
1277
+
1278
+
1279
+def test_standard_attention_still_works_for_non_mla():
1280
+ """Regression test: non-MLA models still use standard AttentionMetrics."""
1281
+ hf_config = Qwen3Config(
1282
+ hidden_size=2048,
1283
+ num_attention_heads=16,
1284
+ num_hidden_layers=12,
1285
+ vocab_size=32000,
1286
+ intermediate_size=8192,
1287
+ )
1288
+ vllm_config = create_mock_vllm_config(hf_config)
1289
+ model_metrics = ModelMetrics(vllm_config)
1290
+
1291
+ component_types = [m.component_type() for m in model_metrics.metrics]
1292
+ assert "attn" in component_types
1293
+ assert "mla_attn" not in component_types
1294
+
1295
+ ctx = ExecutionContext.from_single_request(
1296
+ num_tokens=100, context_len=512, is_prefill=True
1297
+ )
1298
+ total_flops = model_metrics.get_num_flops(ctx)
1299
+ assert total_flops > 0
1300
+
1301
+
1302
+def test_mla_attention_scaling_with_layers():
1303
+ """Test that MLA attention metrics scale proportionally with layers."""
1304
+ base_config = DeepseekV3Config(
1305
+ hidden_size=7168,
1306
+ num_attention_heads=128,
1307
+ num_hidden_layers=8,
1308
+ kv_lora_rank=512,
1309
+ qk_nope_head_dim=128,
1310
+ qk_rope_head_dim=64,
1311
+ v_head_dim=128,
1312
+ q_lora_rank=1536,
1313
+ )
1314
+ double_config = DeepseekV3Config(
1315
+ hidden_size=7168,
1316
+ num_attention_heads=128,
1317
+ num_hidden_layers=16, # Double layers
1318
+ kv_lora_rank=512,
1319
+ qk_nope_head_dim=128,
1320
+ qk_rope_head_dim=64,
1321
+ v_head_dim=128,
1322
+ q_lora_rank=1536,
1323
+ )
1324
+
1325
+ base_vllm = create_mock_vllm_config(base_config)
1326
+ double_vllm = create_mock_vllm_config(double_config)
1327
+
1328
+ base_metrics = MLAAttentionMetrics.from_vllm_config(base_vllm)
1329
+ double_metrics = MLAAttentionMetrics.from_vllm_config(double_vllm)
1330
+
1331
+ ctx = ExecutionContext.from_single_request(
1332
+ num_tokens=100, context_len=512, is_prefill=True
1333
+ )
1334
+
1335
+ # All metrics should double with double layers
1336
+ assert double_metrics.get_num_flops(ctx) == 2 * base_metrics.get_num_flops(ctx)
1337
+ assert double_metrics.get_read_bytes(ctx) == 2 * base_metrics.get_read_bytes(ctx)
1338
+ assert double_metrics.get_write_bytes(ctx) == 2 * base_metrics.get_write_bytes(ctx)
vllm/v1/metrics/perf.py
+285
@@ -396,6 +396,20 @@ class AttentionQuantizationConfigParser(Parser):
396
return args
397
398
399
+class AttentionDetectionParser(Parser):
400
+ """
401
+ Prevents standard AttentionMetrics from being instantiated for MLA models.
402
+ MLA models should use MLAAttentionMetrics instead.
403
+ """
404
+
405
+ def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
406
+ if vllm_config.model_config.is_deepseek_mla:
407
+ raise InvalidComponent(
408
+ "Model uses MLA attention; use MLAAttentionMetrics instead"
409
+ )
410
+ return args
411
+
412
+
413
class AttentionMetrics(ComponentMetrics):
414
# From BaseConfigParser
415
num_hidden_layers: int = Field(..., gt=0)
@@ -423,6 +437,7 @@ class AttentionMetrics(ComponentMetrics):
437
@classmethod
438
def get_parser(cls) -> ParserChain:
439
return ParserChain(
440
+ AttentionDetectionParser(),
441
BaseConfigParser(),
442
BaseAttentionConfigParser(),
443
AttentionQuantizationConfigParser(),
@@ -525,6 +540,276 @@ class AttentionMetrics(ComponentMetrics):
540
}
541
542
543
+#### MLA Attention ####
544
+
545
+
546
+class MLADetectionParser(Parser):
547
+ """
548
+ Validates that the model uses MLA attention.
549
+ Raises InvalidComponent if the model does not use MLA,
550
+ so MLAAttentionMetrics is silently skipped for non-MLA models.
551
+ """
552
+
553
+ def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
554
+ if not vllm_config.model_config.is_deepseek_mla:
555
+ raise InvalidComponent("Model does not use MLA attention")
556
+ return args
557
+
558
+
559
+class MLAConfigParser(Parser):
560
+ """
561
+ Parses MLA-specific configuration fields.
562
+ Provides: kv_lora_rank, qk_nope_head_dim, qk_rope_head_dim,
563
+ v_head_dim, q_lora_rank
564
+ """
565
+
566
+ def parse(self, args: ParsedArgs, vllm_config: VllmConfig) -> ParsedArgs:
567
+ model_config = vllm_config.model_config
568
+ cfg = model_config.hf_text_config
569
+
570
+ args.kv_lora_rank = get_required(cfg, "kv_lora_rank")
571
+ args.qk_nope_head_dim = get_required(cfg, "qk_nope_head_dim")
572
+ args.qk_rope_head_dim = get_required(cfg, "qk_rope_head_dim")
573
+ args.v_head_dim = get_required(cfg, "v_head_dim")
574
+ args.q_lora_rank = getattr(cfg, "q_lora_rank", None)
575
+
576
+ model_dtype = vllm_config.model_config.dtype
577
+ cache_dtype = vllm_config.cache_config.cache_dtype
578
+ kv_cache_torch_dtype = get_kv_cache_torch_dtype(cache_dtype, model_dtype)
579
+ args.cache_byte_size = get_dtype_size(kv_cache_torch_dtype)
580
+
581
+ return args
582
+
583
+
584
+class MLAAttentionMetrics(ComponentMetrics):
585
+ """
586
+ Performance metrics for Multi-Latent Attention (MLA) layers.
587
+
588
+ MLA uses a compressed latent representation for KV cache:
589
+ - KV cache stores a single compressed vector of size
590
+ (kv_lora_rank + qk_rope_head_dim) per token per layer,
591
+ instead of 2 * num_kv_heads * head_dim as in standard MHA/GQA.
592
+ - Q path uses optional low-rank compression:
593
+ h -> q_lora_rank -> num_heads * qk_head_dim
594
+ - KV path: h -> (kv_lora_rank + qk_rope_head_dim),
595
+ then kv_lora_rank -> num_heads * (qk_nope_head_dim + v_head_dim)
596
+
597
+ Used by DeepSeek-V2, DeepSeek-V3, DeepSeek-R1, and similar models.
598
+ """
599
+
600
+ # From BaseConfigParser
601
+ num_hidden_layers: int = Field(..., gt=0)
602
+ hidden_size: int = Field(..., gt=0)
603
+ num_attention_heads: int = Field(..., gt=0)
604
+ activation_byte_size: int = Field(..., gt=0)
605
+ tp_size: int = Field(..., gt=0)
606
+ pp_size: int = Field(..., gt=0)
607
+
608
+ # From BaseConfigParser, can be overridden by AttentionQuantizationConfigParser
609
+ weight_byte_size: int | float = Field(..., gt=0)
610
+
611
+ # From MLAConfigParser
612
+ kv_lora_rank: int = Field(..., gt=0)
613
+ qk_nope_head_dim: int = Field(..., gt=0)
614
+ qk_rope_head_dim: int = Field(..., gt=0)
615
+ v_head_dim: int = Field(..., gt=0)
616
+ q_lora_rank: int | None = Field(None)
617
+ cache_byte_size: int = Field(..., gt=0)
618
+
619
+ @classmethod
620
+ def component_type(cls) -> str:
621
+ return "mla_attn"
622
+
623
+ @classmethod
624
+ def get_parser(cls) -> ParserChain:
625
+ return ParserChain(
626
+ MLADetectionParser(),
627
+ BaseConfigParser(),
628
+ MLAConfigParser(),
629
+ AttentionQuantizationConfigParser(),
630
+ )
631
+
632
+ def get_num_flops_breakdown(
633
+ self, ctx: ExecutionContext, per_gpu: bool = True
634
+ ) -> dict[str, int]:
635
+ """Calculate flops breakdown for MLA attention layers.
636
+
637
+ MLA projection structure:
638
+ - Q path: h -> q_lora_rank -> num_heads * qk_head_dim
639
+ (or h -> num_heads * qk_head_dim if q_lora_rank is None)
640
+ - KV path: h -> (kv_lora_rank + qk_rope_head_dim),
641
+ then kv_lora_rank -> num_heads * (qk_nope_head_dim + v_head_dim)
642
+ - Attention: Q @ K^T and attn @ V
643
+ - Output: num_heads * v_head_dim -> h
644
+ """
645
+ L = self.num_hidden_layers
646
+ D = self.hidden_size
647
+ q = self.num_attention_heads
648
+ qk_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
649
+ v_d = self.v_head_dim
650
+ c = self.kv_lora_rank
651
+ r = self.qk_rope_head_dim
652
+ q_rank = self.q_lora_rank
653
+
654
+ T = ctx.total_num_tokens()
655
+ TC = ctx.total_token_context_product()
656
+
657
+ if per_gpu:
658
+ L //= self.pp_size
659
+ q = max(1, q // self.tp_size)
660
+
661
+ flops: dict[str, int] = {}
662
+
663
+ # Q projection
664
+ if q_rank is not None:
665
+ # Two-stage: h -> q_lora_rank -> num_heads * qk_head_dim
666
+ flops["q_a_proj"] = 2 * T * D * q_rank * L
667
+ flops["q_b_proj"] = 2 * T * q_rank * q * qk_head_dim * L
668
+ else:
669
+ # Direct: h -> num_heads * qk_head_dim
670
+ flops["q_proj"] = 2 * T * D * q * qk_head_dim * L
671
+
672
+ # KV projection (always compressed, shared across heads)
673
+ # kv_a: h -> (kv_lora_rank + qk_rope_head_dim) [replicated]
674
+ flops["kv_a_proj"] = 2 * T * D * (c + r) * L
675
+ # kv_b: kv_lora_rank -> num_heads * (qk_nope + v_head_dim)
676
+ flops["kv_b_proj"] = 2 * T * c * q * (self.qk_nope_head_dim + v_d) * L
677
+
678
+ # Attention core
679
+ flops["attn_qk"] = 2 * q * TC * qk_head_dim * L
680
+ flops["attn_av"] = 2 * q * TC * v_d * L
681
+
682
+ # Output projection: num_heads * v_head_dim -> h
683
+ flops["out_proj"] = 2 * T * q * v_d * D * L
684
+
685
+ return flops
686
+
687
+ def get_read_bytes_breakdown(
688
+ self, ctx: ExecutionContext, per_gpu: bool = True
689
+ ) -> dict[str, int]:
690
+ """Calculate read memory traffic for MLA attention layers."""
691
+ L = self.num_hidden_layers
692
+ D = self.hidden_size
693
+ q = self.num_attention_heads
694
+ qk_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
695
+ v_d = self.v_head_dim
696
+ c = self.kv_lora_rank
697
+ r = self.qk_rope_head_dim
698
+ q_rank = self.q_lora_rank
699
+
700
+ T = ctx.total_num_tokens()
701
+ # Compressed KV cache size per token
702
+ kv_compressed_dim = c + r
703
+
704
+ if per_gpu:
705
+ L //= self.pp_size
706
+ q = max(1, q // self.tp_size)
707
+
708
+ read_bytes: dict[str, int] = {}
709
+
710
+ # Q projection weight + input reads
711
+ if q_rank is not None:
712
+ read_bytes["q_a_input"] = T * D * self.activation_byte_size * L
713
+ read_bytes["q_a_weight"] = int(D * q_rank * self.weight_byte_size * L)
714
+ read_bytes["q_b_input"] = T * q_rank * self.activation_byte_size * L
715
+ read_bytes["q_b_weight"] = int(
716
+ q_rank * q * qk_head_dim * self.weight_byte_size * L
717
+ )
718
+ else:
719
+ read_bytes["q_input"] = T * D * self.activation_byte_size * L
720
+ read_bytes["q_weight"] = int(
721
+ D * q * qk_head_dim * self.weight_byte_size * L
722
+ )
723
+
724
+ # KV projection weight + input reads
725
+ # kv_a is replicated (not TP-sharded)
726
+ read_bytes["kv_a_input"] = T * D * self.activation_byte_size * L
727
+ read_bytes["kv_a_weight"] = int(
728
+ D * kv_compressed_dim * self.weight_byte_size * L
729
+ )
730
+ # kv_b is TP-sharded along heads
731
+ read_bytes["kv_b_input"] = T * c * self.activation_byte_size * L
732
+ read_bytes["kv_b_weight"] = int(
733
+ c * q * (self.qk_nope_head_dim + v_d) * self.weight_byte_size * L
734
+ )
735
+
736
+ # Attention input reads
737
+ # Prefill: read Q activations + K,V from kv_b_proj output
738
+ if ctx.prefill_num_tokens > 0:
739
+ read_bytes["attn_input"] = (
740
+ ctx.prefill_num_tokens * q * qk_head_dim * self.activation_byte_size * L
741
+ + ctx.prefill_context_len
742
+ * q
743
+ * (qk_head_dim + v_d)
744
+ * self.activation_byte_size
745
+ * L
746
+ )
747
+
748
+ # Decode: read Q activations + read compressed KV from cache
749
+ if ctx.decode_num_tokens > 0:
750
+ read_bytes["attn_input"] = read_bytes.get("attn_input", 0) + (
751
+ ctx.decode_num_tokens * q * qk_head_dim * self.activation_byte_size * L
752
+ + ctx.decode_context_len * kv_compressed_dim * self.cache_byte_size * L
753
+ )
754
+
755
+ # Output projection reads
756
+ read_bytes["out_input"] = T * q * v_d * self.activation_byte_size * L
757
+ read_bytes["out_weight"] = int(q * v_d * D * self.weight_byte_size * L)
758
+
759
+ return read_bytes
760
+
761
+ def get_write_bytes_breakdown(
762
+ self, ctx: ExecutionContext, per_gpu: bool = True
763
+ ) -> dict[str, int]:
764
+ """Calculate write memory traffic for MLA attention layers."""
765
+ L = self.num_hidden_layers
766
+ D = self.hidden_size
767
+ q = self.num_attention_heads
768
+ qk_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
769
+ v_d = self.v_head_dim
770
+ c = self.kv_lora_rank
771
+ r = self.qk_rope_head_dim
772
+ q_rank = self.q_lora_rank
773
+
774
+ T = ctx.total_num_tokens()
775
+ kv_compressed_dim = c + r
776
+
777
+ if per_gpu:
778
+ L //= self.pp_size
779
+ q = max(1, q // self.tp_size)
780
+
781
+ write_bytes: dict[str, int] = {}
782
+
783
+ # Q projection outputs
784
+ if q_rank is not None:
785
+ write_bytes["q_a_output"] = T * q_rank * self.activation_byte_size * L
786
+ write_bytes["q_b_output"] = (
787
+ T * q * qk_head_dim * self.activation_byte_size * L
788
+ )
789
+ else:
790
+ write_bytes["q_output"] = (
791
+ T * q * qk_head_dim * self.activation_byte_size * L
792
+ )
793
+
794
+ # KV projection outputs
795
+ write_bytes["kv_a_output"] = (
796
+ T * kv_compressed_dim * self.activation_byte_size * L
797
+ )
798
+ write_bytes["kv_b_output"] = (
799
+ T * q * (self.qk_nope_head_dim + v_d) * self.activation_byte_size * L
800
+ )
801
+
802
+ # KV cache write: one compressed vector per token
803
+ # (kv_lora_rank + qk_rope_head_dim) instead of
804
+ # 2 * num_kv_heads * head_dim in standard MHA
805
+ write_bytes["kv_cache"] = T * kv_compressed_dim * self.cache_byte_size * L
806
+
807
+ # Output projection
808
+ write_bytes["out_output"] = T * D * self.activation_byte_size * L
809
+
810
+ return write_bytes
811
+
812
+
813
#### Ffn ####
814
815