Skip to content

qwen4exp: Qwen3.8-Flash-Next long-context decode, +66% at 131k - #9

Open
firelzrd wants to merge 6 commits into
Nathanw1014:release/v0.7.4-stagingfrom
firelzrd:qwen3.8-flash-next-perf
Open

qwen4exp: Qwen3.8-Flash-Next long-context decode, +66% at 131k#9
firelzrd wants to merge 6 commits into
Nathanw1014:release/v0.7.4-stagingfrom
firelzrd:qwen3.8-flash-next-perf

Conversation

@firelzrd

@firelzrd firelzrd commented Sep 7, 2026

Copy link
Copy Markdown

Six patches on release/v0.7.4-staging (ea35c5066) that cut work the QSA indexer
was 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=1 at two
depths and taking the increment says where it goes:

share of the 2,048 → 32,768 increment
FLASH_ATTN_EXT 29%
CONT 15%
TOPK_QSA GET_ROWS 13%
GET_ROWS 11%
RMS_NORM_MUL 6%
ROPE 5%
ADD 5%

Attention 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, MTP n_max=3, ctx 262144,
llama-server with prompt cache reuse, temp=0, n_predict=160, gfx1151 / Vulkan:

depth base with these patches
2,048 33.69 27.26
8,192 47.29 49.70
32,768 35.22 42.82
131,072 25.05 41.59

+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, ctx
154624): 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's
bandwidth, 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

  1. share the QSA input set per compress ratioset_input_qsa is 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's
    independent port did not carry it, so it is picked back out and applied on its
    own; authorship on the commit is Daniel Han's.
  2. drop the ggml_cont on the block-mean slicesggml_add has no contiguity
    requirement on either the Vulkan or the CUDA backend (both gate it on type alone
    and index through the nb strides), and ggml_dup_tensor already gives the sum a
    contiguous home. The copies were about 34 MB per ubatch at 33k over 12 layers.
    Addition order is unchanged, so the arithmetic is identical.
  3. cache the pooled indexer keys, recompute only the tail — a full block never
    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.
  4. narrow the indexer cache's V — 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. llama_kv_cache allocated one anyway at the model's own
    n_embd_head_v of 256. Worth 1.6 GiB at ctx 262144 with an f16 cache.
  5. narrow the pooled-key cache's V as well — the same dead allocation in the
    cache patch 3 adds. KV buffer size 768.01 → 387.01 MiB, and GTT after load moves
    by the same 381 MiB with every other buffer unchanged to the byte.
  6. drop the 1/r scale — its only consumer is an RMS norm, which is scale
    invariant: rms(x*s) = x / sqrt(mean(x^2) + eps/s^2). This is the one patch
    that is not bit-exact by construction
    : the effective epsilon moves from eps to
    r^2*eps, 1e-6 to 1.6e-5 against a mean square of order 1. It is worth 2.25 ms of
    a 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:

  • Output hashes against the build without the patch, at four depths. Everything
    except patch 6 is bit-exact.
  • Ascending, then descending, then a changed prefix, then back. Descending
    shrinks the cache through seq_rm; the changed prefix goes through the server's
    prompt cache and its restore path, which never calls seq_rm at all. The same span
    must give the same output however it was reached.
  • A profile, not just a timer. GGML_VK_PERF_LOGGER=1 aggregated over 40+ decode
    steps. This is what caught an invalidation bug: end to end the build was only 5%
    slow, but GET_ROWS had not moved at all, which meant the incremental path was
    never running. Speculative decoding calls seq_rm on every single step, and
    treating 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

  • Extending the Vulkan FA gather union to GQA. Dropped after profiling at 131k:
    FLASH_ATTN_EXT is 4.5 ms there against 4.6 ms at 32k, so the gather already caps
    it. Ranking on the 32k profile alone would have overvalued this.
  • Replacing the pooling gather with a view of the cache. Needs the cell layout to
    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.

danielhanchen and others added 6 commits September 8, 2026 00:17
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.
@github-actions github-actions Bot added the model label Sep 7, 2026
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