Skip to content

[None][feat] MiniMax-M3.1 DSpark: unified target and drafter KV cache under disaggregated serving - #25

Draft
zheyuf wants to merge 15 commits into
brb-nv:user/brb/m3.1-flash-gb300-initialfrom
zheyuf:zheyuf/brb-dspark-unified-disagg-519-v2
Draft

zheyuf wants to merge 15 commits into
brb-nv:user/brb/m3.1-flash-gb300-initialfrom
zheyuf:zheyuf/brb-dspark-unified-disagg-519-v2

Conversation

@zheyuf

@zheyuf zheyuf commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

Description

On GB300, a disaggregated MiniMax-M3.1-Flash + DSpark generation server received the target's prompt KV but not the drafter's. The drafter keeps its KV in a separate pool that the transceiver does not move, so every generation request drafted from an empty window. Acceptance length (AL) collapsed on long prompts: in 1P1D golden mode, the 1K-12K ladder fell to 1.59 (2.42 aggregated) and 2-16K code to 1.43 (4.40). The drafter-window transfer on this branch (F0a, disaggregation/draft_kv.py) is enabled only for the sm_107 models.

This PR keeps the DSpark drafter's KV in the target's own KVCacheManagerV2, as explicit draft layer specs with a lifecycle of their own (BF16, 4096-token window, 128-token pages). The existing transceiver then moves the drafter's prompt window with the target's pages, and the generation-side drafter starts at the prompt end. Each run uses one mechanism:

This PR includes a C++ change (commit 14, cpp/tensorrt_llm/batch_manager/kv_cache_manager_v2/kvCache.cpp): rebuild the wheel. Updating only the Python package of an installed wheel is not enough.

Nothing to enable: ctx and gen servers with the same DSpark config use the unified layout. TRTLLM_DISAGG_SHARED_DRAFT_KV=0 keeps the separate pool, and an explicit TRTLLM_DISAGG_SHARED_DRAFT_KV=1 makes a worker that cannot share fail at startup.

Commits

  1. Port of [None][feat] Unified kv cache manager foundation NVIDIA/TensorRT-LLM#20178 (main fce28f0, author Allison Lim): unified KV cache manager foundation.
    • The cpp/ part is not included: BufferConfig.coalescing_group and attentionOp's 3-D pool-pointer table. M3.1 needs neither.
    • _coalescing_group_kwargs passes a coalescing group only when it is nonzero, so the existing bindings accept layer specs that coalesce by size.
    • Nonzero groups are needed only for the base manager's NVFP4 layer specs, which keep data and scales apart. On these bindings they raise NotImplementedError, and their 2 tests skip on bindings without coalescing_group.
    • Hunks for main-only features (mixed-precision KV, pool rebalancing) are dropped.
  2. Port of [TRTLLM-16722][feat] Dflash Unified KVCache NVIDIA/TensorRT-LLM#20486 (net diff at 219e26e, author Allison Lim): unified DFlash KV cache. It is merged with this branch's separate-pool paths:
    • F0a's RankInfo fields;
    • the drafter slot parking and seeds in DFlashSpecMetadata.prepare (the position seeding applies to the unified cache only);
    • the fused block-table refresh (the stock path runs when the drafter's layer sits at a nonzero offset).
  3. Port of [None][feat] Per View KV head transfer NVIDIA/TensorRT-LLM#20709 (net diff at 300a671, author Allison Lim): per-view KV-head transfer.
  4. Bound DFlash's shared context by the allocation's extent. A sliding-window draft layer's released pages come before its live tail.
  5. Re-seed DFlash generation slots in CUDA-graph warmup.
  6. MiniMax-M3.1 DSpark: keep the drafter's KV in the target's manager under disagg.
    • MiniMaxM3KVCacheManagerV2 hosts explicit draft layer specs:
      • index-K buffers follow the spec-built ones;
      • dense target layers stay FP8;
      • size-only coalescing keeps the target pools unchanged;
      • draft layers use the base view;
      • the attention-op pools stay on, because the drafter reads their block offsets.
    • KvCacheCreator unifies a standalone DSpark drafter with such a manager under disaggregated serving when the drafter uses the target's page size. Aggregated serving keeps the separate pool, and so do models that ship the drafter's window themselves (F0a).
    • An NVFP4 target can transfer with a BF16 draft held in pools of its own.
    • Capacity: the draft group's share is sized by its window: every admissible sequence's 4096-token window plus one context chunk. The default bytes-per-token split would give it a quarter of the KV cache.
  7. Reuse a unified DSpark drafter's KV only where its window is complete. A windowed drafter's layers are OPTIONAL. A request that branches off a cached shared prefix reuses the prefix and recomputes only the drafter's window. Without this commit it re-prefills the whole prompt.
  8. MiniMax-M3.1 DSpark: copy only the unified drafter's block offsets per step.
    • A hybrid NVFP4 MiniMax-M3 cache is pool-less: no target layer reads its attention-op block offsets.
    • With explicit draft layers, commit 6 kept every layer's pool. Each step then converted the whole per-layer table (layers x sequence slots x max_seq_len / tokens_per_block): about 3 ms per decode step at max_seq_len 1M.
    • Now only the first draft layer's pool is kept, filled from the draft lifecycle's rows with the copy the separate pool uses.
  9. Allocate the whole block of a drafter that shares the target's KV cache (adapted from [None][feat] DSpark Unified KVCache standalone NVIDIA/TensorRT-LLM#20664, 76c4cef).
    • The drafter appends its block after the request's context, through the request's own pages. Requests were sized with num_extra_kv_tokens = K-1 = 6 beyond their history, but the DSpark block is K = 7 wide.
    • When an allocation ended on a page boundary (at the prompt end, or after a step that accepted all K+1 tokens), the block's last slot had no page.
    • With draft-owner layers in the manager, every allocation site, the generation headroom and the cost estimate now use max(num_extra_kv_tokens, block width). The width is the checkpoint's (shift_label).
  10. Fail at startup when TRTLLM_DISAGG_SHARED_DRAFT_KV=1 cannot be honored ([None][feat] DSpark Unified KVCache standalone NVIDIA/TensorRT-LLM#20664's _check_shared_draft_kv_request).
    • Before, an explicit request on a worker that cannot share silently kept the separate pool, whose KV is not transferred.
    • disaggregated_mpi_worker now writes "auto" for its automatic choice, so a launcher run whose finer per-worker checks refuse still falls back. The unset default is unchanged.
  11. Let disaggregated_mpi_worker share a DSpark drafter's KV. The launcher wrote "0" for any drafter other than DFlash and for pages other than 16/32/64, so an M3.1 DSpark deployment started through it (128-token pages) kept the separate pool.
    • It now accepts a standalone DSpark drafter with attention_backend unset, AUTO or TRTLLM, as [None][feat] DSpark Unified KVCache standalone NVIDIA/TensorRT-LLM#20664 does.
    • It leaves the page size to each worker's KvCacheCreator. If ctx and gen still disagree, peer registration fails loudly on the draft-layer count.
    • An explicit TRTLLM_DISAGG_SHARED_DRAFT_KV=0 is kept.
  12. Keep attention-DP dummy generation rows on DFlash's dummy slot (as [None][feat] DSpark Unified KVCache standalone NVIDIA/TensorRT-LLM#20664). A generation row with the attention-DP dummy id must never inherit a real slot, because the C++ offset copy maps placeholders to page 0.
  13. Size a unified drafter's context stores on the host. DFlash bounds each context store by the row's allocation, which it read back from the device unless the drafter's rows were known to be copied in full. With the unified drafter, every context chunk therefore waited for the target's forward. The manager now says whether it rewrites copied rows in full, and the M3 drafter-only pool does.
  14. KVCacheManagerV2: reuse an OPTIONAL group at a partial endpoint (C++, kvCache.cpp).
    • The C++ core checked an OPTIONAL lifecycle group only at a page-aligned endpoint, so a partial prefix match always left the unified drafter's group incomplete. The caller then cut the drafter's claim back to the last full page, and each request recomputed up to a page.
    • An OPTIONAL group is now checked at any endpoint with the REQUIRED coverage rule: the last page needs only the matched prefix. The first resume copies a selected group's partial page into a private page, as REQUIRED groups do, and counts it as a partial reuse.
    • Partial snapshots publish OPTIONAL pages too, so a continuation of a prompt that ended mid-page finds them.
    • A match whose partial page is missing still falls back to the last full page.

Commits 9 and 12 only guard the drafter's K/V, so they protect acceptance length. Speculative decoding stays lossless with or without them: a wrong draft is rejected, so outputs do not change.

Results

Setup:

  • GB300, MiniMax-M3.1-Flash NVFP4 + DSpark K7.
  • Up to commit 13: wheel built from 519d279 (sha256 3d050d7f35ee7eab4fe0e1fa9b685bd075a08b8b126b7a63f36b19288941c29a), with this PR's Python on top.
  • Rows labeled "this PR" with commit 14: wheel built from commit 14 (sha256 e99637ea8cbde9f25b5665171a502e6bd8bf9122174ac6906859fab5ea84625f). A later amendment of commit 14 changed only a unit test, which the wheel does not package.
  • AL comes from the generation server's counters.
  • Passes:
    • SA gate replay: 80 SPEED-Bench coding prompts, c64, T=1, top_p 0.95, 4096 output tokens;
    • ladder: 64 prompts of 1K-12K tokens, 2048 output tokens;
    • code: 32 prompts of 2.2K / 8.2K / 16.4K tokens.
  • 0 client errors and 0 bad server lines in every run.

Serving performance against the branch base 519d279

Measured at commit 13. Production recipe: max_seq_len 1M, 128-token pages, block reuse on, CUDA graphs, overlap scheduler. Prompts are unique after the chat template, so block reuse covers only the template. Commit 14 changes only that reuse, on the context side (see "Context side with block reuse" below); the generation-side numbers here do not depend on it.

  • Base and this PR ran in the same job with the same frozen autotuner tactics. The MXFP8 decode GEMM backend was not pinned; it moves the step by about ±0.7 ms between server starts at c44, and each side averages 2 runs.
  • Ratios are this PR / base with 95% CI.
  • "SA": the 80 SPEED-Bench coding prompts, 160 requests, up to 4096 output tokens, natural EOS.
  • "8K": 8K-token prompts, which exceed the drafter's 4096-token window; 96-176 requests, 1024 output tokens with ignore_eos.

Natural AL (default sampling):

servers prompts concurrency throughput TPOT p50 AL (this PR / base) generation device step
1P1D TP2 SA 16 / 44 / 88 1.03 / 1.03 / 1.01 0.97 / 0.95 / 0.96 3.97 / 3.83-3.88 1.000 / 1.006 / 1.004
1P1D TP2 8K 16 / 44 / 88 2.62 / 2.71 / 2.58 0.31 / 0.29 / 0.27 3.47-3.50 / 1.13-1.23 1.018 / 1.011 / 1.015
TEP4 -> TEP4 SA 16 / 44 / 88 1.02 / 1.04 / 1.02 0.97 / 0.96 / 0.96 3.93-3.96 / 3.85-3.88 0.999 / 1.004 / 0.996
TEP4 -> TEP4 8K 16 / 44 / 88 2.45 / 2.70 / 2.31 0.31 / 0.28 / 0.29 3.45-3.51 / 1.12-1.22 1.013 / 1.005 / 1.013
  • On 8K prompts the base's generation-side drafter starts with an empty window and drafts badly; this PR moves the window with the target's pages.
  • The device step on 8K prompts is 0.5-1.8% longer. On SA prompts it is unchanged.
  • TTFT p50 on 8K prompts is 0.47-0.59x at c16, 0.74x (1P1D) and 1.40x (TEP4) at c44, and 1.7x at c88. The client is closed-loop: generation finishes 2.3-2.7x faster, so the same context server receives prompts that much more often and queues them.

Forced AL 4 (TLLM_SPEC_DECODE_FORCE_NUM_ACCEPTED_TOKENS=3), generation device step on SA prompts:

servers B1 B16 B44 B88
1P1D TP2 0.981 0.992 1.003 1.002
TEP4 -> TEP4 0.944 1.009 1.005 1.002
  • On 8K prompts the forced step is 1.01-1.14x, but the two sides do not compute the same thing. nsys over 20 steps at B16 (1P1D, kernel time per step, rank 0):

    prompts base this PR this PR, separate pool (=0)
    SA 25.65 ms (MoE 14.03) 25.63 ms (MoE 14.29) 25.37 ms (MoE 13.92)
    8K 24.34 ms (MoE 10.29) 28.31 ms (MoE 14.18) 23.60 ms (MoE 9.55)
    • Attention differs by 0.06 ms. The difference is MoE: with a separate pool, the generation drafter has no prompt context on 8K prompts, its drafts degenerate, and the verified tokens route to fewer experts. On 8K prompts MoE is 3.7 ms (base) and 4.4 ms (separate pool) cheaper than on SA at the same batch; this PR's matches the SA level.
    • So the forced 8K cells measure the base's degenerate drafts. The natural 8K step (+0.5-1.8%) is the real cost.

Agentic multi-turn. 44 conversations x 6 turns of an agentic coding trace (264 requests, 14.4M prompt tokens), c44, 1P1D TP2, production recipe with block reuse, MXFP8 GEMM backend pinned and autotuner tactics frozen on both sides:

base commit 13 this PR
requests / errors 264 / 0 264 / 0 264 / 0
prefilled tokens (of 14.39M prompt tokens) 1,852,336 1,852,336 1,852,336
natural AL 1.23 3.15 3.15
output tokens/s per GPU 271 571 568
tokens/s per user, p50 28.1 74.2 75.3
TTFT p50 / p90 / p99 1.00 / 4.42 / 7.62 s 1.25 / 4.39 / 7.41 s 1.30 / 4.47 / 7.64 s
later-turn TTFT p50 / p90 0.89 / 1.49 s 1.12 / 1.75 s 1.22 / 1.67 s
wall time 93.5 s 44.4 s 44.6 s
  • Both sides prefill the same tokens: 87% of the prompt tokens come from block reuse.
  • TTFT p50 rises because the context server is busier, not because each prompt costs more. Both sides spend the same GPU time in context forwards over the trace (37.5 s base, 37.4 s this PR). This PR finishes in 41.6 s instead of 81.8 s, so at the same 44 concurrency prompts arrive 2x as often, and the context server is busy 90% of the time instead of 46%. Requests then wait longer before their first context step (median 0.26 vs 0.14 s) and share bigger batches (275 vs 233 ms per forward). The p90 and p99 are unchanged.

Context side with block reuse (commit 14). 1P1D TP2, SA prompts that share a 187-token template, MXFP8 GEMM backend pinned, frozen tactics. Context tokens and TTFT at the context server:

base commit 13 this PR
c1: context tokens of 8 requests / TTFT median 8 / 37.9 ms 547 / 40.7 ms 8 / 39.7 ms
c16: context tokens of 32 requests / TTFT median 6,854 / 161.6 ms 8,778 / 167.9 ms 6,854 / 163.1 ms
c16: GPU forward per request, median 74.9 ms 114.6 ms 74.8 ms
  • At commit 13 a prefix match that ends inside a page is rounded down to the page for the unified drafter, so each request re-prefills the template's 59-token partial page. A 15-request wave then crosses the recipe's largest prefill CUDA-graph size (3,072 tokens) and runs eager.
  • Commit 14 restores the partial page: the context tokens equal the base's.
  • Base and this PR ran on the same node in the same hour; commit 13 ran on a second node. Three base runs spread 37.9-40.1 ms at c1 and 160.4-168.0 ms at c16.

TTFT at c1 after long requests. 1P1D TP2, 6 sequential requests with unique prompts and 4096 output tokens at forced AL 4, MXFP8 GEMM backend pinned, frozen tactics, measured at commit 13 (commit 14 does not touch this path):

client TTFT p50 base this PR
enable_iter_perf_stats: true (production recipe) 65.2 ms 77.8 ms
enable_iter_perf_stats: false 55.4 ms 54.5 ms
  • When a request completes, trtllm-serve drains the iteration stats. The worker serializes one record per step with the GIL held, about 1,000 records after a 4096-token request, and a c1 request that reaches the idle generation server during the drain waits for it.
  • This PR's records are about 45% bigger (5.0 vs 3.5 KB median), because they carry the draft group's window, pool group and life cycle. Arrival to KV-transfer start is 13-23 ms instead of 5-9 ms; with the stats off it is 2-3 ms on both sides.
  • The cost belongs to the stats path and shows only at low concurrency right after long requests.

Commits 8-13 against commit 7

Unless a table says otherwise, the results further below were measured at commit 7 with max_seq_len 32K. This part compares commits 8-13 with commit 7.

Production shape, max_seq_len 1M. Generation device step at a forced AL of 4 (TLLM_SPEC_DECODE_FORCE_NUM_ACCEPTED_TOKENS=3); median prev_device_step_time of steady generation-only iterations from print_iter_log. "Unified" is the default; "separate" sets TRTLLM_DISAGG_SHARED_DRAFT_KV=0 on the same build. Batches B1 and B44 run as waves of exactly 1 and 44 requests, with ignore_eos and 1024 output tokens. 0 errors.

gen server batch commit 7: unified / separate commits 8-12: unified / separate commits 8-12: unified / separate, ratio
TEP4 (TEP4 -> TEP4) B1 10.19 / 8.25 ms (1.235) 8.32 / 8.44 ms 0.986
TEP4 (TEP4 -> TEP4) B44 27.99 / 25.68 ms (1.090) 25.47 / 26.18 ms 0.973
TP2 (1P1D) B1 11.72 / 9.69 ms (1.209) 9.46 / 9.71 ms 0.974
TP2 (1P1D) B44 37.94 / 35.09 ms (1.081) 34.66 / 34.44 ms 1.006
  • Before commit 8, every step converted the 65-layer page table: +1.7-2.3 ms per step (+18-24% at B1, +7-9% at B44).
  • Each ratio divides the means of two same-node pairs run in opposite orders. Separate-pool repeats on one node vary by about ±3% at B1 and ±1% at B44.
  • Capacity at 1M on the 1P1D generation rank: unified 120.29 GiB for the target plus the 2.75 GiB window-sized draft group, against 106.95 GiB plus the 13.32 GiB separate pool. That is +9.9% for the target.

Context side at max_seq_len 1M (commit 13). 1P1D TP2 context server, unified vs separate, same build, forced AL 4. TTFT and prefill (scheduled -> first token) come from the context server's perf metrics; the host step comes from its iteration log.

concurrency metric (median) unified separate unified / separate
c1 TTFT at the context server 95.3 ms 95.9 ms 0.99
c1 prefill (scheduled -> first token) 93.7 ms 94.1 ms 1.00
c16 TTFT at the context server 240.0 ms 265.4 ms 0.90
c16 prefill 148.2 ms 169.3 ms 0.88
c16 context-iteration host step 149.6 ms 170.7 ms 0.88

Two rounds with the nodes swapped, block reuse off, max_seq_len 1M. On the c1 rows the host step is not used, because sporadic iterations include idle time.

AL at 32K. Golden mode, paired per prompt, 95% CI, against commit 7 on the same nodes (last row: against commit 13):

shape build SA gate replay ladder code
TEP4 -> TEP4 commits 9-10, same job 1.002 [0.983, 1.023] 0.994 [0.967, 1.021] 1.001 [0.952, 1.049]
1P1D TP2 commits 9-10, same job, explicit TRTLLM_DISAGG_SHARED_DRAFT_KV=1 1.020 [0.983, 1.062] 0.990 [0.966, 1.017] 1.020 [0.975, 1.065]
TEP4 -> TEP4 commits 8-12 0.988 [0.969, 1.008] 1.008 [0.981, 1.035] not run
TEP4 -> DEP4 (attention-DP generation) commits 8-12 1.009 [0.984, 1.034] vs aggregated DEP4 0.997 [0.973, 1.022] vs aggregated DEP4 not run
TEP4 -> TEP4, block reuse on, MXFP8 GEMM backend pinned, frozen tactics commit 14, same job 0.993 [0.965, 1.024] (4.013 vs 4.039) not run not run
  • Commits 9-10 leave the KV budget unchanged: the creator's KV cost per token and fixed cost are identical, and 1P1D blocks per rank are identical (gen 38,957, ctx 36,613, draft group 4,505).
  • Aggregated TP4 golden SA at commits 9-10 is 4.028, against 4.010 at commit 7. The log shows the separate draft pool.
  • A 10-minute soak at commits 9-10 (TEP4 -> TEP4 golden, block reuse on): 2,400 requests, 0 errors, AL 3.39-3.46 per stress pass, SA 4.044 before and 4.049 after.

Commits 1-7

1P1D, ctx TP2/EP2 -> gen TP2/EP2, NIXL + Python transceiver, at commit 7.

  • "Before" = the same build with TRTLLM_DISAGG_SHARED_DRAFT_KV=0.
  • Aggregated = TP4/EP4 on one node.

Golden (rejection sampling + block verification):

before this PR aggregated PR / agg
SA gate replay 3.977 4.088 4.010 1.019
ladder 1.587 2.445 2.418 1.011
code 1.425 4.634 4.400 1.053

Default (rejection sampling off):

this PR aggregated PR / agg
SA gate replay 3.568 3.571 0.999
ladder 2.123 2.151 0.987
code 3.707 3.862 0.960

A second sample at commit 6 gave 4.051 / 2.465 / 4.363 golden and 3.680 / 2.150 / 3.737 default. The 32-prompt code pass varies by about ±4%.

TEP4 -> TEP4 (two nodes), golden, at commit 7 on the same wheel:

before this PR aggregated PR / agg
SA gate replay 3.942 4.014 4.010 1.001
ladder 1.566 2.431 2.418 1.005
code 1.443 4.528 4.400 1.029

A second, streamed ladder gives 2.475. Per output position, its tokens per step match aggregated serving, with no deficit in the first tokens: 2.87 vs 2.88-2.92 at outputs 1-31, and 2.35 vs 2.32-2.36 at outputs 1024-2047.

Soak at commit 7 (TEP4 -> TEP4 golden, block reuse on at ctx and gen): 4,640 requests in 21 minutes (SA, 14 x 320 mixed stress, SA).

  • 0 errors, and every transfer completed.
  • AL stays flat: 3.38-3.45 per stress pass, and SA 3.947 before vs 3.965 after.
  • The draft group peaked at 1,564 of 4,505 blocks.

Topology matrix. 17 arms, 0 errors and 0 bad server lines. "Commits 1-6" rows ran on the previous base 20a1a00.

  • Ratio = disagg / aggregated, paired per prompt, with 95% CI.
  • The aggregated references (TP4/EP4 for TEP4 generation, DEP4 for DEP4 generation) ran on the previous base. The TEP4 table above divides by the same-wheel aggregated run instead.
shape SA gate replay ladder code
TEP4 -> TEP4 golden, commit 7 0.996 [0.981, 1.012] 0.993 [0.976, 1.011]; streamed 1.011 1.028 [0.996, 1.059]
TEP4 -> TEP4 golden, commit 7, separate pool (before) 0.978 0.640 0.328
TEP4 -> TEP4 golden / default, commits 1-6 1.004 / 1.011 0.979 / 1.002 1.001 / 1.017
DEP4 -> TEP4 golden / default, commits 1-6 0.982 / 0.988 0.979 / 1.008 0.997 / 0.999
DEP4 -> TEP4 golden, separate pool (before) 0.975 0.638 0.319
TEP4 -> DEP4 golden / default, commits 1-6 1.000 / 1.027 1.001 / 0.995 not run
2 x DEP2 -> DEP4 golden, commits 1-6 1.019 1.001 not run
2 x TEP2 -> TEP4 golden (TP mismatch), commits 1-6 0.991 0.986 not run

The 0.979 golden ladder of commits 1-6 is sampling noise: commit 7 gives 0.993, and 1.011 when streamed.

Generation step time at a forced AL of 4: unified / separate pool is 1.003 and 1.005 at batch 16, and 1.013 and 0.968 at batch 48-64, over two rounds. That is within run-to-run noise.

KV capacity per rank (free-memory fraction 0.85 on gen and 0.80 on ctx servers, max batch 64, 128-token blocks):

server before: target / separate draft pool this PR: target / draft group (peak use)
1P1D gen TP2 35,646 blocks / 13.32 GiB 39,196 blocks (+10.0%) / 4,505 blocks = 2.75 GiB (1,998)
1P1D ctx TP2 33,291 blocks / 13.32 GiB 36,841 blocks (+10.7%) / 4,505 blocks (241)
TEP4 gen 83,319 blocks / 6.66 GiB 85,742 blocks (+2.9%) / 4,505 blocks = 1.375 GiB (1,994)
TEP4 ctx 78,199 blocks / 6.66 GiB 80,657 blocks (+3.1%) / 4,505 blocks
DEP4 ctx (commits 1-6) 26,948 blocks / 13.32 GiB 29,030 blocks (+7.7%) / 2,304 blocks = 2.81 GiB

Transfer. The drafter's window adds bytes per prompt token, and transfer time does not grow:

KB per prompt token, SA / ladder / code median transfer, SA / ladder / code
1P1D, before 57.8 / 50.0 / 50.3 12.3 / 4.9 / 5.7 ms
1P1D, this PR 69.6 / 56.6 / 54.7 (+20% / +13% / +9%) 9.5 / 5.4 / 5.6 ms
TEP4 -> TEP4, before 75.6 / 65.4 / 65.8 3.9 / 6.0 / 3.4 ms
TEP4 -> TEP4, this PR 87.4 / 72.0 / 70.2 (+16% / +10% / +7%) 3.05 / 6.3 / 3.3 ms

DEP4 -> TEP4 re-shards the KV. Its median transfer is 70-136 / 402-449 / 555-693 ms, against 100 / 539 / 839 ms before.

Block reuse (commit 7). 1P1D TP2, block reuse on at ctx and gen.

  • Arms: "with" = commit 7; "without" = commits 1-6; "off" = reuse off.
  • Passes:
    • a greedy hit set at c1: repeats, two-turn, and a 16K shared prefix with a short (16.8K) or long (23K) new tail;
    • the MLPerf agentic v6 replay: 531 requests at c48, temperature 1.
without, per_conversation with, per_conversation without, all_reusable with, all_reusable off
16K prefix + short tail: cached tokens per hit 128 12,416 16,571 16,512 0
16K prefix + long tail: cached tokens per hit 2,868 16,570 16,570 16,570 0
agentic: prefilled tokens 0.87M 0.60M 0.53M 0.56M 14.2M
agentic: AL 3.269 3.252 3.184 3.253 3.227
client errors / bad server lines 0 / 0 0 / 0 0 / 0 0 / 0 0 / 0
  • Under per_conversation, the short-tail branch recomputes one drafter window plus its tail and publishes the window at the branch point. Without commit 7 it re-prefills the whole prompt.
  • Repeats and two-turn hits reuse in every arm. With commit 7 alone a hit was at most one page shorter, because the window completed only at a page boundary; commit 14 restores the partial page.
  • AL varies by about 1.5% between runs of the same code; every arm is within that noise.

Relation to NVIDIA#20664

NVIDIA#20664 (DSpark Unified KVCache standalone, Allison Lim, head 4cd7c93) is the upstream form of this work for plain KVCacheManagerV2 targets. It is stacked on NVIDIA#20486.

  • Shared base: commits 1-3 port its prerequisites ([None][feat] Unified kv cache manager foundation NVIDIA/TensorRT-LLM#20178, [TRTLLM-16722][feat] Dflash Unified KVCache NVIDIA/TensorRT-LLM#20486 at 219e26e, [None][feat] Per View KV head transfer NVIDIA/TensorRT-LLM#20709).
  • Taken from it, each as a separate commit here:
  • Why it does not serve M3.1 today:
    • _dflash_unify_blocker refuses specialized managers ("the target does not use KVCacheManagerV2"), and M3.1 uses MiniMaxM3KVCacheManagerV2.
    • Its peer registration refuses any TP or attention-DP mismatch once draft layers are shared. We validated DEP4 -> TEP4, TEP4 -> DEP4, 2 x DEP2 -> DEP4 and 2 x TEP2 -> TEP4 at AL parity; [None][feat] Per View KV head transfer NVIDIA/TensorRT-LLM#20709's per-view head check guards the transfer.
    • Sharing under disaggregated serving is off by default: every worker needs TRTLLM_DISAGG_SHARED_DRAFT_KV.
    • BowenFu's required page-size fallback is still open. As a fixed tokens_per_block in (16, 32, 64) it would refuse M3.1's 128-token pages; this PR's condition is that the drafter's page equals the target's.
    • It does not have commits 4 and 5. Its _check_block_within_allocation turns both bugs into a device assert: past one window with a sliding-window drafter on per-layer page tables, and at server start in CUDA-graph warmup.
  • M3.1-specific, proposed as follow-ups on main once [None][feat] DSpark Unified KVCache standalone NVIDIA/TensorRT-LLM#20664 lands:
    • commit 6: the M3 manager opt-in, window-sized draft capacity, an NVFP4 target with a BF16 draft in its own pools, and default-on for such managers;
    • commit 7: reuse rules for an OPTIONAL draft lifecycle;
    • commits 8 and 13: one attention-op pool for the drafter, and host-sized context stores for it.
    • commit 14: an OPTIONAL lifecycle complete at a partial page (C++), which any OPTIONAL draft group on main needs for partial-page reuse.

Not covered

Test Coverage

  • New tests:

    • tests/unittest/_torch/attention/sparse/msa/test_minimax_m3_unified_draft_kv.py:
      • GPU: the hybrid NVFP4 manager with draft specs; target pools are unchanged and the draft share is window-sized.
      • CPU: the unify gate (disagg only, opt-out, page size, F0a models), the NVFP4 target + BF16 draft layout check, and the share arithmetic.
    • tests/unittest/_torch/executor/kv_cache/test_unified_draft_reuse.py: the reuse rules on a real unified manager.
    • Commits 8-13, one focused test each:
      • commit 8: test_minimax_m3_unified_draft_kv.py checks the single drafter pool and its rows;
      • commit 9: test_kvcm2_integration.py::test_shared_draft_pages_cover_the_drafter_block, with the allocation ending on a page edge at the prompt end and after a full-acceptance step. It fails at commit 7;
      • commit 10: test_dflash_unified_kv_gate.py::test_explicit_shared_draft_kv_request_fails_fast;
      • commit 11: test_dflash_unified_kv_gate.py::test_launcher_lets_dspark_workers_decide_on_128_token_pages;
      • commit 12: test_dflash_dummy_slot.py::test_attention_dp_dummy_generation_row_takes_the_dummy_slot;
      • commit 13: test_dflash_host_ctx_alloc.py and test_minimax_m3_unified_draft_kv.py.
      • commit 14: C++ ReplayCacheTest.OptionalWindowClaimsAPartialEndpoint in kvCacheManagerV2StatsTest.cpp, and test_unified_draft_reuse.py::test_a_partial_match_reuses_the_drafters_partial_page. In test_a_branch_point_window_is_recomputed_once_and_then_shared, the third request now claims all 43 shared tokens, because the second publishes the drafter's partial page.
    • test_disagg_receive_ordering.py stubs the new _kv_alloc_extra_tokens on its Mock(spec=KVCacheManagerV2) fixture (one line).
    • Existing CI lists already collect both: unittest/_torch/attention in l0_b200.yml, and unittest/_torch/executor in l0_gb300_multi_gpus.yml and l0_dgx_b300.yml.
  • Ported upstream tests, with main-only cases adapted: test_dflash_unified_kv_gate.py, test_kvcm2_integration.py, test_kv_cache_estimation.py, test_peer.py, test_rank_info.py, test_extractor.py, test_bounce.py.

  • Unit runs on GB300, base 519d279 vs this PR on the same wheel. They cover CI's unittest/_torch/executor, unittest/disaggregated and unittest/_torch/speculative/hw_agnostic, plus the touched attention / MSA files.

    • No failure is new: every failure on this PR also fails on the base, with the same test ID.
    • The 2 extra skips are the conditional mixed NVFP4/BF16 pool tests.
    group base 519d279 this PR
    executor 4,087 passed, 119 failed, 5 errors, 1 skipped 4,156 passed, 119 failed, 5 errors, 3 skipped
    disaggregated 1,688 passed, 651 failed, 4 errors 1,706 passed, 651 failed, 4 errors
    speculative/hw_agnostic 1,002 passed, 44 failed 1,002 passed, 44 failed
    attention / MSA files 305 passed, 6 failed, 21 skipped 309 passed, 6 failed, 21 skipped

    Not run to completion on either side:

    • test_hang_detector_kill.py SIGKILLs its process group.
    • test_py_cache_transceiver_mp.py's MPI cases end the job step on this cluster. Its first 4 cases fail on both sides.
    • In the 3 test_transfer_lifecycle_* files, the cases that ran fail identically on both sides, at several minutes each. The rest were not waited for.
    • test_world_ranks_converge_on_hostless_fallback waits for an MPI world.
  • Commits 8-13 against commit 7, on the same wheel. No failure ID is new in any of these runs:

    • executor: the full suite at commits 9-10; at the head, the kv_cache files and test_disagg_receive_ordering.py;
    • disaggregated: at commits 9-10, outside the files above;
    • hw_agnostic and the attention / MSA files: at the head.
    • The head's new tests fail at commit 7 as intended: 15 failures there.
    • test_cancelled_admission_releases_event fails at commit 7 and at the head.
  • Commit 14 on its own wheel:

    • All 13 C++ gtest binaries for kvCacheManagerV2* and radixBlockTree pass (199 tests).
    • The KV cache manager V2 binding suite gives 355 passed and 12 skipped.
    • unittest/_torch/executor/kv_cache ran before a test-only amendment of commit 14. Every failure also fails on the base, with two exceptions: test_world_ranks_converge_on_hostless_fallback, which needs an MPI world (see above), and the branch-point reuse test, whose expectation the amendment updates.
    • At the head, test_unified_draft_reuse.py and test_paired_draft_reuse.py pass (17 tests).
  • Release Checks, at commit 7 for the first three points:

    • Every pre-commit hook passes on the 32 changed files, and the hooks rewrite nothing.
    • release_check.py's bandit -r tensorrt_llm reports only the base's 10 existing issues, none in a file this PR touches.
    • bandit on the 30 changed Python files flags no line this PR adds. It reports 3 findings, all on unchanged lines of test_disaggregated_serving.py.
    • The 13 files of commits 8-13 are clean under ruff 0.9.4 (check and format), yapf 0.43.0, isort, autoflake, codespell, and the whitespace and end-of-file hooks.
    • Commit 14's Python files are clean under ruff 0.9.4 (check and format), and its C++ files under clang-format 16.0.0.

PR Checklist

  • Please check this after reviewing the above items as appropriate for this PR.

🤖 Generated with Claude Code

allisonlim-nv and others added 14 commits October 28, 2026 01:58
Signed-off-by: Allison Lim <allim@nvidia.com>
(cherry picked from commit fce28f0)

Backport to this branch: the cpp/ part (BufferConfig.coalescing_group,
the attentionOp 3-D pool-pointer table) is not included;
_coalescing_group_kwargs passes the group only when it is nonzero, so
layer specs without NVFP4 buffers run on the existing bindings, and the
two mixed NVFP4/BF16 pool tests skip on bindings without
coalescing_group. Hunks for main-only features (mixed-precision KV,
pool rebalancing) are dropped, and layer_specs is declared on the class
for managers built without __init__.

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>
Port of NVIDIA#20486 at 219e26e (net diff against the
main it merges, df95eda).

Signed-off-by: Allison Lim <allim@nvidia.com>

Backport to this branch: conflicts resolved against the separate draft
pool's disagg window transfer (RankInfo keeps both draft fields), the
drafter slot parking and seeds in DFlashSpecMetadata.prepare (the
position seeding applies to the unified cache only), and the fused
block-table refresh (skipped when the layer has a block offset). Hunks
for code this branch does not have (spec_tail_kernels, model-level
layer specs) are dropped; Gemma4 hybrid geometry stands in for
_has_variable_kv_cache_geometry.

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>
Port of NVIDIA#20709 at 300a671 (net diff against the
main it merges, 0746a23).

Signed-off-by: Allison Lim <allim@nvidia.com>

Backport to this branch: import and registration-check conflicts
resolved by keeping both sides.

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>
DFlash took the number of valid block-table entries as the pages a request
holds. On a unified cache with per-layer page tables (e.g. an FP8 target
with a BF16 drafter), a sliding-window draft layer's evicted pages stay
BAD_PAGE_INDEX in front of its live tail, so the count stopped at about one
window (66 blocks for window 2048, page 32) whatever the context length. The
decode-time allocation check then trapped once a context passed ~2.1K
tokens, and prefill skipped the drafter's context store for later chunks and
for prefix hits past the window.

Use the extent of the allocation instead, the last valid entry + 1. Reads
below the window are never issued and writes land in the live tail; for a
full-attention drafter, and on the cpp backend, the extent equals the count.

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>

(cherry picked from commit ff11364)
CUDA-graph warmup builds every batch from dummy requests whose ids repeat
across batches (0..bs-1), so a reused id keeps its drafter slot and the
previous batch's context length, which the replay after each capture
advances for good. A later batch can give the same id far fewer pages, and
on a unified KV cache the drafter's allocation check then trapped at server
start: during the advanced-sampling capture pass the filler rows reached
31..79 positions against a 24-position allocation.

Only warmup runs a CUDA-graph batch eagerly, so there re-seed each
generation row's slot from this batch's positions, as prepare() already
seeds a new request.

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>

(cherry picked from commit 2b23449)
…'s manager under disagg

On GB300 a disaggregated generation worker gets the target's prompt KV but
not the DSpark drafter's, which lives in a separate pool: the drafter
starts from an empty window at block 0 and acceptance collapses on long
prompts. Hosting the draft layers in the target's KVCacheManagerV2 lets
the transceiver move them with the target's pages.

- MiniMaxM3KVCacheManagerV2 hosts explicit draft layer specs: index-K
  buffers follow the spec-built ones, dense target layers stay FP8,
  size-only coalescing keeps the target pools, draft layers use the base
  view.
- KvCacheCreator unifies a standalone DSpark drafter with such a manager
  under disaggregated serving when the drafter reads the target's page
  size. Agg keeps the separate pool paired with block reuse, and a model
  that ships that pool's window (F0a) keeps it in disagg.
  TRTLLM_DISAGG_SHARED_DRAFT_KV=0 opts out.
- An NVFP4 target transfers with a BF16 draft held in pools of its own.
- A windowed draft lifecycle gets the hot-tier bytes of every admissible
  sequence's window plus one context chunk, instead of the bytes-per-token
  split that handed it a quarter of the KV cache.

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>
…w is complete

With the drafter's layers in the target's KV cache manager, the draft
lifecycle's REQUIRED policy let the drafter's window constrain every
prefix match: a request branching off a shared prefix found no
committed window there and re-prefilled its whole prompt.

- A windowed drafter's layer specs are OPTIONAL. The target alone sets
  the match, and a new cache's first resume selects the drafter's
  layers where their window is complete (page-aligned matches only).
- Where the window is missing, prepare_context caps the claim so the
  prefill writes everything the drafter reads (from L + 2 - W on): at
  L + 1 - W, or one window before a branch point within W of the prompt
  end, whose window the request then publishes. A partial match first
  retries at its last full page.
- The commit gate for unstored drafter context, its unwritten-prefix
  marks and branch-point publication cover a manager that hosts the
  drafter's layers, not only a paired draft pool.

A disagg generation request needs no cap: the KV transfer requests an
unselected OPTIONAL group in full.

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>
…ck offsets per step

A hybrid NVFP4 MiniMax-M3 cache is pool-less: no target layer reads its
attention-op block offsets. With explicit draft layers it kept every
layer's pool, so each step converted the whole per-layer table (layers x
sequence slots x max_seq_len / tokens_per_block) on the device: about
3 ms per decode step at max_seq_len 1M on TP2.

Keep one attention-op pool, the first draft layer's, which is the only
one the unified drafter reads, and fill it from the draft lifecycle's
rows with the copy a separate draft pool uses.

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>
…get's KV cache

A unified DFlash/DSpark drafter appends its block after the request's
context, through the request's own pages. The manager sized every request
with num_extra_kv_tokens = K-1 beyond its history, but the DSpark
shift_label block is K wide (DFlash K+1). When the allocation ended on a
page boundary, at the prompt end or after a step that accepts all K+1
tokens, the block's last slot had no page: the drafter then truncated the
advertised context and its scratch overwrote the last context position in
the draft layers, silently.

When DRAFT-owner layers live in the manager, size each request beyond its
history by max(num_extra_kv_tokens, drafter block width) at every
allocation site and in the generation headroom. The creator passes the
checkpoint-resolved width (shift_label). Adapted from NVIDIA/TensorRT-LLM

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>
NVIDIA#20664 (76c4cef); num_extra_kv_tokens keeps its position-side meaning.
…ot be honored

An explicit TRTLLM_DISAGG_SHARED_DRAFT_KV=1 on a disaggregated worker whose
target and drafter cannot share one KV cache manager silently kept the
separate draft pool, whose KV is not transferred. Raise at KV cache
creation instead, as NVIDIA/TensorRT-LLM NVIDIA#20664's
_check_shared_draft_kv_request does.

disaggregated_mpi_worker now writes "auto" rather than "1" when both
sections pass its coarse checks, so its automatic choice can still fall
back where a worker's finer checks refuse; the creator shares under "1" and
"auto" alike. The unset default is unchanged.

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>
The launcher wrote TRTLLM_DISAGG_SHARED_DRAFT_KV=0 for any drafter other than DFlash, and for any page
size other than 16, 32 or 64, so a MiniMax-M3.1 DSpark deployment started through it (128-token pages)
kept the separate draft pool, whose KV is not transferred.

Accept a standalone DSpark drafter with attention_backend unset, AUTO or TRTLLM, as NVIDIA/TensorRT-LLM
NVIDIA#20664 does, and leave its page size to each worker's KvCacheCreator: the launcher cannot know whether
the drafter reads the target's page. It writes "auto"; if the two sides still disagree, the peers'
draft-layer check fails loudly. An explicit TRTLLM_DISAGG_SHARED_DRAFT_KV=0 is now kept.

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>
… slot

A generation row with the attention-DP dummy id must never inherit a real slot, because the C++ offset
copy maps placeholders to page 0: a slot that a context warmup stored under that id would let the
dummy row write its drafter K/V past its own pages, onto another request's page. As in
NVIDIA/TensorRT-LLM NVIDIA#20664, such rows now always take the dummy slot.

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>
…ores on the host

DFlash bounds each context store by the row's allocation, which it reads
back from the device unless the drafter's block-table rows are known to
be written in full. That was only recognized for a separate draft pool,
so with the unified drafter every context chunk waited for the target's
forward before the store was issued.

Let the KV cache manager say whether it rewrites copied rows in full,
and check the target manager's own table for a unified drafter. The
MiniMax-M3 manager's drafter-only pool is copied in full.

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>
…ndpoint

An OPTIONAL lifecycle group was checked only at a page-aligned endpoint,
so a partial prefix match always left it incomplete. A unified DSpark
drafter then lost its window's partial page and the caller cut its claim
back to the last full page, recomputing up to a page per request.

Check an OPTIONAL group at any endpoint with the REQUIRED coverage rule:
the last page needs only the matched prefix. The first resume copies a
selected group's partial page into a private page, as for REQUIRED, and
counts it as a partial reuse. Partial snapshots now publish OPTIONAL
pages as well, so a continuation of a prompt that ended mid-page finds
them. A match whose partial page is missing still falls back to the
last full page.

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>
The stock DFlashWorker._refresh_ctx_block_tables reads _ctx_block_cols since
the allocation-extent fix; the test's object.__new__ stub never set it.

Signed-off-by: Zheyu Fu <zheyuf@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants