qwen4exp: Qwen3.8-Flash-Next long-context decode, +66% at 131k - #9
Open
firelzrd wants to merge 6 commits into
Open
qwen4exp: Qwen3.8-Flash-Next long-context decode, +66% at 131k#9firelzrd wants to merge 6 commits into
firelzrd wants to merge 6 commits into
Conversation
build_qsa_top_k built a fresh llm_graph_input_qsa on every call, and it is called once per QSA layer -- twelve input sets per ubatch holding byte-identical data. set_input_qsa is an O(n_kv) per-cell scan (the file's own TODO measures it at 865 us at 33k context), so that was twelve scans where one would do: about 10 ms per step at 33k and 41 ms at 131k, all on the CPU and invisible to a GPU profiler. Key the inputs by compress ratio and reuse them across layers. The ratio is fixed per layer and the resolved layout depends on nothing else, so layers that share a ratio can share the whole set. This is not new work: upstream ggml-org has had it since the commit that added qwen4exp, and the code here is that code. This fork's independent port did not carry it, so it is picked back out and applied on its own. (cherry picked from commit 6c84c7d, "model: add Qwen3.8-Flash-Next (qwen4exp)", ggml-org#27742) Measured on Strix Halo (gfx1151, Vulkan), Qwen3.8-Flash-Next UD-IQ4_XS, -ctk f16 -ctv f16, MTP n_max=3, ctx 262144, llama-server with prompt cache reuse, temp=0, n_predict=160: depth before after 2048 33.69 27.55 8192 47.29 48.10 32768 35.22 39.67 131072 25.05 28.19 Output is bit-identical at every depth. The swing at 2048 is speculative decoding landing on a different accept/reject path, not a regression.
The block mean cut `members` into r strided views and materialised each one before adding. ggml_add has no contiguity requirement -- both the Vulkan and CUDA backends gate it on type alone and their kernels index through the nb strides -- and ggml_dup_tensor already gives the sum a contiguous home, so no cont is needed for the accumulator either. Removing it drops r reads plus r writes of [idx_dim, n_blocks] f32 per layer per ubatch: about 34 MB at 33k context over 12 layers. In a profile the CONT ops were +2.07 ms (15%) of the graph-time increment from depth 2048 to 32768. The addition order is unchanged, so the arithmetic is identical. Measured on Strix Halo (gfx1151, Vulkan), ctx 262144, f16 KV, MTP n_max=3: 131072 goes 25.05 -> 28.26 t/s (+12.8%) against the base, output bit-identical. On a CUDA host with far more bandwidth (2x RTX 3090, IQ1_S, ctx 154624) the same patch is worth +2.7% (11.97 -> 12.29 at 131072). What it removes is memory traffic, so the gain tracks how scarce bandwidth is.
A full block never changes again: its cells are written once, and the pooling,
normalisation and rotation that turn them into an indexer key are all determined by
position. build_qsa_top_k rebuilt every block on every ubatch anyway. At 131k over
12 layers that is GET_ROWS 7.8 ms + adds 13.8 ms + RMS_NORM_MUL 4.1 ms + ROPE
3.0 ms, about a quarter of a 97 ms decode step.
Add a cache holding one row per block. The graph recomputes the last n_recomp
blocks, writes them back with ggml_set_rows, and the score matmul reads the whole
cache. n_recomp is n_blocks while the cache is invalid, so the incremental and full
paths are the same code.
Four things this has to get right, each of which cost a real bug:
- Stored at F32. Pooling is per block with no reduction across rows, so a block
computed in a window of 66 gives bit for bit what it gave when all 32768 were
computed inline. Storing at full width keeps that true end to end, which turns the
correctness check from "the output is self consistent" into "the output matches
the build without the cache". f16 would halve the score matmul's read -- 0.6 ms of
97 -- and is not worth giving up an exact reference for.
- Single-stream caches only. Rows are addressed by block index with no stream
offset. The test is on the cache, not the ubatch: a slot of a multi-stream cache
also sees n_stream == 1, but its rows start at sinfo.s0.
- The block table is host scratch, not a graph tensor. ggml-alloc gives data only to
tensors some node reads. Once the graph reads the recompute window instead of the
whole table, blk_cells and blk_pos are orphans with data == nullptr, and set_input
writes through a null pointer.
- Invalidation is a position watermark, not a flag. Blocks are cut on the position
line, so an operation that only touches positions above some point leaves
everything below it correct. Speculative decoding drops its rejected tail with
seq_rm on every single step; treating that as "forget everything" makes the cache
recompute the whole table each time -- correct output, no speedup, and nothing in
an end-to-end timing says why.
state_read invalidates too. The server's prompt cache restores a slot through that
path and never goes through seq_rm; without the hook the cache keeps scoring the
blocks of whatever prompt ran before.
Measured on Strix Halo (gfx1151, Vulkan), Qwen3.8-Flash-Next UD-IQ4_XS,
-ctk f16 -ctv f16, MTP n_max=3, ctx 262144:
depth before after
2048 27.55 27.26
8192 49.41 49.70
32768 41.00 42.82
131072 32.46 41.59
Cumulative with the previous patches: 25.05 -> 41.59 t/s at 131072, +66% against
the base. Output is bit-identical to the build without the cache at every depth, and
via every path: ascending, descending (which shrinks the cache through seq_rm), a
changed prefix (which goes through the server's prompt cache and its restore path),
and back.
Nothing reads it. build_qsa_top_k only ever calls cpy_k and get_k on that cache: the indexer scores blocks against a key and has no value side at all. llama_kv_cache allocates a V anyway (it skips one only for MLA), and at the model's own n_embd_head_v of 256 that is dead weight of n_head_kv(1) * 256 elements per cell per layer. Narrow it to a single element. The type has to go with it: a quantised row must be a whole number of blocks and one element of q8_0 is not, so ask for F32 and the whole V costs 4 bytes per cell per layer. Measured on Strix Halo at ctx 262144 with an f16 cache: 90444 MB -> 88810 MB after load, 92714 MB -> 91251 MB after generation, against a computed 1610 MB. Output unchanged at 2048 and 8192. On a 2x RTX 3090 host at ctx 154624 with a q8_0 cache it is worth 481 MiB, which is what let that host's context ceiling move from 157696 to 180224.
Same dead allocation, in the cache the pooled-key patch adds. The graph writes pooled keys with ggml_set_rows and reads them back as a view; it never asks for a value side. That cache was created with V at the key width, which still left indexer_head_size F32 elements per block per layer. One element instead. The K side is already F32, so the type stays. Measured on Strix Halo at ctx 262144, f16 KV, MTP n_max=3: llama_kv_cache: Vulkan0 KV buffer size 768.01 MiB -> 387.01 MiB GTT after load 83754 MiB -> 83373 MiB Both move by the same 381 MiB, and every other buffer is unchanged to the byte (attention KV 6144.00, indexer 780.00, RS 112.57, compute 8535.86 / 1599.59). Output sha is identical at 2048 / 8192 / 32768 and so are the speculative decoder's accept counts (147/81, 119/119, 121/115), so the draft path is unaffected too.
Its only consumer is the RMS norm below it, and RMS norm is scale invariant: rms(x*s) = x*s / sqrt(mean(x^2)*s^2 + eps) = x / sqrt(mean(x^2) + eps/s^2) so dropping the divide only moves the effective epsilon from eps to r^2*eps -- 1e-6 to 1.6e-5 against a mean square of order 1. The scale was a full read and write of [idx_dim, n_blocks] f32 per layer per ubatch: 2.25 ms of a 97 ms decode step at 131k context. This is the one patch in the series that is not bit-exact by construction. Measured on Strix Halo (gfx1151, Vulkan), ctx 262144, f16 KV, MTP n_max=3: 131072 goes 28.26 -> 32.46 t/s cumulative with the previous patch.
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.
Six patches on
release/v0.7.4-staging(ea35c5066) that cut work the QSA indexerwas repeating, plus two dead allocations in the caches around it. No shader is
touched: every change is in how the graph is assembled, or in what the memory module
keeps.
Why
Qwen3.8-Flash-Next slows down badly with context. On a Strix Halo it does 47 t/s at
8k and 25 t/s at 131k. Profiling the decode step with
GGML_VK_PERF_LOGGER=1at twodepths and taking the increment says where it goes:
FLASH_ATTN_EXTCONTTOPK_QSA GET_ROWSGET_ROWSRMS_NORM_MULROPEADDAttention itself is 29%. The other 55% is the plumbing that feeds the indexer, and
most of it is recomputed from scratch every ubatch even though nothing it depends on
has changed.
Result
Qwen3.8-Flash-Next UD-IQ4_XS,
-ctk f16 -ctv f16, MTPn_max=3, ctx 262144,llama-server with prompt cache reuse,
temp=0,n_predict=160, gfx1151 / Vulkan:+66% at 131k. The gain scales with context because what is removed is
proportional to
n_kv, so there is little to win at 2,048 and a lot at 131,072.The 2,048 row moves around between runs rather than tracking the patches: with MTP
on, a run that happens to accept one more draft token per step reads several percent
faster, and at that depth the patches themselves are worth almost nothing. The deep
rows are the ones that say what the series does.
Also measured on 2x RTX 3090 (CUDA, IQ1_S,
-ctk q8_0 -ctv q8_0, no MTP, ctx154624): 131,072: 11.97 → 13.52 t/s, +13.0%. What is removed is memory traffic
and dispatches proportional to
n_kv, and a 3090 has several times a Strix Halo'sbandwidth, so removing the same bytes buys less. The two V narrowings gave back
700 MiB there, which moved that host's usable context ceiling from 157696 to 180224.
The patches
set_input_qsais an O(n_kv)scan and it ran once per QSA layer on byte-identical data: twelve scans where one
would do, about 41 ms per step at 131k, on the CPU and invisible to a GPU
profiler. Not new work: this is upstream ggml-org's code, from the commit
that added qwen4exp (
6c84c7d5d, model: add Qwen3.8-Flash-Next (qwen4exp) ggml-org/llama.cpp#27742). This fork'sindependent port did not carry it, so it is picked back out and applied on its
own; authorship on the commit is Daniel Han's.
ggml_conton the block-mean slices —ggml_addhas no contiguityrequirement on either the Vulkan or the CUDA backend (both gate it on type alone
and index through the
nbstrides), andggml_dup_tensoralready gives the sum acontiguous home. The copies were about 34 MB per ubatch at 33k over 12 layers.
Addition order is unchanged, so the arithmetic is identical.
changes again: its cells are written once, and pooling, normalisation and rotation
are all position-determined. Rebuilding every block every ubatch was about a
quarter of a 97 ms decode step at 131k. The commit message documents four things
this has to get right, each of which cost a real bug during development.
build_qsa_top_konly evercalls
cpy_kandget_kon that cache; the indexer scores blocks against a keyand has no value side.
llama_kv_cacheallocated one anyway at the model's ownn_embd_head_vof 256. Worth 1.6 GiB at ctx 262144 with an f16 cache.cache patch 3 adds.
KV buffer size768.01 → 387.01 MiB, and GTT after load movesby the same 381 MiB with every other buffer unchanged to the byte.
1/rscale — its only consumer is an RMS norm, which is scaleinvariant:
rms(x*s) = x / sqrt(mean(x^2) + eps/s^2). This is the one patchthat is not bit-exact by construction: the effective epsilon moves from
epstor^2*eps, 1e-6 to 1.6e-5 against a mean square of order 1. It is worth 2.25 ms ofa 97 ms decode step at 131k.
It is last on purpose. Everything below it is bit-exact, so if that trade is not
wanted, dropping the top commit leaves a series that changes no output at all.
The per-commit numbers were taken in the order the patches were originally
developed, which had 6 in third position. The endpoints are unaffected; only the
intermediate cumulative column would shift.
Verifying
Speed alone will not catch the failure modes here. Patch 3 in particular can be
completely inert — recomputing everything every step — and still produce correct
output, so a correctness suite that only walks forward passes while the cache does
nothing. What was used:
except patch 6 is bit-exact.
shrinks the cache through
seq_rm; the changed prefix goes through the server'sprompt cache and its restore path, which never calls
seq_rmat all. The same spanmust give the same output however it was reached.
GGML_VK_PERF_LOGGER=1aggregated over 40+ decodesteps. This is what caught an invalidation bug: end to end the build was only 5%
slow, but
GET_ROWShad not moved at all, which meant the incremental path wasnever running. Speculative decoding calls
seq_rmon every single step, andtreating that as "forget everything" makes the cache correct and useless at once.
One caveat on that profiler: it inserts a sync per op, so an op's time there is not
its contribution to the step. Two other candidates were dropped after checking that
distinction end to end.
Not included
FLASH_ATTN_EXTis 4.5 ms there against 4.6 ms at 32k, so the gather already capsit. Ranking on the 32k profile alone would have overvalued this.
be the identity, and checking that is an O(n_kv) CPU scan at graph build time — the
cost patch 1 removes, reintroduced in another form.