Skip to content

fix(offload): key multimodal prompts by media, one LMCache engine id per server, MP anchors on the chunk grid, keep a hybrid's last chunk - #3027

Open
ZLkanyo009 wants to merge 9 commits into
mainfrom
lingzha/offload-model-independent
Open

ZLkanyo009 wants to merge 9 commits into
mainfrom
lingzha/offload-model-independent

Conversation

@ZLkanyo009

@ZLkanyo009 ZLkanyo009 commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Split out of #2636 at valarLip's request (review, the offload/config.py:463 comment): these offload fixes are not Qwen3.8-specific, so they get their own PR and their own validation. #2636 now targets this branch and keeps only the Qwen3.8 changes. Merge order: this PR → #2636 → #2668.

What

  1. One LMCache engine id per server (offload/config.py, lmcache_engine_id). The id names the ZMQ socket under a fixed host directory, so two servers on one host both bound dp0, and one server's scheduler could follow the other server's hits. The id is now atom-offload-r{data_parallel_base_port}-dp{rank}. If the port is unresolved (0), the DP rank alone is used, as on main. The id is not part of a chunk's key, so stored chunks keep their keys.
  2. Multimodal prompts key by their media (_offload_common.tier_token_ids). Image placeholder ids are the same for every picture. Before this change, two prompts with the same text and different images had identical tier keys, and B loaded A's KV. Every lookup, save and load request now keys its ids through tier_token_ids(seq, end), which folds Sequence.cache_seed into the first 4 ids. This covers ChunkedOffloadSchedulerBase (in-process lmcache_offload and lmcache_mp), the native-state lookup, and the DSV4 PAGE+SLOT scheduler. DSV4's SLOT boundary hash chain (_chained_prefix_hashes(seed=)) now starts from cache_seed as BlockManager's chain does; before, it started from −1. Text prompts key exactly as before.
  3. lmcache_mp state images on its chunk grid (BlockManager.align_checkpoint_anchor, align_midstep_chunk; mp/native_state_scheduler.py). MP keeps a checkpoint only on its own chunk grid, so the prompt-end anchor is floored onto that grid when the grid still lies past where the request resumes. A prefill chunk that crosses an on-grid anchor ends at the anchor. Off-grid anchors are not cut for. The duplicate _record_checkpoint_end call after _commit_joint_boundary is removed.
  4. A hybrid's full-prompt hit keeps its last chunk (_withholds_last_prompt_token, _loadable_hit). A full-prompt hit gives back its last token and is then floored to a chunk. For a chunk-aligned prompt, that floor drops a whole chunk. The generic default in _offload_common keeps that behavior. The Kimi-K3 scheduler, and therefore its Qwen GDN subclasses, does not give back when the joint boundary lies past the withheld chunk: the joint boundary already leaves a token to compute. _loadable_hit(hit, num_prompt, seq) now has one signature, and all three callers pass seq: chunked, DSV4 and mooncake_store.
  5. A native lmcache_mp restore is timed from when it is enqueued (mp/native_state_worker.py, _begin_restore). Found by the GPU runs below. The transfer deadline (30 s since fix(runtime): propagate MP failures and return unhealthy status #2837) was counted from the retrieve's submission. The worker polls only between forwards, so one long forward, such as the first one after a restart, which compiles the restore kernels, could use up the deadline before the restore copy was even enqueued. The engine then stopped with LMCacheTransferUnprovable. Main rarely reaches this path, because MP keeps few state images off its chunk grid; with change 3, every restarted request restores. While the retrieve is pending, the deadline still counts from its submission. Once the copy is enqueued, it bounds only the copy.
  6. A lmcache_mp load queued behind a finished one starts its scheduler bound again (mp/scheduler.py, _restart_loads_queued_behind_a_finished_one). Also found by the GPU runs. The scheduler stops the engine when a load is not terminal 40 s (30 + 10) after it first sees it. A native-state worker restores two loads at a time, in the order it received them, and only between forwards. With change 3, 64 requests arriving at once after a restart need 64 restores. On a busy host (a 29 s first forward, then a 21 s step), the last 12 were still queued when the bound ran out, with nothing in flight: LoadOperationId(req_id=62) is still not terminal after 51s. Now, when a load finishes, every load dispatched after it starts its bound again. Dispatch order comes from _active_load_operations, which build_connector_meta fills in meta.add_request order, so it holds within one step too. A load is restarted only by loads dispatched before it, so if its completion is lost, the engine still stops one bound after the last of those finishes. The defaults (30 s, 10 s, two slots) are unchanged. Main has the same bound but rarely restores.

Each change has its tests (test_lmcache_offload_connector.py, test_state_checkpoint.py, test_gdn_midstep_checkpoints.py, test_lmcache_mp_*), and the docs are updated (offload/README.md, docs/environment_variables.md).

Not changed

  • The vLLM plugin path (atom/plugin/vllm/kv_transfer/) keeps main's behavior for changes 1 and 2. SeqView has no cache_seed, so plugin media keys are raw ids, as on main. vLLM's ParallelConfig has no data_parallel_base_port, so the plugin engine id stays atom-offload-dp{rank}. Neither is a regression. For the same reason tier_token_ids reads getattr(seq, "cache_seed", -1) (main's tests' fake seqs lack it too); declaring cache_seed on SeqView is left for a follow-up.

Validation

Head 1e6fe588d. The branch started from main 69d7931b0; d513f0ca9 merges main 30d2ad88f. Each GPU number is labeled with the head it ran on. f1662fe4c is change 5, 4162fcde5 adds only a CPU test, 23d9ee232 is change 6, and 753a55282 makes change 6 number loads in dispatch order (valar-cop's r4477625065). 45123b668 builds the two-servers test's configs with conftest.atom_config_double(), so the test keeps up with fields main adds to Config (main's #2912 added keep_finished_outputs). 00dd30e79 and 1e6fe588d tidy this PR's own code without changing behavior.

CPU

  • black / ruff clean on the changed files.
  • review-pr's affected_tests.py, CPU arm: 4162fcde5 1975 passed / 12 skipped; 23d9ee232 1988 passed / 12 skipped; 753a55282 1989 passed / 12 skipped; 45123b668 against main 30d2ad88f 1989 passed / 12 skipped; 00dd30e79 1988 / 12; 1e6fe588d 1987 / 12 (each drops one test); nothing failing on head.
  • CPU full suite (run_unit_tests.sh) on 45123b668: 11234 passed, 1164 skipped, 3 failed; on 00dd30e79: 11233 passed, the same 3 failed. The 3 failures are test_graph_marker ×2 and test_sparse_indexer_side_effect_op, which fail the same way on main (inductor g++).
  • Mutation checks. Reverting each of these makes a test fail (a CPU test unless noted):
    • media keys never salted;
    • the Kimi-K3 override always withholding;
    • the midstep cut ignoring the grid;
    • an unseeded DSV4 SLOT chain;
    • raw ids in the DSV4 lookup;
    • raw ids in the DSV4 save;
    • raw ids in the DSV4 load;
    • an engine id that ignores the port;
    • the restore deadline counted from the retrieve (test_lmcache_mp_native_worker.py, GPU);
    • the scheduler bound never restarted (test_lmcache_mp_native_scheduler.py);
    • the restart numbering one step's loads in hash order instead of dispatch order (same file).

GPU, MI355X, rocm/atom-dev:nightly_202610221638:

  • GPU unit tests (the offload, checkpoint and lmcache_mp files): 2caf12f1e 1249 passed / 9 skipped / 0 failed; 4162fcde5 1252 / 9 / 0; 23d9ee232 1254 / 9 / 0. The new tests account for the increases. affected_tests.py's GPU-only files on 1e6fe588d: 325 passed / 9 skipped. The skips need ATOM_DSV41_REFERENCE or a Mooncake Store service.

  • Two servers on one host (2caf12f1e). Qwen3.5-35B-A3B-FP8, in-process lmcache_offload, one server per GPU, interval 256. Prompt X was stored on S1 and prompt Y on S2. Then X was sent to S2 and Y to S1. Then 60 fillers per server pushed both out of HBM, and X was sent to S1 and Y to S2 again.

    • The servers bound different sockets: engine_atom-offload-r35849-dp0_… and engine_atom-offload-r34969-dp0_….
    • Crossed requests: offload_tokens 0, same answers as when stored.
    • Own tier: X 4864 and Y 5120 offload_tokens, state_tier 1, same answers.
    • On main 69d7931b0, both servers bind engine_atom-offload-dp0_…. The crossing did not show in that run, because each scheduler connected before the other server rebound the path; the shared path is the bug.
  • DeepSeek-V4.1-Flash, two images with the same text (4162fcde5). TP4, lmcache_mp (V4.1 accepts only lmcache_mp or Mooncake), thinking off. A (red circle, "CAT") and B (blue square, "DOG") each have 559 prompt tokens with the same ids.

    • First pass, A then B: LMCache stored A's 512 chunk-aligned tokens before B was sent. B: offload_tokens 0, cached_tokens 0, and it answered "a square, it is blue … DOG".
    • ATOM restarted with LMCache kept, then A and B again: each got offload_tokens 512 from its own entries, and each answered with its own picture (red circle "CAT", blue square "DOG").
    • Control on main 69d7931b0, same steps: B got offload_tokens 512 from A's entries, and answered "a circle, it is red, and the word written on it is "CAT"".
  • Qwen3.5 / Qwen3-Next + lmcache_mp GSM8K. Questions 0-199. Pass 1 stores. ATOM is then restarted with LMCache kept, and pass 2 loads from LMCache.

    Head Model Pass 1 / pass 2 Pass-2 LMCache hits
    main 69d7931b0 Qwen3-Next-80B-A3B-FP8 0.965 / 0.97 11 of 200 requests, 5.3% of prompt tokens
    2caf12f1e Qwen3-Next-80B-A3B-FP8 0.965 / 0.97 (2nd run; the 1st stopped in pass 2, change 5) 200, 88.3%
    2caf12f1e Qwen3.5-35B-A3B-FP8 0.975 / 0.98, 0.97 / 0.975 (an earlier run stopped in pass 2, change 5) 200, 88.5%
    f1662fe4c Qwen3-Next-80B-A3B-FP8 0.97 / 0.97 200, 88.3%
    f1662fe4c Qwen3.5-35B-A3B-FP8 0.985 / 0.975, 0.97 / 0.965 200, 88.5%
    4162fcde5 Qwen3.5-35B-A3B-FP8 stopped in pass 2 (change 6) –
    23d9ee232 Qwen3.5-35B-A3B-FP8 0.975 / 0.975, 0.965 / 0.965 200, 88.5%
    00dd30e79 Qwen3.5-35B-A3B-FP8 0.96 / 0.96, 0.97 / 0.96 200, 88.5%
    963d7fa0d ([Qwen 3.8] LMCache offload for QSA + GDN + PLE state #2636, before the split) Qwen3-Next eager / Qwen3.5 0.96 / 0.965, 0.98 / 0.98 –

    Main's low hit rate is the anchor bug change 3 fixes: MP keeps a state image only on its chunk grid, and main's anchors are off it.

    Qwen3-Next has no 23d9ee232 row. Since about 06:15 UTC on 11-05, Qwen3-Next stops in pass 1 with a HIP illegal memory access (recv_async_output, copy_done.synchronize()) in this environment. That includes main 69d7931b0 without any offload: 4 runs on two GPUs, including runs with a fresh TMPDIR and inductor cache. The Qwen3-Next rows above ran before that.

    A/B for changes 5 and 6. Each natural failure needs a slow host, so the A/B shortens lmcache.mp.transfer_deadline_s on the pass-2 server only:

    • Change 5, deadline 5 s. On 2caf12f1e, pass 2 stopped at once with load:3:5 is still not terminal after 9s (native_state_worker.py:502). On f1662fe4c, it scored 0.97 / 0.965 with all 200 retrieved.
    • Change 6, deadline 1 s, so the scheduler bound is 11 s. On 4162fcde5, pass 2 stopped at once with LoadOperationId(req_id=62, generation=62) is still not terminal after 18s, the same message as the natural failure; LMCache had retrieved only the first 64. On 23d9ee232, it scored 0.975 / 0.975, with all 200 hitting (88.5%).
  • Kimi-K3 + lmcache_offload, TP8 on one node, recipes/Kimi-K3.md flags but with prefix caching on (details: comment). Before each tier load, a 1.3× HBM flush; /debug/cache_stats shows the load came from the tier.

    Head Aligned 2048-token prompt: loaded from tier Unaligned 2148-token prompt GSM8K pass 1 / pass 2 Pass-2 tier hits
    main 30d2ad88f 0 2048 not run
    753a55282 1920 2048 0.975 / 0.985 108 of 200 requests, 50.3% of prompt tokens
    45123b668 1920 2048 0.98 / 0.985 108 of 200 requests, 50.3% of prompt tokens

    1920 is 2048 minus one 128-token block: the joint KV + state boundary is the last block boundary before the end. With the recipe's --no-enable_prefix_caching, Kimi-K3 never hits the tier, on main and here alike: state checkpoints need prefix caching (block_manager.py:364). The run with the recipe's flags on 753a55282 had GSM8K 0.99 / 0.995 and 0 tier tokens.

The 963d7fa0d row did not have the Kimi-K3 give-back, the DSV4 keys or seed, anchor % grid == 0, or changes 5 and 6. It is context only. Earlier, on 963d7fa0d (comment): MiniMax-M3.1 with two images and the same text, on both in-process lmcache_offload and lmcache_mp. B did not hit A in either.

Notes

  • The type(port) is int check is removed per valarLip's review. valar-cop had asked for it in r4439120010. The port is now read as int(... or 0) and used only when > 0.
  • An on-grid anchor inside the prompt's last chunk still costs one more prefill forward. That cost is intended: it is what lets this checkpoint be kept by LMCache. The align_midstep_chunk docstring says so.

@valarLip

…plit servers

Four model-independent LMCache fixes, moved out of the Qwen3.8 offload PR:

- tier_token_ids folds Sequence.cache_seed into the first ids of every
  LMCache lookup, save and load key the chunked schedulers and the DSV4
  scheduler build, so prompts with the same text and different images no
  longer share KV keys; DSV4's SLOT boundary hashes start from the same seed,
  as BlockManager's chain does.
- A hybrid's full-prompt hit on a chunk-aligned prompt keeps its last chunk
  when a joint boundary lies in it (KimiK3OffloadScheduler and its Qwen
  subclass); _loadable_hit always takes the sequence.
- Under lmcache_mp with state images the prompt-end anchor is floored onto
  the chunk grid, the only place MP saves an image, and a prefill chunk ends
  on such an anchor.
- lmcache_engine_id adds data_parallel_base_port, so two servers on one host
  no longer bind the same lookup socket.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

github-actions Bot commented Nov 5, 2026

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 3027 --add-label <label>

@ZLkanyo009

ZLkanyo009 commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor Author

Opened at 2caf12f1e (from main 69d7931b0), split out of #2636 per @valarLip's review (split comment). #2636 now targets this branch.

How each point from that review maps here:

  • Multimodal keys missed DSV4 (r4456064945): DSV4's lookup, load and save key through tier_token_ids. I swept the SLOT boundary chain too (_chained_prefix_hashes(seed=…)); it's the same bug, per Fix-then-sweep.
  • Give-back lived on Qwen (r4456064953): the override is on KimiK3OffloadScheduler, and the default stays in _offload_common.
  • _loadable_hit had two modes (r4456064957): seq is required, and all three callers pass it.
  • Final-chunk cut (r4456064980):
    • an anchor off the grid is no longer cut for (anchor % grid == 0);
    • an on-grid anchor in the last chunk still costs one forward, on purpose, so the checkpoint reaches LMCache. The docstring says so.
  • Second _record_checkpoint_end (r4456064987): removed.
  • type(port) is int: removed. The port is now int(... or 0) and used only when > 0. valar-cop had asked for the check in r4439120010; it's removed per valarLip's review.

Self-check with the repo's review-pr skill (an independent agent, before this push):

Validation on 2caf12f1e (CPU only; no GPU available right now):

  • black / ruff clean.
  • The changed test files: 727 passed.
  • affected_tests.py CPU arm: 1972 passed, 12 skipped.
  • CPU full suite: 11217 passed. The 3 failures are the same as on main (inductor g++).
  • 7 mutations, one per change, each caught by a CPU test.

Pending: the four GPU checks in the description:

  • Kimi-K3 full-prompt hit and GSM8K;
  • DSV4.1 two images;
  • Qwen3-Next or Qwen3.5 on lmcache_mp GSM8K;
  • two servers on one host.

Please don't merge before those are in. CI is green (Black, Ruff, non-GPU unit tests).

cc @valarLip @yhl-amd

@valar-cop valar-cop left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

some changes needed..

kimi-k3 + lmcache_offload: same chunk-aligned prompt twice (offload_tokens == whole prompt) and a gsm8k pass1/pass2 round? deepseek-v4.1: two images same text, B must not hit A? qwen3-next/3.5 + lmcache_mp: gsm8k pass1/pass2 vs #2636 963d7fa? two servers on one host: lmcache sockets do not cross?

ci green.

ZLkanyo009 and others added 2 commits November 5, 2026 05:18
With images now saved on the chunk grid, lmcache_mp restarts restore state
for most hits. A worker polls a retrieve only between forwards, and the first
forward after a cold start can spend ~30 s compiling kernels. By the time the
retrieve is seen terminal and its restore enqueued, the 30 s transfer deadline,
counted from the retrieve's submission, has already passed, so the first
pending restore stopped the engine (Qwen3.5 and Qwen3-Next GSM8K pass 2 after
an ATOM restart).

The retrieve is terminal at that point, so the deadline now bounds only the
restore copy, counted from when it is enqueued. A restore whose fence raises
keeps the submission clock, and a copy that never completes still stops the
engine.

Co-authored-by: Cursor <cursoragent@cursor.com>
The DSV4 scheduler's lookup and save keys had tests; its load did not, so
building the load request from raw token ids passed every test. The new test
fails under that change.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ed one

A native-state worker restores two loads at a time, in order, between
forwards. On a restarted server, a burst of 64 restores plus two slow
first forwards (29 s and 21 s under host load) kept the last loads
queued past the scheduler's 40 s bound with nothing in flight, and the
engine stopped. A finished load now restarts the bound of the loads seen
after it; a load whose completion is lost still stops the engine one
bound after the loads ahead of it finish.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ZLkanyo009

ZLkanyo009 commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor Author

Pushed 2caf12f1e..23d9ee232 (three commits, no rebase).

The GPU runs for valar-cop's review found two real problems in lmcache_mp restarts. Both exist on main, but main rarely restores; with this PR's anchor alignment (change 3), every restarted request restores.

1. Restore deadline counted from the retrieve (f1662fe4c, change 5). On 2caf12f1e, Qwen3.5 and Qwen3-Next on lmcache_mp each stopped once at the start of GSM8K pass 2, after the ATOM restart, with LMCacheTransferUnprovable: … still not terminal after 30s (native_state_worker.py:502).

  • Cause: the worker polls only between forwards, and the first forward after a restart compiles the restore kernels. When that forward took ~30 s, the deadline was spent before the restore copy was enqueued.
  • Fix: _begin_restore restarts the clock when the copy is enqueued. While the retrieve is pending, the deadline still counts from its submission. New test test_restore_deadline_counts_from_the_restore_not_the_retrieve; reverting the fix fails it.
  • A/B, pass-2 deadline 5 s: 2caf12f1e stopped at once (load:3:5 is still not terminal after 9s); f1662fe4c scored 0.97 / 0.965 with all 200 restored.

2. Scheduler bound counted from dispatch, including the queue (23d9ee232, change 6). On 4162fcde5 (and on #2636's head), Qwen3.5 stopped once at the start of pass 2 with LoadOperationId(req_id=62) is still not terminal after 51s (mp/scheduler.py, _enforce_transfer_deadlines).

  • Cause: 64 requests arrived at once. LMCache retrieved all of them within 1 s, but the worker restores two at a time, in order, between forwards. On a busy host (load average 26: a 29 s first forward, then a 21 s step), loads 52-63 were still queued with nothing in flight when the 40 s bound (30 + 10, from dispatch) ran out.
  • Fix: when a load finishes, every load the scheduler saw after it starts its bound again. A load is restarted only by loads seen before it, so a lost completion still stops the engine one bound after the last of those finishes. The defaults (30 s, 10 s, two slots) are unchanged. New test test_a_load_queued_behind_a_finished_one_starts_its_bound_again[earlier|later]; skipping the restart fails [earlier].
  • A/B, pass-2 deadline 1 s (bound 11 s): 4162fcde5 stopped at once with LoadOperationId(req_id=62, generation=62) is still not terminal after 18s; 23d9ee232 scored 0.975 / 0.975 with all 200 hitting.

Self-check with the repo's review-pr skill, second round on 2caf12f1e (Must 0, Worth 1, Smaller 1, Follow-up 1):

  • Worth: the DSV4 load key had no failing test. 4162fcde5 adds test_a_dsv4_load_keys_its_pages_by_the_media[11/-1]; reverting the load to raw ids fails it.
  • Smaller: the description now says the vLLM plugin path is unchanged (no cache_seed on SeqView, no data_parallel_base_port on vLLM's ParallelConfig), so it behaves as on main.
  • Follow-up: the plugin path is recorded under "Not changed" in the description; no issue.

Validation on 23d9ee232:

  • black / ruff clean.
  • affected_tests.py CPU arm: 1988 passed, 12 skipped, nothing failing on head.
  • GPU unit tests: 1254 passed, 9 skipped, 0 failed.
  • Qwen3.5 + lmcache_mp GSM8K: 0.975 / 0.975 and 0.965 / 0.965, all 200 requests hitting LMCache in pass 2 (88.5%).
  • Qwen3-Next can't run in this environment right now: since ~06:15 UTC it hits a HIP illegal memory access in pass 1 on main without offload too. Its rows in the description are from 2caf12f1e / f1662fe4c, before that.
  • valar-cop's four GPU checks: three are done; the results are in the reply to the review and the description.

Pending: Kimi-K3 + lmcache_offload (needs a whole node); CI running.

The description is updated to this head.

@valar-cop cc @valarLip

@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

@valar-cop, results for the four checks in your review. MI355X, rocm/atom-dev:nightly_202610221638. Each result is labeled with its head.

1. Kimi-K3 + lmcache_offload: pending. It needs a whole node; I've asked for one. The chunk-aligned full-prompt hit and a GSM8K pass1/pass2 round will be posted here.

2. DeepSeek-V4.1-Flash, two images with the same text: B does not hit A (4162fcde5).

  • Setup: TP4, lmcache_mp (V4.1 accepts only lmcache_mp or Mooncake), --enable-prefix-caching, thinking off. A is a red circle labelled "CAT" and B is a blue square labelled "DOG". Both have 559 prompt tokens with identical ids.
  • A then B: LMCache stored A's 512 chunk-aligned tokens before B was sent. B got offload_tokens 0 and answered "a square, it is blue … DOG".
  • ATOM restarted with LMCache kept, then A and B again: each got offload_tokens 512 from its own entries, and each described its own picture.
  • Control on main 69d7931b0, same steps: B got offload_tokens 512 and answered "a circle, it is red, and the word written on it is "CAT"".

3. Qwen3-Next / Qwen3.5 + lmcache_mp GSM8K pass1/pass2. Questions 0-199. ATOM is restarted between the passes with LMCache kept.

Head Model Pass 1 / pass 2 Pass-2 LMCache hits
main 69d7931b0 Qwen3-Next-80B-A3B-FP8 0.965 / 0.97 11 of 200 requests, 5.3% of prompt tokens
2caf12f1e Qwen3-Next-80B-A3B-FP8 0.965 / 0.97 200, 88.3%
2caf12f1e Qwen3.5-35B-A3B-FP8 0.975 / 0.98, 0.97 / 0.975 200, 88.5%
f1662fe4c Qwen3-Next-80B-A3B-FP8 0.97 / 0.97 200, 88.3%
f1662fe4c Qwen3.5-35B-A3B-FP8 0.985 / 0.975, 0.97 / 0.965 200, 88.5%
23d9ee232 (head) Qwen3.5-35B-A3B-FP8 0.975 / 0.975, 0.965 / 0.965 200, 88.5%
#2636 963d7fa0d Qwen3-Next eager / Qwen3.5 0.96 / 0.965, 0.98 / 0.98 –

Accuracy matches 963d7fa0d within noise. Main's 11 hits are the off-grid anchor bug this PR fixes.

This round also found two real problems, both at the start of pass 2 after the restart, where every request now restores:

  • On 2caf12f1e, one Qwen3-Next and one Qwen3.5 run stopped with LMCacheTransferUnprovable from the worker's restore deadline. Fixed in f1662fe4c.
  • On 4162fcde5, one Qwen3.5 run stopped with the scheduler's bound (LoadOperationId(req_id=62) is still not terminal after 51s) while loads waited for a restore slot. Fixed in 23d9ee232.

The causes and deterministic A/Bs (old head stops, new head passes) are in this comment.

Qwen3-Next has no row on the head. Since ~06:15 UTC on 11-05 it hits a HIP illegal memory access in pass 1 in this environment, including on main 69d7931b0 without any offload (4 runs, two GPUs). Its rows above were run before that.

4. Two servers on one host: the sockets don't cross (2caf12f1e).

  • Setup: Qwen3.5-35B-A3B-FP8, in-process lmcache_offload, one server per GPU, interval 256.
  • Prompt X was stored on S1 and Y on S2. Then X was sent to S2 and Y to S1. Then 60 fillers per server pushed both out of HBM, and X was sent to S1 and Y to S2 again.
  • The servers bound engine_atom-offload-r35849-dp0_… and engine_atom-offload-r34969-dp0_….
  • The crossed requests got offload_tokens 0, with the same answers as when stored.
  • Each server's own tier: X 4864 and Y 5120 offload_tokens, state_tier 1, same answers.
  • On main 69d7931b0, both servers bind engine_atom-offload-dp0_…. The crossing did not show in that run, because each scheduler connected before the other server rebound the path. The shared path is the bug.

cc @valarLip

@valar-cop

Copy link
Copy Markdown

@ZLkanyo009 already reviewing this commit, results in a few minutes.. say @valar-cop re-review if you want a fresh one.

@valar-cop valar-cop left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

some changes needed..

ci green.

again. A load is restarted only by loads seen before it, so one whose
completion is lost still stops the engine one bound after the last of
those finishes."""
loads = {operation for _, operation in self._active_load_operations.values()}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

loads is a set, so position is hash order not dispatch order.. iterate self._active_load_operations.values() instead? one meta can dispatch several loads and the worker restores in that order

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@valar-cop Right, thanks. A restart's first step dispatches the whole burst at once, so hash order could leave a load queued behind a finished one on its old bound, which is the failure this method is for. Fixed in 753a552: positions now come from _active_load_operations.values(), which build_connector_meta fills in meta.add_request order. The set is kept only for membership. New test test_loads_dispatched_together_restart_in_dispatch_order (8 loads in one step) fails on 23d9ee2 under PYTHONHASHSEED 0-5 and passes with the fix.

cc @valarLip

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

looked at it, yes: _active_load_operations is filled in _decide_load_after_alloc right before meta.add_request (mp/native_state_scheduler.py:565, chunked_scheduler.py:812), so the numbering is in dispatch order now, also within one step. resolved from my side.

The restart numbered the loads it first saw in one pass by iterating a
set, so loads dispatched in the same step got hash order. A load
dispatched after a finished one could then hold a lower number and keep
its old bound. Iterate `_active_load_operations`, which is in dispatch
order, the order the worker restores in.

Co-authored-by: Cursor <cursoragent@cursor.com>
@valar-cop

valar-cop commented Nov 5, 2026 •

Copy link
Copy Markdown

@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

Pushed 23d9ee232..753a55282 (one commit, no rebase).

valar-cop's r4477625065 is a real bug in change 6. _restart_loads_queued_behind_a_finished_one numbered the loads it saw for the first time by iterating a set, so the loads dispatched in one step got hash order. The worker restores them in dispatch order. So when a load finished, a load dispatched after it could hold a lower number, keep its old bound, and stop the engine. A restart's first step, which dispatches a whole burst at once, is exactly the case change 6 is for.

  • Fix: iterate _active_load_operations.values() as suggested. That dict is filled in build_connector_meta, in the same order as meta.add_request, so it is in dispatch order, also within one step. The set is kept only for the membership test.
  • New test test_loads_dispatched_together_restart_in_dispatch_order: 8 loads in one step, finished one by one, each just under the bound after the previous one. On 23d9ee232 it fails (LMCacheTransferUnprovable) under PYTHONHASHSEED 0-5; with the fix it passes under all six.

Validation on 753a55282:

  • black / ruff clean on the two changed files.
  • affected_tests.py CPU arm: 1989 passed, 12 skipped, nothing failing on head.
  • No GPU run on this commit yet: 035 is running the Kimi-K3 + lmcache_offload check (TP8, the last of the four in review 5779752875), which does not use the MP scheduler. The GPU numbers in the description are labeled 23d9ee232.

Also: the description's "Not changed" no longer links #3029. That issue was closed; the plugin-path note stays in the description.

Pending: Kimi-K3 results (server loading); CI running.

The description is updated to this head.

@valar-cop cc @valarLip

ZLkanyo009 and others added 2 commits November 5, 2026 07:44
main's #2912 made `CoreManager._init_shared_state` read
`keep_finished_outputs` and `asyncio_mode`, which the hand-built
namespace lacked. `atom_config_double` derives its fields from
`dataclasses.fields(Config)`, so a field main adds next arrives with its
default.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

Pushed 753a55282..45123b668 (a merge of main and one test commit, no rebase).

Pre Checkin was red on this PR's own test (run 41987910858): test_two_default_tp1_servers_on_one_host_get_distinct_engine_ids raised AttributeError: 'types.SimpleNamespace' object has no attribute 'keep_finished_outputs' at engine_core_mgr.py:234.

Validation on 45123b668:

  • black / ruff clean on the changed file.
  • review-pr's affected_tests.py, CPU arm, against main 30d2ad88f: 1989 passed / 12 skipped, nothing failing.
  • CPU full suite (run_unit_tests.sh): 11234 passed, 1164 skipped, 3 failed. The 3 are the inductor g++ failures main has (test_graph_marker ×2, test_sparse_indexer_side_effect_op).
  • Earlier this round, 753a55282 (load restart in dispatch order) was confirmed in r4477625065; it is unchanged here.

Pending: CI on this head. Kimi-K3 + lmcache_offload (TP8 on one node) is running: the main control and a run on this head. Its results will be in a separate comment.

@valar-cop cc @valarLip

@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

Review 5779752875, item 1: Kimi-K3 + lmcache_offload results. No code change; the description's Validation now has them.

Setup: one MI355X node, TP8, rocm/atom-dev:nightly_202610221638. The flags are recipes/Kimi-K3.md's (fp8 KV, block 128, FULL cudagraph, online_quant_config), except that prefix caching is on (see the last point). Other settings:

  • lmcache_offload, chunk 256, 48 GiB CPU per rank;
  • gpu-memory-utilization 0.80 (5222 blocks).

Before each tier load, 107 random 8192-token prompts (876k tokens, 1.3× the HBM pool) push the test prompt out of HBM. /debug/cache_stats then confirms the load came from the tier (offload_tokens) and not from HBM (cached_tokens 0). state_offload_hashes_evicted stayed 0, so the fillers did not evict the test's entries.

Chunk-aligned prompt, sent twice:

Head Aligned prompt, 2048 tokens: loaded from tier Unaligned prompt, 2148 tokens: loaded from tier
main 30d2ad88f 0 2048
753a55282 1920 (+1 state image) 2048 (+1 state image)
45123b668 (head) 1920 (+1 state image) 2048 (+1 state image)
  • On main, the aligned prompt loads nothing, while a prompt 100 tokens longer loads 2048. This is the case change 4 fixes.
  • With the fix, the aligned prompt loads 1920 = 2048 − one 128-token block. The joint KV + state boundary is the last block boundary before the prompt end, and the last token is computed.
  • Greedy output is not byte-identical between two sends, even on main with both sends fully recomputed (0 tier tokens). So these runs don't use text identity as a check; GSM8K below is the quality check.

GSM8K pass1 / pass2 (questions 0-199, 5-shot). After pass 1, the same 1.3× flush pushes pass 1 out of HBM, so pass 2's hits come from the tier.

Head Pass 1 / pass 2 Pass-2 tier hits
753a55282 0.975 / 0.985 108 of 200 requests restored a state image; 125952 of 250357 prompt tokens (50.3%) from the tier, 640 from HBM
45123b668 (head) 0.98 / 0.985 108 of 200; 125952 of 250357 (50.3%) from the tier, 640 from HBM

State loads: 111 attempted, 111 completed, 0 failed. No tracebacks in the server log.

Prefix caching: recipes/Kimi-K3.md passes --no-enable_prefix_caching. With it, lmcache_offload never hits on Kimi-K3, on main and on this PR alike. State checkpoints are enabled only with prefix caching (block_manager.py:364, enabled = self.enable_prefix_caching and self.num_state_slots > 0), and a hybrid tier hit needs a state image.

  • Run with the recipe's flags on 753a55282: GSM8K 0.99 / 0.995, 0 tier tokens in pass 2.
  • Not changed here; recorded so nobody reads a no-hit Kimi-K3 offload run as a regression.

@valar-cop cc @valarLip

@valar-cop

Copy link
Copy Markdown

@ZLkanyo009 already looked at 45123b6, nothing new since my last review. say @valar-cop re-review if you want a fresh one.

ZLkanyo009 and others added 2 commits November 6, 2026 03:16
- mp scheduler: the load restart keeps one insertion-ordered dict of live
  loads instead of a dict of positions, a counter and a min(); same rule,
  same tests (dispatch-order mutation still fails under PYTHONHASHSEED 0-3).
- BlockManager._joint_kv_boundary takes the tier hit as a required
  argument; the optional one gave the method two modes. Its two other
  callers pass `seq.offload_joint.kv_prefix_tokens`, the value it read.
- tier_token_ids: `cache_seed` is never None on a Sequence (it is computed
  when the multimodal data has none); drop that check.
- Comments: drop a measured figure (~30 s compile) and a default
  (8192-token ladder) that go stale; keep the constraints.
- Drop the test that counted `_walk_prefix` calls through a monkeypatched
  private method: it tested the memo's mechanics, not a result.

Co-authored-by: Cursor <cursoragent@cursor.com>
…nch, name the replay decision

- align_checkpoint_anchor: its one caller already validates the grid against
  the hash block size, so the ValueError and its test go.
- Kimi _withholds_last_prompt_token: a native seq always has a bound block
  manager; the plugin SeqView returns before the check.
- _replay_decision returns a _ReplayDecision instead of a 3-tuple.
- _record_checkpoint_end: cached_tokens -> resume_tokens.
- Shorter restart docstring; drop a measured figure from a test docstring.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

Update 45123b668..1e6fe588d: a tidy-up of this PR's own code. No behavior changes, and nothing from main is touched.

What changed

  • Unreachable checks removed.
    • align_checkpoint_anchor no longer raises on an off-grid size, and its test is gone. Its one caller (native_state_scheduler.py) has already checked the chunk size against the hash block size by then.
    • Kimi-K3 _withholds_last_prompt_token drops its _block_manager is None branch. Native ATOM always binds the block manager, and a plugin SeqView returns one line earlier.
    • tier_token_ids drops seed is None, because Sequence.cache_seed is never None.
  • One mode per function. _joint_kv_boundary's kv_prefix_tokens is now required; each caller passes the value it used to read.
  • A named result instead of a 3-tuple. _replay_decision returns a frozen _ReplayDecision(hit, replays, kv_load), next to JointBoundaryDecision.
  • Less state. The load restart keeps dispatch order in one insertion-ordered dict, instead of a position map, a counter and min().
  • Names and comments.
    • _record_checkpoint_end(cached_tokens) is renamed to resume_tokens. It is max(HBM hit, tier load point), so the docstring line explaining it goes too.
    • Comments and docstrings no longer quote measured times or defaults (~30 s, 8192 tokens).
    • The restart docstring is down to its three constraints.
  • Tests. Removed the test that monkeypatched _walk_prefix to count calls, because it tested the memo, not a result. Also removed the test for the anchor-grid check above.

Size vs. merge-base 30d2ad88f: source +223/−30 → +216/−31, tests +621/−6 → +602/−7, docs unchanged.

Validation

  • black 26.10.1 / ruff clean.
  • affected_tests.py CPU arm on 1e6fe588d: 1987 passed / 12 skipped. That is 2 fewer than 45123b668, the two removed tests.
  • affected_tests.py GPU-only files on 1e6fe588d: 325 passed / 9 skipped.
  • CPU full suite on 00dd30e79: 11233 passed. The same 3 baseline failures as main (test_graph_marker ×2, test_sparse_indexer_side_effect_op).
  • Qwen3.5-35B-A3B-FP8 + lmcache_mp GSM8K on 00dd30e79, two runs: 0.96 / 0.96 and 0.97 / 0.96. Each run retrieved all 200 requests, and pass 2 hit the tier on 88.5% of prompt tokens, the same as earlier heads. 1e6fe588d changes no run path.

Not changed

  • getattr(seq, "cache_seed", -1) stays. The plugin SeqView and main's test fakes have no cache_seed, and reading it directly fails 140 of main's tests. Declaring it there is a follow-up.
  • joint_boundary_tokens picks the walk with the same one-liner as main's can_allocate_memoized. Sharing it would mean editing main's line.

Pending: Pre Checkin on this head (CI running). After that, #2636 merges this head.

cc @valarLip

This branch has not been deployed

No deployments
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.

3 participants