Skip to content

fix: surface a failed K-shift instead of decoding over stale K - #213

Open
yingying0906 wants to merge 9 commits into
temp-10297from
fix/kv-shift-failure-visible-10297
Open

fix: surface a failed K-shift instead of decoding over stale K#213
yingying0906 wants to merge 9 commits into
temp-10297from
fix/kv-shift-failure-visible-10297

Conversation

@yingying0906

@yingying0906 yingying0906 commented Aug 25, 2026

Copy link
Copy Markdown

What this fixes

When the K-shift graph fails to allocate or compute, llama_decode still returns 0 and the decode carries on. By that point the cells already hold their shifted positions but K was never rotated to match, so attention runs against stale K. The result is wrong and nothing reports it.

Two layers were swallowing the failure, and both had the same shape: a bool that could not distinguish "nothing to do" from "it failed". A third bool then flattened the failure itself, so the caller could not tell an abort from a real error.

Layer 1: apply() discarded the result

if (ubatches.empty()) {
    kv->update(lctx, do_shift, sc_info);

    return true;
}

llama_kv_cache::update() could not simply be forwarded, because it reported whether it had done any work, not whether it succeeded. updated starts false and is only set once a shift actually runs, so a no-op update returned false. It now reports success instead. Nothing consumed the old meaning: update() is declared on llama_kv_cache rather than the llama_memory_i interface, and apply() is its only caller.

Layer 2: memory_update() logged it and returned true

if (!mctx->apply()) {
    LLAMA_LOG_ERROR("%s: failed to apply memory update\n", __func__);
}

and llama_context::decode calls it for exactly this purpose, discarding the return:

// handle any pending shifts/copies
memory_update(false);

memory_update's bool had the same problem one level up: LLAMA_MEMORY_STATUS_NO_UPDATE returns false, so forwarding it would have failed every decode that had nothing pending. It now returns a three-state result:

enum class memory_update_status { no_update, updated, failed };

decode() stops on failed, and so does the cache-optimization retry, which used to fall through to return 1 and tell the caller to try a smaller batch.

Returning early there also skips the worst-case graph reserve below it. That reserve was what restored the reservation after the memory module reset the scheduler to build its own graph, and it was the only synchronize on the path, since graph_compute dispatches async and ggml_backend_sched_reset does not drain. The failure path drains and asks for a fresh reserve on the next call instead.

Layer 3: the status was flattened into one return code

A false out of the K-shift said nothing about what went wrong. graph_compute() != GGML_STATUS_SUCCESS is true for ALLOC_FAILED, FAILED and ABORTED alike, so all three reached the caller as one -2. decode() does not flatten them for its own ubatch compute:

case GGML_STATUS_ABORTED:      return  2;
case GGML_STATUS_ALLOC_FAILED: return -2;
case GGML_STATUS_FAILED:       return -3;

The abort callback is installed on every backend from the constructor and nothing suspends it for the shift, so a caller cancelling mid-shift got a fatal -2 instead of the non-fatal 2. Stopping is right either way, since K is left half-rotated, but the caller has to be able to tell the two apart. llama_memory_context_i::apply() returns bool and cannot carry the status out, so llama_context records it on the way through and maps it in one place.

Deliberate non-change

has_shift is left set on the failure path. The shift is still owed, and clearing it would silently discard a pending correction rather than let the caller decide. A caller that ignores the decode error will retry the same failing shift, which is preferable to it silently attending over stale K.

There was no way out of it, though. seq_rm resets each cell's own shift but never the cells-level flag, and retrying does not converge because the shift graph is sized off the cache capacity rather than its occupancy, so llama_memory_clear was the only escape and llama-server never calls it. An empty stream owes no shift, so seq_rm clears the flag there.

Context

Found while removing context shifting from reasoning-block compaction in the llm-addon, tetherto/qvac#3938. That change removes the addon's only path into this code, so the addon no longer depends on this fix. It is still reachable by any other caller that shifts, including tools/completion (both the context-shift loop and the self-extend path), examples/passkey, and anything else calling llama_memory_seq_add.

The server side

Two changes are outside src, both pre-existing and both made easier to hit by this one.

update_slots() throws on ret < -1 and nothing catches it, so llama-server goes down with the batch. The other -2 returns in decode() already reach it. It is caught in the callback now, where the slots are, because not every throw site releases them first: common_speculative_process failing, the unsupported has_embd + spec case and an out-of-range speculative batch index all throw mid-batch. A slot left processing is picked up on the next wake-up and throws again, so the request would hang rather than error. The queue loop keeps its own catch as the backstop for a teardown that throws too, since start_loop runs outside any try.

decode() left err empty for ret == 2, so an abort fell through to the "no free space in the KV cache" path and halved n_batch. Retrying cannot help: the abort came from outside, and when it hit a pending K-shift the shift is still owed, so the retry aborts in the same place while n_batch walks down to zero. It stops like the other fatal returns now.

Testing

Full build clean with server on, ctest 67/68 locally on temp-10297. The one failure is test-jinja-py, missing jinja2 in my env. The failure paths are allocation and compute failures inside the K-shift graph, which the test suite cannot synthesize without a memory-constrained backend, so the propagation itself is reviewed by inspection of the call sites above.

`llama_kv_cache_context::apply()` called `kv->update()` and threw the result
away, always returning true. So when the K-shift graph failed to allocate or
compute, `llama_decode` still returned 0 and carried on. The cells already
carry their shifted positions at that point but K was never rotated to match,
so attention runs against stale K and produces a wrong result that nothing
reports.

`update()` could not be propagated as it stood, because it returned whether it
had done any work, not whether it succeeded. A no-op update returned false, so
forwarding that would have failed every decode that had nothing pending. It now
reports success, which is what the single caller needs; nothing consumed the
old meaning.

The three `apply()` call sites already handle a false return and were simply
never reached: the decode path in `llama_context::process_ubatch` sets
`GGML_STATUS_FAILED`, so a failed shift now surfaces as a non-zero
`llama_decode` and the caller can drop the sequence.

`has_shift` is deliberately left set on the failure path. The shift is still
owed, and clearing it would silently discard a pending correction rather than
let the caller decide.
Propagating the failure out of `apply()` was not enough on its own.
`llama_context::memory_update` logged the failure and returned true anyway, and
`decode()` discarded its return, so `llama_decode` still returned 0 after a
K-shift that never happened.

The return could not simply be forwarded either. It reported whether an update
had been applied, so `LLAMA_MEMORY_STATUS_NO_UPDATE` returned false: forwarding
that would have failed every decode with nothing pending. "Nothing was pending"
and "the update failed" are both not-updated, and only one of them means the
caller must stop.

`memory_update` now returns a three-state result. `decode()` stops on `failed`
rather than attending over a cache it cannot trust, and the cache-optimization
retry still only retries on `updated`, which is the distinction it needs.
has_shift is cleared only by llama_kv_cells::reset() and reset_shift(), and
reset_shift() runs at the end of the K-shift path, after the graph has both
allocated and computed. On a failed shift it is skipped, which is deliberate:
the shift is still owed.

Nothing gave the caller a way out of that, though. seq_rm resets each cell's
own shift through rm() and seq_rm() but never the cells-level flag, so even
llama_memory_seq_rm(mem, -1, -1, -1) left it set. Retrying does not converge
either: build_graph_shift is sized off get_size()*n_stream, so it does not
shrink when the cache empties and an allocation failure reproduces exactly.
The only escape was llama_memory_clear(), which llama-server never calls.

A stream with no used cells owes no shift, so clear the flag there.
…heduler sane

The K-shift path tested graph_compute() != GGML_STATUS_SUCCESS and returned a
bare false, so all three non-success statuses arrived at the caller as one -2.
decode() does not collapse them for its own ubatch compute: ABORTED returns 2,
ALLOC_FAILED returns -2 and FAILED returns -3.

That matters most for an abort. The abort callback is installed on every
backend from the constructor and nothing suspends it for the shift, so a caller
cancelling mid-shift got a fatal -2 instead of the documented non-fatal 2.
Stopping is right either way, since K is left half-rotated, but the caller must
be able to tell the two apart.

llama_memory_context_i::apply() returns bool and cannot carry the status out,
so llama_context records it on the way through and maps it in one place,
matching what the ubatch path returns for the same failure.

The optimize retry had the mirror of this problem: a failed update fell through
to "could not find a KV slot", which callers answer by halving the batch and
retrying. It now stops the same way the pending-update check does. This is
unreachable today, since init_update ignores optimize, but it is a trap once
that grows a real implementation.

Returning early also skips the worst-case graph reserve that used to run even
after a failed apply(). That reserve was what restored the reservation after
the memory module reset the scheduler to build its own graph, and it was the
only synchronize on the path, since graph_compute dispatches async and
ggml_backend_sched_reset does not drain. Drain there and ask for a fresh
reserve on the next call instead.

Also drop the duplicate log in decode(), which restated what memory_update()
had already reported one frame down, and the stale doc comment left above the
memory_update_status enum.
update_slots() throws on ret < -1 from llama_decode to abandon a batch it
cannot finish, after it has already sent the error and released the affected
slots. Nothing catches it: server_queue::start_loop calls the callback bare and
start_loop itself runs outside any try, so the exception escapes main and takes
the process down with every other in-flight request.

A failed K-shift is a new way to reach that path, so catch it in the loop and
carry on serving.
A composite apply(), iswa or hybrid, runs every sub-context even after one of
them has failed, because it accumulates with & rather than &&. Each failing
sub-context calls set_memory_update_result, so the last one to report won.

That downgrades a real failure. A base cache that fails to allocate followed by
an SWA cache that aborts reported ABORTED, and memory_update_ret maps that to a
non-fatal 2, hiding the allocation failure the caller had to treat as fatal.
Rank the statuses instead and keep the most severe.

Also spell out what sched_need_reserve costs on that path: sched_reserve builds
a whole new scheduler rather than only re-reserving, which is what an
allocation failure wants, since the old one is freed before the next attempt
asks for memory.
decode() bumped n_queued_tokens and started the compute timer before applying
pending memory updates. A failed update now returns from there, so those tokens
stay queued and synchronize() charges them to whichever decode completes next,
inflating the prompt eval count and time.

The stream-copy path has the same problem without any failure: it calls
llama_synchronize while the batch is already counted. Count the batch once it
is actually going to be evaluated.
llama_decode returns 2 when the abort callback cancels a compute. decode() left
err empty for it, so it fell through to the "no free space in the KV cache"
path, halved n_batch and tried again.

Retrying cannot help. The abort came from outside and nothing about a smaller
batch changes that, and when the abort hit a pending K-shift the shift is still
owed, so the retry aborts in the same place while n_batch walks down to zero.
Treat it like the other fatal returns: report it, release the slots and stop.
Catching the exception in the queue loop kept the process up, but not every
throw site in update_slots has released its slots first. common_speculative_process
failing, the unsupported has_embd + spec case and an out-of-range speculative
batch index all throw with slots still processing. Swallowing those leaves the
slot in that state, so the next wake-up picks it up, throws again and the
request never gets an answer.

Catch it where the slots are instead. The callback errors and releases anything
still mid-batch, reusing the teardown the fatal decode path already does, and
the queue-level catch stays as the backstop for a teardown that throws too,
since start_loop runs outside any try and the exception would reach main.
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.

2 participants