Skip to content

llama.cpp server: on a hybrid (recurrent) model the prefill handoff is thrown away — the decoder re-processes the whole prompt after ?action=restore or ?action=adopt #941

Closed

Description

@bong-water-water-bong

Note: filed here because the fork (1bit-MONSTER/llama.cpp) has issues disabled; the code lives in third_party/llama.cpp (tools/server/server-context.cpp, src/llama-memory-hybrid.cpp) and the fix is a fork PR.

Measured on strixhalo, 2026-11-03 ~21:00-22:10, bonsai2-unrot-q4_0.gguf (qwen35 27B: 48 GDN + 16 full-attention layers), engine 22d8047f, llama.cpp e0e2c26f5.

After a prefill handoff, llama-server re-processes the whole prompt. On a hybrid (recurrent) model this makes the
handoff pointless end to end, and it does it on both handoff routes, so it is not specific to the shared-region
work in #181.

route handoff prompt tokens re-evaluated by the decoder decoder prefill
?action=restore (state file, shipped) 39 / 34 / 44 ms 287 / 847 / 3 286 (all of it) 1.51 / 4.43 / 16.90 s
?action=adopt (shared region, #181) 0.78 / 0.50 / 1.15 ms + ≤1 ms adopt 287 / 847 / 3 286 (all of it) 1.40 / 4.41 / 19.59 s

(One run, the same three prompts and the same lengths on every row — the two routes were measured on separate boots of
the same build, and the 4 096 point shows the usual box-load spread between them.)

Answers are correct and identical to --device hrx on both routes; the problem is purely that the decoder throws the
handed-over state away and redoes the prefill. For scale: the handoff is 1-44 ms and the wasted prefill is 1.4-16.9 s.

Why — the server's checkpoint test fires, and a handoff installs no checkpoint. llama_memory_hybrid::seq_pos_min
returns max(attn_min, recr_min) (src/llama-memory-hybrid.cpp:172), and after a handoff the recurrent half holds one
cell at the last position, so seq_pos_min == n_past - 1 instead of 0. In launch_slot_
(tools/server/server-context.cpp:3488-3522) that makes pos_min >= pos_min_thold true, the code goes looking for a
context checkpoint, finds none (neither ?action=restore nor ?action=adopt creates one) and takes
do_reset → pos_next = 0; n_past = 0 → full re-process. On a plain llama_kv_cache model seq_pos_min == 0, the
test is false, no checkpoint is needed and the adopted cells are used (Qwen3-0.6B, same harness: prompt_n = 1).

Checked, so the diagnosis is not a guess:

  • It is not "hybrid memory cannot use its prompt cache": plain llama-server on the same model and build, two
    identical /completion requests, cache_prompt=true → prompt_n = 161 then prompt_n = 4 (the checkpoint the
    server created during the first request was restored). Caching works when a checkpoint exists.
  • It is not the shared-region route: the file route re-evaluates exactly the same token counts.
  • It is not a tokenization mismatch: /apply-template + /tokenize of the templated text and the chat path's own
    prompt_n agree (35 tokens, same first id 248045).

Fix (added after auditing the consumers — my first draft of this section was wrong, see the note below). The
state a handoff installs is already in place, so the fix is a checkpoint that carries the position bookkeeping and
no serialized data, pushed by the handoff handlers (SERVER_TASK_TYPE_SLOT_RESTORE and SERVER_TASK_TYPE_SLOT_ADOPT)
right after the cells are in place:

auto & cur = slot->prompt.checkpoints.emplace_back();
cur.update_pos(/*n_tokens=*/ n, /*pos_min=*/ 0, /*pos_max=*/ n - 1);
// deliberately no update_tgt()/update_dft(): the state is already where a restore would put it

The audit that makes this safe rather than plausible:

  • slot.prompt.checkpoints has exactly two consumers: the search here (:3493-3506) and the eviction/pruning in
    create_checkpoint (:2400-2423). Nothing else reads the list.
  • A data-less entry is already a supported shape: common_prompt_checkpoint::load_tgt returns early when
    data_tgt.empty() (common/common.cpp:2178), so the search's it->load_tgt(...) becomes a no-op and the flow then
    computes pos_next = min(pos_next, max(pos_min + 1, pos_max)) and n_past from the memory as it stands — which is
    exactly right when the memory is already correct.
  • The speculative rollback path uses a different object (slot.spec_ckpt, :227, filled at :3159, restored at
    :4044), so it is not affected by entries in slot.prompt.checkpoints at all.
  • pos_min = 0 makes the entry eligible under both clauses of the search predicate (cur.pos_min < pos_min_thold || cur.pos_min == 0), and pos_max <= pos_next holds because the handoff covers exactly the prompt.

Update, 2026-11-03 21:55 — the obvious version of that fix was written, built and tried, and it is structurally
wrong for a recurrent model.
With the in-place checkpoint pushed, the server did take the handoff (n_past came
out at 271 of 272 tokens instead of 0) and then aborted:

common/common.cpp:1538: failed to remove sequence 0 with p0=271, p1=-1
#6  common_memory::seq_rm(int, int, int) const
#7  server_context_impl::pre_decode()::{lambda(server_slot&)#5}

The server's rule that a checkpoint must leave "at least one token being processed" ([TAG_PROMPT_LOGITS]) is
implemented by removing the tail beyond n_past, and llama_memory_recurrent::seq_rm refuses a partial removal —
a recurrent state is a summary, not per-position data, so there is nothing to trim and re-running the last token would
apply it twice. So on a hybrid model an exact-state handoff cannot be followed by even one token evaluation, and
no checkpoint bookkeeping can change that. (Repro: ~/wt/zc-probes/t1_reuse_check.py; patch attempt kept at
/tmp/zc/941-inplace-checkpoint.patch, not pushed.)

The two designs that can work, both slightly bigger than a checkpoint:

  • (A) leave the decoder one token. The provider prefills n-1 tokens and hands over state for [0, n-2]; the
    decoder then evaluates the last prompt token from that state — a legitimate single recurrent step, no rollback, one
    token of decoder compute. Needs the provider (tools/kvshare-prefill) and the adopt count to agree on n-1.
  • (B) hand over the logits. The provider already computes the last token's logits (it reports an argmax); let it
    pass them back and have the engine sample the first token without the decoder evaluating anything. Bigger, but it
    is the only version that costs the decoder zero prompt work.

Not fixed here because it wants its own gate and it moves the prompt-cache path for every model: with a data-less
checkpoint in play, the check that matters is that a second request with a diverging prefix still produces the
same text as a fresh one (that is where a stale "in place" claim would show up), plus timings.prompt_n dropping to
1 on the 27B with the answer still identical to --device hrx.

Gate it deserves (proposed, not run): on the 27B, after a handoff the decoder's timings.prompt_n must be small
(not the prompt length) with the answer identical to --device hrx and 0 gfxhub faults; plus a prompt-cache rollback
test (second request with a diverging prefix) that must still produce identical text — that second half is the one
that catches the trap above.

Found while gating #181 (the recurrent half of the shared-region handoff); filed here because the file route has the
same defect and the fix is in this repo. Numbers and reproductions: ~/wt/zc-probes/t1_hybrid_share3.py,
logs in /tmp/zc-gate/, lane record in ~/briefs/TZEROCOPY-STATE.md (2026-11-03T21:45).

Activity

  1. bong-water-water-bong commented on Nov 4, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Triaged at engine e8bd2b30 / fork pin d8bd0ff6052a. The mechanism reproduces at the pin exactly as written —
    and the measurement's own commit is not the pin: e0e2c26f5 is refs/pull/181/head (fork #181, the recurrent
    shared-region handoff). That sets the row's scope: the file route (?action=restore) is shipped and reproduces at
    the pin; the ?action=adopt half needs #181 merged.

    src/llama-memory-hybrid.cpp:172-175   seq_pos_min = max(mem_attn->seq_pos_min, mem_recr->seq_pos_min)      confirmed
    tools/server/server-context.cpp:3438  pos_min_thold = max(0, pos_next - n_swa - (has_new_tokens ? 0 : 1))
    :3490  if (pos_min >= pos_min_thold) { ... search slot.prompt.checkpoints
    :3502  return cur.pos_min < pos_min_thold || cur.pos_min == 0;      <- a handoff installs no checkpoint
    :3520-3523  do_reset -> pos_next = 0; n_past = 0    (upstream's own comment names "hybrid/recurrent memory")
    

    (the issue cites :3488-3522; at the pin it is :3490-3523, i.e. ~2 lines of drift, nothing structural.)

    bug, perf, owner lane pi-zerocopy (it owns the shared-region work and this measurement), bucket A. Two
    notes for whoever takes it, both taken from the issue's own update rather than re-derived:

    1. Do not re-try the in-place checkpoint. That variant was built and aborts in common_memory::seq_rm, because a
      recurrent state has no partial tail to remove — an exact-state handoff cannot be followed by even one token
      evaluation. The two shapes that can work are the issue's (A) provider prefills n-1 and the decoder evaluates the last
      prompt token, or (B) the provider hands back that token's logits. Price (B) first: it is the only one that costs
      the decoder zero prompt work, and the provider already computes that distribution (it reports an argmax).
    2. The gate is the half the issue already names, and the second arm is the load-bearing one: on the 27B after a
      handoff, timings.prompt_n must be small with the answer identical to --device hrx and 0 gfxhub faults, and a
      second request with a diverging prefix must still produce identical text. That second arm is where a stale "the state
      is already in place" claim would show up.

    Next action, one: pick (A) or (B) in a fork PR against 1bit/hrx-vulkan-patched carrying that two-arm gate (this row is
    the fork's, the engine only pins it). Closes when the merge is in an engine pin and timings.prompt_n drops on both
    handoff routes.

  2. added
    bugSomething isn't working
    perfMeasured performance gap against a named target
    on Nov 4, 2026
  3. bong-water-water-bong commented on Nov 4, 2026

    @bong-water-water-bong
    CollaboratorAuthor

    Design (A) is implemented and gated — engine#967 + fork 1bit-MONSTER/llama.cpp#189.

    Why the handoff was being thrown away, from the code: the prompt-cache search in update_slots only consults slot.prompt.checkpoints, and a restore/adopt creates none, so the search resets n_past to 0 and the whole prompt is re-processed. The shortcut (a checkpoint claiming the whole prompt) aborts instead: the server trims the memory back by one position to leave a token to evaluate, and llama_memory_recurrent::seq_rm refuses (failed to remove sequence 0 with p0=271, p1=-1, measured 11-03 21:58).

    (A) as built: the provider is handed the prompt minus its last token, and restore/adopt carry "leave_one_token": true; the decoder records the handoff as a data-less in-place checkpoint (common_prompt_checkpoint::load_tgt returns at once when data_tgt is empty) and evaluates exactly that one token.

    Measured tonight (gpu-hold -l zerocopy, 02:12-02:17, instrument = the server's prompt eval time = X ms / N tokens line and reply timings.prompt_n), 27B hybrid, file route, P → Q (diverges after the shared prefix) → P again:

    request prompt tokens evaluated prompt_n prompt eval answer
    seq-P1 1 1 117 ms = fresh P, = --device hrx
    seq-Q 1 1 196 ms = fresh Q, = --device hrx
    seq-P2 1 1 226 ms = fresh P
    plain --device hrx 272 / 296 272 / 296 3104 / 2099 ms reference

    Before: the whole prompt on every arm (287 / 847 / 3 286 tokens, recorded when this issue was filed with the same instrument; 1.5 / 4.5 / 17.3 s at the 190 tok/s measured on that route). After: one token. Zero need to evaluate at least 1 token lines, zero gfxhub faults, restore 33 / 43 / 57 ms (the 151 MB recurrent state, untouched by this fix).

    Same contract on the shared route (Qwen3-4B, ?action=share/adopt): prompt_n 1 on all three requests, answers identical, share 0.6-1.6 ms, restore 0 ms. CPU with a real llama-server (tests/hybrid_e2e.sh): identical reply to --device cpu, prompt_n 1 vs 22.

    Two honest limits: #967 must not land before #189 (the engine half alone is a no-op on the pinned fork — the pointer bump follows the fork merge), and design (B) is not built — its price (graph-free sample path, a slot state with no batch, get_timings() zero-token guards, a logits file across both repos) is written up in my state file if the owner wants it priced against a number instead of an argument. (A) removes the prompt for one decoder token; that is what the table above measures.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingperfMeasured performance gap against a named target

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions