Skip to content

Commit 44ca2b1

Browse files
M3 disagg: per-layer MoRIIO KV transfer for hybrid sparse-attn (partial)
MiniMax-M3 (MiniMaxM3SparseForCausalLM) is a hybrid sparse-attention model: sparse layers register a separate lightning-indexer cache (MLAAttentionSpec, rank-3, bf16, key-only) alongside the main cache (FullAttentionSpec, rank-5, fp8, K+V). The MoRIIO connector assumes one uniform KV layout -- it derives block geometry from the first cache and reuses first_layer's offsets for every layer (see its own "hybrid attn" TODO) -- so the bf16 key-only index cache is transferred with fp8 K+V sizing and gets corrupted on the decode worker, producing garbage output (disagg gsm8k ~= 0 while single-node M3 is correct). This is the vLLM analogue of the SGLang MoRI DSA-state bug in patches/mori_conn.py. - patches/moriio_heterogeneous_kv.py: compute the READ-path transfer geometry per layer (own shape/stride/dtype/rank) instead of from the first cache. Idempotent; no-op for homogeneous models. - setup_deps.sh: apply it on the vllm-disagg path. NOTE: partial fix -- necessary but not yet sufficient. The index cache is also a separate KV-cache group whose block-table/num_blocks the single-namespace MoRIIO connector cannot map, so M3 disagg accuracy is still broken pending a larger multi-group / index-state transfer change. (Disabling sparse attention is not a viable workaround: M3's fused QKV carries index_k weights, so dropping the indexer breaks weight load.) Refs #1762 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 549fb1b commit 44ca2b1

2 files changed

Lines changed: 168 additions & 0 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
#!/usr/bin/env python3
2+
"""Patch vLLM's MoRIIOConnector to transfer heterogeneous KV caches per-layer.
3+
4+
Why
5+
---
6+
MiniMax-M3 (MiniMaxM3SparseForCausalLM) is a hybrid sparse-attention model:
7+
8+
* main attention layers register a ``FullAttentionSpec`` KV cache:
9+
rank-5 ``[2, num_blocks, block_size, num_kv_heads, head_dim]``, **fp8**, K+V
10+
* the lightning indexer (sparse layers) registers a separate
11+
``MLAAttentionSpec`` index cache (``MiniMaxM3IndexerCache``):
12+
rank-3 ``[num_blocks, block_size, head_dim]``, **bf16**, key-only
13+
14+
The upstream MoRIIOConnector assumes a *single uniform* KV layout: it derives
15+
``self.kv_cache_shape`` / ``block_len`` / ``element_size`` from the **first**
16+
cache, and ``_read_blocks`` computes the transfer offsets **once** from
17+
``first_layer`` and reuses them for **every** layer (see the in-code TODO
18+
"block_len needs to be per-layer for ... hybrid attn"). For M3 this transfers
19+
the bf16 key-only rank-3 index cache using the fp8 K+V rank-5 main-cache sizing,
20+
corrupting the indexer state on the decode worker. The sparse layers then select
21+
the wrong KV blocks and the model emits incoherent tokens (gsm8k ~= 0).
22+
23+
This is the vLLM analogue of the already-shipped SGLang MoRI DSA fix in
24+
``patches/mori_conn.py`` (see patches/README.md).
25+
26+
Fix
27+
---
28+
Compute transfer geometry **per layer** from each layer's own tensor
29+
(``shape`` / ``stride`` / ``element_size`` / rank), instead of from the first
30+
cache. For homogeneous models every layer's geometry equals the first cache's,
31+
so behaviour is unchanged; only hybrid models (M3) are affected.
32+
33+
Two minimal, targeted edits (READ path, which the M3 recipe uses with
34+
``read_mode: true``):
35+
36+
1. ``_compute_block_transfer_offsets`` -> use ``self.kv_caches[layer_name]``'s
37+
own shape (rank/dims) instead of the global ``self.kv_cache_shape``.
38+
2. ``_read_blocks`` -> call ``_compute_block_transfer_offsets`` inside the
39+
per-layer loop instead of once for ``first_layer``.
40+
41+
Idempotent: re-running detects the ``PATCHED heterogeneous-kv`` marker and exits.
42+
"""
43+
import os
44+
import sys
45+
46+
47+
def _default_target() -> str:
48+
try:
49+
import vllm
50+
except Exception:
51+
return ""
52+
return os.path.join(
53+
os.path.dirname(vllm.__file__),
54+
"distributed/kv_transfer/kv_connector/v1/moriio/moriio_connector.py",
55+
)
56+
57+
58+
OLD1 = ''' assert self.kv_cache_shape is not None, "KV caches shape not initialized"
59+
is_mla = len(self.kv_cache_shape) == 3
60+
stride = self.kv_caches[layer_name].stride()
61+
sz = self.kv_caches[layer_name].element_size()
62+
if is_mla:
63+
blknum, blksize, hs = self.kv_cache_shape
64+
hn = 1
65+
block_stride = stride[0]
66+
else:
67+
_, blknum, blksize, hn, hs = self.kv_cache_shape'''
68+
69+
NEW1 = ''' # [PATCHED heterogeneous-kv] Use this layer's own shape so caches with a
70+
# different rank/dtype (MiniMax-M3: bf16 key-only rank-3 index cache vs
71+
# fp8 K+V rank-5 main cache) are sized per-layer, not from the first cache.
72+
layer_shape = tuple(self.kv_caches[layer_name].shape)
73+
assert layer_shape, "KV caches shape not initialized"
74+
is_mla = len(layer_shape) == 3
75+
stride = self.kv_caches[layer_name].stride()
76+
sz = self.kv_caches[layer_name].element_size()
77+
if is_mla:
78+
blknum, blksize, hs = layer_shape
79+
hn = 1
80+
block_stride = stride[0]
81+
else:
82+
_, blknum, blksize, hn, hs = layer_shape'''
83+
84+
OLD2 = ''' first_layer = list(self.layer_name_to_local_kv_cache_metadata.keys())[0]
85+
offs = self._compute_block_transfer_offsets(
86+
first_layer, local_block_ids, remote_block_ids, remote_moriio_meta
87+
)
88+
89+
for layer_name in self.layer_name_to_local_kv_cache_metadata:
90+
sess_idx = list(self.layer_name_to_local_kv_cache_metadata.keys()).index(
91+
layer_name
92+
)
93+
# TODO : apply multi-session batch-read when moriio support it
94+
transfer_status = self.moriio_wrapper.read_remote_data(
95+
offs[2], offs[0], offs[1], sessions[sess_idx]
96+
)'''
97+
98+
NEW2 = ''' for layer_name in self.layer_name_to_local_kv_cache_metadata:
99+
sess_idx = list(self.layer_name_to_local_kv_cache_metadata.keys()).index(
100+
layer_name
101+
)
102+
# [PATCHED heterogeneous-kv] Per-layer offsets so the bf16 key-only
103+
# MiniMax-M3 index cache is transferred with its own geometry instead
104+
# of the first (main fp8 K+V) layer's.
105+
offs = self._compute_block_transfer_offsets(
106+
layer_name, local_block_ids, remote_block_ids, remote_moriio_meta
107+
)
108+
# TODO : apply multi-session batch-read when moriio support it
109+
transfer_status = self.moriio_wrapper.read_remote_data(
110+
offs[2], offs[0], offs[1], sessions[sess_idx]
111+
)'''
112+
113+
114+
def main() -> int:
115+
target = sys.argv[1] if len(sys.argv) > 1 else _default_target()
116+
if not target or not os.path.isfile(target):
117+
print(f"[PATCH] moriio_connector.py not found ({target!r}); skipping")
118+
return 0
119+
src = open(target).read()
120+
if "PATCHED heterogeneous-kv" in src:
121+
print("[PATCH] moriio heterogeneous-kv already applied")
122+
return 0
123+
if OLD1 not in src:
124+
print("[PATCH] WARN: _compute_block_transfer_offsets pattern not found; "
125+
"connector version changed — skipping (no-op)")
126+
return 0
127+
if OLD2 not in src:
128+
print("[PATCH] WARN: _read_blocks pattern not found; "
129+
"connector version changed — skipping (no-op)")
130+
return 0
131+
src = src.replace(OLD1, NEW1, 1).replace(OLD2, NEW2, 1)
132+
# Validate it still compiles before writing.
133+
try:
134+
compile(src, target, "exec")
135+
except SyntaxError as e:
136+
print(f"[PATCH] ERROR: patched source fails to compile: {e}")
137+
return 1
138+
open(target, "w").write(src)
139+
print("[PATCH] Applied: moriio heterogeneous-kv per-layer transfer "
140+
"(MiniMax-M3 sparse index cache)")
141+
return 0
142+
143+
144+
if __name__ == "__main__":
145+
sys.exit(main())

benchmarks/multi_node/amd_utils/setup_deps.sh

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,13 +185,36 @@ install_transformers_glm5() {
185185
_SETUP_INSTALLED+=("transformers-glm5")
186186
}
187187

188+
# ---------------------------------------------------------------------------
189+
# vLLM: Patch MoRIIOConnector for heterogeneous (hybrid sparse-attn) KV caches.
190+
#
191+
# MiniMax-M3 registers a bf16 key-only rank-3 lightning-indexer cache alongside
192+
# the fp8 K+V rank-5 main cache. Upstream MoRIIO derives one uniform block
193+
# geometry from the first cache and reuses the first layer's transfer offsets
194+
# for every layer, corrupting the index cache on the decode worker -> garbage
195+
# output (gsm8k ~= 0). The overlay makes the READ path compute geometry/offsets
196+
# per layer. Idempotent; no-op on connector versions that don't match.
197+
# See patches/moriio_heterogeneous_kv.py and patches/README.md.
198+
# ---------------------------------------------------------------------------
199+
patch_moriio_heterogeneous_kv() {
200+
local patcher
201+
patcher="$(dirname "${BASH_SOURCE[0]}")/patches/moriio_heterogeneous_kv.py"
202+
if [[ ! -f "$patcher" ]]; then
203+
echo "[SETUP] moriio heterogeneous-kv patcher not found, skipping"
204+
return 0
205+
fi
206+
python3 "$patcher" || echo "[SETUP] WARN: moriio heterogeneous-kv patch returned non-zero"
207+
_SETUP_INSTALLED+=("moriio-heterogeneous-kv")
208+
}
209+
188210
# =============================================================================
189211
# Run installers (engine-gated)
190212
# =============================================================================
191213

192214
if [[ "$ENGINE" == "vllm-disagg" ]]; then
193215
install_recipe_deps
194216
install_amd_quark
217+
patch_moriio_heterogeneous_kv
195218

196219
# =========================================================================
197220
# vLLM: Export UCX/RIXL paths (persists since this file is sourced)

0 commit comments

Comments
 (0)