QVAC-23752 feat[bc]: remove sliding-context support from the llm-addon - #3938
Merged
Merged
Conversation
Contributor
Review StatusCurrent Status: ❌ PENDING Pending reviews: Needs 1 Management or Team Lead, and 1 more from Management, Team Lead, or Member. |
Contributor
License compliance — cleanNo new dependency license findings in this PR. Warn-only (shadow) mode — this check does not block merges yet. Updated automatically by the canonical license compliance workflow. NOTICE presence (advisory)Missing NOTICE (advisory, does not block):
|
yingying0906
force-pushed
the
feat/QVAC-23752-remove-sliding-context
branch
from
August 19, 2026 08:54
12d3096 to
ea16aa0
Compare
yingying0906
marked this pull request as draft
August 19, 2026 10:08
A relative cacheKey wrote the session file next to the sources, and one of them, window-prefill-cache.bin, was committed by mistake in 68f3187. The three prefill-cancel tests take a ScopedCacheFile under the temp dir now, removed on entry and on every exit, so a run cannot start from what the last one left behind.
gianni-cor
reviewed
Aug 26, 2026
Five blocks still described the removed design: post-reasoning capture as recurrent-only, computeRecurrentSnapshotBoundary gating on whether the memory module supports shift and snapshotting at the end of prefill, the replay buffer's seeded prefix as a close marker, template shapes that no longer get refused, and the Qwen3 verifier script claiming compaction runs through seq_rm + seq_add. Each now says what the code does: every model rewinds to a reasoning boundary anchored before the span and replays only non-reasoning tokens, with no marker seeded or replayed.
Second pass over the comment leftovers a reviewer listed. Replay is not recurrent-only, so the perf-snapshot docs, the seeding sites and the double-append guard say replay rather than recurrent replay. The append primitive seeds a pre-reasoning preamble, never a close marker. The Qwen3 verifier no longer describes a pure-attention seq_rm plus seq_add rejection, and the integration helper no longer calls the files it writes sliding-context caches.
recurrentReasoningBoundaryDecision took five arguments and read two. Memory kind stopped deciding when pure attention started rewinding instead of shifting, and close-marker length stopped deciding when replay stopped seeding the marker; both had been commented out or voided in the body. The signature says what the policy actually reads now, and the five call sites plus the policy tests pass only that.
Anchoring the boundary before the forced opener meant stopping the prefill decode mid-prompt, and on Vulkan with coopmat2 that changes the answer. Same tokens fed as one decode and as two land on different SSM state, so Qwen3.5 took another path from the first generated token and never closed its reasoning span. cpu is invariant to the split, and so is Vulkan with GGML_VK_DISABLE_COOPMAT2=1, which is what points at coopmat2. The full-state path anchors at the end of prefill again and seeds the close marker, so the restored prefix opens a block and the replay balances it. No decode is split anywhere: the chunk cap never fires, the batch pause is gone, and the MTMD chunk split and its text-range helper go with it. A cache warm keeps the whole prompt again. Pure attention is untouched. Its anchor is an absolute position before the span, it seeds no marker, and it still compacts to preamble plus answer. Verified on a Tesla T4 with Vulkan: reasoning.test.js is 16/16, 88/88 asserts, and the Qwen3.5 turn is back to 290 tokens with eos and one discard, matching cpu. 842 unit tests pass on Metal.
gianni-cor
previously approved these changes
Aug 28, 2026
gianni-cor
reviewed
Aug 28, 2026
The close-marker replay landed the boundary at the end of prefill, so prefillBoundaryPauseIndex has returned -1 on every driver since. Nothing in production could reach onPrefillBoundaryPause, the batcher's pause slot, or the scheduler callback, so the interface only existed for its own unit tests. Drops the SequenceDriver hooks and their TextLlmContext overrides, MultiRequestBatcher::setPrefillBoundaryPause with the request field and the feed-limit clamp, ContinuousBatchScheduler::prefillBoundaryPauseFn with both admission branches, and the three batcher pause tests. Also refreshes the TextLlmContext and MtmdLlmContext boundary comments, which still described stopping before the forced opener.
Dropping the pause callback shortened the parameter list enough for clang-format to want the return type on its own line.
gianni-cor
reviewed
Aug 28, 2026
…ment The comment still described the split-before-opener fix. The boundary now anchors at the end of prefill and the compactor seeds the close marker so the restored span is balanced.
gianni-cor
previously approved these changes
Aug 28, 2026
iancris
reviewed
Aug 28, 2026
iancris
reviewed
Aug 28, 2026
The Qwen3 EOS-substitution branch requested the close capture but stopped seeding the marker, leaving one close site out of seven without it. EOS substitution skips the updateReasoningBuffer handshake, so nothing else reaches the capture site, and on Qwen3.5-VL the replay then restored an end-of-prefill prefix that opens a think block with nothing closing it. The next cached turn resumed inside that block.
Skipping every terminal sample matched the single-prompt path at the EOS boundary but not at the prediction limit, where reachedBudget is gated on the batch path so single-prompt decodes and counts the token instead. An identical predict: N request reported N on one path and N-1 on the other, and predict: 1 reported 0 next to non-empty streamed output. generatedTokens now holds what the caller received as content. StopReason gains PredictionLimit so the batcher can tell that sample from an EOG, the scheduler carries the driver's reason through the way it already does for ContextOverflow, and hasUnfedSample keeps the counted entry out of the feed queue.
iancris
approved these changes
Aug 28, 2026
gianni-cor
approved these changes
Aug 28, 2026
Contributor
Author
|
/review |
This branch was previously deployed
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 problem does this PR solve?
firstMsgTokensso the system prompt was never what got dropped. It was opt-in throughn_discardedand defaulted to off, so the shipped behaviour was already the non-sliding path while the slide machinery added state every other path had to keep correct.seq_rmthe<think>span, thenseq_addthe tail down over it. That is context shifting under another name, so it goes too.📝 How does it solve it?
Sliding removed.
ContextSliderandContextShifterare gone, along withfirstMsgTokens/protectedPrefix,slideCapableadmission,applySlide,supportsSlidingandSequenceStepResult::discarded.Overflow is now the single path
n_discarded=0already took. A prefill that does not fit throwsContextOverflow. A generation that fills the window stops withstopReason=contextOverflowand still returns what it produced, so a caller can tell a full context from a prediction-limit cutoff. Both prefill guards name the quantity they report.Compaction replays instead of shifting. Every model rewinds to a boundary anchored before the reasoning span and re-decodes what it keeps, which is what the recurrent path already did. Pure attention does not need that path's full-state snapshot: its cells are positionally indexed, so the boundary is just a position and rewinding is a tail trim.
KvCacheOps.hpp/.cppandcompactKvRangeare deleted, so noseq_addremains in the addon and the deferred K-shift never runs.Outcome::Kindcollapses to oneCompacted, andFailedKvIntactgoes with both drivers' roll-back-to-pre-request-cursor recovery: compaction rewinds before it replays, so by the time anything can fail only a wipe leaves the cache coherent.Compaction was pointer math over cells and is now a re-decode of the kept tokens, once per reasoning turn. Measured on Qwen3-0.6B on Metal that is 0.85 ms to replay 30 tokens and 2.2 ms to replay 71, so it scales like prefilling those tokens rather than like generating them.
A multi-token reasoning close marker works now. The policy refused markers that tokenise to several pieces, because replay could only seed the single token that tripped the close detector. Nothing structural is replayed at all now, so marker length decides nothing and the refusal path goes with it.
Pure attention compacts to
preamble + answer. The full-state path replays the close marker. Pure attention anchors an absolute position before the span, so a force-open opener is trimmed with everything else and nothing structural is seeded or replayed. Recurrent and hybrid anchor at the end of prefill, which keeps the opener in the restored prefix, so the canonical close is seeded and replayed to balance it. The compacted cache there ispreamble + <think></think> + answer, and the reasoning body is gone either way.Anchoring the full-state boundary before the opener would mean stopping the prefill decode mid-prompt, and that is not free: on Vulkan with coopmat2 the same tokens fed as one decode and as two land on different SSM state, and Qwen3.5 then takes another path from the first generated token.
cpuis invariant to the split, and so is Vulkan withGGML_VK_DISABLE_COOPMAT2=1. So no decode is split anywhere: the chunk cap never fires, the batch prefill takes no pause, and multimodal feeds its chunks whole.Compaction replay runs outside the scheduler mutex.
finalizeTerminalDriverreachescompactThinkSpan, so on a reasoning turn the drain now runs a realllama_decode.stepLockedalready drops the lock for the main decode and for media eval;drainFinishedLockeddid not, so a replay stalled every co-tenant slot and blocked a cross-threadcancel()for its whole length. This window holds a reference intoslots_across the unlock, so the usual reconcile on every reacquisition is wrong here: a cancel recorded during it still passesslotOwnedByLockedand would runonCancelon a driver mid-finalize.TeardownDeferGuardsuspends application for the window without dropping anything, and the worker applies the records at its next loop top where the ownership re-check discards the stale ones. The direct teardown paths defer for the window too.cancel,cancelGroupQueuedandclearkept a branch that freed the slot outright once the scheduler was stopping, andsubmitLockedcould re-admit a seqId whoseSlotStatethe drain had not freed yet, since the batcher releases its own slot first. Both destroyed a driver mid-finalize. Deferring while stopping is safe because the destructor joins and then clears every slot.generatedTokensno longer comes from llama's perf counters.llama_perf_contextkeys on batch size, not meaning:n_queued_tokens == 1bumpsn_eval, larger bumpsn_p_eval. Generation decodes singly, so the two agreed by coincidence until replay started decoding in batches. The stat is counted in the generation loops now, where a token is committed. On the single-prompt path that restores the old numbers rather than redefining them. The batch path used to count whatever the sampler returned, which included an EOG that was never decoded, so it now counts at the commit site too and reports one less. A prediction-limit sample is still counted, because the caller received it and the single-prompt loop decodes and counts it as well. See Breaking Changes.TTFTdoes not follow that rule. The caller sees the terminal token, sinceonLogitsReadystreams it before the slot is marked finished, so apredict: 1batch request was returning output while reportingTTFT0. The first stamp is taken before the terminal filter now, and the rate window still closes on a counted token, so a request whose only token also ended it reports a TTFT with no rate.Batch path.
advancemarked a slot finished the momentcurrentPoshitmaxTokensPerSequence, so it was filtered out before the driver's overflow check and ordinary generation reportedsequenceLimitfor a full window. That limit IS the slot's share of the context, andsubmitrejects any request that would not fit, so a full window is the one thing it can mean. It now says so.Shared helpers.
contextWindowFull(pos, ceiling)replaces four hand-rolled context-full checks that had drifted between>=,+1 >, rawllama_n_ctxandctxCeiling(), and the MTMD prefill guards move ontoexceedsContextWindowbeside the text ones. That last one is a small behaviour change: those guards compared>= llama_n_ctxfor every request, so a prefill-only prompt that exactly filled the window was refused. It is accepted now, which is the rule the text path already had, because a request that will not generate needs no free cell.SessionMetadatamoves intoLlmContext.hppso the{nPast, nPast, cacheTokens, cacheTokens}layout has one home instead of three.Session metadata keeps its four-slot width so files written by either build still load. Slots 1 and 3 are retired and this build's readers ignore them, but they are not written as 0: an older build reads slot 1 as its protected prefix and evicts from there, so a 0 would point that at position 0 and silently drop the system prompt and tool definitions. They mirror the live cursors instead, which drives that build's slide guard negative so it refuses the slide and reports a context overflow with the cache intact. That refusal covers its prefill slide only, since the generation slide never carried the guard, so mirroring is the better of the two values to write rather than a guarantee at every slide site.
The SDK half is in #3999, and the two can land in either order:
sdkandinferencepin@qvac/llm-llamacpp@^0.47.0from npm, and #3999 still parses the published wordings. A fabric bug found on the way is fixed in tetherto/qvac-fabric-llm.cpp#213; this PR does not depend on it, since it removes the addon's only route into that code.🧪 How was it tested?
reasoning16/16 with real compactions through the replay path,api-behavior14/14,cache-state-machine21/21,generation-params2/2.api-behavior.test.jscovers both prefill guards, including a cached follow-up that only the second guard can reject, and pins each guard's wording since the SDK parser matches them separately.test/integration/_context-overflow.jsowns the tokenizer-sensitive sizing both files share.sliding-context.test.jsandmrope-sliding-context.test.jsdeleted, with all three mobile registries updated so a run does not abort on an unregistered test.clang-formatandclang-tidyclean on every changedaddon/srctranslation unit.d3071e090, cpp and desktop: https://github.com/tetherto/qvac/actions/runs/33367447657💥 Breaking Changes
n_discardedis no longer consumed, so it reaches llama's own argument parser and fails model load as an unknown option.BEFORE:
AFTER:
A batched sequence that fills its window reports the full context instead of the per-sequence cap.
BEFORE:
AFTER:
A batched request that stops on EOG reports one less generated token. That sample is never decoded, so it never reached the cache and is no longer counted. This matches what the single-prompt path has always reported.
TPSshifts with it.BEFORE:
AFTER:
A batched request that stops on the prediction limit reports one more. That sample is ordinary content the caller already received, and the single-prompt loop decodes and counts it, so the two paths now agree at both boundaries instead of only at EOG.
predict: 1used to report 0 next to non-empty output.BEFORE:
AFTER:
generationParamswith a key the addon does not read now throws instead of ignoring it. The binding pulls each param by name, so a near miss liken_predictforpredictused to run with the load-time default and report nothing. Only own keys count, and only own keys are forwarded: the binding's named get walks the prototype chain, so a value reachable only through the prototype used to reach sampler config without ever being checked.BEFORE:
AFTER:
🔌 API Changes
contextSlidesis gone from the runtime stats snapshot and fromRuntimeStatsin the type declarations.