Skip to content

feat(kimi-k3): support P/D disaggregation (re-land of #2154) - #2332

Merged
Yuechguo merged 19 commits into
mainfrom
zwan/feat-k3-pd-disagg
Oct 10, 2026
Merged

Yuechguo merged 19 commits into
mainfrom
zwan/feat-k3-pd-disagg

Conversation

@wanzhenchn

@wanzhenchn wanzhenchn commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Re-submits #2154 with the root-cause fix for the hang #2315 reverted it over, and with answers to each concern #2315.

1. The hang

#2315's diagnosis was correct, and this PR implements it rather than working around it.

47d77d3 turned is_deferred_out off on a P/D producer. _dp_draft_lockstep_active() was reading that same flag to mean "pipeline parallelism is off". Under DP attention a producer processing an output-less middle prefill chunk therefore skipped the aligning propose() while its idle DP peers entered the draft collectives and waited in DPMetadata.make's all_reduce. One rank short, the all_reduce never completed — 142 warmup requests sent, zero returned after 210 s.

fix(k3-disagg): keep P/D producers in the DP draft lockstep makes the predicate ask is_pipeline_parallel directly. A P/D producer keeps its drafter loaded and still owes its peers the collectives propose() carries, so it stays in the lockstep; only PP ranks are excluded, and now by name. The two flags had been coextensive until #2154 gave is_deferred_out a second reason to be false; using one to test the other was the latent coupling, and naming the condition removes that class of bug rather than special-casing the producer.

A second commit narrows the trigger itself. fix(k3-disagg): limit the producer's deferred-output opt-out to recurrent state: the opt-out exists so the consumer applies the handoff token T0 exactly once, but only a recurrent state notices a second application — a paged write lands at T0's own position, so repeating it is idempotent. Family.has_recurrent_state states this per family, and V4, MLA and MHA producers keep the deferred-output path they used before P/D handoff existed. The V4 configuration that hung is no longer on the modified path at all.

Coverage against recurrence:

  • test_draft_lockstep_reads_pipeline_parallel_not_deferred_output asserts _dp_draft_lockstep_active reads exactly is_pipeline_parallel and nothing else, so a future stand-in for the flag fails here.
  • test_only_recurrent_state_producers_give_up_deferred_output sweeps every attention family against a producer config: V4, MLA and MHA keep deferred output, the hybrids and pure GDN give it up.
  • test_every_family_is_covered fails if a family is added without stating its answer, so the sweep cannot go stale.

2. The fixes the rollback withdrew

#2315 noted that it was a full rollback, that removing listener readiness and save fencing can reintroduce failures outside Kimi-K3 including other Mooncake P/D and offload users, and that those fixes need to be reintroduced. That is what this PR does, and it reintroduces them behind the draft-lockstep fix rather than in front of it: the Mooncake listener bind/readiness handoff, the CUDA-stream fence before asynchronous offload saves, the offload load-start tracking that publishes restored prefixes, the asymmetric-only DCP guard on slot-region transfer, port 0 surviving endpoint parsing, the full first-decode draft width, and the Kimi-K3 P/D state handoff itself.

3. Validation

Full unit suite in the ROCm container, tests/plugin excluded (the image has no sglang.srt.layers):

Tree Result
This branch 16 failed, 8355 passed, 207 skipped, 3 xfailed, 4 errors
main baseline, same environment 16 failed, 8298 passed, 207 skipped, 3 xfailed, 4 errors

The failure sets are identical: test_fused_compress_ragged (4), test_mega_mxfp4_method (1), test_mla_flydsl_gather_dispatch (2 failed + 4 errors), test_scheduler_metrics (7). test_accumulation.py differs only in which parameters fail, in both trees — it is flaky. test_scheduler_metrics passes 34/34 in isolation on both trees and fails only in full-suite context, on main as well. No regression is attributable to this branch; the passed-count difference is the tests this branch adds.

@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 2332 --add-label <label>

@NidhoggD1 NidhoggD1 mentioned this pull request Sep 21, 2026
4 of 5 tasks
@zufayu
zufayu requested a review from yhl-amd September 22, 2026 01:51
@wanzhenchn
wanzhenchn force-pushed the zwan/feat-k3-pd-disagg branch 2 times, most recently from d42a86a to d6ba6ce Compare September 22, 2026 13:14
@wanzhenchn wanzhenchn changed the title feat(kimi-k3): support P/D disaggregation feat(kimi-k3): support P/D disaggregation (re-land of #2154) Sep 22, 2026
@ZhangLirong-amd

Copy link
Copy Markdown
Collaborator

The fix for the original V4 DPA8 + DSpark hang looks correct. _dp_draft_lockstep_active() now checks is_pipeline_parallel directly, so disabling deferred output no longer causes a producer to skip draft collectives while its DP peers enter them. Keeping deferred output
enabled for V4 also restores its previous behavior at that decision point.

However, disabling Scheduler.use_spec for all P/D producers introduces two additional cases that need attention:

  1. EAGLE / serial MTP loses successor-token metadata during chunked prefill.
    Setting self.use_spec=False also makes self.drafter_needs_next_token=False, although the runner still retains and invokes the drafter. Consequently, batch.next_token_ids is absent. For a non-DP middle chunk, EagleProposer.compute_draft_kv() returns without writing draft
    KV; for a DP alignment pass, propose_draft_token_ids() cannot replace the dummy anchor with the actual successor token. Please separate “the producer does not perform speculative verification” from “the drafter still needs successor-token metadata.” This does not affect
    DSpark through this particular path, since its context-KV write does not use successor tokens.

  2. DSpark dynamic q-bucketing can expand a producer’s target-only decode beyond its scheduled width.
    The scheduler now gives the producer a q=1 decode batch, but _dspark_apply_q_bucket() still derives full_q from self.drafter.mtp_k + 1. Executing the existing decision logic with mtp_k=3, ell=1, and buckets 1,2,4 selects q=2 for a batch scheduled with only one token per
    request. This can make the rebuilt token layout and attention metadata exceed what the scheduler reserved. Please skip this transformation for batches without speculative verification, or otherwise ensure it cannot increase any request’s scheduled width.

I checked the lockstep behavior and the two cases above with CPU-only source/control-flow checks; these are not end-to-end GPU reproductions.

The passing V4/DSpark accuracy jobs are useful, but the P/D benchmark and Mooncake RDMA smoke jobs are currently skipped. Before re-landing, I would also like to see the current PR head pass the original regression configuration: 1P1D, DPA8, DSpark, chunked prefill, and
concurrency 128, including warmup and sustained request progress. The original hang fix looks sound, but that distributed regression coverage is still needed for the full re-land

@wanzhenchn
wanzhenchn force-pushed the zwan/feat-k3-pd-disagg branch from 50e48a9 to 3759c4e Compare September 23, 2026 05:55
Yuechguo
Yuechguo previously approved these changes Sep 24, 2026
Comment thread atom/model_engine/model_runner.py Resolved
Comment thread atom/model_engine/model_runner.py Resolved
Comment thread atom/model_engine/scheduler.py Resolved
Yuechguo
Yuechguo previously approved these changes Oct 8, 2026
wanzhenchn and others added 6 commits October 10, 2026 06:57
…rs that cannot carry it

K3's cache is in two halves and the MLA base class describes one: the 24
full-attention layers live in the paged pool it walks, the 69 KDA layers in
the slot-indexed recurrent-state pool `build_kv_cache_tensor` tags
`per_request_state=True`. A request's KDA state is not derivable from its MLA
blocks, so a transfer carrying only the latter leaves decode running most of
the model on a zeroed state.
…ries none

The producer skipped Phase 2 whenever either slot index was negative and
returned success. Phase 1 has already moved the block regions by then, so on
Kimi-K3 the decode side got all 24 MLA layers and a zeroed recurrent state for
the other 69 -- and since that still decodes fluent text, nothing downstream
could tell. Raise instead, but only where the two facts contradict each other:
slot regions were registered, so there is per-request state to move, and no
slot came with the transfer to move it from. Backends with nothing slot-indexed
registered keep the skip.
Keep transferred state at the prompt boundary so the consumer processes the first token exactly once, avoiding duplicate KDA recurrent-state updates.
Use the final recurrent-state slot instead of the admission-time slot.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
- derive MLA query width from per-forward batch metadata
- fall back to eager execution when an exact CUDA graph shape is unavailable
- return the actual staged decode token range
- initialize remote first-decode MTP windows without prompt-token leakage
- preserve first-decode metadata without an extra scheduler scan
- add P/D handoff and shape regression coverage

Co-authored-by: Cursor <cursoragent@cursor.com>
Yuechguo
Yuechguo previously approved these changes Oct 10, 2026
A P/D producer never speculates but keeps its drafter, so its DP dummy
ran at q=mtp_k+1 while real decodes ran at q=1, mismatching
`running_tokens` on unified steps.

- `dummy_execution` uses the scheduler's spec width (q=1 on a producer).
- Only a producer with a drafter passes `graph_shapes`; elsewhere a
  missing recording stays a KeyError. `decide` warns once on a miss.
- Drop the dead spec-token overwrite in the non-deferred
  `prepare_input_ids` branch; assert `num_spec_step == 0`.
- Tests for the above; fix stubs broken by the rebase.
@wanzhenchn
wanzhenchn force-pushed the zwan/feat-k3-pd-disagg branch from 02992bc to e7f78e3 Compare October 10, 2026 12:13
@Yuechguo
Yuechguo merged commit 58ec62c into main Oct 10, 2026
@Yuechguo
Yuechguo deleted the zwan/feat-k3-pd-disagg branch October 10, 2026 15:06
Jasen2201 added a commit that referenced this pull request Oct 11, 2026
main's #2332 made ChunkedOffloadSchedulerBase set
seq.offload_load_start_tokens = hbm right before it creates each load
operation, so every chunked offload scheduler publishes a restored prefix
into the HBM prefix index. mooncake_store's _new_load_operation override
set the same value (seq.num_cached_tokens, which is that hbm) and is now
redundant. The dense-scheduler test no longer asserts that the attribute
is absent, since main sets it there on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jasen2201 added a commit that referenced this pull request Oct 11, 2026
main's #2332 made ChunkedOffloadSchedulerBase set
seq.offload_load_start_tokens = hbm right before it creates each load
operation, so every chunked offload scheduler publishes a restored prefix
into the HBM prefix index. mooncake_store's _new_load_operation override
set the same value (seq.num_cached_tokens, which is that hbm) and is now
redundant. The dense-scheduler test no longer asserts that the attribute
is absent, since main sets it there on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jasen2201 added a commit that referenced this pull request Oct 11, 2026
main's #2332 made ChunkedOffloadSchedulerBase set
seq.offload_load_start_tokens = hbm right before it creates each load
operation, so every chunked offload scheduler publishes a restored prefix
into the HBM prefix index. mooncake_store's _new_load_operation override
set the same value (seq.num_cached_tokens, which is that hbm) and is now
redundant. The dense-scheduler test no longer asserts that the attribute
is absent, since main sets it there on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jasen2201 added a commit that referenced this pull request Oct 12, 2026
main's #2332 made ChunkedOffloadSchedulerBase set
seq.offload_load_start_tokens = hbm right before it creates each load
operation, so every chunked offload scheduler publishes a restored prefix
into the HBM prefix index. mooncake_store's _new_load_operation override
set the same value (seq.num_cached_tokens, which is that hbm) and is now
redundant. The dense-scheduler test no longer asserts that the attribute
is absent, since main sets it there on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jiayyu added a commit that referenced this pull request Oct 12, 2026
…rebase

main dropped `total_tokens = batch.total_tokens_num` from prepare_input_ids
(its own paths no longer read it); the mixed branch still did, so every mixed
step raised NameError. The rebase merged cleanly -- the conflict was semantic.
test_mixed_deferred_inputs_under_spec caught it.

Also point the mixed shrink-guard notes at `dspark_scheduler.apply_q_bucket`,
where main (#2332) moved the prefill guard `_dspark_apply_q_bucket` used to
hold inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zejunchen-zejun added a commit that referenced this pull request Oct 12, 2026
… stuck load visible

Review round on 3a61b3d (#issuecomment-6098310910). Most of that review
is already answered by the previous commit; this one covers what remained.

`orphan()` took the slot of any outstanding load whose slot matched, even
one never handed to a worker, so a torn-down request kept its slot until
the reclaim window passed, or forever with reclamation off. The connector
now tells the index when it puts a state leg on a request
(`note_handed_off`, through the bound block manager). `orphan()` holds the
slot only for a load in a worker's hands; any other outstanding load is
abandoned and the caller frees the slot, as on the merge base.

`dispatched == settled + outstanding` holds by construction, so it cannot
see a load that never settles. `reclaim` now refreshes `stale_live_loads`:
live loads outstanding past the reclaim window (300 s when reclamation is
off). It warns when the count rises, and `stats()` exports it next to
`loads_handed_off`. `loads_attempted` keeps main's meaning (counted at
request time). `orphan_load_slots_reclaimed` is exported only by
`checkpoint_funnel`, not twice.

A failed load now only lowers the save frontier, never raises it: a
frontier below the load's floor means the chunks in between were never
stored.

Also drops `hash_of` and `pending_loads`, which nothing called; brings the
index docstring and the scheduler's completion comment up to date; and
notes that a K3 P/D transfer (#2332) brings its own state, which is one
more reason the tier's uncarried state leg is given back.
Jasen2201 added a commit that referenced this pull request Oct 12, 2026
Resolve the mooncake_connector.py conflict by taking main's version. Main
(#2332) closed the same write-done port race differently: the listener binds
port 0 so the kernel assigns a port the socket already holds, and
register_kv_caches waits on _await_listener, raising if the bind failed. That
removes the window this branch's _bind_notification_socket retried around,
and it also covers the producer's write listener, so the retry loop and
NOTIFY_BIND_ATTEMPTS go.

Rewrite tests/test_mooncake_notify_bind.py against main's mechanism: the
published port is held by the listener and a write-done sent to it arrives,
and a listener that cannot bind fails registration instead of leaving
notification_port unset behind a dead thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jasen2201 added a commit that referenced this pull request Oct 13, 2026
main's #2332 made ChunkedOffloadSchedulerBase set
seq.offload_load_start_tokens = hbm right before it creates each load
operation, so every chunked offload scheduler publishes a restored prefix
into the HBM prefix index. mooncake_store's _new_load_operation override
set the same value (seq.num_cached_tokens, which is that hbm) and is now
redundant. The dense-scheduler test no longer asserts that the attribute
is absent, since main sets it there on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jiayyu added a commit that referenced this pull request Oct 13, 2026
…rebase

main dropped `total_tokens = batch.total_tokens_num` from prepare_input_ids
(its own paths no longer read it); the mixed branch still did, so every mixed
step raised NameError. The rebase merged cleanly -- the conflict was semantic.
test_mixed_deferred_inputs_under_spec caught it.

Also point the mixed shrink-guard notes at `dspark_scheduler.apply_q_bucket`,
where main (#2332) moved the prefill guard `_dspark_apply_q_bucket` used to
hold inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
valarLip pushed a commit that referenced this pull request Oct 13, 2026
…ore (#2464)

* fix(atomesh): keep process_is_running's answer inside the EXIT trap

cleanup_processes runs from the EXIT trap whenever the launcher exits on an
error. In a function the trap runs, a bare `return` returns the trap's $? --
the exit status -- instead of the status of the test before it, so
process_is_running reported every live process as gone: the reap then called
`wait` on a live LMCache MP server and blocked forever, and
terminate_process_group returned without killing anything. Return the test's
status explicitly.

Error exits now do kill, so terminate_process_group no longer waits without
bound after SIGKILL: a process in uninterruptible sleep (a GPU or RDMA
teardown) outlives it, and `wait` would have held the launcher, and so the
container, until the job's time limit. It gives up after 30 s with a warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(offload): mooncake_store — keep the prefix cache in a Mooncake Store without LMCache

A new offload connector, `mooncake_store` (ATOM_KV_OFFLOAD=mooncake_store),
moves each PP stage's 256-token KV chunks straight between its GPU and a
Mooncake Store whose owner processes hold the memory -- on the prefill node
and, across two nodes, on the decode node. No LMCache engine, CPU L1 or lookup
server is involved.

- Data path: the existing BlockGPUConnector + DenseKVByteCodec pack a save's
  blocks into slots of a GPU buffer registered with the Store, and
  batch_put_from sends them from HBM (GPUDirect RDMA); a load is a
  batch_get_into into those slots followed by the unpack. The slot pool is
  bounded (save and load regions); `pool_device: cpu` puts it in pinned host
  memory instead.
- Lookup: the scheduler asks the master(s) with one batch_is_exist over every
  PP stage's keys and takes the shortest contiguous prefix. batch_is_exist
  grants a read lease, so there are no pins to release; a hit older than 1 s
  is looked up again before its load is dispatched.
- Keys: atomkv1-<namespace>/w<rank>of<world>/<blake2b-128 prefix-chained chunk
  digest>. The namespace covers the PAGE geometry, the PP layer split, the
  chunk size and the online quantization. The scheduler hashes each prompt
  once and ships the digests to the workers instead of the token ids.
- Reuses ChunkedOffloadSchedulerBase (save/load scheduling, early block
  release, exact completion ids) unchanged; the only base change is a
  `_build_load_request` hook whose default builds the same request as before.
  Loaded prefixes are published into the HBM prefix index
  (offload_load_start_tokens), as lmcache_mp does.
- Failure handling: get results must equal the chunk size (a foreign layout
  fails the load); a failed put fails the save with the source quiescent and
  reports its unread chunks source-safe so PP quorums drain; a transfer that
  outlived Mooncake's 60 s batch wait holds its slots for a bounded time;
  failed lookups back off. Each worker probes one chunk through the Store at
  startup.
- NIC per rank from the PCI topology (one PCI-local NIC per GPU), per-NIC
  Store pools, MC_NUM_QP_PER_EP=1 enforced.

Phase 1 covers the dense layout (GLM-5.2 / DeepSeek MLA+DSA, plain dense
models) with DCP=1 on the offload side; other layouts are refused at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(atomesh): start a Mooncake Store for the mooncake_store offload

MOONCAKE_STORE=1 in the prefill role env makes the launcher start a Mooncake
Store (masters with HTTP metadata, THP owner processes bound to their NUMA
nodes) before the prefill servers, and hand the prefill workers
ATOM_KV_OFFLOAD=mooncake_store with the Store's endpoints in
ATOM_KV_OFFLOAD_EXTRA_CONFIG, plus MC_NUM_QP_PER_EP=1, MC_MAX_MR_SIZE and
MC_TCP_BIND_ADDRESS. The decode gets MC_NUM_QP_PER_EP=1 for P->D.

The Store mechanics are PR #2459's validated launcher (ports below the
ephemeral range, page-cache drop, NUMA budget and THP compaction, readiness by
pool capacity, per-NIC pools, decode-node owners for two nodes, metrics saved
at shutdown, capacity check, liveness hooks) under backend-neutral
MOONCAKE_STORE_* names, without the LMCache pieces. New: preflight of the
image's Mooncake Store binaries, MOONCAKE_STORE_CONNECTOR_CONFIG (validated
against the connector's keys), MOONCAKE_STORE_LEASE_TTL_MS,
MOONCAKE_STORE_HOST_POOL_GIB, and refusals of a second offload connector in
PREFILL_KV_TRANSFER_CONFIG, a case-set ATOM_KV_OFFLOAD and MC_MS_AUTO_DISC.

numa_exec.py, drop_page_cache.py, numa_memory_budget.py and
compact_huge_pages.py are byte-identical copies of PR #2459's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(atomesh): GLM-5.2 cpp4/dcp4 cells with the mooncake_store offload

Dispatch-only suite `mooncake_store`: the nightly lmcache-1m cpp4/dcp4 cells
with their prefix tier in a Mooncake Store and no LMCache. Single node
(...-mcstore-1m-c72/c96/c112/c128): owners 768 + 768 GiB on rdma4-7.
Two half nodes (...-mcstore-2n-1m-c96/c112): per-NIC pools of 4 x 240 GiB on
each node's NUMA0. They need an image with Mooncake Store
(e.g. rocm/atom-dev:mooncake-store-20261002) and have their own
display_topology. The cpp4/dcp4 decode env gets the anchor the new cells use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(offload): pack and unpack mooncake_store windows in place in the GPU pool

A window's slots are now handed out as one run of the transfer pool when the
region has one, and a chunk that is a page multiple (every GLM-5.2 stage's)
leaves no padding between slots, so the run is a chunk-major buffer. The dense
codec packs a save window straight into it and unpacks a load window straight
out of it -- one prepared block-ID upload, one kernel and one stream sync per
window -- instead of going through the block GPU connector's 48 MB staging
buffer with a copy per chunk and a stream handshake per group. On the first
native c96 run every load spent 55-120 ms unpacking ~290 chunks against
22-35 ms getting them from the Store; each of those staged steps is a GIL
round trip against the forward thread.

Scattered slots (a region broken up by held-back slots) and a pool in host
memory keep the staging path. `mooncake_store.direct_copy: false` turns the
in-place path off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(offload): pin mooncake_store's in-place gate and its source-safe accounting

Runs the real in-place gate across direct_copy, the pool's device and the
codec's fused staging; checks direct_copy's default and type; and checks that
an in-place save whose later window fails, and one that mixes in-place and
staged windows, still report every chunk source-safe exactly once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(offload): mooncake_store's data path packs and unpacks in place by default

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(offload): name the PP frontier warning after the shared offload scheduler

The chunked scheduler now also backs mooncake_store, whose PP4 cells log this
warning at startup; it named LMCache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(atomesh): MOONCAKE_STORE_COMPACT_TIMEOUT for nodes slow to compact

CI run 37148703448 lost its Store on pit2-p03-g27: right after a fresh 69 GB
image pull NUMA1 held 145 GiB of page cache, compacting it for the 768 GiB owner
did not finish in the fixed 600 s, and the owner's segment came up partly in
4 KiB pages that the NICs refused to register (ENOMEM, owner abort). The
compaction budget is now a setting, default unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(atomesh): state what was seen on pit2-p03-g27, not a guessed cause

The comment blamed the slow compaction on page cache from a fresh image pull.
The numbers do not support that: NUMA1 had 1342 GiB free for a 768 GiB owner,
yet did not compact in 600 s, while NUMA0 on the same node, and both nodes of
pit2-p03-g19 and -g23, took 34 s. Record those observations instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(atomesh): mooncake_store cells at c48 and c56

The suite started at c72. c48 and c56 complete the concurrency sweep the
nightly lmcache-1m cells cover. The anchor moves to the c48 cell, so the
existing cells generate exactly the same matrix entries as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): address the review of mooncake_store

A four-way review with adversarial verification confirmed eight defects.
Every one was reproduced before it was fixed. None changes the successful
save/load path that the benchmarks measure.

- A stale hit could hang a request. A hit older than a second is looked up
  again when its load is dispatched, to renew its read leases. When that
  lookup came back short, the load was dropped. By then the engine had
  already parked the request, and only the load's report wakes it, so the
  request held its blocks and a max_num_seqs slot forever. The load now
  always goes ahead. A chunk evicted in the meantime fails the worker's
  all-or-nothing get, and the request recomputes; load_failed lowers the
  save floor so those chunks are saved again.
- The source read deadline started too late. A worker counted it from when
  its stage received the save. A downstream PP stage receives a save only
  after the forwards queued ahead of it, while the engine reclaims the
  source save_abandon_timeout_s after dispatch. Saves now carry their
  dispatch time, and the deadline counts from it.
- The Store namespace missed the environment switches that rearrange the
  bytes inside a KV block: ATOM_MLA_PAGE_SIZE, ATOM_USE_TRITON_MLA,
  ATOM_USE_TRITON_MLA_SHUFFLE_KV and ATOM_USE_UNIFIED_ATTN. Servers that
  differ in them read each other's KV as their own layout.
- mooncake_store.publish_loaded_prefix=false broke HBM hashing. Every block
  after a loaded prefix stayed unhashed, so the request's own suffix was
  never reused, and each suffix block logged an "Unhashed parent block"
  ERROR. The setting is removed, and a loaded prefix is always published.
- The reconnect pause started too early. After a failed connect, the
  scheduler counted its pause from before the blocking setup, which can run
  for minutes. It now counts from the failure and grows with how long the
  setup blocked.
- A multi with mooncake_store and another offload connector was refused
  only at its first KV poll. Offload backends now register as such, and
  _build_subconnectors refuses a second one before building either.
- The launcher had three gaps:
  - MOONCAKE_STORE=1 with several prefill workers on one node
    (prefill_single_node with xP > 1, or packed_nodes) failed only after the
    first worker had started. It is now refused up front.
  - Each branch now installs its EXIT trap before its first server, so a
    failed start stops what started before it.
  - Two-node Store waits now allow for the other node's compaction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(offload): evict a chunk's PP-stage objects as one Mooncake group

Each PP stage puts its own object of a chunk, and the master evicted them
one by one, oldest lease first. A round's cutoff could fall between a
chunk's stages. The chunk was then a miss for every lookup, since a load
needs all of them, while the other stages' objects still held memory.

Every rank's object of a chunk now goes into one Mooncake group,
{namespace}/group/{digest}, through ReplicateConfig.group_ids. The master
evicts a group whole, by the lease its members share. A rank that re-puts
a missing chunk joins the live group, because an existing key keeps its
group. mooncake_store.chunk_groups (default true) turns it off.

The scheduler now counts lookups whose ranks held prefixes of different
lengths, and the rank objects found past the shared prefix, in
[OFFLOAD-LOOKUP-STATS] once a minute.

Checked against a real mooncake_master (0.3.13.post1, tcp) with the owner
overfilled 6x by 400 four-object chunks. Without groups, 2-3 chunks were
left with only some of their stage objects; with groups, none were.
ATOM's own client put and read back grouped objects byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(offload): what uneven lookups count once chunks are grouped

At c112 with chunk groups, 22 of 12,504 lookups found the stages holding
prefixes of different lengths. No stage had a failed put, and 13 of them
came before the master's first eviction. They are saves a slower stage was
still writing: each stage puts a prompt's chunks tail first, so its prefix
grows only when its head window lands. Say so where the counters are
documented, so they are not read as eviction leftovers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): quarantine a slot an unsettled transfer may reach for good

A slot whose last Store transfer gave up after Mooncake's 60 s batch wait
(or whose call raised) went back into use after 300 s. Nothing bounds how late
that RDMA work lands: Mooncake gives up without cancelling it, and a NIC
whose completion queue stalls holds it indefinitely -- rdma2 of pit2-p03-g23
held 256 outstanding requests for the last 5 minutes of a c56 run. A late get
could then overwrite the next chunk's bytes before its unpack, and a late put
read another chunk's. Such slots are now quarantined for good, as a GPU copy
that did not fence already was.

close() unregistered and freed the pool even with quarantined or leased
slots, before the Store client was torn down, and a startup probe whose put
or get failed reached the same cleanup without settling its slots. The probe
now settles its slots as saves and loads do, and a pool with a quarantined or
leased slot stays registered and allocated for the life of the process; the
client's teardown ends its RDMA work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(atomesh): refuse NaN and negative budgets, and trap EXIT before any start

numa_memory_budget.py took --reserve-gib, --per-gpu-gib and each pin's GiB
as any float: a NaN compares false with every node's memory and a negative
amount cancels other pins, so either passed a plan that cannot fit, and
--compact-timeout 0 stopped every compaction at once. They are now refused.

The four single-server branches of the role dispatch (the prefill on node 0,
the decode node, a prefill node of several, the decode of a prefill-single-
node job) installed their EXIT trap only after start_prefill or start_decode
returned. Both start the Store's masters and owners before their server, so a
failure in between left them running. Every branch now traps first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): name stored formats, RoPE and overrides in the Store namespace; pace failed saves

The namespace hashed the configured dtype names. AITER stores "fp8" as
e4m3fnuz on gfx942 and e4m3fn on gfx950 -- one byte each, so a load's size
check cannot tell them apart -- and a DSA index cache is fp8 under a bf16 KV
cache too: an MI300X and an MI355X on one Store shared keys for different
bytes. It now also names the GPU arch and the dtypes AITER resolves the KV
and index caches to.

Two servers on one model path also shared keys across different RoPE
settings or any other --hf-overrides field (only 12 geometry fields were
hashed), across checkpoint revisions, and across ATOM_FORCE_ATTN_TRITON,
whose fp8 MHA KV uses one fixed scale instead of per-token scales. The
namespace now covers the RoPE settings, the overrides, the HF config's
_commit_hash and that switch. The RoPE settings come from a snapshot Config
takes next to the HF geometry: llama.py adds a key to the live
rope_parameters while a worker builds the model, so hashing them live would
split the scheduler's namespace from its workers'.

A failed save was retried on the next step, and a failure that repeats -- a
full Store, a master that is down, a stage whose save slots are all
quarantined, which is now for good -- re-packed the request's unsaved range on
every PP stage every step. Saves now pause 1 s after a failure, doubling for
each further one in a row up to 30 s, until a save is stored.

The README states what per-NIC pools cost when servers place a rank on
different NICs: a hit the worker then misses, once per prefix, never another
layout's bytes. Asking each rank's own pool is a follow-up.

The in-place load test now checks each window's unpack is waited for before
its slots return to the pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(atomesh): refuse numeric Store settings that are not numbers before anything starts

MOONCAKE_STORE_COMPACT_TIMEOUT and the other numeric MOONCAKE_STORE_*
settings went to the helpers, a Store process or bash arithmetic unchecked.
A compaction timeout of 0 or below cancelled the compaction while startup
went on, NaN or a negative NODE_RESERVE_GIB passed the NUMA budget, and a
wait timeout bash cannot add aborted the wait without stopping the masters
and owners it waited for. The preflight now refuses each that is not a
number of its kind: the compaction timeout, node reserve, both wait
timeouts, page-cache drop period, owner threads and MR size, master NUMA node
and the eviction ratios.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(docker): build Mooncake with Mooncake Store (WITH_STORE=ON)

The release image built Mooncake with WITH_STORE=OFF, so no nightly had what
the mooncake_store offload needs: the mooncake.store client in the workers
and the mooncake_master / mooncake_client processes the atomesh launcher
starts under MOONCAKE_STORE=1. Its CI cells could run only on a hand-built
image passed as atomesh_image.

Turn WITH_STORE on (the Rust bindings stay off), and fail the build when the
Store is not configured or its binaries or Python module are missing. Taken
from #2455: the same Mooncake commit (v0.3.14-rc1) and flags built as
rocm/atom-dev:mooncake-store-20261002 (docker-release 37074895608), the image
every mooncake_store result in this PR ran on. #2455's lmcache_mooncake
builder stage is not carried; nothing here loads it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(atomesh): the mooncake_store cells run on the release image

With Mooncake built WITH_STORE=ON in the release image, the mooncake_store
suite needs no image of its own. Drop the notes that told a dispatch to pass
one as atomesh_image; the launcher still refuses an image without the Store
before it starts anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(offload): keep the mooncake_store transfer pool in HBM only

mooncake_store.pool_device=cpu put the transfer pool in pinned host memory,
but the launcher's NUMA budget and compaction counted host pools only
through a separate MOONCAKE_STORE_HOST_POOL_GIB that defaulted to 0. A case
that switched the pool to the CPU without also setting that variable left its
pools out of the budget, and PyTorch's pinned allocator rounds each pool up
to a power of two: 2 GiB per worker at the default sizes.

The CPU pool was never the path to run: it adds a copy each way between HBM
and host memory, its 4 KiB pages run into the ionic NIC's ~3 GiB
registration limit, and every result of this connector ran with the pool in
HBM. Drop the setting and the launcher's host pool budget instead of keeping
the two in step. A worker whose GPU memory the NIC cannot register already
fails at startup, naming what it needs.

The rule that one node runs one prefill worker stays: the case places the
owners around that worker's GPUs, and another worker on the node's other GPUs
would share a NIC with an owner or find no pool for its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(tests): format the launcher tests with black 26

The pre-check installs black 26.10.1, whose stable style hugs a call's
parentheses around a multi-line string argument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(offload): drop mooncake_store code and settings nothing uses

- The in-place pack and unpack are one method. Window keys and the
  per-chunk source-safe report have one helper each. The window's block
  groups come from BlockGPUConnector._ranges_to_block_ids over the
  window's slice of the block table.
- direct_copy, chunk_groups, startup_probe and lookup_batch_keys are
  gone; no deployment set them. In-place copies whenever the slots are
  one run, chunk groups and the startup probe stay on, and a lookup
  still batches 8192 keys.
- Store pools are parsed once, in config.py, and a pool must name rdma.
  Pools given as JSON text are refused. nic.py only finds NICs.
- The hash chain reads token ids with one np.asarray. Keys are
  byte-identical: old and new were compared on about 10^5 inputs
  (lists, array("i"), int32 and int64 arrays, chunk sizes 1-300, with
  and without a cache seed).
- Removed: overrides that did nothing (_lookup_unpin, the transfer
  stats hooks, request_finished), the raw-token lookup and chunk_key
  that only tests used, the MooncakeStoreOffloadConnector alias, client
  fields nothing read, a group-id length check its only caller cannot
  fail, and the per-request error counters (the PROF lines carry
  status=; the counts stay in [OFFLOAD-STORE-STATS]).
- Connect and lookup back-off share one pause timer; they never
  overlap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(atomesh): drop Store launcher settings no cell sets

- MOONCAKE_STORE_CONNECTOR_CONFIG is gone, with its inline validator (a
  copy of the connector's key table that an ast test kept in sync). The
  workers get the launcher's connector JSON as is; on both cell shapes
  it is byte-identical to the old round trip.
- No cell or workflow set OWNER_THREADS, OWNER_MAX_MR_SIZE,
  EVICTION_HIGH_WATERMARK, EVICTION_RATIO, NODE_RESERVE_GIB,
  MASTER_WAIT_TIMEOUT, LEASE_TTL_MS, OWNER_RDMA_DEVICES or
  PAGE_CACHE_DROP_SECONDS (all MOONCAKE_STORE_*). Each is now fixed at
  its old default; owners still take their NICs from each OWNERS entry.
- Branches that the up-front refusals already make unreachable (a
  second Store start in one shell, decode owners in the Store's shell)
  are removed. The cross-node waits are WAIT_TIMEOUT + 2 x
  COMPACT_TIMEOUT instead of a count of the other node's NUMA nodes.
- The launcher header and the cell comments keep one line per setting
  and what an operator needs. The measurements live in the PR and the
  README.
- Tests: dropped source-text checks that running tests already cover,
  repeated role-prefix and default-port checks, and a duplicate
  owner-death test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(offload): let the shared scheduler name where a load starts

main's #2332 made ChunkedOffloadSchedulerBase set
seq.offload_load_start_tokens = hbm right before it creates each load
operation, so every chunked offload scheduler publishes a restored prefix
into the HBM prefix index. mooncake_store's _new_load_operation override
set the same value (seq.num_cached_tokens, which is that hbm) and is now
redundant. The dense-scheduler test no longer asserts that the attribute
is absent, since main sets it there on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): end the save pause when a save is stored

A failed mooncake_store save pauses every save for 1 s, doubling with each
further failure in a row up to 30 s, and the pause is meant to last until a
save is stored. A stored save reset only the failure count, though, and left
the deadline in place. Saves run concurrently, so a save that was in flight
when another one failed can report its success afterwards, and every new save
stayed suppressed for the rest of a pause of up to 30 s while the Store was
taking saves again.

A stored save now clears the deadline as well as the count. A failure that
reports after the success still sets a fresh 1 s pause. The new test runs
two concurrent saves, one refused by the Store, and reports them in both
orders: stored last, the failed save is retried at once; stored first, the
failure's pause holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): name MiMo-V2's sliding-window RoPE base and V scale in the Store namespace

The Store namespace hashes the RoPE settings K is cached with, from a snapshot
`Config` takes, but the snapshot read only the generic names. MiMo-V2, which
runs on the dense PAGE path, rotates K in its sliding-window layers with
`swa_rope_theta` and multiplies V by `attention_value_scale` before the cache
write (models/mimo_v2.py). Two configs that differ only in either value
write different KV bytes for the same tokens under the same namespace, at
the same chunk size, so a load's size check accepts the other one's chunks.

Both fields are now in the snapshot. Since it no longer holds RoPE settings
alone, it is renamed for what it covers, how a layer encodes K and V on their
way into the cache: `snapshot_kv_encoding`, `Config.offload_kv_encoding` and
the namespace's `kv_encoding` entry. The namespace changes, which only means a
Store filled by an earlier build is not reused.

The sweep went over every model on the dense path (llama and eagle3, qwen3
and qwen3_moe, the deepseek_v2 family, glm4_moe, glm5_next, gpt_oss,
minimax_m2, mimo_v2 and its MTP, mixtral and mistral3, kimi_k25) for the K/V
settings their attention reads from the HF config. MiMo-V2 is the only one
with RoPE settings under a name of its own. Its `context_len` only sizes the
RoPE table, since MiMo-V2 passes no scaling, so it changes no bytes and is
left out. Switches that select a checkpoint's weights (`use_qk_norm`,
`attention_bias`) change only with the checkpoint, and norm epsilons change
bytes only within rounding, so they stay out as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): stamp a save's dispatch on the clock the reclaim runs on

A mooncake_store worker stops starting GPU reads of a save's source shortly
before the scheduler may reclaim the source blocks, `save_abandon_timeout_s`
after dispatch on the scheduler's CLOCK_MONOTONIC. The scheduler stamped the
dispatch with `time.time()`, and the worker turned that into an age against its
own wall clock, clamped at zero. A backward wall-clock step between dispatch
and receipt shortened that age, down to nothing, so a downstream PP stage that
received the save seconds late started its read window at receipt. With a
short timeout and a slow stage, it could still read blocks the scheduler had
already handed to another request, and store their KV under this prompt's
keys.

The scheduler now stamps the dispatch with `time.monotonic()`, the clock its
reclaim deadline runs on, and names that clock by the host's boot ID
(`dispatch_clock`). The worker places the deadline on the same clock, with no
transit to estimate. This is exact, because every process of an engine runs on
one host: its PP stages exchange metadata over ipc:// sockets, its TP workers
take their calls from a shared-memory queue, and engine_core_mgr refuses
multi-node data parallelism combined with PP. On one host CLOCK_MONOTONIC is a
single system-wide clock that no wall-clock step moves, so the age a worker
reads is the age the reclaim counts, never less. A stamp from any other clock
fails safe: the save is placed a whole read window before its receipt and
reads nothing (a rate-limited warning names both boot IDs). That covers a
boot ID that differs, as on another host, and one either side could not read.
A stamp in the future is capped at its receipt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): drop the PP fence warning and pin the order that makes it moot

Under pipeline parallelism the chunked offload scheduler logged at every start
that saves "may include a chunk the producer fence does not cover". The fear
was this: `schedule()` advances `num_cached_tokens` as it dispatches a chunk,
and the save frontier reads that field, so a save can cover chunks no stage
has finished. That is true, but the fence was never recorded "at dispatch".
Every stage records it when it takes the save's metadata, and every stage
takes metadata and forwards in dispatch order:

- `Scheduler.schedule()` builds the connector metadata before it advances the
  frontier past the chunk it is dispatching (`build_connector_meta`, then
  `_advance_prefill_on_schedule`). The saves batch N carries never cover batch
  N's own chunk, only the chunks of batches 0..N-1.
- The head runs `process_kvconnector_output(meta N)`, then `send_metadata`, then
  `forward(N)`, and it ran `forward(N-1)` before it scheduled N
  (`PPEngineCoreProc._pp_head_step`). A downstream stage takes batches off a
  single ZMQ PUSH/PULL socket and runs the metadata RPC before the forward of
  the same batch (`_downstream_busy_loop`). Connector-only and idle dispatches
  use the same socket and the same RPC queue.
- Each TP worker runs its RPCs one at a time, in order, from one shared-memory
  queue (`AsyncIOProc.busy_loop`). `forward` and `start_load_kv` both run on
  that thread's current stream, and a downstream stage's activation receive is
  ordered on that stream too.

So when a stage records the event for meta N, it has already enqueued, on the
same stream, the forward of every chunk the meta's saves read. The
mooncake_store save waits on that event before its first GPU read
(`_copy_in_place` or `BlockGPUConnector.batched_from_gpu`).

The warning is removed. The frontier comment in `build_connector_meta`, the
dense fence comments and the README invariant now state the actual guarantee.
A new CPU test drives the real Scheduler with PP=4 and the mooncake_store
scheduler through the real `_pp_head_step` and `_downstream_busy_loop`. Each
save reaches both stages while its chunk is still in flight (no stage returns
a token), and the test checks that every stage enqueued the forward of every
chunk a save covers before taking that save's metadata. With the metadata
built after the advance instead, the test fails on the first save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): load nothing from the Store once a put may still write memory it lost

A mooncake_store put whose transfer Mooncake gave up on after its 60 s batch
wait (or one that raised) leaves its RDMA work posted. The branch already
quarantines the source slot for good, because the abandoned work may still
read it. The write can also still land on the far side, and that memory is no
longer the put's. In Mooncake v0.3.14-rc1, `Client::FinalizeBatchPut` sends
`BatchPutRevoke` for a failed transfer before `batch_put_from` returns, and
the master's `PutRevoke` erases the processing replica, which hands its buffer
straight back to the allocator for the next put of that size. The hold a
master keeps on a put-start it discards (`put_start_release_timeout`) does not
apply to a revoke. A late write therefore lands inside another key's object
of the same size, which a load's size check accepts, and the load would
install another prompt's KV.

No ordering on the ATOM side can come before that reuse, so this fails
closed. After such a put the worker records why and loads nothing more from
the Store, checked before every get, and logs one error. A load needs every
rank's chunks, so every load of the engine then fails and its request
prefills, until the engine and its Store restart. Saves go on. A put that
failed within the batch wait leaves nothing posted and changes nothing.

Neither of the obvious alternatives closes the hole:

- "Never treat the timed-out key as stored" holds already. The put reports
  TRANSFER_FAIL, the save fails, and Mooncake revokes that key. The exposure
  is to other keys.
- A client reset runs only after the revoke, so it cannot come before the
  memory is reused. It also has to wait out the other copy threads' calls on
  the same client, which the same stall can hold for up to 60 s.

An end-to-end per-chunk digest would keep loads going but changes the object
format. Mooncake's own (`MOONCAKE_STORE_CHECKSUM=1`) copies each object off the
GPU and runs a byte-wise CRC64 on the CPU, far below the 12 GB/s in-place load
path. The root fix belongs in Mooncake: keep a revoked replica's memory out of
the allocator the way a discarded put-start's is, or cancel the posted work
before revoking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): distrust the Store only for a put Mooncake may still run

The worker quarantined a put's slots and stopped loading from the Store
for any exception from the client's put. But the put checks its keys are
unique and builds the ReplicateConfig that groups them before it calls
batch_put_from, and nothing is posted at that point. A duplicate key, or
a binding that refuses group_ids, therefore turned off every load for
the life of the process and blamed RDMA in the log.

The client now raises CallRefused for anything it refuses before handing
the call to Mooncake, for puts and gets alike. The worker releases the
slots of a refused call and fails only that save or load; every other
exception, from batch_put_from itself, still quarantines the slots and,
for a put, distrusts the Store. A get that raises keeps quarantining its
slots and nothing more: its late RDMA can only write those slots, never
the owners' memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): refuse to start mooncake_store without a boot ID

A worker reads a save's dispatch stamp only on the clock the stamp names,
the boot ID of /proc/sys/kernel/random/boot_id. Where that file cannot be
read, the scheduler stamped no clock and the worker had none to compare,
so every save was treated as stamped on a foreign clock and read nothing.
Each save then failed on its deadline, and the only sign was a
rate-limited warning.

The scheduler and the worker now refuse to start when the boot ID cannot
be read, with an error that names the file. The worker's dispatch check
no longer needs a case for its own clock being unnamed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): pace failed saves only when the base retries them

The mooncake_store scheduler counted every failed save into its streak
and pushed the save pause out after the base's _retry_or_retire_failed_save
returned, whether the base had set up a retry or not. The base retires a
failed save instead when its request has finished, its ID was reused, or
nothing of it is tracked any more. So the failed last save of a finished
request paused every live request's saves for 1 s, then 2 s, up to 30 s,
and the streak kept growing although nothing was retried.

The base now returns whether it set up a retry, and the scheduler paces
only those. A retired failure leaves the pause and the streak as they
were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): one failing Store pool no longer breaks every lookup

With per-NIC pools the scheduler asks every pool for every key. A key
one pool held was present, but any other key got the lowest code of the
pools, and the lookup discarded its whole answer on any negative code.
So with pools rdma0 and rdma1 and the rdma1 master down, the keys past
the hit came back as errors, every lookup answered nothing, and the
prefixes rdma0 did hold could not be loaded. Opening the clients had the
same shape: when one pool failed to connect, every client was closed and
all lookups paused.

Each pool now has its own client, opened on the first lookup that asks
it, and its own pause after a failed connect or lookup. A key is present
if any pool holds it, absent if a pool answered and none holds it, and
unanswered if no pool answered; a pool that fails or is paused therefore
counts as absent for every key another pool answers. A lookup answers
only what the unanswered keys cannot change: it computes the hit with
those keys counted as absent and as present, and answers None ("no
answer", retried later) when the two differ. An error past the hit can
no longer void a lookup, and no key a pool failed to answer can make a
hit longer than the pools that answered allow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): mark a Store a put may still write, for every client of it

After a put Mooncake gave up on with its RDMA write possibly still
posted, the worker stopped loading from the Store, but only through a
flag in its own process. The master had already given the revoked owner
memory to other puts, of any namespace, so the late write can corrupt an
object that a load's size check accepts. The other engines sharing the
Store never learned of it, and neither did an engine restarted against
it (the README told users to restart only the engine); the scheduler of
the same engine also kept dispatching loads that were bound to fail.
The startup probe's put left the same hazard behind when it gave up.

The worker now creates a marker, atomkv/store-tainted, outside every
namespace, in its Store: on such a put, and before startup fails on such
a probe put. Every worker looks for it at startup (loads stay off if it
is there; a Store that cannot say refuses startup) and before each load,
at most once a second, and a load whose look gets no answer fails. Every
scheduler lookup asks for it in the same batch_is_exist as the chunks; a
pool that carries it holds nothing for the lookups from then on and is
not asked again, and a pool that cannot say answers nothing. A process
that has seen the marker never trusts that Store again.

Why a marker in the Store, and how it is kept: the pinned Mooncake
(v0.3.14-rc1) has a hard pin, ReplicateConfig.with_hard_pin, which every
eviction path of the master skips, and a put session
(batch_put_session_start/end) that has the master reserve and commit an
object with no transfer at all, so the marker gets there while the
fabric that stalled the put is still stalled. The marker asks for 100
replicas: the master places at most one per segment, best effort, so a
segment that unmounts (taking its own objects, late write included)
leaves the marker in the others. A marker the Store does not take is
retried with backoff up to 30 s, logged as an error each time, while the
worker's own loads stay off. Only a restart of the Store's master and
owners clears it, which is also what frees the memory the late write can
reach. What can still lose it is a remove of the key or a remove_all,
which leave the late write in place, and the loss of every segment that
holds a replica; the README says to restart a tainted Store and never
just empty it.

Tying the Store's lifetime to the worker's in the launcher would not
have been enough: the launcher already starts its workers once and the
Store with them, and the gap was other engines and deployments outside
it. Its comment claimed the Store outlived worker restarts, which it
never does, and now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(offload): renew only the leases of the chunks a load reads

The mooncake_store scheduler renews a hit's read leases right before it
dispatches the load, since a request that waited for KV blocks outlives
the 10 s lease its lookup took. It did that by running a whole lookup
again: every chunk of the prompt on every rank, through the base's
bookkeeping, only to log the answer and dispatch the load anyway. The
chunks below the HBM frontier are not read, and neither are those past
the transfer end.

The lookup client now renews exactly the chunks the load reads,
[hbm, transfer_end) of every rank, in one batch_is_exist per pool, and
the scheduler logs how many of those objects the Store holds. The load
still goes ahead whatever the answer: the engine parked the request
when it admitted it, and only the load's report wakes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(offload): skip the Store lookup of a prompt too short to load

Every new request cost the scheduler thread a blocking batch_is_exist,
also when its prompt could never be loaded: a load must move at least
OFFLOAD_MIN_LOAD_TOKENS, and no hit is longer than the prompt less one
token, floored to a chunk (_loadable_hit). Such a prompt's lookup always
ended in "too small" after admission.

The mooncake_store scheduler now answers a miss for those prompts
without asking the Store. What this gives up is the hit's save floor:
such a prompt's save no longer skips the chunks the Store already holds,
and their puts return at once (the master refuses an existing key before
any transfer), off the scheduler thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): hold the mooncake_store transfer pool back from the KV budget

The worker allocates its transfer slot pool, 1.25 GiB of HBM by default,
in register_kv_caches, after the model runner has sized the KV cache.
mooncake_store did not override kv_budget_reserve_bytes, so the runner
reserved nothing for it: at a high --gpu-memory-utilization the pool
could fail to allocate, or take the headroom meant for activations and
CUDA graphs.

The worker class now answers kv_budget_reserve_bytes from its config,
which the factory reaches through the backend's registration, also
inside a multi beside a P/D connector. The pool's allocation and the
reserve come from one function, pool_allocation_bytes: both regions
whole plus 2 MiB to align the base, so the runner holds back exactly
what registration allocates, before the chunk size is known. A region
that is not a whole number of slots leaves its tail unused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(atomesh): read MOONCAKE_STORE_WAIT_TIMEOUT in decimal

The launcher validates MOONCAKE_STORE_WAIT_TIMEOUT with 10#, which
accepts a leading zero, and then used the raw value in bash arithmetic,
which reads one as octal. 0900 passed validation and then aborted the
launcher under set -e ("value too great for base"), after the Store had
started; 0600 silently waited 384 s, while the cross-node wait, computed
in awk, used 600 s.

Every wait now reads the value through mooncake_store_wait_timeout,
which normalizes it with $((10#value)) once validation has passed: the
prefill node's owner wait, the decode node's, and the cross-node wait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(offload): one definition of an offload backend

Three places decided which connectors are offload tiers: a hardcoded
_OFFLOAD_CONNECTORS set in arg_utils, the offload=True flag on the
factory's registrations (is_offload_backend), and the launcher's bash
list, which refuses an offload connector in the P/D config before
anything starts. A backend registered later would be refused as a
second offload tier by the multi connector but let through by
ATOM_KV_OFFLOAD's composition.

arg_utils now resolves each configured name through the factory, by
name or alias in any case, and asks is_offload_backend; a name the
factory does not know is not an offload tier there, and building it
fails later with the factory's own error. The launcher checks the
config before ATOM is importable, so it keeps its list, and a test now
holds that list equal to the factory's offload names and aliases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(atomesh): signal the page-cache dropper only while it is ours

The launcher's page-cache dropper runs with --for 1800 and exits on its
own, but stop_mooncake_store still sent TERM, then KILL, to its saved
pid and process group at cleanup, hours later in a long job. By then the
kernel may have given that pid to an unrelated process, which the
cleanup would kill.

The cleanup now signals the dropper only while its pid still runs
drop_page_cache.py --every, read from /proc/<pid>/cmdline, and forgets
the pid either way. Until the forked child execs, though, its cmdline is
the launcher shell's, so a cleanup right after the start would take the
dropper for another process and leave it running. The start therefore
waits for the exec, two seconds at most. The launcher test stub keeps
the dropper's command line, as python3 would.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(atomesh): refuse the Store in an aggregated cell, and trap before its start

main's #2528 adds an aggregated mode to the launcher: one server, no P/D
roles, a start_aggregated branch ahead of the P/D branches. Two things
of this PR meet it there:

- MOONCAKE_STORE starts the Store from start_prefill, and an aggregated
  cell has no prefill, so its server would run silently without the
  offload the case asked for. validate_mooncake_store_settings now refuses
  that combination before anything starts.
- Every branch installs its EXIT trap before its first start (the rebase
  moved the aggregated branch's trap ahead of start_aggregated, so the
  rule holds there too). The launcher test now finds nine branches and
  counts start_aggregated as a start.

The Store-env command-line test also stubs build_torch_profiler_args and
ATOM_SOURCE_ENV, which #2528 added to start_prefill and start_decode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): look for the Store's taint marker before and after every get

A worker looked for the taint marker at most once a second and trusted a
"not there" answer for the rest of that second. If it looked at t=0,
another client marked the Store at t=0.1 and a load ran at t=0.2, the load
skipped the look, and its batch_get_into could install the same-sized
object that the abandoned put corrupted. The marker exists to stop
exactly that.

_load_refusal now looks before every get (once per load window, right
before its client.get) and never trusts an earlier answer. A load also
looks once more after its last get: a mark placed while a get ran is one
the look before it missed, and that get may have read what the given-up
put wrote, so the load fails and nothing it unpacked is used. A load costs
one batch_is_exist on one key per window, plus one. Once seen, the marker
still holds for good, and a look that gets no answer still refuses that
load.

What no look can catch is a late write that lands, and is read by a load
that ends, between Mooncake giving up the put and the mark landing; the
README says so.

Tests: a load right after another client marked the Store gets nothing and
issues no get (fails with the one-second cache); a load whose only get ran
while another client marked the Store fails (fails without the look after
the get).

Reported by Copilot in review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): hold back the Store's staging buffers from the KV budget

A window whose slots are scattered goes through the block GPU connector,
which keeps one staging buffer per save and load thread, allocated after
the KV cache is sized. The reserve covered only the transfer pool, so the
degraded path allocated into the headroom the forward pass was sized
against.

The worker now builds the connector with a bound known from settings
alone, OFFLOAD_GPU_STAGING_MAX_BYTES or 48 MiB, and the reserve adds that
bound for every save and load thread. A chunk larger than the bound
refuses to start instead of exceeding what was held back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(atomesh): refuse Store ports that collide or leave the TCP range

Owner i binds ATOMESH_MOONCAKE_OWNER_PORT + i with nothing keeping it clear
of the masters' ports: with the defaults, the 29th owner of a shared pool
took 26080, the metadata server's port, and failed to bind once the Store
started. Only the last pool's master ports were range-checked.

check_mooncake_store_ports now runs with the other Store settings, before
the node starts anything. It checks every port the prefill node's Store
binds (each pool's master, metadata and metrics ports, and each owner's)
for range and uniqueness, and the decode owners' ports for range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): close a Store client whose setup failed

MooncakeStoreClient raised on a failed setup before keeping the store
object, so nothing closed it, and whatever setup had started stayed. With
a master that is down, the scheduler reconnects every 30 s and left one
such client per attempt. The client is now closed before the error is
raised; a close that raises is logged, and the setup failure is still what
the caller sees.

Reported by valarLip in review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(atomesh): wait for the other node's compaction of every NUMA node

A node waits for the other node's part of the Store for
MOONCAKE_STORE_WAIT_TIMEOUT plus that node's compaction, which comes
first there. The compaction takes up to MOONCAKE_STORE_COMPACT_TIMEOUT
for each NUMA node its owners pin, one node at a time, but the wait
allowed for two. With owners on four NUMA nodes (NPS4) the other node
could give up during a normal compaction.

mooncake_store_cross_node_timeout now takes the other node's owner
setting and counts its distinct NUMA nodes: MOONCAKE_STORE_DECODE_OWNERS
for the prefill node's wait, MOONCAKE_STORE_OWNERS for the decode node's.

Reported by valarLip in review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(docker): build the Mooncake Store only where it is asked for

Every image that installed Mooncake (INSTALL_MOONCAKE=1) was built with
WITH_STORE=ON and the three Store checks. An image that only moves P->D
KV through the TransferEngine then failed to build wherever a Store
dependency (cachelib, ...) does not build on its base, and every image grew
and built slower.

INSTALL_MOONCAKE_STORE (default 0) now decides: off, Mooncake is
configured WITH_STORE=OFF and none of the Store checks run, which is the
build from before; on, the build is the Store build, and a missing binary
or a mooncake.store that does not import still fails it. docker-release.yaml
turns it on: the release images are what the mooncake_store connector and
the atomesh mooncake_store suite run on. The launcher's image preflight
names the switch.

Checked by running the build step's shell with stub tools: off configures
WITH_STORE=OFF and needs neither the binaries nor the module; on configures
WITH_STORE=ON and fails without either.

Reported by valarLip in review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(offload): bound the Store's pending saves by OFFLOAD_MAX_PENDING_SAVES

The Store scheduler bounded running-plus-queued saves only when the
connector config set max_pending_saves, while docs/environment_variables.md
says OFFLOAD_MAX_PENDING_SAVES bounds them too. Setting it did nothing. It
now bounds them when set, the connector config still taking precedence;
with neither set, saves stay unbounded, which the docs now say.

Also says what put_bytes in [OFFLOAD-STORE-STATS] counts: every chunk a
put left in the Store, including those it already held, since Mooncake
answers 0 for both and moves nothing for the latter.

Reported by valarLip in review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(docker): build the Mooncake Store by default

INSTALL_MOONCAKE_STORE now defaults to 1, so every image built from
atom_release.dockerfile carries the Store (mooncake_master,
mooncake_client and mooncake.store), not only the release images.
Nothing runs it unless ATOM_KV_OFFLOAD=mooncake_store or the launcher's
MOONCAKE_STORE=1 asks for it, and P->D KV moves through the same
TransferEngine as without it. INSTALL_MOONCAKE_STORE=0 still configures
WITH_STORE=OFF, for a base on which a Store dependency does not build.
docker-release.yaml no longer passes the switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(atomesh): read the dropper's command line again while it execs

A process's /proc/<pid>/cmdline reads empty while it execs: its new
memory map has no arguments yet. The page-cache dropper execs more than
once as it starts (setsid, then python3), so a check that landed in
such a window took a running dropper for gone. Right after its start
that failed test_cleanup_signals_the_page_cache_dropper_only_while_it_runs
in CI; at cleanup it would have left the dropper running.

mooncake_page_cache_dropper_running now reads the command line again,
for up to half a second, while it is empty. Repeating that test six at a
time on the dev host failed 13 of 360 runs before and 0 of 600 after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
valarLip pushed a commit that referenced this pull request Oct 13, 2026
* fix(pd): a producer's DP dummy stages no drafts

#2332 sized a P/D producer's DP dummy at q=1 (num_spec_step=0) to match the
producer's real q=1 decodes. Such a batch has no scheduled_spec_decode_tokens,
but the deferred decode path stages a new sequence's draft columns whenever
the processor's use_spec is set -- and a producer keeps use_spec, since its
drafter writes context KV. The dummy is always a new sequence, so every idle
DPA producer rank raised AttributeError when a peer ran the q=1 decode that
surfaces a deferred T0.

Stage drafts only when the batch carries them (num_spec_step > 0).

test_a_producer_dp_dummy_stages_without_a_draft_table builds the dummy with
dummy_execution and stages it; without the fix it raises the same
AttributeError seen in CI. The flat-width test's batch now carries the
num_spec_step its q=4 rows imply.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(pd): pin that new requests still get their draft columns

Review follow-up. The flat-width test asserted only shape and counts, so a
predicate that stopped staging drafts for consumers would stay green while
MTP verified committed history instead of drafts. It now checks the staged
draft values. The producer-dummy test no longer pins that ScheduledBatch omits
the draft table, only that it carries no drafts. The comment joins the one it
qualifies instead of following the loop-vs-scatter note.

With the guard removed the producer-dummy test fails (AttributeError); with
drafts never staged the flat-width test fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
jiayyu added a commit that referenced this pull request Oct 14, 2026
…rebase

main dropped `total_tokens = batch.total_tokens_num` from prepare_input_ids
(its own paths no longer read it); the mixed branch still did, so every mixed
step raised NameError. The rebase merged cleanly -- the conflict was semantic.
test_mixed_deferred_inputs_under_spec caught it.

Also point the mixed shrink-guard notes at `dspark_scheduler.apply_q_bucket`,
where main (#2332) moved the prefill guard `_dspark_apply_q_bucket` used to
hold inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jiayyu added a commit that referenced this pull request Oct 15, 2026
…rebase

main dropped `total_tokens = batch.total_tokens_num` from prepare_input_ids
(its own paths no longer read it); the mixed branch still did, so every mixed
step raised NameError. The rebase merged cleanly -- the conflict was semantic.
test_mixed_deferred_inputs_under_spec caught it.

Also point the mixed shrink-guard notes at `dspark_scheduler.apply_q_bucket`,
where main (#2332) moved the prefill guard `_dspark_apply_q_bucket` used to
hold inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jiayyu added a commit that referenced this pull request Oct 21, 2026
…rebase

main dropped `total_tokens = batch.total_tokens_num` from prepare_input_ids
(its own paths no longer read it); the mixed branch still did, so every mixed
step raised NameError. The rebase merged cleanly -- the conflict was semantic.
test_mixed_deferred_inputs_under_spec caught it.

Also point the mixed shrink-guard notes at `dspark_scheduler.apply_q_bucket`,
where main (#2332) moved the prefill guard `_dspark_apply_q_bucket` used to
hold inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jiayyu added a commit that referenced this pull request Oct 22, 2026
…rebase

main dropped `total_tokens = batch.total_tokens_num` from prepare_input_ids
(its own paths no longer read it); the mixed branch still did, so every mixed
step raised NameError. The rebase merged cleanly -- the conflict was semantic.
test_mixed_deferred_inputs_under_spec caught it.

Also point the mixed shrink-guard notes at `dspark_scheduler.apply_q_bucket`,
where main (#2332) moved the prefill guard `_dspark_apply_q_bucket` used to
hold inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

4 participants