[Bugfix][Model] Fix MiMo-V2.5 fused fp8 qkv_proj sharding (pre-shard count is num_key_value_heads; MTP path too) (#57508)

Signed-off-by: vllmellm <vllm.ellm@embeddedllm.com>

vllmellm committed Sep 19, 2026 at 21:05 UTC 211e252d0b4f8429f9b15fc52bdfed07782c7f70
3 files changed +415 -63
tests/models/quantization/test_mimo_v2_qkv_shard.py new
+225
@@ -0,0 +1,225 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit test for MiMo-V2 fused fp8 ``qkv_proj`` sharding.
4 +
5 +The checkpoint stores the fused QKV pre-sharded at ``num_key_value_heads``
6 +chunks (``ckpt_tp``), each holding that slice's ``[Q_c | K_c | V_c]`` rows with
7 +its own block scale grid. ``ckpt_tp`` is not the layer's KV-head count: a
8 +MiMo-V2.5 SWA layer has 8 KV heads but 4 chunks of 3712 rows (= 29 blocks), so
9 +each chunk carries two KV heads. Reading the scales as one slice per KV head
10 +expands 14 x 128 = 1792 rows for an 1856-row slice and fails.
11 +
12 +These tests build such checkpoints for the GA, SWA and Pro geometries and check
13 +that every rank gets exactly the weights it should own, for TP sizes below,
14 +equal to and above ``ckpt_tp`` (the last case replicating KV heads).
15 +"""
16 +
17 +import pytest
18 +import torch
19 +
20 +from vllm.model_executor.models.mimo_v2 import _shard_fp8_qkv_proj
21 +from vllm.utils.math_utils import cdiv
22 +
23 +pytestmark = pytest.mark.cpu_test
24 +
25 +BLOCK = 128
26 +FP8_DTYPE = torch.float8_e4m3fn
27 +FP8_MAX = torch.finfo(FP8_DTYPE).max
28 +COLS = 128 # one column block is enough to exercise the row mapping
29 +# fp8 e4m3 has 3 mantissa bits, so a single block-quantization round trip costs
30 +# ~2-3% relative L2; a wrong row -> scale pairing costs >= 10%.
31 +TOL = 0.06
32 +
33 +# (num_heads, num_kv_heads, head_dim, v_head_dim, ckpt_tp): MiMo-V2.5 GA, its
34 +# SWA/MTP layers, and MiMo-V2.5-Pro.
35 +GEOMETRIES = {
36 + "ga": (64, 4, 192, 128, 4),
37 + "swa": (64, 8, 192, 128, 4),
38 + "pro": (128, 8, 192, 128, 8),
39 +}
40 +
41 +
42 +def _chunk_rows(num_heads, num_kv_heads, head_dim, v_head_dim, ckpt_tp):
43 + return (
44 + num_heads // ckpt_tp * head_dim
45 + + num_kv_heads // ckpt_tp * head_dim
46 + + num_kv_heads // ckpt_tp * v_head_dim
47 + )
48 +
49 +
50 +def _quantize_chunks(truth, num_heads, num_kv_heads, head_dim, v_head_dim, ckpt_tp):
51 + """Block-quantize each checkpoint chunk on its own grid, like the export."""
52 + rows_per_chunk = _chunk_rows(num_heads, num_kv_heads, head_dim, v_head_dim, ckpt_tp)
53 + weights, scales = [], []
54 + for chunk in range(ckpt_tp):
55 + rows = truth[chunk * rows_per_chunk : (chunk + 1) * rows_per_chunk]
56 + quantized = torch.zeros_like(rows)
57 + scale = torch.zeros(
58 + cdiv(rows_per_chunk, BLOCK), rows.shape[1] // BLOCK, dtype=torch.float32
59 + )
60 + for row in range(0, rows_per_chunk, BLOCK):
61 + for col in range(0, rows.shape[1], BLOCK):
62 + block = rows[row : row + BLOCK, col : col + BLOCK]
63 + amax = block.abs().max().clamp(min=1e-12) / FP8_MAX
64 + scale[row // BLOCK, col // BLOCK] = amax
65 + quantized[row : row + BLOCK, col : col + BLOCK] = block / amax
66 + weights.append(quantized.to(FP8_DTYPE))
67 + scales.append(scale)
68 + return torch.cat(weights, dim=0), torch.cat(scales, dim=0)
69 +
70 +
71 +def _owned_rows(
72 + truth, num_heads, num_kv_heads, head_dim, v_head_dim, ckpt_tp, tp_rank, tp_size
73 +):
74 + """Rows the rank must own, in the forward's `[Q | K | V]` order."""
75 + rows_per_chunk = _chunk_rows(num_heads, num_kv_heads, head_dim, v_head_dim, ckpt_tp)
76 + q_per_chunk = num_heads // ckpt_tp * head_dim
77 + k_per_chunk = num_kv_heads // ckpt_tp * head_dim
78 + kv_heads: list[int] = []
79 + if tp_size <= num_kv_heads:
80 + kv_heads = list(
81 + range(
82 + tp_rank * (num_kv_heads // tp_size),
83 + (tp_rank + 1) * (num_kv_heads // tp_size),
84 + )
85 + )
86 + else:
87 + kv_heads = [tp_rank // (tp_size // num_kv_heads)]
88 + index = []
89 + for head in range(
90 + tp_rank * (num_heads // tp_size), (tp_rank + 1) * (num_heads // tp_size)
91 + ):
92 + chunk, offset = divmod(head, num_heads // ckpt_tp)
93 + start = chunk * rows_per_chunk + offset * head_dim
94 + index += list(range(start, start + head_dim))
95 + for head in kv_heads:
96 + chunk, offset = divmod(head, num_kv_heads // ckpt_tp)
97 + start = chunk * rows_per_chunk + q_per_chunk + offset * head_dim
98 + index += list(range(start, start + head_dim))
99 + for head in kv_heads:
100 + chunk, offset = divmod(head, num_kv_heads // ckpt_tp)
101 + start = chunk * rows_per_chunk + q_per_chunk + k_per_chunk + offset * v_head_dim
102 + index += list(range(start, start + v_head_dim))
103 + return truth[torch.tensor(index)]
104 +
105 +
106 +def _dequantize(weight, scale):
107 + """What the kernel computes: scale[row // block] applied per weight row."""
108 + per_row = scale.repeat_interleave(BLOCK, dim=1)
109 + return weight.to(torch.float32) * per_row[torch.arange(weight.shape[0]) // BLOCK]
110 +
111 +
112 +def _relative_l2(got, expected):
113 + return ((got - expected).norm() / expected.norm()).item()
114 +
115 +
116 +@pytest.mark.parametrize("geometry", list(GEOMETRIES))
117 +@pytest.mark.parametrize("tp_size", [1, 2, 4, 8])
118 +def test_fused_qkv_proj_sharding(geometry, tp_size):
119 + num_heads, num_kv_heads, head_dim, v_head_dim, ckpt_tp = GEOMETRIES[geometry]
120 + if num_heads % tp_size or (tp_size > num_kv_heads and tp_size % num_kv_heads):
121 + pytest.skip("TP size does not divide the head counts")
122 +
123 + rows_per_chunk = _chunk_rows(num_heads, num_kv_heads, head_dim, v_head_dim, ckpt_tp)
124 + truth = torch.randn(ckpt_tp * rows_per_chunk, COLS)
125 + for chunk in range(ckpt_tp): # per-chunk magnitude spread, as in the export
126 + truth[chunk * rows_per_chunk : (chunk + 1) * rows_per_chunk] *= 1.0 + chunk
127 + weight, scale = _quantize_chunks(
128 + truth, num_heads, num_kv_heads, head_dim, v_head_dim, ckpt_tp
129 + )
130 +
131 + kv_per_rank = (num_kv_heads // tp_size) if tp_size <= num_kv_heads else 1
132 + rows_rank = (num_heads // tp_size) * head_dim + kv_per_rank * (
133 + head_dim + v_head_dim
134 + )
135 + for tp_rank in range(tp_size):
136 + w_rank, s_rank = _shard_fp8_qkv_proj(
137 + weight,
138 + scale,
139 + num_heads=num_heads,
140 + num_kv_heads=num_kv_heads,
141 + head_dim=head_dim,
142 + v_head_dim=v_head_dim,
143 + tp_rank=tp_rank,
144 + tp_size=tp_size,
145 + ckpt_tp=ckpt_tp,
146 + )
147 + assert w_rank.shape == (rows_rank, COLS)
148 + assert s_rank.shape == (cdiv(rows_rank, BLOCK), scale.shape[1])
149 + expected = _owned_rows(
150 + truth,
151 + num_heads,
152 + num_kv_heads,
153 + head_dim,
154 + v_head_dim,
155 + ckpt_tp,
156 + tp_rank,
157 + tp_size,
158 + )
159 + assert _relative_l2(_dequantize(w_rank, s_rank), expected) <= TOL
160 +
161 +
162 +@pytest.mark.parametrize("geometry", ["ga", "swa"])
163 +@pytest.mark.parametrize("tp_size", [1, 2, 4])
164 +def test_exact_chunk_matches_checkpoint(geometry, tp_size):
165 + """At tp_size <= ckpt_tp the chunk count is the only thing that matters."""
166 + num_heads, num_kv_heads, head_dim, v_head_dim, ckpt_tp = GEOMETRIES[geometry]
167 + rows_per_chunk = _chunk_rows(num_heads, num_kv_heads, head_dim, v_head_dim, ckpt_tp)
168 + truth = torch.randn(ckpt_tp * rows_per_chunk, COLS)
169 + weight, scale = _quantize_chunks(
170 + truth, num_heads, num_kv_heads, head_dim, v_head_dim, ckpt_tp
171 + )
172 + if tp_size == ckpt_tp:
173 + w_rank, s_rank = _shard_fp8_qkv_proj(
174 + weight,
175 + scale,
176 + num_heads=num_heads,
177 + num_kv_heads=num_kv_heads,
178 + head_dim=head_dim,
179 + v_head_dim=v_head_dim,
180 + tp_rank=0,
181 + tp_size=tp_size,
182 + ckpt_tp=ckpt_tp,
183 + )
184 + # The checkpoint chunk *is* the rank's slice: no re-quantization.
185 + # (torch.equal has no fp8 kernel, so compare the underlying bytes.)
186 + expected_w = weight.chunk(tp_size, dim=0)[0]
187 + assert torch.equal(w_rank.view(torch.uint8), expected_w.view(torch.uint8))
188 + assert torch.equal(s_rank, scale.chunk(tp_size, dim=0)[0])
189 +
190 +
191 +def test_wrong_chunk_count_is_detected():
192 + """Reading SWA scales as one slice per KV head must not look correct.
193 +
194 + This is the regression the fix addresses: 116 scale rows are 4 x 29 (29 =
195 + ceil(3712 / 128)), not 8 x 15, so ``ckpt_tp = num_kv_heads`` pairs every
196 + weight row with the wrong scale.
197 + """
198 + num_heads, num_kv_heads, head_dim, v_head_dim, ckpt_tp = GEOMETRIES["swa"]
199 + rows_per_chunk = _chunk_rows(num_heads, num_kv_heads, head_dim, v_head_dim, ckpt_tp)
200 + truth = torch.randn(ckpt_tp * rows_per_chunk, COLS)
201 + for chunk in range(ckpt_tp):
202 + truth[chunk * rows_per_chunk : (chunk + 1) * rows_per_chunk] *= 1.0 + chunk
203 + weight, scale = _quantize_chunks(
204 + truth, num_heads, num_kv_heads, head_dim, v_head_dim, ckpt_tp
205 + )
206 + assert scale.shape[0] == ckpt_tp * cdiv(rows_per_chunk, BLOCK)
207 + assert scale.shape[0] != num_kv_heads * cdiv(rows_per_chunk, BLOCK)
208 +
209 + kw = dict(
210 + num_heads=num_heads,
211 + num_kv_heads=num_kv_heads,
212 + head_dim=head_dim,
213 + v_head_dim=v_head_dim,
214 + tp_rank=0,
215 + tp_size=1,
216 + )
217 + correct = _shard_fp8_qkv_proj(weight, scale, ckpt_tp=ckpt_tp, **kw)
218 + wrong = _shard_fp8_qkv_proj(weight, scale, ckpt_tp=num_kv_heads, **kw)
219 + assert correct[0].shape == wrong[0].shape
220 +
221 + expected = _owned_rows(
222 + truth, num_heads, num_kv_heads, head_dim, v_head_dim, ckpt_tp, 0, 1
223 + )
224 + assert _relative_l2(_dequantize(*correct), expected) <= TOL
225 + assert _relative_l2(_dequantize(*wrong), expected) > 5 * TOL
vllm/model_executor/models/mimo_v2.py
+141 -55
@@ -50,6 +50,7 @@ from vllm.model_executor.model_loader.weight_utils import (
50 )
51 from vllm.model_executor.models.utils import sequence_parallel_chunk
52 from vllm.sequence import IntermediateTensors
53 +from vllm.utils.math_utils import cdiv
54 from vllm.v1.attention.backend import AttentionType
55 from vllm.v1.attention.backends.registry import AttentionBackendEnum
56
@@ -460,6 +461,28 @@ class MiMoV2FlashDecoderLayer(nn.Module):
461 return self.config.hybrid_layer_pattern[self.layer_id] == 1
462
463
464 +def _requantize_fp8(
465 + grouped: torch.Tensor, rows_rank: int, block: int, dtype: torch.dtype
466 +) -> tuple[torch.Tensor, torch.Tensor]:
467 + """Block-quantize a rank's ``[Q | K | V]`` rows back to fp8.
468 +
469 + A rank's rows need not end on a block boundary (a single 1856-row slice is
470 + 14.5 blocks) while ``scaled_quantize`` requires both dims to be multiples of
471 + the block size, so pad the tail with zeros: zero rows cannot raise a block's
472 + amax, so every scale -- and the number of scale rows -- is unchanged. The
473 + padding is dropped again here.
474 + """
475 + padded = cdiv(rows_rank, block) * block
476 + if padded != rows_rank:
477 + grouped = torch.cat(
478 + [grouped, grouped.new_zeros(padded - rows_rank, grouped.shape[1])], dim=0
479 + )
480 + w_rank, s_rank = scaled_quantize(
481 + grouped, GroupShape(block, block), dtype, compute_dtype=torch.float32
482 + )
483 + return w_rank[:rows_rank], s_rank
484 +
485 +
486 def _shard_fp8_qkv_proj(
487 w_full: torch.Tensor,
488 s_full: torch.Tensor,
@@ -469,75 +492,136 @@ def _shard_fp8_qkv_proj(
492 v_head_dim: int,
493 tp_rank: int,
494 tp_size: int,
495 + ckpt_tp: int,
496 block: int = 128,
497 ) -> tuple[torch.Tensor, torch.Tensor]:
498 """Shard the fp8 qkv_proj weights for ``tp_rank``.
499
476 - The checkpoint stores the fused QKV as ``num_kv_heads`` contiguous groups
477 - (one per KV head; ``n`` below), each ordered ``[Q | K | V]``:
500 + The checkpoint stores the fused QKV pre-sharded for ``ckpt_tp`` ranks (the
501 + model config's ``num_key_value_heads``), each chunk holding that slice's
502 + Q, K and V rows:
503
479 - [Q_1 | K_1 | V_1 | Q_2 | K_2 | V_2 | ... | Q_n | K_n | V_n]
504 + [Q_0 | K_0 | V_0 | Q_1 | K_1 | V_1 | ... | Q_n | K_n | V_n] (n = ckpt_tp)
505
481 - Per group, Q has ``(num_heads / num_kv_heads) * head_dim`` rows, K has
482 - ``head_dim`` rows, and V has ``v_head_dim`` rows.
506 + Per chunk, Q has ``(num_heads / ckpt_tp) * head_dim`` rows, K has
507 + ``(num_kv_heads / ckpt_tp) * head_dim`` rows, and V has
508 + ``(num_kv_heads / ckpt_tp) * v_head_dim`` rows, and the fp8 block scales
509 + are tiled per chunk too (``ceil(rows_per_chunk / block)`` rows each).
510
484 - Each TP rank owns ``g = num_kv_heads / tp_size`` of these groups, and the
485 - forward expects them de-interleaved into a single Q, K, and V block:
511 + ``ckpt_tp`` is not the layer's KV-head count: a MiMo-V2.5 SWA layer has 8
512 + KV heads over 4 chunks, so each chunk carries two KV heads (3712 rows = 29
513 + blocks) while a GA layer has 4 KV heads over 4 chunks (3392 rows each).
514 +
515 + The forward expects each rank's slice de-interleaved:
516
517 [Q_1 | Q_2 | ... | Q_g | K_1 | K_2 | ... | K_g | V_1 | V_2 | ... | V_g]
518
489 - When ``g == 1`` the rank's slice is already ``[Q | K | V]``, so a plain
490 - chunk suffices. When ``g > 1`` we cannot reach the de-interleaved layout by
491 - re-permuting the fp8 block scales: each scale covers a 128-row block, and
492 - since K is 192 rows (1.5 blocks) a block straddles the K/V boundary, so no
493 - whole-block permutation produces it. Instead we dequantize this rank's
494 - groups to float (dropping the block constraint), reorder the rows into the
495 - layout above (Q, K, and V then each span a whole number of blocks), and
496 - re-quantize to fp8.
519 + When ``tp_size == ckpt_tp`` the checkpoint chunk *is* that layout, so a
520 + plain chunk of both weight and scale suffices. Otherwise each rank's Q, K
521 + and V rows are gathered from the chunks that hold them, dequantized with
522 + the chunk's own scales, reordered, and re-quantized to fp8.
523 """
498 - assert tp_size <= num_kv_heads and num_kv_heads % tp_size == 0, (
499 - "TP size must evenly split the number of KV heads."
524 + assert num_heads % tp_size == 0, (
525 + f"num_heads={num_heads} must be divisible by tp_size={tp_size}."
526 )
501 -
502 - kv_heads_per_rank = num_kv_heads // tp_size
503 - if kv_heads_per_rank == 1:
504 - # One KV head per rank. The weights and scale can be trivially sharded
505 - # without re-quantization.
506 - w = w_full.chunk(tp_size, dim=0)[tp_rank]
507 - s = s_full.chunk(tp_size, dim=0)[tp_rank]
508 - return w, s
509 -
510 - q_rows_per_group = (num_heads // num_kv_heads) * head_dim
511 - k_rows_per_group = head_dim
512 - v_rows_per_group = v_head_dim
513 - rows_per_group = q_rows_per_group + k_rows_per_group + v_rows_per_group
514 - scale_rows_per_group = s_full.shape[0] // num_kv_heads
515 - qs, ks, vs = [], [], []
516 - for g_idx in range(tp_rank * kv_heads_per_rank, (tp_rank + 1) * kv_heads_per_rank):
517 - row_start = g_idx * rows_per_group
518 - scale_row_start = g_idx * scale_rows_per_group
519 - # Dequantize this group's weights.
520 - w_g = w_full[row_start : row_start + rows_per_group].to(torch.float32)
521 - s_g = s_full[scale_row_start : scale_row_start + scale_rows_per_group].to(
522 - torch.float32
523 - )
524 - s_g_expanded = s_g.repeat_interleave(block, dim=0).repeat_interleave(
525 - block, dim=1
526 - )[:rows_per_group]
527 - w_g_dequant = w_g * s_g_expanded
528 - # Track the dequantized q, k, and v weights separately.
529 - qs.append(w_g_dequant[:q_rows_per_group])
530 - ks.append(w_g_dequant[q_rows_per_group : q_rows_per_group + k_rows_per_group])
531 - vs.append(w_g_dequant[q_rows_per_group + k_rows_per_group :])
532 -
533 - # Combine the q, k, and v weights into the following layout:
534 - # [Q_1, Q_2, .., Q_g, K_1, K_2, ..., K_g, V_1, V_2, ..., V_g]
535 - grouped = torch.cat([torch.cat(qs), torch.cat(ks), torch.cat(vs)], dim=0)
536 - # Quantize back to fp8.
537 - return scaled_quantize(
538 - grouped, GroupShape(block, block), w_full.dtype, compute_dtype=torch.float32
527 + if ckpt_tp <= 0 or num_heads % ckpt_tp or num_kv_heads % ckpt_tp:
528 + raise ValueError(
529 + f"fused qkv_proj is pre-sharded at {ckpt_tp} chunks, which do not "
530 + f"divide num_heads={num_heads} / num_kv_heads={num_kv_heads}."
531 + )
532 + # When there are fewer KV heads than ranks, vLLM replicates them
533 + # (`num_kv_head_replicas`) and rank r owns KV head r // replicas, which
534 + # keeps every Q head grouped with the KV head it attends to.
535 + if tp_size <= num_kv_heads:
536 + assert num_kv_heads % tp_size == 0, (
537 + f"num_kv_heads={num_kv_heads} must be divisible by tp_size={tp_size}."
538 + )
539 + kv_head_ids = list(
540 + range(
541 + tp_rank * (num_kv_heads // tp_size),
542 + (tp_rank + 1) * (num_kv_heads // tp_size),
543 + )
544 + )
545 + else:
546 + assert tp_size % num_kv_heads == 0, (
547 + f"tp_size={tp_size} must be divisible by num_kv_heads={num_kv_heads}."
548 + )
549 + kv_head_ids = [tp_rank // (tp_size // num_kv_heads)]
550 + q_head_ids = list(
551 + range(tp_rank * (num_heads // tp_size), (tp_rank + 1) * (num_heads // tp_size))
552 )
553
554 + rows_per_chunk = w_full.shape[0] // ckpt_tp
555 + q_per_chunk = (num_heads // ckpt_tp) * head_dim
556 + k_per_chunk = (num_kv_heads // ckpt_tp) * head_dim
557 + v_per_chunk = (num_kv_heads // ckpt_tp) * v_head_dim
558 + if q_per_chunk + k_per_chunk + v_per_chunk != rows_per_chunk:
559 + raise ValueError(
560 + f"fused qkv_proj has {w_full.shape[0]} rows, not {ckpt_tp} chunks of "
561 + f"{q_per_chunk} Q + {k_per_chunk} K + {v_per_chunk} V rows."
562 + )
563 +
564 + # The scales are tiled per chunk; they collapse to one continuous grid when
565 + # a chunk is a whole number of blocks (the SWA chunks are: 3712 = 29 * 128).
566 + chunk_scale_rows = cdiv(rows_per_chunk, block)
567 + rows = torch.arange(w_full.shape[0])
568 + per_chunk_scales = s_full.shape[0] == ckpt_tp * chunk_scale_rows
569 + if per_chunk_scales:
570 + scale_index = (rows // rows_per_chunk) * chunk_scale_rows + (
571 + rows % rows_per_chunk
572 + ) // block
573 + elif s_full.shape[0] == cdiv(w_full.shape[0], block):
574 + scale_index = rows // block
575 + else:
576 + raise ValueError(
577 + f"fused qkv_proj scale has {s_full.shape[0]} rows, expected either "
578 + f"{ckpt_tp * chunk_scale_rows} ({ckpt_tp} chunks of "
579 + f"{chunk_scale_rows} rows) or {cdiv(w_full.shape[0], block)} "
580 + f"(one continuous grid)"
581 + )
582 +
583 + if tp_size == ckpt_tp and per_chunk_scales:
584 + # One checkpoint chunk per rank: already [Q | K | V] for that rank, and
585 + # its scale rows line up with ceil(rows_per_chunk / block).
586 + return (
587 + w_full.chunk(ckpt_tp, dim=0)[tp_rank],
588 + s_full.chunk(ckpt_tp, dim=0)[tp_rank],
589 + )
590 +
591 + # Gather this rank's Q, K and V rows from the chunks that hold them.
592 + q_heads_per_chunk = num_heads // ckpt_tp
593 + kv_heads_per_chunk = num_kv_heads // ckpt_tp
594 + head_rows = torch.arange(head_dim)
595 + v_head_rows = torch.arange(v_head_dim)
596 + row_index: list[torch.Tensor] = []
597 + for head in q_head_ids:
598 + chunk = head // q_heads_per_chunk
599 + row_index.append(
600 + chunk * rows_per_chunk + (head % q_heads_per_chunk) * head_dim + head_rows
601 + )
602 + for head in kv_head_ids:
603 + chunk = head // kv_heads_per_chunk
604 + row_index.append(
605 + chunk * rows_per_chunk
606 + + q_per_chunk
607 + + (head % kv_heads_per_chunk) * head_dim
608 + + head_rows
609 + )
610 + for head in kv_head_ids:
611 + chunk = head // kv_heads_per_chunk
612 + row_index.append(
613 + chunk * rows_per_chunk
614 + + q_per_chunk
615 + + k_per_chunk
616 + + (head % kv_heads_per_chunk) * v_head_dim
617 + + v_head_rows
618 + )
619 + index = torch.cat(row_index)
620 + grouped = w_full[index].to(torch.float32) * s_full[
621 + scale_index[index]
622 + ].repeat_interleave(block, dim=1)
623 + return _requantize_fp8(grouped, index.numel(), block, w_full.dtype)
624 +
625
626 @support_torch_compile
627 class MiMoV2Model(nn.Module, EagleModelMixin):
@@ -819,6 +903,8 @@ class MiMoV2Model(nn.Module, EagleModelMixin):
903 v_head_dim=attn.v_head_dim,
904 tp_rank=tp_rank,
905 tp_size=tp_size,
906 + # The fused qkv_proj is pre-sharded for this many ranks.
907 + ckpt_tp=self.config.num_key_value_heads,
908 )
909 sharded = {"weight": w_rank, "weight_scale_inv": s_rank}
910 for kind, tensor in sharded.items():
vllm/model_executor/models/mimo_v2_mtp.py
+49 -8
@@ -45,7 +45,7 @@ from .interfaces import (
45 SupportsMultiModal,
46 _require_is_multimodal,
47 )
48 -from .mimo_v2 import MiMoV2Attention, MiMoV2MLP
48 +from .mimo_v2 import MiMoV2Attention, MiMoV2MLP, _shard_fp8_qkv_proj
49 from .utils import _merge_multimodal_embeddings, maybe_prefix
50
51 # MiMo-V2 checkpoints contain multiple MTP layers, but vLLM currently supports
@@ -266,6 +266,7 @@ class MiMoV2MTP(nn.Module):
266
267 params_dict = dict(self.named_parameters())
268 loaded_params: set[str] = set()
269 + pending_qkv_proj: dict[str, dict[str, torch.Tensor]] = {}
270
271 for name, loaded_weight in weights:
272 if "rotary_emb.inv_freq" in name:
@@ -279,14 +280,54 @@ class MiMoV2MTP(nn.Module):
280 ):
281 continue
282
282 - # Support fused qkv_proj checkpoint (Pro format).
283 - # The checkpoint is stored pre-sharded for TP=8 as
284 - # [Q_rank0, K_rank0, V_rank0, Q_rank1, ...], so splitting along
285 - # dim 0 with chunk(tp_size) gives each rank its Q+K+V slice for
286 - # both the FP8 weight and the block weight_scale_inv. This matches
287 - # how the main model loads the same layout.
283 + # Fused qkv_proj: the fp8 weight and its block scales arrive as
284 + # separate tensors and are pre-sharded at `config.num_key_value_heads`
285 + # chunks of [Q_c | K_c | V_c] (see _shard_fp8_qkv_proj for the
286 + # layout). Shard them together: a plain chunk(tp_size) only happens
287 + # to be right when tp_size is that chunk count *and* the scales are
288 + # tiled per chunk (they are 4 x 29 = 116 rows for MiMo-V2.5, not
289 + # tp_size slices of an even split).
290 if "qkv_proj" in name:
289 - if name in params_dict:
291 + is_fp8_weight = (
292 + name.endswith("qkv_proj.weight")
293 + and loaded_weight.dtype == torch.float8_e4m3fn
294 + )
295 + is_fp8_scale = name.endswith("qkv_proj.weight_scale_inv")
296 + if is_fp8_weight or is_fp8_scale:
297 + prefix, kind = name.rsplit(".", 1)
298 + if (
299 + f"{prefix}.weight" not in params_dict
300 + or f"{prefix}.weight_scale_inv" not in params_dict
301 + ):
302 + continue
303 + entry = pending_qkv_proj.setdefault(prefix, {})
304 + entry[kind] = loaded_weight
305 + if "weight" not in entry or "weight_scale_inv" not in entry:
306 + # Waiting for the other half of the fused projection.
307 + continue
308 + del pending_qkv_proj[prefix]
309 +
310 + # Geometry comes from the module the weights belong to.
311 + attn = self.get_submodule(prefix.rsplit(".", 1)[0])
312 + w_rank, s_rank = _shard_fp8_qkv_proj(
313 + entry["weight"],
314 + entry["weight_scale_inv"],
315 + num_heads=attn.total_num_heads,
316 + num_kv_heads=attn.total_num_kv_heads,
317 + head_dim=attn.head_dim,
318 + v_head_dim=attn.v_head_dim,
319 + tp_rank=tp_rank,
320 + tp_size=tp_size,
321 + ckpt_tp=self.config.num_key_value_heads,
322 + )
323 + for k, tensor in (("weight", w_rank), ("weight_scale_inv", s_rank)):
324 + param = params_dict[f"{prefix}.{k}"]
325 + if tensor.shape[0] > param.shape[0]:
326 + tensor = tensor[: param.shape[0]]
327 + default_weight_loader(param, tensor)
328 + loaded_params.add(f"{prefix}.{k}")
329 + elif name in params_dict:
330 + # Non-fp8 fused checkpoint: unchanged behaviour.
331 param = params_dict[name]
332 loaded_weight = loaded_weight.chunk(tp_size, dim=0)[tp_rank]
333 default_weight_loader(param, loaded_weight)