Skip to content

[DCP] CPP-prefill -> DCP-decode KV transfer, replicated index cache, and MTP - #2008

Closed
Jasen2201 wants to merge 79 commits into
mainfrom
Jasen/dcp-kv-transfer
Closed

Jasen2201 wants to merge 79 commits into
mainfrom
Jasen/dcp-kv-transfer

Conversation

@Jasen2201

@Jasen2201 Jasen2201 commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

Makes a CPP (PP4) prefill node feed a TP4 x DCP4 decode node over Mooncake RDMA,
for GLM-5.2 (DSA / sparse MLA). Three pieces:

1. CPP prefill -> DCP decode KV transfer

The producer is not DCP, so its KV blocks do not match the consumer's layout: a
DCP rank owns global token g iff (g // S) % W == r. The connector now plans
the push per region and relays the blocks into the consumer's interleaved
layout, instead of assuming both sides share an addressing scheme.

  • mooncake_connector.py: sharded transfer plan, per-region dispatch, virtual
    block accounting for prefix-cache offsets.
  • types.py: region roles the relayout dispatches on. A DSA index page is
    preshuffled (MFMA-tiled fp8 keys + a plane of fp32 scales) and is not
    token-addressable, so it moves plane by plane rather than by token range.

2. ATOM_DCP_REPLICATE_INDEX_CACHE

Opt-in (default off, decode-side only). The sharded index cache reaches its
global top-k through a cross-rank candidate all-gather. Replicating the index
cache across the DCP ranks removes that exchange, at the cost of W x index
cache memory. Enabling it on prefill raises at startup: prefill runs pp4/dcp1
and the layout rejects both.

  • envs.py, dcp_utils.py: the flag and its model/topology gate (glm_moe_dsa
    • dcp > 1).
  • aiter_mla.py: index_slot_mapping buffers, startup validation of the
    supported transfer topology.
  • deepseek_v2.py: index pages addressed at block_size * dcp_world, and the
    candidate exchange skipped when the cache is replicated.
  • offload/config.py: the PAGE namespace now carries replicated_index_cache
    and indexer_types, so a replicated layout can never reuse a sharded one's
    cached objects.

3. MTP on the DCP decode node

Previously sparse_dcp_metadata_rebuild excluded speculative decode, so MTP
fell off the persistent kernel. The blocker was the DCP top-k filter keying on
requests (qlen == 1); MTP verify forwards max_seqlen_q draft positions per
request, each with its own top-k.

  • dcp_ops.py: triton_filter_and_convert_dcp_index now keys on the query
    token
    (token_to_seq_idxs + num_tokens) instead of qo_indptr /
    global_kv_indptr. The block table is still per request, so a draft position
    looks up its owner. At qlen == 1 the two are identical.
  • attention_mla.py: drop the speculative_config is None gate; extend the
    DCP paged_kv_indptr override to the MTP verify branch (the filter's per-token
    cumsum, not sparse_kv_indptr's uniform stride).
  • deepseek_v2.py: DCP takes priority over the dense MTP gather path.

Signed-off-by: zhuyuhua-v <yuhzhu@amd.com>
Signed-off-by: zhuyuhua-v <yuhzhu@amd.com>
Copilot AI lite review requested due to automatic review settings August 24, 2026 10:53
@Jasen2201
Jasen2201 marked this pull request as draft August 24, 2026 10:53
@github-actions

Copy link
Copy Markdown
Contributor

🏷️ CI Guide

Runs automatically on every eligible PR before approval:

  • ✅ Pre Checkin: Black, Ruff, catalog schema validation, non-GPU unit tests

Heavy model tests:

  • ✅ Run after the PR is approved and Pre Checkin passes
  • ✅ Run immediately when an approval review is submitted
  • ✅ Can be requested before approval with labels
Label Tests
ci:full Run all heavy PR model tests: native ATOM, vLLM, and SGLang
ci:atom Run native ATOM model accuracy tests
ci:vllm Run ATOM vLLM OOT model accuracy tests
ci:sglang Run ATOM SGLang model accuracy tests

Heavy jobs are skipped when the PR is not approved and no matching ci:* label is present.
Add labels via the sidebar or gh pr edit 2008 --add-label <label>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a CPU-testable transfer-planning layer for mapping contiguous CPP-prefill KV blocks into DCP-decode layouts, and updates KV-transfer completion handling so offload-style SaveOperationId(req_id, generation) completions correctly pair with per-request state and PP-wide save quorum logic.

Changes:

  • Introduce atom/kv_transfer/disaggregation/dcp_plan.py to compute sharded/replicated DCP block/token copy plans and coalesce contiguous transfer runs.
  • Normalize completion identity pairing via completion_req_key() and propagate it through PP KV aggregation and multi-connector send/save pairing.
  • Fix PD incremental offset calculation on DCP decode nodes by counting virtual blocks (block_size * dcp_size) instead of plain blocks, with dedicated tests.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated no comments.

Show a summary per file
File Description
tests/test_pp_kv_status.py Adds regression coverage for pairing finished_sending with SaveOperationId-shaped finished_saving via request-id normalization.
tests/test_multi_connector.py Adds coverage for scheduler-side process_completions fan-out and for worker-side pairing when saves report SaveOperationId.
tests/test_dcp_transfer_plan.py New comprehensive unit tests validating DCP sharded/replicated plans, partitioning, coalescing, and incremental slicing behavior.
tests/test_dcp_connector_offset.py New tests ensuring DCP-aware PD incremental offset counts virtual blocks and handles partial/per-request cache cases.
tests/aiter_stub.py Extends the AITER stub module set to include aiter.dist.parallel_state for import compatibility in tests.
atom/model_engine/pp_engine_core.py Uses completion_req_key() so PP-head send-holding correctly pairs against save completions regardless of completion id shape.
atom/kv_transfer/disaggregation/types.py Adds completion_req_key() helper to collapse completion variants (raw req id vs operation id) onto request identity.
atom/kv_transfer/disaggregation/multi/multi_connector.py Uses completion_req_key() for save pairing keys and adds scheduler-side process_completions() fan-out.
atom/kv_transfer/disaggregation/mooncake/mooncake_connector.py Makes PD incremental num_computed_blocks DCP-aware by dividing by block_size * dcp_size.
atom/kv_transfer/disaggregation/dcp_plan.py New DCP transfer planner implementing sharded/replicated mapping plus contiguous-run coalescing.
atom/kv_transfer/disaggregation/init.py Exports completion_req_key.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Signed-off-by: zhuyuhua-v <yuhzhu@amd.com>
@Jasen2201
Jasen2201 force-pushed the Jasen/dcp-kv-transfer branch 2 times, most recently from c7d3018 to c3c4577 Compare August 24, 2026 12:38
Integrate main's compact PP-aware index-cache mapping while retaining DCP-expanded replicated pages, and fix the PR's formatting checks.
@Jasen2201
Jasen2201 force-pushed the Jasen/dcp-kv-transfer branch 2 times, most recently from f806e57 to 44116b2 Compare August 26, 2026 03:55
Squashed import of xingjche/test_pd_dcp, taken as its diff against the
main it branches from, so only that branch's own work lands here -- the
main commits it had merged are already on this line.

  - ATOM_DCP_REPLICATE_INDEX_CACHE (default off): under native GLM-5.2
    DCP, keep MLA KV sharded but replicate the full IndexShare index
    cache on every DCP rank, which drops the indexer candidate
    all-gather and global merge. Gated to glm_moe_dsa, dcp>1, no spec
    decode. Index pages, the persistent-path plan, and the offload PAGE
    identity (layout version 3) all expand by dcp_world_size with it.
  - PD prefix-cache offsets and received-prompt hashing count in
    hash_block_size (block_size * dcp_size) instead of block_size: under
    DCP one block-table entry is a virtual block, so hashing at the
    physical size bound several token ranges to the same block.
  - Dense offload loads the largest chunk-aligned prefix instead of the
    unaligned full-prompt hit, which the worker was reading as a failed
    load and recomputing whole.
  - atomesh CI: DCP PD accuracy and benchmark cases.

One conflict resolved: aiter_mla's index_page_factor locals, where the
incoming definitions were needed by uses that had already merged.
A non-DCP prefill node holds a sequence's KV contiguously; a DCP decode
node holds it round-robin across dcp_size ranks at interleave_size
granularity, and one of its block-table entries is a virtual block
covering block_size * dcp_size global tokens. The producer's whole-block
descriptors no longer line up with what the rank will read back, so plan
the transfer in token runs instead, and tell the producer which shard to
write by shipping the consumer's DCP topology in the write_request.

Every block region shares the KV cache's slot mapping (the indexer cache
included) and DCP only runs on MLA, whose layout stores a token
contiguously, so one plan covers all of them, scaled by the region's own
per-token width. It takes one planner per region class, each given the
source and destination block id lists and returning coalesced
(src_offset, dst_offset, length) triples:

  - plan_sharded (MLA latent): rank r takes the runs of interleave_size
    tokens it owns. block_size / interleave_size descriptors per
    destination block, degenerating to one whole-block descriptor at
    interleave_size == block_size. Both sides derive their (block, offset)
    pair by splitting a slot index on block_size -- the local slot for the
    destination, the global position for the source -- so the two halves
    read the same way.
  - plan_replicated (index cache under ATOM_DCP_REPLICATE_INDEX_CACHE=1):
    every rank holds the full rows, so a destination virtual block is
    filled by dcp_size consecutive source blocks, independent of rank and
    interleave.

The choice is per region, not per request: aiter_mla fills the
semantic_role the region type already carries, the consumer ships whether
it replicates, and the indexer regions take plan_replicated while the MLA
latent stays interleave-sharded. Relayouting the dcp_size-times-wider
index page with the 1/W formula writes real KV to the wrong slots, which
decode reads back as plausible text rather than a fault, so
_uses_standalone_lmcache_offload becomes _uses_replication_safe_kv_transfer
and admits mooncake instead of blocking the combination. The multi fan-out
stays out: that is the prefill node's shape, and prefill runs
dcp_world_size == 1, where this layout is rejected anyway.

No new geometry crosses the wire. The plan already counts in the
destination's token space, and token_bytes = src_unit_bytes // block_size
holds for both ends because only the page width differs, in whole tokens.

Deriving the block counts from the array sizes removes the count mismatch a
separate-arguments signature would make expressible. Layout validation is
left to config construction, which already asserts every one of these
invariants (interleave_size >= 1, 1 <= interleave_size <= kv_cache_block_size
and block_size % interleave_size == 0 at config.py:1444, 1708, 1713;
tensor_parallel_size % dcp_size == 0 at config.py:1685) -- a server that
could violate them would not have started.

What config cannot see, the producer checks per transfer: that its
per-region plan lands on the same kind of region on the consumer, which
matching region counts do not establish, and that a region's block bytes
divide into whole tokens, without which the per-token size truncates.
Both otherwise corrupt KV silently.

One destination block spanning dcp_size source blocks is also what the
incremental-prefix offset and the producer's src/dst count check have to
account for: the offset divided num_cached_tokens by block_size, which
counts virtual blocks dcp_size times over and would skip live prefix
blocks. Without DCP nothing changes: same whole-block descriptors, same
arithmetic.

scripts/start_glm52_pp4pd_dcp.sh brings the pair up -- PP4xTP1 prefill on
GPUs 0-3, TP4xDCP4 decode on 4-7 -- and sets the replicated index cache on
the decode node alone, since the check reads the bare environment variable
and a prefill rank that saw it would abort on "decode context parallel size
must be > 1".

The planners are numpy-only and reachable through the aiter stub, so the
mapping is tested without a GPU.

GSM8K over that pair, all 1319 5-shot samples: 0.9295 with the index cache
sharded, 0.9356 with it replicated. Producer bytes confirm the split plan --
a 20-MLA/7-index stage sending 48 source tokens writes
20*576*12 + 7*144*48 = 186624, the latent still one quarter and the indexer
whole. Costs HBM: the block pool drops 144111 -> 121736 blocks per rank, the
~19% the wider index page predicts. docs/dcp_decode_topk_bottleneck.md
records where the decode time goes once it runs.
The replicated index cache refused speculative decode outright, which cost
the DCP decode node its tokens-per-step: the DPA baseline reached 797 tok/s
against 431 here almost entirely because it ran MTP.

Nothing about the replicated path needs qlen=1. Three things had to change.

The compacting DCP top-k filter was written per request -- `qo_indptr` picked
one query token per row -- so widen its row unit to the query token and let
each draft position carry its own compacted region. `token_to_seq_idxs`
supplies the owning request for the block-table lookup, which is the identity
at qlen=1 and not at qlen>1; both metadata builders now populate it for
non-MTP sparse DCP too, since the filter is shared. `index_slot_mapping`
grows to one slot per query token for the same reason, over the same
`[seq_len - max_seqlen_q, seq_len)` range the main KV slot mapping walks.

The filter writes compacted regions and records their lengths in
`dcp_sparse_kv_indptr_buffer`, but only the qlen=1 branch of `_forward_decode`
read that buffer -- the MTP branch still used `sparse_kv_indptr`, which
describes uniform top-k-wide regions, so attention walked past each written
region into stale indices. `B` is `q.shape[0]`, a token count on both
branches (sparse layers in MTP verify run at max_q_len=1), so one
substitution covers them.

Third, MTP faulted in aiter's `_fwd_kernel_stage2_asm` -- 382 wave dumps, one
kernel in all of them. That kernel is the intra-rank split-KV merge, and the
validated non-MTP config never reaches it: `speculative_config` being set
cleared `sparse_dcp_metadata_rebuild`, which cleared `use_persistent_mode`,
which dropped aiter onto the non-persistent path. So MTP was not breaking the
merge, it was switching to a different one -- and the split-KV merge cannot
describe a DCP row. It gives every row the same number of split slots and
then caps the count at `cdiv(cur_kv_seq_len, mgc)`, where the length comes
from the compacted indptr, but a DCP row only holds the top-k slots this rank
owns, so the lengths vary per row. The persistent work plan encodes exactly
that, which is why the non-MTP path has always been fine. Nothing here is
specific to speculative decode: DPA runs MTP on the persistent kernel already.
So stop clearing the flag, and point the work-plan rebuild at the
`sparse_mtp_*` buffers when verifying -- those were already built and already
selected by `_forward_decode`, only the rebuild call still wrote the
unprefixed set, whose empty-prefix branch asserts qlen==1.

The assert in `_dcp_decode_candidate_exchange` stays: it guards the sharded
branch, which a replicated index cache never reaches.

The PAGE namespace carried its own copy of the same predicate and so needed
the same edit; it now calls `dcp_replicated_index_cache_enabled` instead. A
desync between the two would silently change an offload cache key.

GSM8K 1319 = 0.9303 +/- 0.007 with zero faults, between the 0.9295
(replicate=0) and 0.9356 (replicate=1) no-MTP baselines on the same harness.
Throughput is still unmeasured -- tokens per step was the point, accuracy
only the gate. The two filter tests track the new signature but skip without
aiter installed, so they did not run here.
@Jasen2201
Jasen2201 force-pushed the Jasen/dcp-kv-transfer branch from 44116b2 to 445fa83 Compare August 26, 2026 08:48
Phi-C and others added 13 commits August 26, 2026 10:04
Signed-off-by: Phi-C <chenxjhit@163.com>
A preshuffled index page is not token-addressable. indexer_k_quant_and_cache
writes it as an MFMA-16x16 tiled fp8 key run, then one fp32 scale plane for
the whole page, then padding, so no 144-byte window of it is a token. The
mooncake block transfer addressed every block region as
token_index * (per_block_bytes / block_size), which is only true of the MLA
latent, and so handed the decode node a page whose keys were shuffled against
their scales.

The damage showed up twice and looked like two bugs. The plane the indexer
built from those bytes was 5-7 % NaN, which is a wrong answer on any context
past the W * index_topk = 8192 short-circuit; and an all-NaN region is a single
radix bucket the one-block top-k cannot split, which is where the 1.6 ms
radix_topk_one_block_kernel came from. Moving pages page-aware answers both:
needle retrieval goes 0/4 -> 4/4 at 7 k and 6/6 at 23 k and 46 k, and the same
kernel drops to 0.40 ms with every other GPU bucket unchanged.

The replicated plan now emits a descriptor per plane instead of per page, and
the index region carries its two plane widths in KVTransferRegion so the plan
knows where the split is. A sub-page interleave on an unreplicated index region
has no valid plan at all and is now a hard error rather than silent corruption.
Decode runs under a whole-forward graph, so the Python body of
sparse_attn_indexer only executes at capture time and the plane the top-k
kernel actually consumed is unreachable from the eager side. record() captures
a copy_ into a buffer allocated during the eager warmup forward that precedes
each capture, so the copy replays with every decode step and flush(), called
from the run_model tail, reads back the plane of the step that just ran.

Off unless ATOM_DSA_LOGITS_DUMP names a directory. This is what showed the
index-cache relayout was corrupting the plane rather than the kernel being
slow on honest data.
The script posted to /v1/completions with no chat template and the default
generation length, which lands around 0.930 and is not comparable to the
0.9742 / 0.9727 row recipes/GLM-5.md publishes for TP4 MXFP4. It now uses the
recipe's arguments: the chat endpoint, --apply_chat_template, and
max_tokens=16384,temperature=0,top_p=1 at 64 concurrent.

The model class is local-chat-completions, not the recipe's local-completions:
that class posts `prompt`, so against a chat endpoint every request comes back
400 missing field `messages`.
Against the recipe's published TP4 row the disaggregated path looks 1.6 pp
short, but that row was not measured on this machine. A single-node TP4 server
started from the recipe's own command, same box and image and model copy,
scores 0.9644 / 0.9651, which puts PD+DCP 0.6 pp behind it -- 0.8 sigma, no
measurable cost to disaggregation. The remaining 1.0 pp is this machine
against the published reference.
The GSM8K number in this doc predates the DSA index-page relayout fix, and
nothing here said what the MTP head's real acceptance rate is. Both are now
measured on the fixed stack: MTP costs no accuracy and runs the same eval
1.56x faster, and acceptance is 0.60 eval-wide but 0.62-0.65 while the batch
is full. Single-request probes at 222, 11k and 40k prompt tokens pin the
variation on generated content rather than on context length or batch size,
which matters because 40k is past the point where the DSA top-k stops
short-circuiting.
The script serves the throughput runs, where MTP=3 at a fixed acceptance
rate is part of the configuration being measured, so every perf launch was
prefixing ENABLE_MTP=1. Make that the default and keep ENABLE_MTP=0 for
isolating a DCP change from the draft KV layer and the q>1 verify path.

Note that DCP_INTERLEAVE > 1 now needs an explicit ENABLE_MTP=0: the
existing guard rejects the combination rather than silently dropping the
speculative flags.
The .github benchmark/workflow edits and the DCP design notes under docs/
are not part of the KV-transfer change itself. Restore them to main so the
PR carries only the engine, kernel, script, and test changes.
Signed-off-by: Mengqing Cao <cmq0113@gmail.com>
Signed-off-by: Mengqing Cao <cmq0113@gmail.com>
Signed-off-by: Mengqing Cao <cmq0113@gmail.com>
Signed-off-by: Mengqing Cao <cmq0113@gmail.com>
The guards protect a (num_tokens,) Triton grid launched over raw pointers,
so the failure they stand in front of is an out-of-bounds read and write,
not a wrong answer. `python -O` strips a bare assert, which is exactly the
configuration a production run is most likely to use.

_assert_dcp_filter_rows is renamed _check_dcp_filter_rows to match, and the
argument checks it makes raise ValueError/TypeError.
sharded_plan addresses a block in MLA token units, so it is meaningful for
the latent cache and, through the replicated planes, for the index cache.
A region registered with any other role -- an EAGLE3 draft KV region is the
one that exists today -- was relaid out under that rule anyway, silently
writing the consumer's blocks at the wrong offsets. Read the role once and
raise on anything else.

The descriptor loop also boxed one Python int per token on the replicated
index plan; scale the offsets in the numpy arrays and tolist() them
instead, and skip the debug format when DEBUG is off.
The key/scale plane split the replicated relayout addresses through assumes
one fp32 scale per token, which holds only while the indexer quantizes a
whole key row as a single block. The old check compared the row against the
page bytes, which a wider row can still satisfy while carrying
index_head_dim // quant_block_size scales -- so the plane boundary would be
short and each destination sub-page would overwrite the previous one's
scales. Compare against Indexer.quant_block_size instead.
The replicated index path set it on the metadata object and both readers
reached it through getattr(..., None), so the field existed only where it
happened to be assigned. Declare it beside sparse_kv_last_page_lens, which
is the same shape of optional sparse buffer.
Both the comment and the guide still described the persistent path as
non-speculative, from before the MTP verify step rebuilt into its own
sparse_mtp_ work buffers. A verify step is per-token q_len=1 rows and takes
the same rebuild, so what remains non-persistent is a missing per-layer
rebuild: gfx942, or a page size above 1.
The dashboard derived prefill PP from the topology label, so `cpp4-dcp4`
worked and a run whose label does not spell it out did not; the decode side
had no PP term at all, so a decode PP run would under-report its GPUs. Read
--pipeline-parallel-size out of each role's extra server args and let it
outrank the label, and multiply the decode GPU count by it. DECODE_EXTRA_
SERVER_ARGS is now carried into the payload so the decode side has a source.

The label regex also matched the `pp` inside a longer word; anchor it to a
separator.

apply_role_env substituted the global HANDSHAKE_PORT into a ${HANDSHAKE_PORT}
placeholder, not the port of the launch it is building.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated 1 comment.

Comment on lines +1737 to +1740
plan_src, plan_dst, plan_len = plan
src_addrs.extend((src_base + plan_src * unit).tolist())
dst_addrs.extend((dst_base + plan_dst * unit).tolist())
sizes.extend((plan_len * unit).tolist())
Three files conflicted, all where main's #2102 (published DCP local context
lens) landed next to this branch's replicated index cache:

- forward_context.py / aiter_mla.py: both sides declare a new optional field
  and a new __init__ line in the same spot. Kept both; they are independent.
- test_mla_index_cache.py: both sides append tests. Kept both, and dropped the
  function-local `import torch` now that main imports it at module scope.

The two features do not overlap at runtime: dcp_local_context_lens localizes
context for scoring a *sharded* index cache, and the replicated path skips that
consumer entirely via _dcp_index_comm_required().
Copilot AI review requested due to automatic review settings September 1, 2026 13:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

atom/model_ops/dcp_ops.py:1346

  • This function still relies on multiple assert checks (dtype/shape, dcp_rank bounds, and out is not None). Since asserts are stripped under python -O, the Triton launch can proceed with invalid inputs and potentially read/write out of bounds despite the new _check_dcp_filter_rows guard. Convert these asserts into explicit exceptions (and validate token_indices shape/dtype) so the safety checks are always enforced.
    assert 0 <= dcp_rank < dcp_world_size
    assert out is not None, "sparse_kv_indices_buffer (out) is required"

Comment thread atom/model_ops/dcp_ops.py
Comment on lines 1577 to 1578
assert 0 <= dcp_rank < dcp_world_size
assert out is not None, "sparse_kv_indices_buffer (out) is required"
@wuhuikx
wuhuikx requested a review from valarLip September 2, 2026 01:31
Copilot AI review requested due to automatic review settings September 2, 2026 02:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It changes core sparse-attention/DCP indexing semantics and production RDMA KV-transfer behavior across multiple subsystems, so it needs careful human validation on real deployments beyond unit tests.

Review details

Suppressed comments (1)

atom/kv_transfer/disaggregation/mooncake/mooncake_connector.py:1377

  • When has_slot_regions is true and consumer_dcp_size > 1, this path logs and returns without sending any notification to the consumer. The consumer has no timeout/cleanup for _pending_recv_*, so the receive can hang indefinitely. If this configuration is truly unsupported, it should fail in a way that unblocks the consumer (e.g., an explicit error notification + consumer-side cleanup, or a consumer-side preflight rejection before enqueueing the request).
            if has_slot_data and consumer_dcp_size > 1:
                # _execute_block_slot_transfer pairs whole blocks, so it has no
                # way to express a consumer block that holds every dcp_size-th
                # token. The block-count check below now admits that ratio, so
                # refuse here rather than write another rank's KV.
                logger.error(
                    "[PRODUCER] req %s carries per-request state regions and "
                    "the consumer runs dcp_size=%d; the slot-transfer path has "
                    "no DCP relayout. Aborting instead of writing misaligned "
                    "KV.",
                    req_id,
                    consumer_dcp_size,
                )
                return
  • Files reviewed: 23/23 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment on lines +2811 to +2815
if getattr(attn_metadata, "index_slot_mapping", None) is not None:
# Absolute cache addresses, so a token slice needs no rebase. Its
# presence is also what selects the replicated index layout in the
# indexer -- dropping it would silently fall back to the sharded one.
ub_attn.index_slot_mapping = attn_metadata.index_slot_mapping[ts]
Comment on lines +590 to +593
# Absolute addresses into the replicated DSA index cache, one per query
# token. Its presence is the switch: not None makes the indexer use it in
# place of slot_mapping, None leaves the dcp-sharded index layout.
index_slot_mapping: torch.Tensor | None = None
Two DCP<->MTP adaptation defects found in PR review.

1. Draft steps reused the target step's token_to_seq_idxs.

The DCP top-k filter's row unit is the query token; it reads
token_to_seq_idxs to pick the owning request's block_tables row. The
target step publishes one entry per verify token
(arange(bs).repeat_interleave(max_seqlen_q)), but a draft step runs one
row per sequence, so the map must be the identity. Nobody rebuilt it,
and draft steps indexed the block table through the verify-step map.

Add AiterMLAMetadataBuilder.rebuild_draft_token_to_seq_idxs() and call
it from the proposer's per-step metadata rewrite. Padded rows carry no
top-k but still index the table, so the rebuild covers running_bs.

2. index_share_for_mtp_iteration never worked and is removed.

compact_topk_indices resolved self_attn.mla_attn -- the
paged_attention.Attention wrapper -- while sparse_kv_indices_buffer
lives one level down on mla_attn.impl (MLAAttention). The getattr
returned None, so the buffer-copy branch has never executed. Even
reaching the buffer, the copy indexed a flat 1-D allocation as if it
were [bs, index_topk], moving elements rather than rows, and under DCP
the layout is ragged: correct compaction has to repack
sparse_kv_indices_buffer, dcp_sparse_kv_indptr_buffer and
dcp_owned_counts_buffer together.

None of that surfaced because set_skip_topk had no effect at the
serving compilation level. use_custom_dispatcher (level >= 2) replays
compiled code without Dynamo guards, so the

    if self.is_v32 and self.indexer is not None and not self.skip_topk

branch in the draft's attention is baked at first trace with
skip_topk=False. The indexer therefore ran on every draft step, which
is also why the flag showed no measurable effect. One frozen branch
cannot serve step 0 (must compute) and steps 1+ (must skip), so no
warmup ordering recovers it; the indexer call would have to move behind
a Dynamo-opaque custom op the way the PCP path does.

Forcing eager to make the sharing real measures the trade at 8k prompts,
concurrency 8: acceptance 0.5254 vs 0.6399 and 443 vs 490 requests, for
0.39 ms/forward (~1.6%) saved. At bs=2 a draft step reads rows 0 and 1,
so sequence 1 attends through sequence 0's top-k. Not worth repacking
three GPU buffers per step to keep.

Serving behaviour is unchanged -- level 3 already recomputed every step.
Removing the flag drops a real correctness bug at level 0/1.

tests/test_mtp_index_share.py goes with it: it re-implemented both
helpers inside the test file against a fake whose buffer was 2-D and
hung off mla_attn directly, so it asserted against the two properties
production does not have.
Copilot AI review requested due to automatic review settings September 2, 2026 06:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

It makes broad, high-impact changes across distributed RDMA transfer, DCP sparse attention kernels, and speculative decode metadata paths that require careful end-to-end validation on real multi-GPU topologies.

Review details

Suppressed comments (1)

atom/utils/forward_context.py:592

  • The comment describes index_slot_mapping as “absolute addresses”, but this field is populated with cache slot indices (e.g., block_id * page_width + offset, like slot_mapping), not byte addresses/pointers. Clarifying this avoids confusion for future callers and keeps terminology consistent with the rest of AttentionMetaData.
    # Absolute addresses into the replicated DSA index cache, one per query
    # token. Its presence is the switch: not None makes the indexer use it in
    # place of slot_mapping, None leaves the dcp-sharded index layout.
  • Files reviewed: 25/25 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@Jasen2201

Copy link
Copy Markdown
Contributor Author

Second pass over this branch. I checked the load-bearing claims against the tree rather than reasoning about the diff, and cite line numbers from the PR head.

Nothing here is a crash — every item below is a path that produces plausible-but-wrong output, or a guard that cannot fire.

1. token_to_seq_idxs is not rewritten for MTP draft steps — and this PR is what made that load-bearing

The diff swaps the row-owner source for the DCP filter:

-                attn_metadata.cu_seqlens_q,
+                attn_metadata.token_to_seq_idxs,

EagleProposer._enter_decode_metadata rewrites everything else the draft step needs — max_seqlen_q (:384), cu_seqlens_q (:386-387), slot_mapping (:385), and this PR's own index_slot_mapping (:393). It does not rewrite token_to_seq_idxs; a repo-wide grep finds that name zero times in eagle_proposer.py.

So on draft step 1+, prepare_decode (aiter_mla.py:2311) has left token_to_seq_idxs = arange(scheduled_bs).repeat_interleave(max_seqlen_q) = [0,0,0,0,1,1,1,1,...]. sparse_attn_indexer takes the if dcp_world_size > 1: branch (deepseek_v2.py:1727) with num_index_tokens = topk_indices.shape[0] == running_bs, and _compact_filter_dcp_kernel does req_id = tl.load(token_to_seq_idxs + token_id) (dcp_ops.py:1207) — draft row t indexes block_table row t // (k+1). Every sequence but the first gathers another request's KV slots.

_assert_dcp_filter_rows cannot catch it: it checks have >= needed (dcp_ops.py:1277), and the stale buffer is longer, not shorter.

Pre-PR the same branch read cu_seqlens_q, which _enter_decode_metadata does rewrite — so this is a regression introduced here, not a pre-existing gap.

It does not fire on the headline config only by coincidence: /data/GLM-5.2-MXFP4/config.json sets index_share_for_mtp_iteration: true, so set_skip_topk(True) (eagle_proposer.py:604) suppresses the whole indexer on steps 1+. Any DSA+MTP model without that flag (DeepSeek-V3.2) under dcp>1 corrupts silently.

Fix: point token_to_seq_idxs at arange(running_bs), mirroring aiter_mla.py:2328-2332.

2. The index-sharing path — the one that is live for GLM-5.2 — compacts indices but not the DCP indptr

compact_topk_indices (deepseek_mtp.py:263) compacts sparse_kv_indices_buffer. Under DCP the per-row region offsets live in dcp_sparse_kv_indptr_buffer, which no draft step rewrites.

With skip_topk on for steps 1+, no layer runs the filter, so that buffer still holds the target step's per-query-token indptr of length running_bs * max_seqlen_q + 1. attention_mla.py:2105 then reads self.dcp_sparse_kv_indptr_buffer[: B + 1] with B = q.shape[0] = running_bs, so draft row i gets the compacted region of the i-th verify token, not the i-th sequence.

Compounding it: owns_sparse_indexer is a static indexer is not None (attention_mla.py:632), so _should_rebuild_sparse_dcp_persistent_metadata still fires and rebuilds the persistent work/reduce descriptors from that stale indptr.

Separately in the same helper: sparse_buf[:num_slots] = sparse_buf[slot_ids] (aiter_mla.py:495) moves num_slots individual int32 elements of a flat 1-D buffer, not topk-wide rows.

Findings 1 and 2 are complementary — the config flag that masks 1 is exactly what activates 2, so there is no configuration where the draft step's DCP row mapping is correct.

3. All five new write_request keys fail open, and the payload carries no schema version

mooncake_connector.py:1632 and around:

consumer_dcp_size            -> 1
consumer_dcp_interleave      -> 1
consumer_replicates_index_cache -> False
consumer_region_roles        -> None   # and None skips the entire new role-pairing check

A decode node built before this PR that runs -dcp 4 sends none of them. The new producer takes dcp_size=1, skips plan_sharded, and writes whole 16-token blocks into consumer blocks that physically hold every 4th token. Nothing raises — expected_dst_blocks == len(src) == len(dst) because both sides believe dcp=1 — and the decode node serves fluent but wrong text with 3/4 of every block being another rank's KV.

P and D are separate servers, often separate images. Version skew is the expected operating condition here, not an edge case. A schema_version on the write request, refused loudly on mismatch, is cheaper than any of the individual defaults.

4. The strict src/dst equality became a ceiling-divide, so the guard now accepts an off-by-1..dcp_size-1

mooncake_connector.py:1434:

expected_dst_blocks = -(-len(src_block_ids) // consumer_dcp_size)

At dcp_size=4, len(src) in {13,14,15,16} all yield expected_dst_blocks == 4; only 16 is exact coverage. plan_sharded then silently drops the shortfall (keep = src_block < src_ids.size, and its docstring frames dropping as intended), and nothing downstream re-establishes it — _execute_block_transfer validates region roles and interleave but never block counts.

The consumer's trailing tokens are simply never written: uninitialized KV, no error, generation degrading mid-request. The error message right below still says "aborting transfer to avoid misaligned KV", which is now the one thing it no longer guarantees.

At consumer_dcp_size == 1 this is exactly the old equality, so the TP-TP path is unaffected.

The coarsening is arithmetically forced by the wire format — request_data carries no token count — so the fix is to ship num_tokens on the write request and check that, not to restore the equality.

Refusals that strand the peer

All three new refusal paths — the slot-regions+DCP return at :1359, the region-role mismatch raise at :1622, and the producer-runs-DCP raise at :1638 — terminate without sending any failure notification to the consumer, and the two raises are swallowed by _execute_transfer's blanket except Exception.

The consumer clears _pending_recv / _pending_recv_expected only on an incoming write_done (:2070-2111); there is no timeout. So one refusal leaves the request in the recving set forever — KV blocks and state slot never freed, client never gets a first token.

:1359 additionally strands a staging-pool slot the consumer already acquired in start_load_kv (:1147), released only on the write_done path (:2104). _acquire_staging_slot busy-waits while True: time.sleep(0.001) when the pool empties, so after ATOM_PD_STAGING_POOL such requests the connector stops admitting remote prefills entirely. On the producer, _completed_prefills.pop and done_sending are also skipped, leaking the shared page-table entry per failed request under pp4.

All three conditions are startup-config properties, not per-request ones. They belong in the existing MSG_GET_META handshake (:1284), failing loudly at pairing time — where a human sees them once instead of a request hanging silently.

Per-region dispatch is by exclusion, not by role

:1677: plan = sharded_plan is the default and only INDEX_CACHE_ROLE + replicates_index diverts. Any region whose semantic_role is None — or anything else — silently receives the MLA token-interleave plan whenever the consumer declares dcp_size > 1.

The comment justifies token-unit addressing with "DCP only runs on MLA, which stores a token contiguously", but nothing enforces it. aiter_attention.py:750 and eagle3_kv_builder.py:162/178 register regions with no semantic_role; the new role-pairing check passes them (None == None), and the only remaining gate is bpb % self.block_size (:1716), which most block-major layouts satisfy. A draft-KV or non-MLA region is then relaid out as if it were MLA.

The loop should assert role == MLA_KV_ROLE under dcp_size > 1 and raise on anything else — turning the comment into the check it describes.

interleave_size=1 degenerates to one descriptor per token, and the suggested remedy is unreachable

The new suite is MTP (--method mtp --num-speculative-tokens 3), so dcp_config.interleave_size is pinned to 1 by config.py:1773-1789 — an explicit assert that interleave_size > 1 is incompatible with speculative decode. That makes the error message's advice ("run with an interleave of block_size") impossible to follow on this topology.

plan_sharded then emits dst_blocks * block_size runs of length 1, and _coalesce provably merges none of them (source stride is dcp_size while run length is 1). Measured on this box: 94208 descriptors take 17.1 ms in the for src_off, dst_off, run_len in zip(*plan) Python loop with three int() casts each, versus 2.58 ms for (base + arr*unit).tolist().

For a 4096-token request at dcp=4 / block_size=16 that is ~1024 descriptors x ~92 regions ~= 94k entries (pre-PR: ~23.5k whole-block descriptors), and the descriptors are 576-byte MLA token rows instead of 9216-byte pages, so _rdma_write_with_retry's blocking 4096-entry batches go from ~6 to ~23 synchronous round trips. This lands on the producer's transfer thread, serializing every other request behind it.

Also: sum(sizes) in the logger.debug at :1736 is evaluated eagerly regardless of log level, over ~94k elements.

Dropping the speculative exclusion changes numerics for every sparse-MLA + DCP + spec server

attention_mla.py:749 loses and getattr(atom_config, "speculative_config", None) is None, while the new gate lives only in dcp_replicated_index_cache_enabled — which neither sparse_dcp_metadata_rebuild nor ATOM_DCP_REPLICATE_INDEX_CACHE=0 consults.

sparse_dcp_metadata_rebuild feeds mla_dcp_decode_is_persistent -> dcp_kernel_num_heads / dcp_head_pad, and mla_dcp_sparse_prefill_is_persistent -> dcp_sparse_prefill_num_heads plus the hard assert at :1799. Serve GLM-5.2 DSA with -dcp 4 --method mtp and the flag at its default 0: prefill — which never reaches the decode-side candidate-exchange assert — now runs the persistent sparse-DCP path at gqa=64 instead of the previously-validated non-persistent gqa=128 fallback. Numerics change before the documented failure point is reached.

docs/context_parallel_guide.md:540-544 still reads:

The implementation is scoped to native, non-speculative serving ... speculative sparse DCP paths without the per-layer rebuild remain non-persistent and round a gathered 64 up to 128.

and attention_mla.py:2115-2118 carries the same now-false comment. This PR updated three other paragraphs of that guide but not the one describing the condition it deleted.

The replicated decode logits plane is a second dcp_size-x memory term, undocumented and unbudgeted

dcp_ops.py:1061-1064 sizes the sharded path at l_max = ceil(max_model_len / dcp_world_size). The replicated path takes the else branch at deepseek_v2.py:1698 with the full max_model_len. The chunking that respects ATOM_SPARSE_INDEXER_LOGITS_BUDGET_MB is only in the prefill branch (:1583-1600), so the decode allocation is unbudgeted.

The new suite sets max_model_len: "", so the model's own 1,048,576 (GLM-5.2 max_position_embeddings) applies, and it is a fresh torch.empty per full-IndexShare layer (21 of 78) per decode step.

docs/environment_variables.md:167 documents the cost as only "dcp_size x index-cache memory" and "widens an index page". The decode-time logits plane is a second, larger dcp_size-x term stated nowhere, and it interacts with the suite's gpu_memory_utilization: 0.85.

The index-page geometry the whole relayout addresses through is asserted nowhere

aiter_mla.py:1313: key_plane_bytes = tokens * index_head_dim and scale_plane_bytes = tokens * 4 hardcode 1-byte keys and one fp32 scale per token. quant_block_size is hardcoded to 128 with a TODO: get from config at deepseek_v2.py:2076; the aiter kernel writes head_dim / quant_block_size floats per token, so any model with index_head_dim != 128 makes the scale plane wider and each destination sub-page overwrites the previous one's scales.

Neither new guard next to it can fail:

  • bpb % index_row_bytes only checks whole token rows.
  • tokens_per_page * (index_head_dim + 4) > bpb is a tautology: bpb == tokens_per_page * aligned_index_dim with aligned = ceil((index_head_dim + 4) / 16) * 16 >= index_head_dim + 4 by construction (same formula at :1124 / :1170).

Nothing anywhere asserts that the scale plane starts at exactly key_plane_bytes.

tests/test_pd_pp.py:2585 derives its expected dst offsets from d*page*dcp_size + s*key — the implementation's own arithmetic. Its sibling _sharded_reference derives from the write-side ownership rule instead, which is why that one is discriminating and this one is not: it stays green against a wrong split.

The failure mode is corrupted-but-plausible index keys, not a fault. What this needs is a bit-exact producer->consumer round-trip against indexer_k_quant_and_cache(preshuffle=True) for W in {2,4,8}, plus an explicit index_head_dim == quant_block_size assertion at region registration.

_assert_dcp_filter_rows is built from bare assert, against this repo's stated convention

It is the only Python-side bound on Triton kernels whose grid became a caller-supplied num_tokens, and its own docstring names the exact consequence it prevents ("the launch then reads and writes past the end with no Python-side error").

The repo states the raise-not-assert rule in seven places, including deepseek_v4_dspark.py:741 verbatim: "raise, not assert: a bare assert vanishes under python -O". Also block_manager.py:191, engine_stats.py:178, layer_sparse_mla.py:466, v4_kernels/paged_decode_indices.py:380.

The relation it enforces is newly load-bearing: the row unit changed from per-request to per-query-token, so the required length of token_to_seq_idxs / out_kv_indptr / owned_counts is multiplied by max_seqlen_q, while the buffers stay sized max_num_batched_tokens (aiter_mla.py:503-512).

No Dockerfile or script currently sets PYTHONOPTIMIZE, so this is a convention defect rather than a live OOB. But the gap it points at is real and unguarded: nothing validates max_num_seqs * (num_spec_tokens + 1) <= max_num_batched_tokens (the related check at config.py:1871 is commented out), and the new suite leaves decode_max_num_batched_tokens empty.

Two independent sources of truth for whether replication is on

_replicated_index_cache_unsupported_reasons (aiter_mla.py:346) is reachable from exactly one constructor, AiterMLAMetadataBuilder.__init__. MooncakeConnectorWorker derives the same flag independently via dcp_replicated_index_cache_enabled, which checks only ATOM_DCP_REPLICATE_INDEX_CACHE, decode_context_parallel_size > 1, and model_type == "glm_moe_dsa" — and the connector's copy is what selects plan_replicated_index on the producer.

Boot a glm_moe_dsa decode node with the flag set and a different MLA backend (the recipe pins ATOM_USE_TRITON_MLA: "0" precisely because 1 is reachable) and nothing raises: the index cache is allocated at 1x page width while the producer writes it with the dcp_size-x replicated layout, scattering keys and scales into neighbouring blocks.

The model-type string is also duplicated between dcp_utils.py:47 and aiter_mla.py:186, so a renamed model_type in a newer HF config silently disables the feature in one place and changes the error in the other.

The gate belongs next to the flag, or asserted where the connector reads it.

Scripts and CI

pd_server_atom.sh:249 — the ${HANDSHAKE_PORT} placeholder ignores the per-invocation $3, and the stride is not PP-aware. side_channel_port_offset is pp_rank*(dp_size*tp_size) + dp_rank*tp_size + tp_rank (port_offset.py:18), so a cpp4 prefill (pp4 x tp1) occupies base+0..3, while decode_handshake_port = HANDSHAKE_PORT + PREFILL_TP_SIZE = base+1 (:1176) and prefill_single_node's per-worker handshake_port = HANDSHAKE_PORT + idx*PREFILL_TP_SIZE (:1197) reserves one port per TP rank. Only the producer binds _side_channel_port (:1276, guarded by if self.is_producer at :1019), so the single-worker case this PR ships happens to survive — but a second prefill worker at pp4/tp1 would bind base+1, colliding with worker 0's pp_rank=1 stage, and the substituted base value would be identical for every worker regardless of $3. This PR introduces the first PP-wide prefill, so both the stride and the placeholder need pp_size.

Related in the same function: apply_role_env's unset sweep (:267) removes names verbatim from env output with no filtering, so a role-scoped name colliding with a script-level variable (PREFILL_KV_TRANSFER_CONFIG at :199 is one) is destroyed and only restored if the common block or the other role redefines it — under set -euo pipefail a later read aborts mid-launch. Launching each role with its env array on the existing start_logged_process ... env ... prefix (:601/:654) removes the global, the unset, and the replay all at once.

process_result.py:31 — CPP_PP_RE is unanchored, name-derived PP outranks the actual CLI flag, and the multiplier is applied to only one side. text (:199-206) joins display_topology + topology + fields['topology'], and (?:cpp|pp)(?P<pp>\d+) matches anywhere — app4, dpp2, a future decode-side ..._pp2. Both EVAL_CONC_RE and EVAL_TOPOLOGY_RE in the same file are anchored, so this is a local inconsistency, not a house style.

num_prefill_gpu = prefill_workers * prefill_tp * prefill_pp is applied unconditionally at :253, so a decode-side pp inflates the PREFILL GPU count and tput_per_gpu — the headline metric of this benchmark — silently drops by that factor with no error. PP_ARG_RE against prefill_extra_server_args is only consulted when the name regex misses, so a wrong name-derived match wins over the authoritative flag. num_decode_gpu (:254) still ignores PP entirely, so the first decode-side PP topology is under-counted by the same mechanism, unmarked.

The structured data already exists upstream: pd_submit.sh:173 emits PREFILL_TP as a first-class field from the same YAML block. Emitting PREFILL_PP / DECODE_PP there deletes both regexes.

models_atomesh.yaml:370 — TOPK_FORCE_PATH: "one" is set in the new suite's common: block (both roles) with no explanatory comment. In aiter csrc/kernels/topk_per_row_kernels.cu:2633 the override makes should_use_mulblocks return false whenever env[0]=='o', bypassing the tuned crossover that selects the mul-block path at seq_len >= 65536..131072 for small batches — exactly the regime of this suite's 1M-token agentic traces at concurrency 32/40/48.

So the published tok/s for the cpp4-dcp4 topology is measured on a deliberately de-tuned top-k, and is not comparable against the neighbouring glm-52 tp4 / tp4-dpa suites, which do not set it. Every other behaviour-changing env var in that block carries a comment; this one does not, so the next reader cannot tell whether it is a workaround for a known crash or a leftover from bisecting. Either way it needs one line saying which.

Tests

Every new kernel and transfer test sits behind a GPU / triton / aiter skip, so CI runs none of them. The two pure-Python startup validators (_replicated_index_cache_unsupported_reasons and the dcp_utils gate) have no tests at all — and they are the parts that need none of that hardware.

Given findings 1 and 2, the test that would have caught the most is a draft-step assertion that token_to_seq_idxs, cu_seqlens_q, and dcp_sparse_kv_indptr_buffer all agree on the same row unit after _enter_decode_metadata. That is pure Python against a fake metadata object.

Smaller, confirmed

  • rebuild_draft_index_slots writes only the .gpu half of the index_slot_mapping CpuGpuBuffer while _prepare_ubatch_decode reads .np — latent until TBO co-occurs with a drafter.
  • index_slot_mapping is an undeclared attribute on AttentionMetaData, used as a presence-based mode switch across a module boundary. token_to_seq_idxs is declared, at forward_context.py:631 — so the convention exists and this one departs from it.
  • index_page_factor is triplicated behind defensive getattr that exists only because the tests build the builder with object.__new__. Fixing the test construction removes all three.
  • plan_replicated_index's docstring says "source pages stay whole" while the function splits each page into two runs.
  • hash_block_size kept its name and its wire key but is now compared against block_size.

Checked and refuted

Recording these so they are not re-raised:

  • The CUDAGraph fill_ is not a no-op-with-consequences. Pad rows are already written -1 by the captured full-width top-k kernel, so the extra fill is redundant, not load-bearing.
  • prepare_prefill's whole-batch zip is not a live bug. The scheduler never mixes prefill and decode in one batch today, so the shape it assumes holds.

Thanks — went through these one at a time. MTP findings are in 6337d9b.

Fixed

MTP 1 — draft token_to_seq_idxs. The target step writes one entry per verify token; a draft step is one row per sequence, so the map must be the identity, and nobody rebuilt it. Added AiterMLAMetadataBuilder.rebuild_draft_token_to_seq_idxs() and call it in the proposer's per-step rewrite. Covers running_bs — padded rows carry no top-k but still index the block table.

MTP 2 — index_share_for_mtp_iteration removed, not fixed. compact_topk_indices read self_attn.mla_attn (the wrapper); the buffer is on .impl, so the copy branch has never executed. It also indexed a flat 1-D buffer as [bs, index_topk], and under DCP the layout is ragged — correct compaction has to repack sparse_kv_indices_buffer, dcp_sparse_kv_indptr_buffer and dcp_owned_counts_buffer together.

None of that showed up because set_skip_topk is inert at level ≥ 2: use_custom_dispatcher (decorators.py:336) replays without Dynamo guards, so the not self.skip_topk branch (deepseek_v2.py:2682) is baked at first trace. The indexer ran every draft step. One frozen branch can't serve step 0 and steps 1+, so the call would have to move behind an opaque op like the PCP path does.

Forcing eager to make sharing real (8k prompts, conc 8): acceptance 0.5254 vs 0.6399, 443 vs 490 requests, for 0.39 ms/forward — 1.6%, and 1.2–2.3% at level 3. At bs=2 a draft step reads rows 0 and 1, so sequence 1 attends through sequence 0's top-k. Not worth repacking three buffers per step. Serving behaviour is unchanged (level 3 already recomputed); the removal drops a real level-0/1 bug.

Finding 11 (filter row guard). _assert_dcp_filter_rows → _check_dcp_filter_rows, bare asserts → ValueError/TypeError. python -O strips asserts and these kernels take raw pointers, so the stripped build puts the OOB launch back, silently. Test added in tests/test_dcp_sparse_filter.py.

pd_server_atom.sh handshake port. ${HANDSHAKE_PORT} now substitutes the launch's port, not the global base — decode reads 6302, not 6301. No number moves (only the producer binds _side_channel_port), fixed because the next role that binds would get it wrong with no error.

process_result.py. CPP_PP_RE anchored like the other two regexes in the file, so app4/dpp2/1p1d_dcp4 stop matching. --pipeline-parallel-size now outranks the topology label. Added symmetric decode_pp into num_decode_gpu (1 today, so no row changes). Dropped the unread "prefill_pp" key. Not doing the pd_submit.sh route: PP lives inside extra_args, and emitting PREFILL_PP/DECODE_PP without teaching the launcher to consume them creates a second source of truth.

index_slot_mapping undeclared. Declared on AttentionMetaData, next to sparse_kv_last_page_lens (same precedent). Call sites keep their getattr checks; the comment now says its presence is what selects the replicated layout.

plan_replicated_index docstring. Dropped the one clause that contradicted the rest of the paragraph.

hash_block_size vs block_size. Behaviour kept, invariant written down. num_computed_blocks counts consumer blocks and the producer converts back with * consumer_dcp_size, so the check has to be physical — the old comparison is what disabled incremental transfer on CPP→DCP. The wire value equals physical only while the producer is dcp=1; the comment says so. No new wire key for a topology that doesn't exist yet.

Not changing

  • Startup validator for the spec-token arithmetic — the guard already turns a shortfall into a typed error at the first forward; a second copy is one more thing to keep in sync.
  • Finding 12, two glm_moe_dsa gates — TritonMLAMetadataBuilder subclasses AiterMLAMetadataBuilder and calls super().init, so the Triton path runs the same gate. The literal is in 12+ places; singling out these two is arbitrary.
  • PP-unaware port stride (:1180, :1201, :1246) — predates this PR, only fires with workers > 1, inert on the single-worker suite. Own change.
  • apply_role_env unset sweep — real, but PREFILL_KV_TRANSFER_CONFIG is read only in start_prefill and no branch calls it after start_decode.
  • .gpu write vs .np read in the ubatch path — the eager path slices index_slot_mapping directly and is correct; only the CUDAGraph TBO path is affected, which needs enable_tbo_decode (default off) plus a drafter plus the replicated layout. Not fixing a default-off path this PR can't exercise.
  • index_page_factor getattr defaults — only needed by a CPU-skipped test file, so the cleanup's effect is unobservable in CI.
  • TOPK_FORCE_PATH: "one" — a workaround, not a bisect leftover: aiter's multi-block persistent top-k deadlocks on batch <= 128 && seq_len >= batch * 2048. See docs/dcp_prefill_topk_persistent_deadlock.md. Your comparability point stands against the tp4 / tp4-dpa suites.

Tests

The transfer tests aren't skipped — tests/test_pd_pp.py runs on CPU (61 cases over plan_sharded, plan_replicated_index, _coalesce, region maps, write_done nonce/dedup), as does tests/test_lmcache_offload_config.py (34). Only the kernel files skip.

replicated_index_cache* and rebuild_draft_token_to_seq_idxs are uncovered for a structural reason, not oversight: they live in aiter_mla.py, which does import triton at module scope, so any test importing them skips on CPU. Covering them means relocating them — the move argued against in finding 12. tests/conftest.py:89-94 documents this trap.

tests/test_mtp_index_share.py deleted with the feature. It was green because it never imported deepseek_mtp — it re-implemented both helpers against a fake whose buffer was 2-D and hung off mla_attn directly, i.e. the two properties production doesn't have.

Full suite (minus tests/plugin): 37 failed / 4883 passed. All pre-existing — clean HEAD in the same image gives 8, and the extra 29 are tests/test_sparsekv_coordinator.py, untracked from another branch. The 3 tests/plugin errors are the image lacking sglang/vllm.

Preserve replicated index-cache decode while adopting the fused sharded DCP top-k path.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copilot AI review requested due to automatic review settings September 2, 2026 07:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new triton_filter_and_convert_dcp_index still relies on assert for required safety invariants, which can be stripped under python -O, reintroducing unsafe kernel launches.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

atom/model_ops/dcp_ops.py:1165

  • This function intentionally adds runtime size checks because Triton kernels take raw pointers and out-of-bounds accesses can be silent, but it still uses assert for several required invariants (dtype/shape, divisibility, rank bounds, and out not None). Those asserts are stripped under python -O, reintroducing the unsafe behavior this helper is trying to prevent.
    assert token_indices.dtype == torch.int32
    assert token_indices.shape[1] == NUM_TOPK_TOKENS
    assert NUM_TOPK_TOKENS % BLOCK_N == 0, (
        f"NUM_TOPK_TOKENS ({NUM_TOPK_TOKENS}) must be divisible by"
        f"BLOCK_N ({BLOCK_N})"
  • Files reviewed: 25/25 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread atom/model_ops/dcp_ops.py
Comment on lines +1143 to +1147
rank keeps a position ``g`` only if ``g % W == dcp_rank`` and maps it to its
physical slot via the round-robin (virtual-block) layout, computed directly
from ``block_table`` (like vLLM):
vbs = block_size * W
slot = block_table[req, g // vbs] * block_size + (g % vbs) // W
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants