fix: surface a failed K-shift instead of decoding over stale K - #213
Open
yingying0906 wants to merge 9 commits into
Open
fix: surface a failed K-shift instead of decoding over stale K#213yingying0906 wants to merge 9 commits into
yingying0906 wants to merge 9 commits into
Conversation
`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.
aegioscy
approved these changes
Aug 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
When the K-shift graph fails to allocate or compute,
llama_decodestill 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
boolthat could not distinguish "nothing to do" from "it failed". A thirdboolthen flattened the failure itself, so the caller could not tell an abort from a real error.Layer 1:
apply()discarded the resultllama_kv_cache::update()could not simply be forwarded, because it reported whether it had done any work, not whether it succeeded.updatedstarts 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 onllama_kv_cacherather than thellama_memory_iinterface, andapply()is its only caller.Layer 2:
memory_update()logged it and returned trueand
llama_context::decodecalls it for exactly this purpose, discarding the return:memory_update'sboolhad the same problem one level up:LLAMA_MEMORY_STATUS_NO_UPDATEreturns false, so forwarding it would have failed every decode that had nothing pending. It now returns a three-state result:decode()stops onfailed, and so does the cache-optimization retry, which used to fall through toreturn 1and 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_computedispatches async andggml_backend_sched_resetdoes 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
falseout of the K-shift said nothing about what went wrong.graph_compute() != GGML_STATUS_SUCCESSis true forALLOC_FAILED,FAILEDandABORTEDalike, so all three reached the caller as one-2.decode()does not flatten them for its own ubatch compute: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
-2instead of the non-fatal2. 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()returnsbooland cannot carry the status out, sollama_contextrecords it on the way through and maps it in one place.Deliberate non-change
has_shiftis 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_rmresets 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, sollama_memory_clearwas the only escape and llama-server never calls it. An empty stream owes no shift, soseq_rmclears 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 callingllama_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 onret < -1and nothing catches it, so llama-server goes down with the batch. The other-2returns indecode()already reach it. It is caught in the callback now, where the slots are, because not every throw site releases them first:common_speculative_processfailing, the unsupportedhas_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, sincestart_loopruns outside any try.decode()lefterrempty forret == 2, so an abort fell through to the "no free space in the KV cache" path and halvedn_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 whilen_batchwalks 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 istest-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.