[Bugfix][KV Connector][Mooncake] Keep TP-sharded Mamba state out of the KV-head dedup - #49499
Merged
WoosukKwon merged 3 commits intoJul 25, 2026
Merged
Conversation
ivanium
force-pushed
the
fix/mooncake-tp-sharded-mamba-dedup
branch
from
July 23, 2026 02:33
9b81a77 to
1c056f8
Compare
With MLA (or GQA with num_kv_head < tp_size) all TP ranks hold the same attention KV, so the store dedups: ranks share one tp_rank namespace and stripe PUTs across blocks. That assumption is wrong for the hybrid models' Mamba/linear-attention groups, whose state is head/dim-sharded: striping persists each boundary block's state from only one rank, and on a warm hit every other rank silently loads a foreign shard. On Kimi-Linear TP2 this corrupted every external mamba-state load (gsm8k warm 0.72 vs cold 0.87, every answer a plausible paraphrase; per-layer probing shows rank 1's layer-0 initial state wrong while the transported bytes match the stored bytes exactly). Replace the model-wide dedup triple (num_kv_head, put_step, head_or_tp_rank) with a per-group TP replication factor: the number of ranks holding byte-identical bytes for that group (MLA latent KV: tp_size; GQA: tp_size // num_kv_head ranks per shared KV head; Mamba state: 1; any group under DCP: 1). Each group's key namespace is its shard id (tp_rank // factor), PUTs stripe across the ranks within a replication set, and lookup requires one key per distinct shard before reporting a boundary. This produces identical store keys for every currently-supported model — Mamba groups simply stop sharing a namespace and striping — and gives future mixed MLA+GQA+Mamba hybrids the correct per-group treatment instead of a model-wide guess. Cold/warm divergence on a 40-prompt temp=0 repro drops from 38/40 to 0/40; full gsm8k two-pass: cold strict 0.8673, warm strict 0.8726 (was 0.7225), zero KV load failures. Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai>
ivanium
force-pushed
the
fix/mooncake-tp-sharded-mamba-dedup
branch
from
July 23, 2026 03:50
1c056f8 to
2675643
Compare
ivanium
marked this pull request as ready for review
July 23, 2026 04:06
ivanium
requested review from
ApostaC,
NickLucche,
orozery and
xuechendi
as code owners
July 23, 2026 04:06
GirasoleY
approved these changes
Jul 23, 2026
GirasoleY
left a comment
Contributor
There was a problem hiding this comment.
Nice fix :) ! Minor nits, nothing blocking.
Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai>
njhill
approved these changes
Jul 23, 2026
ivanium
force-pushed
the
fix/mooncake-tp-sharded-mamba-dedup
branch
from
July 23, 2026 07:56
8dcabff to
e1c3576
Compare
Dao007forever
approved these changes
Jul 23, 2026
| return self.tp_size | ||
| return max(1, self.tp_size // self.num_kv_head) | ||
|
|
||
| def _compute_group_tp_replication_factors(self) -> tuple[int, ...]: |
Collaborator
Author
There was a problem hiding this comment.
I realized that tuple can indicate the sequence is immutable; so maybe we will keep tuple.
Contributor
There was a problem hiding this comment.
Should we update group_put_steps to tuple as well?
Collaborator
Author
There was a problem hiding this comment.
some discussion with agents and basically the tl;dr is for input arg type we can type loose to be more general; while for return type we can be more concrete. I think it makes sense to me, so we should still keep group_put_steps Sequence?
Comment on lines
+1156
to
+1166
| if isinstance(spec, UniformTypeKVCacheSpecs): | ||
| inner_factors = tuple( | ||
| self._spec_tp_replication_factor(inner_spec) | ||
| for inner_spec in spec.kv_cache_specs.values() | ||
| ) | ||
| else: | ||
| # Without DCP, TP ranks that share a KV head write identical KV, so | ||
| # lookup only needs one TP namespace per unique KV head. | ||
| tp_count = min(self.tp_size, self.num_kv_head) | ||
| rank_namespaces = tuple( | ||
| (tp_rank, pcp_rank, 0, pp_rank) | ||
| return gcd(*inner_factors) if inner_factors else 1 | ||
| if isinstance(spec, MambaSpec): | ||
| return 1 | ||
| if isinstance(spec, (MLAAttentionSpec, SlidingWindowMLASpec)): | ||
| return self.tp_size | ||
| return max(1, self.tp_size // self.num_kv_head) |
Contributor
There was a problem hiding this comment.
Suggested change
| if isinstance(spec, UniformTypeKVCacheSpecs): | |
| inner_factors = tuple( | |
| self._spec_tp_replication_factor(inner_spec) | |
| for inner_spec in spec.kv_cache_specs.values() | |
| ) | |
| else: | |
| # Without DCP, TP ranks that share a KV head write identical KV, so | |
| # lookup only needs one TP namespace per unique KV head. | |
| tp_count = min(self.tp_size, self.num_kv_head) | |
| rank_namespaces = tuple( | |
| (tp_rank, pcp_rank, 0, pp_rank) | |
| return gcd(*inner_factors) if inner_factors else 1 | |
| if isinstance(spec, MambaSpec): | |
| return 1 | |
| if isinstance(spec, (MLAAttentionSpec, SlidingWindowMLASpec)): | |
| return self.tp_size | |
| return max(1, self.tp_size // self.num_kv_head) | |
| inner_specs = ( | |
| tuple(spec.kv_cache_specs.values()) | |
| if isinstance(spec, UniformTypeKVCacheSpecs) | |
| else (spec,) | |
| ) | |
| # Any rank-specific state makes the complete packed value rank-specific. | |
| if any(isinstance(inner, MambaSpec) for inner in inner_specs): | |
| return 1 | |
| # A pure MLA packed value is replicated on every TP rank. | |
| if all(isinstance(inner, MLA_SPECS) for inner in inner_specs): | |
| return self.tp_size | |
| # All ordinary attention layers use the same model-wide KV-head count. | |
| return max(1, self.tp_size // self.num_kv_head) |
If we assume UniformTypeKVCacheSpecs has only 1 level (which I think is a good assumption)
UniformTypeKVCacheSpecs only ever holds raw per-layer specs, so replace the recursive gcd over inner factors with a flat any-Mamba / all-MLA / GQA check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GzJ49XLtcNtpm8r7iLemaT Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai>
1 task
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose
Fix silent Mamba/linear-attention state corruption in the Mooncake store connector when serving hybrid models with TP > 1.
With MLA (or GQA with
num_kv_head < tp_size) all TP ranks hold identical attention KV, so the store dedups: ranks share onetp_rankkey namespace and stripe PUTs across blocks (put_step). That assumption is wrong for hybrid models' Mamba/linear-attention KV-cache groups, whose state is head/dim-sharded across TP ranks: striping persists each boundary block's state from only one rank, and on a warm hit every other rank silently loads a foreign shard. On Kimi-Linear TP=2 this corrupted every external mamba-state load — gsm8k warm 0.72 vs cold 0.87, with every wrong answer a plausible paraphrase; per-layer probing showed rank 1's layer-0 initial state wrong while the transported bytes matched the stored bytes exactly, i.e. the transfer was faithful but the stored shard belonged to the other rank.Fix: per-group TP replication factor
The root cause is that replication is a per-group property but the connector computed it model-wide (one
num_kv_head/put_step/head_or_tp_ranktriple applied to every KV-cache group). This PR replaces that triple with a per-group TP replication factor — the number of TP ranks holding byte-identical cache bytes for that group:tp_sizemax(1, tp_size // num_kv_head)Everything else is derived from the factor uniformly: each group's key namespace is its shard id (
tp_rank // factor), PUTs stripe only across the ranks within a replication set, and lookup requires one key per distinct shard before reporting a boundary (the exists accounting is per-group instead of a single global_lookup_expected_per_key).This produces byte-identical store keys for every currently-supported model — replicated attention groups keep the dedup exactly as before; Mamba groups simply stop sharing a namespace and striping (the bug fix). It also gives future mixed MLA + GQA + Mamba hybrids the correct per-group treatment instead of a model-wide guess, and unwraps
UniformTypeKVCacheSpecsbefore dispatch (matching the coordinator) so wrapped Mamba groups are classified correctly.Why this is not duplicating an existing PR
Searched open PRs/issues for mooncake+mamba, mooncake+dedup, KV-head striping, and hybrid-model KV-transfer accuracy. #48361 (EAGLE cache-peek gating for Mamba lookups), #48970 (e2e hybrid-Mamba regression tests for #43559), and #48216 (group-aware KV load-failure recovery) touch nearby code but none address the TP-shard vs KV-head-dedup namespace bug; no open PR changes the
put_step/namespace logic for sharded groups.Test Plan
Unit tests:
New tests:
test_tp_sharded_group_saves_every_block_on_every_rankandtest_lookup_key_prefixes_expand_tp_sharded_groups_per_rankfail on currentmain(reproducing the bug) and pass with this change;test_group_tp_replication_factors_mixed_mla_gqa_mambacovers the per-group factors on a mixed MLA+GQA+Mamba worker (factors 4/2/1 at TP=4);test_lookup_rejects_boundary_missing_one_mamba_shardlocks down the per-group exists accounting inlookup()end-to-end. All pre-existing tests pass unchanged (including the DCP/PCP namespace and PUT-striding coverage tests, which pin the key format).E2E validation on Kimi-Linear-48B-A3B-Instruct, TP=2, Mooncake store as external KV cache, two-pass (cold then warm) runs at temp=0.
Test Result
E2E on this exact branch (GB200, TP=2, Mooncake store, temp=0, per-run nonce so every pass 1 is cold; hit/failure counters verified nonzero/zero from
/metricsfor every claim below):Kimi-Linear-48B-A3B-Instruct TP2 (hybrid MLA + KDA; the bug scenario — without partial-hit support the aligned hit granularity is 2048 tokens, so prompts use a ~4.2k-token primer):
load_get ok= 12 req × 2 ranks, external hits = 12 × 2048 tokens,save_puton both ranks — no striping for the sharded group, per this PR's design): 0/12 cold-vs-warm divergence, byte-identical outputs, zero KV load failures.DeepSeek-V4-Flash TP2, fp8 KV (MLA
num_kv_heads=1→ KV-head dedup + PUT striping active — the regression guard for the path this PR must not break):Historical (internal tree, same fix, pre-cherry-pick): Kimi-Linear TP2 40-prompt repro 38/40 divergent before → 0/40 after; gsm8k two-pass strict-match cold 0.8673 / warm 0.8726 (warm was 0.7225 before the fix).
AI assistance disclosure
This change was developed with AI assistance (Claude). The submitting human (@ivanium) has reviewed every changed line, ran the tests above, and can defend the change end-to-end. The commit was additionally reviewed by an independent code-review model.