Repository navigation
perf(qwen-flash): prefetch n-gram rows across cache checkpoints - #683
Merged
francescobozzo merged 3 commits intoOct 20, 2026
Merged
Conversation
francescobozzo
marked this pull request as ready for review
October 20, 2026 15:20
✅ Devlab functional checks · PR tier: 4 configuration groups passed
Inference phases · merge base → PRflowchart LR
subgraph g0["Startup"]
direction TB
g0n0["Process init<br/>50 ms → 34 ms<br/>-32.1%"]:::noisy
g0n1["Weights and model<br/>16.00 s → 16.00 s<br/>-0.0%"]:::same
g0n0 --> g0n1
g0n2["Vision sidecar<br/>525 ms → 538 ms<br/>+2.6%"]:::same
g0n1 --> g0n2
g0n3["Structured output<br/>32 ms → 33 ms<br/>+4.4%"]:::same
g0n2 --> g0n3
g0n4["HTTP ready<br/>11 ms → 11 ms<br/>+0.3%"]:::same
g0n3 --> g0n4
end
subgraph g1["Short prompt · 1,549 tok"]
direction TB
g1n0["Prefill<br/>943 ms → 947 ms<br/>+0.4%"]:::same
g1n1["Decode<br/>2.61 s → 2.61 s<br/>+0.0%"]:::same
g1n0 --> g1n1
g1n2["Cache snapshot<br/>3.9 ms → 3.3 ms<br/>-16.0%"]:::same
g1n1 --> g1n2
end
subgraph g2["Long prompt · 12,370 tok"]
direction TB
g2n0["Prefill<br/>7.57 s → 7.48 s<br/>-1.1%"]:::same
g2n1["Decode<br/>2.20 s → 2.21 s<br/>+0.1%"]:::same
g2n0 --> g2n1
g2n2["Cache snapshot<br/>15 ms → 16 ms<br/>+0.8%"]:::same
g2n1 --> g2n2
end
subgraph g3["Follow-up · 1,964 tok"]
direction TB
g3n0["Cache restore<br/>3.0 ms → 2.2 ms<br/>-24.7%"]:::same
g3n1["Prefill<br/>407 ms → 409 ms<br/>+0.6%"]:::same
g3n0 --> g3n1
g3n2["Decode<br/>2.14 s → 2.14 s<br/>-0.0%"]:::same
g3n1 --> g3n2
g3n3["Cache snapshot<br/>3.3 ms → 3.3 ms<br/>-0.5%"]:::same
g3n2 --> g3n3
end
g0 --> g1 --> g2 --> g3
classDef slower fill:#cf222e,stroke:#82071e,color:#fff
classDef faster fill:#1a7f37,stroke:#044f1e,color:#fff
classDef noisy fill:#bf8700,stroke:#7d4e00,color:#fff
classDef same fill:#6e7781,stroke:#424a53,color:#fff
classDef missing fill:#d0d7de,stroke:#8c959f,color:#24292f
Medians of 2 alternating base/PR arms after GPU cool-down. 🟥 slower and 🟩 faster in every repeat (>5% and >3 ms); 🟨 mixed repeats, inconclusive; ⬜ unchanged. Observations, not qualified timing regressions. Measured in 2.7 min. Every repeat
Workflow run · PR |
francescobozzo
deleted the
perf/qwen-flash-prefetch-across-checkpoints
branch
October 20, 2026 16:34
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.
Summary
Serving prefill on Flash-Next has been reading PLE n-gram rows synchronously
since #573. #470 overlapped each chunk's gather with the previous chunk by
prefetching the next chunk's rows. #573 then made serving steps end on planned
cache checkpoints every 2048 tokens (
src/cache/retention.cppgrid). The runneronly saw the prompt up to that frontier, so its prefetch hint was empty and
every chunk waited on a blocking read: 124–178 ms per 2048-token chunk on a
cold prompt (~11% of the chunk), with ~17 ms of overlap.
This PR:
The completion line gains
ngram_rows,ngram_dedup_rows,ngram_cache_hitsandngram_disk_reads, only for models with a PLE table.Rows are attributed to the session that gathered them: gathers carry their
owner through the read pool, and the request reports what its state gathered
since it was leased. Concurrent requests do not see each other's rows.
Every row is exactly one of a dedup copy, a row-cache hit or a disk read:
rows == dedup + hits + diskis checked in tests and held on every liverequest. No new log lines; four fields on the existing
event=completedline.tokens between this step's frontier and the next planned one
(
TextRunnerState::prefill_lookahead), and Flash-Next hints those when astep ends on its frontier.
now uses prefetched rows when either token list is a prefix of the other,
reading only the remainder. This covers a scheduler step shrunk to 512
tokens while a peer captures a checkpoint, which previously discarded every
such prefetch. Rows are a pure function of tokens and history, so outputs
are unchanged.
qwen38_flash_next_session_teston main, broken by perf(qwen38_flash_next): price greedy MTP drafts at the decoding occupancy #664.CheckBatchedSessions,CheckLookupBatchand the draft-vocabularylifecycle check compared batched cohorts with a serial reference priced at
occupancy 1, while perf(qwen38_flash_next): price greedy MTP drafts at the decoding occupancy #664 intentionally prices unplanned greedy rows at the
cohort width.
Session::DecodeSteptakes an optionaloccupancy(default 1,so production callers are unchanged), and the references pass the cohort
width. Before perf(qwen38_flash_next): price greedy MTP drafts at the decoding occupancy #664 (8e8a23e) the test passes; at a7ca5f0 it fails with
batched decode tokens, RNG or acceptance differ; with this PR it passes.How the counters found it: two concurrent prefills reported ~2k tokens more
rows than their tokens; a trace showed the shrunk-step discards and, more
importantly,
next=0on every solo serving chunk.Validation
All on gfx1151, pinned Flash-Next Q4_K_XL + Q8 MTP sidecar.
Exactness
qwen38_flash_next_session_test(full): PASS. NewCheckPrefetchHintscompares exact, shorter, longer and diverging hints with a bulk prefill:
logits bit-exact, and the row counters confirm each claim (no double reads).
component_text_cache_test: newPrefillLookaheadReachesTheNextFrontierchecks the lookahead starts at each frontier, ends at the next planned one and
is cleared after the call.
qwen38_flash_next.ngram: new owner/dedup/cache/disk attribution checks.text_model_runner_test, format check: PASS.every request of the timing matrix below.
Timing — release
nix buildof a7ca5f0 (base) and this branch, defaultserver flags (
--model,--speculative mtp,--mtp-model), four armsalternating base → PR → PR → base with 60 s cool-down, one fresh server per
arm. Content blocks are exactly 512/1024/2048/8192/16384/32768 tokens (sized
with
count_tokens); depth cases add a new turn to a cached prefix.prompt_msfrom the response timings, per request; prefill tok/s = (prompt − cached) tokens /
prompt_ms. Paired arm1 vs arm2 and arm4 vs arm3.Verdict rule: slower/faster only if both pairs move > 5 % and > 3 ms.
within ±1 % (cold 512 ≈ 905–917 tok/s, cold 2048 ≈ 1600–1614 tok/s, a 2K
turn at 32K ≈ 1531–1540 tok/s on both), and decode stays at 42.0–42.2 tok/s.
1572–1590 → 1642–1657 tok/s, 16K 1670–1679 → 1750–1756 tok/s. Consistent,
but under the 5 % rule, so not claimed as a qualified gain. The first chunk
of a cold prompt cannot be prefetched, which bounds the gain.
plans only 4 checkpoints, so main already prefetched most of its chunks.
Functional (
tests/functional/run.py --record-baseline, candidate releasebinary,
--sessions 2 --context 32768):prefill-scheduling,cache(allRAM and disk cases),
long-context,cache-depth,cache-concurrencyandcache-shared-prefixpass in both Flash-Next modes:mtp(64 drafts, 27accepted) and
offwith the sidecar path kept (0 drafts). Every completion linein those runs carries the counters, e.g. a cold and a repeated request:
ngram_rows=416 ngram_dedup_rows=8 ngram_cache_hits=0 ngram_disk_reads=408, thenngram_rows=416 ngram_dedup_rows=8 ngram_cache_hits=407 ngram_disk_reads=1.Not in this PR
gfx1150 build,
GET /) are separate decisions.https://claude.ai/code/session_011rm7aPexQHBinHKAMkLup4