Skip to content

serve: a decoder that cannot share its KV gets the state-file route, not the decoder's own prefill - #1203

Merged
bong-water-water-bong merged 4 commits into
mainfrom
1bit/kvshare-file-fallback
Nov 6, 2026
Merged

bong-water-water-bong merged 4 commits into
mainfrom
1bit/kvshare-file-fallback

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

What

ONEBIT_KV_SHARE=1 makes the hybrid route hand the prompt's KV over as dma-buf fds instead of a state file. The handshake had one failure mode with no recovery in it: when the decoder refuses ?action=share — a params or model mismatch, or a hybrid cache on a pin without the recurrent-state arm — hybrid::Prefill::prepare() returned the refusal and the provider never ran, so the request fell back to the decoder prefilling the whole prompt. That is the exact cost the hybrid route exists to remove, and it arrives silently: one stderr line, and a request that looks like a plain one.

Now the handshake failures (share refused, reply without a share_id, no socket for the decoder) fall back to the state-file route for that request, which still keeps the prompt off the decoder's prefill path, and say which route ran:

1bit serve: hybrid: share answered 400: {...}; this decoder cannot share its KV, so the state-file route is used
1bit serve: hybrid: 30 prompt tokens: prefill 0 ms, restore 2 ms, share 0.000 ms, first byte 103 ms

Only the handshake falls back. A provider that already wrote into the region, or an adopt that failed after it, keeps today's behaviour (the request is forwarded as it came): falling back there would pay for the prompt's prefill twice.

Measured, before and after

tests/fake_backend.py learns FAKE_KVSHARE_REFUSE=1 — the decoder answers the real refusal shape. One 30-token prompt through 1bit serve --device hybrid, same build tree, only the code below differs (~/wt/zc-probes/kvshare-fallback-{before,after}/):

stderr, per request
before (29ec80c6) hybrid: share answered 400: {"error": {"message": "the KV cache of a hybrid model cannot be shared"}}; the backend prefills this request itself — no provider launch, no restore, id_slot unset
after ...; this decoder cannot share its KV, so the state-file route is used then prefill 0 ms, restore 2 ms, share 0.000 ms, first byte 103 ms (the provider ran, the state file was restored into slot 0, the request is pinned to it)

Gates (device-free, on today's main 29ec80c6)

gate before after
tests/hybrid_kvshare_route.sh PASS 17 checks PASS 23 checks — 6 new, and 4 of them fail before the change
tests/hybrid_route.sh PASS PASS (unchanged)
tests/hybrid_e2e.sh (real llama-server, Qwen3-0.6B) PASS PASS: greedy reply equal to --device cpu, prompt_n 1, plain 22
ctest -R hybrid 2/2 2/2 passed
tools/copyright.py --check clean clean

The new case asserts the four things that were wrong and two that keep it honest: the provider's state file is restored (share 0.000 ms in the log line is the file route), the request is pinned to slot 0, the fallback is named, and the decoder is not left to prefill the prompt itself.

Also in this diff

docs/hybrid.md carried a stale limit: "the region is llama_kv_cache, so a hybrid model's recurrent half is not shared and ?action=share refuses a hybrid cache". The pinned fork has shared the recurrent state since 1bit-MONSTER/llama.cpp#181 (llama_memory_recurrent arm in src/llama-kv-share.cpp), so the doc said "not supported" about a route this branch now falls back to on a decoder that predates it. The Tests section also gains the hybrid_kvshare_route bullet it never had.

Not claimed

  • No device run: no dma-buf, no HRX decoder, no NPU. Both arms of the before/after are the fake backend, which is what the route's contract is gated on in CI.
  • The fallback is exercised for the handshake failure only; a mid-prefill provider failure still lands on the plain path (by design, see above).
  • This does not change the flag's default. ONEBIT_KV_SHARE stays opt-in, and the file route stays the default for everyone else.

Same class as engine#941 (a handoff silently thrown away); part of engine#698.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

#1203 ready for review at efa2e51 — MERGEABLE, gates in the body (hybrid_kvshare_route 23/23 with the new case failing 4/6 before the change, hybrid_route 33/33, hybrid_e2e PASS, ctest -R hybrid 2/2, copyright check clean), device-free.

…not the decoder's own prefill

`ONEBIT_KV_SHARE=1` makes the hybrid route hand the prompt over as dma-buf fds, and the handshake has
one failure mode with no recovery in it: when the decoder refuses `?action=share` (a params or model
mismatch, or a hybrid cache on a pin without the recurrent-state arm), `prepare()` returned the refusal
and the provider never ran, so the request fell back to the decoder prefilling the whole prompt -- the
exact cost the hybrid route exists to remove.

Now the handshake failures (share refused, no share_id, no socket) fall back to the state-file route
for that request, which still keeps the prompt off the decoder's prefill path, and say so once:
`... this decoder cannot share its KV, so the state-file route is used`, with `share 0.000 ms` in the
per-request line. A provider that already wrote into the region, or an adopt that failed after it, is
still a plain fallback: falling back there would pay for the prefill twice.

Measured with tests/fake_backend.py in a new mode (`FAKE_KVSHARE_REFUSE=1`, a decoder that answers the
real refusal), one 30-token prompt, same binary before and after:

  before  hybrid: share answered 400: {...cannot be shared}; the backend prefills this request itself
          (no provider launch, no restore, id_slot unset)
  after   hybrid: share answered 400: {...cannot be shared}; this decoder cannot share its KV, so the
          state-file route is used
          hybrid: 30 prompt tokens: prefill 0 ms, restore 2 ms, share 0.000 ms, first byte 103 ms

Gates, all on today's main (29ec80c), device-free: `hybrid_kvshare_route` PASS with 6 new checks (the
new case fails 4 of them before the change), `hybrid_route` PASS, `hybrid_e2e` PASS against a real
llama-server and Qwen3-0.6B (identical greedy reply to `--device cpu`, `prompt_n 1, plain 22`),
`ctest -R hybrid` 2/2, `tools/copyright.py --check` clean.

docs/hybrid.md also loses a stale limit: the shared region has covered a hybrid model's recurrent state
since fork 1bit-MONSTER/llama.cpp#181, and the doc still said `?action=share` refuses a hybrid cache.

Same class as engine#941 (a handoff silently thrown away); part of engine#698.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (f9c00c3d, which touched none of these files) to clear BEHIND: head is now f8d39b6. Re-gated on the rebased tree — hybrid_kvshare_route PASS (23 checks), hybrid_route PASS (33 checks).

…ke the state-file fallback

The fallback added in the previous commit is deliberately limited to the handshake (share refused, no share_id, no
socket). The boundary is worth a case of its own, because the wrong side of it is expensive rather than wrong-looking:
if an `?action=adopt` refusal also fell back, the engine would re-run the whole prompt through the state-file route
after the provider had already prefilled it -- paying for the prefill twice, which is the failure the route exists to
avoid.

New arm: `FAKE_KVSHARE_ADOPT_REFUSE=1` makes the decoder refuse the adopt the way the real one does when another
sequence holds the cells ("Cannot adopt the shared cells: ..."). The arm asserts four things at once: the reply still
comes through, the provider *did* run and wrote into the region (its marker), the refusal is named (`adopt answered
400`), and `cannot share its KV` -- the fallback line -- does **not** appear.

`tests/hybrid_kvshare_route.sh` PASS, 28 checks (23 before this commit).
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Added the other side of the boundary as a second commit (d31b14b), because the wrong side of it is expensive rather than visibly wrong: an ?action=adopt refusal must not take the state-file fallback, or the engine would re-run the prompt through the file after the provider already prefilled it — the prefill paid twice.

New arm in tests/hybrid_kvshare_route.sh (FAKE_KVSHARE_ADOPT_REFUSE=1, the real refusal shape "Cannot adopt the shared cells: ..."): the reply still comes through, the provider did run and wrote into the region (its marker), the refusal is named (adopt answered 400), and the fallback line does not appear. hybrid_kvshare_route now PASS 28 checks (23 in the first commit, 17 before the PR). Squash-merge means this lands as one commit; say the word if you would rather I drop it and keep the PR to the one case.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Companion half found while gating this PR against a real decoder, and it is the case this fallback cannot reach on its own: with ONEBIT_KV_SHARE=1, a decoder whose KV backend cannot export (ggml-cpu — a hybrid route may decode on the CPU) dies at load, so there is no request to fall back for.

Root cause is in the fork and two-layered: share_proc()'s "ask every backend" fallback (added so the CPU could import) also answered the export lookup, handing a CPU buffer ggml-hrx's exporter and calling it on a foreign buffer; and llama_share_alloc() threw instead of degrading. 1bit-MONSTER/llama.cpp#212 makes it degrade: warn once naming the backend, allocate the KV normally, and refuse ?action=share per request — which is exactly the state this PR's fallback consumes.

Measured end to end through this route, one request, real model, flag on:

1bit serve: hybrid: share answered 501: {"error":{"message":"This server's memory has no shareable region: either it was not created with ONEBIT_KV_SHARE=1, or its KV backend cannot export memory (the load-time warning names the backend)"}}; this decoder cannot share its KV, so the state-file route is used
1bit serve: hybrid: 15 prompt tokens: prefill 20 ms, restore 0 ms, share 0.000 ms, first byte 160 ms

with cached_tokens 14 of 15 — the decoder evaluated the last token only, on the fallback path, from a real state file. So this PR does not need #212 to be correct or to merge; #212 is what makes the fallback reachable on a CPU-decoding deployment. Both are stated as separate in the bodies.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Composition check, both PRs together on current main (965cb4bc), full suite: cherry-picked e5ba8bd7 + dff5c8f7 (#1203) and 1c169aa8 (#1207) onto a throwaway branch, Release CPU build, ctest -j2:

100% tests passed, 0 tests failed out of 92        (75.15 s; 11 npu_repack_path Skipped -- no NPU ticket)

against 89 passed / 1 failed of 90 for a -j2 sweep on 29ec80c6 before #1207 (that one failure being the box-global /tmp verdict). So the two changes compose and the class of red the sweep was producing is gone. No review comments are outstanding on either PR.

…fore adopting its cells

The shared route took the region on the provider's exit code alone. `?action=adopt` then hands the decoder
`ids.size()` cells whatever happened, so a provider that exits 0 after writing fewer rows than it was given leaves the
decoder decoding from a region whose last rows were never written -- a wrong answer, not a failed request, and nothing
in the log says so. The file route has carried this cross-check since engine#941 (`provider prefilled N of M tokens`,
then the state file is removed and the request goes out plain); the shared route had no equivalent.

Now it parses the provider's last stdout line the same way and refuses the adopt on a mismatch:

    hybrid: provider prefilled 29 of 30 tokens into the shared region; the backend prefills this request itself

A line that does not parse is still not an error (on this route, like on the file route, the region is the result), and
a provider's own `prefill_ms` is taken from the same line, which the file route already did -- so the two routes report
the same number for the same work.

Reproduced first, with a provider that exits 0 and claims one token fewer (`FAKE_KVSHARE_SHORT=1`): before the change
the adopt happened and the run answered from the short region (the per-request line showed `share 0.433 ms, first byte
19 ms`); after it, the three new checks pass -- the claim is named, no cells are adopted, and the decoder prefills the
prompt itself.

Gates: `tests/hybrid_kvshare_route.sh` **PASS 33 checks** (28 before this commit, 23 before the PR), `tests/hybrid_route.sh`
PASS, `ONEBIT_KV_SHARE=1 tests/hybrid_e2e.sh` against a real llama-server + Qwen3-0.6B PASS (greedy reply equal to
`--device cpu`, `prompt_n 1, plain 22`).
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Third commit (2cf36a9), and it is the one I would have wanted as a reviewer: the shared route adopted a region it had never been told was complete.

?action=adopt takes ids.size() cells regardless, so a provider that exits 0 after writing fewer rows than it was given left the decoder decoding from a region whose last rows were never written — a wrong answer, not a failed request, with nothing in the log. The file route has had this cross-check since engine#941; the shared route had no equivalent.

Reproduced first with a provider that exits 0 and claims one token fewer (FAKE_KVSHARE_SHORT=1): before, the adopt happened and the request was answered from the short region (share 0.433 ms, first byte 19 ms); after:

hybrid: provider prefilled 29 of 30 tokens into the shared region; the backend prefills this request itself

A provider line that does not parse is still not an error (the region is the result, as the state file is on the file route), and the provider's own prefill_ms is now taken from that line, which the file route already did — same number for the same work on both routes.

Gates after this commit: hybrid_kvshare_route PASS 33 checks (28 before it, 17 before the PR), hybrid_route PASS, ONEBIT_KV_SHARE=1 hybrid_e2e against a real llama-server + Qwen3-0.6B PASS (identical greedy reply, prompt_n 1, plain 22).

So the PR now covers three failure paths of the same route, all measured before/after: a decoder that cannot share (state-file fallback), an adopt that is refused (deliberately not a fallback), and a provider whose claim does not match the region (no adopt). Happy to split any of them out if you would rather review them separately.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

VERDICT: READY at 2cf36a95.

Both halves are provable on this box with the fake backend, so I ran them rather than reading them. Two binaries built the same way (Release, gcc-16, ninja 1bit in ~/lb/thermal-run.sh): main 29ec80c6 and this head.

this head's binary, this head's tests   -> 33 ok, 0 FAIL
main's binary,      this head's tests   -> 26 ok, 7 FAIL:
   FAIL the request was pinned to slot 0
   FAIL the fallback to the state file is named
   FAIL the provider's state file was restored (share 0.000 ms, the file route)
   FAIL the decoder was not left to prefill the prompt itself
   FAIL a short claim is named
   FAIL no cells were adopted from a short region
   FAIL the decoder prefills the prompt itself instead

So the suite is not vacuous on either half: the four fallback checks and the three n_tokens cross-check checks are red on main and green here, and the other 26 stay green across both. The green run's own log lines are the ones the body quotes (share 0.000 ms, first byte on the file route; provider prefilled N of M tokens into the shared region on the short claim).

Read against the code, the boundary is where the body says it is: decoder_cannot_share is set only at the three handshake points (listen failed, share answered non-2xx, reply without share_id), never for a provider failure or a failed adopt — which is what keeps the double-prefill case out. The n_tokens check deliberately does not set it, so a provider that wrote fewer rows returns "not handed over" and the decoder prefills, rather than adopting a region whose last row was never written.

Instrument note, because it cost me a false red and will cost the next reviewer the same: my first run of this suite failed exactly these checks. The cause was mine — I started the build in the same worktree and then checked another PR's branch into it while ninja was running, so the binary was stitched from two trees. A clean build in its own worktree (~/wt/w1203 + ~/wt/b1203) passes. Nothing in the PR was wrong; the numbers above are from the clean pair.

Nit: j["n_tokens"].get<size_t>() on a negative integer would wrap rather than be refused; the field is a count so it does not matter today, and a >= 0 guard is one line.

@bong-water-water-bong
bong-water-water-bong merged commit e0f0362 into main Nov 6, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/kvshare-file-fallback branch November 6, 2026 02:38
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

181 - Partially compliant

Compliant requirements:

  • Repack: prefer the canonical npu-infer converter to fix token decoding issue
  • Forward: re-sync from the canonical npu-infer forward to include MLA/MoE support
  • Forward-serve: fix the streaming path's dangling capture to prevent crashes

Non-compliant requirements:

  • None

Requires further human verification:

  • None

941 - Partially compliant

Compliant requirements:

  • Fix the prefill handoff issue in hybrid models where the decoder re-processes the whole prompt after ?action=restore or ?action=adopt
  • Ensure that the state a handoff installs is correctly handled to avoid full re-processing

Non-compliant requirements:

  • None

Requires further human verification:

  • None

698 - Partially compliant

Compliant requirements:

  • Ensure that the hybrid KV handoff goes through DMA-buf instead of a state file when possible
  • Implement fallback to state-file route when decoder cannot share its KV cache

Non-compliant requirements:

  • None

Requires further human verification:

  • None
⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Incorrect fallback logic

The fallback logic in Prefill::prepare() incorrectly handles the case where the decoder cannot share its KV cache. It sets o.decoder_cannot_share = true when the handshake fails, but then proceeds to return the shared outcome instead of the o outcome, which means the fallback to the state-file route is never executed. This could lead to the decoder re-processing the prompt entirely, negating the performance benefits of the hybrid route.

    if (shared.handed_over || !shared.decoder_cannot_share) return shared;
    std::fprintf(stderr, "1bit serve: hybrid: %s; this decoder cannot share its KV, so the state-file route is used\n",
                 shared.why.c_str());
    o = Outcome{};
    o.n_tokens = ids.size();
}
Missing error handling for provider token count mismatch

The code attempts to parse the provider's output to check if it prefilled the correct number of tokens, but it does not properly handle cases where the JSON parsing fails or the n_tokens field is missing. This could lead to incorrect behavior if the provider's output is malformed or incomplete, potentially causing the decoder to adopt an incorrect number of tokens.

try {
    std::string last = run.out;
    while (!last.empty() && (last.back() == '\n' || last.back() == '\r')) last.pop_back();
    if (const size_t nl = last.rfind('\n'); nl != std::string::npos) last = last.substr(nl + 1);
    const json j = json::parse(last);
    if (j.contains("n_tokens") && j["n_tokens"].is_number_integer() &&
            j["n_tokens"].get<size_t>() != ids.size()) {
        o.why = "provider prefilled " + std::to_string(j["n_tokens"].get<size_t>()) + " of " +
                std::to_string(ids.size()) + " tokens into the shared region" + late;
        return o;
    }
    if (j.contains("prefill_ms") && j["prefill_ms"].is_number()) o.prefill_ms = j["prefill_ms"].get<double>();
} catch (const std::exception&) {
    // no JSON line: the region is the result
}

bong-water-water-bong added a commit that referenced this pull request Nov 7, 2026
…r exists (Refs #753) (#1317)

engine#753 was parked on an NPU-side K/V writer. It exists now, and it is bit-exact out of an imported
dma-buf, so the two doc pages that said "not yet" are wrong and the contract the next provider needs
was never written down.

docs/hybrid.md: the shared route's region is the *decoder's* cache, so its layout is llama.cpp's --
cache_k_l<L> and cache_v_l<L> separate, position-major, each tensor at a 4 KiB boundary (share_layout).
A token's K|V row is contiguous in a prefill's output and not in the cache: two destinations per layer,
row stride n_embd_k_gqa, and the provider addresses its own mapping of the dma-buf (a driver-made
mapping of the import poisons the submission, #812). Nothing is re-checked after the write, because the
handoff is llama_kv_cells_adopt.

docs/zero-copy.md: target 1's provider status, and the two facts above as the bounds on a device
provider. The engine side needs no change for any of it: ?action=share, ?action=adopt and
llama_kv_cells_adopt are shipped (#1203), and the provider's gate stays text identity against
--device hrx.

No kernel internals in either page: the private tree holds the design, the measurement and the traps.

Refs #753

Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant