Skip to content

qwen4-next-fixes: upstream perf fixes for qwen4exp (Vulkan TOP_K, kv-cells, GDN/LID, graph splits) - #229

Merged
amangupta-tether merged 4 commits into
tetherto:temp-10297from
amangupta-tether:qwen4-next-fixes
Aug 31, 2026
Merged

amangupta-tether merged 4 commits into
tetherto:temp-10297from
amangupta-tether:qwen4-next-fixes

Conversation

@amangupta-tether

@amangupta-tether amangupta-tether commented Aug 31, 2026 •

Copy link
Copy Markdown

Summary

Cherry-picks from ggml-org/llama.cpp for Qwen3.8-Flash-Next (qwen4exp), validated on Vulkan (Strix Halo), CUDA (GB10), and Metal (M3 Ultra):

  • context: disable non-fused GDN and LID ops (ggml-org#27877, merged) — stop re-resolving fusion logic; CPU fallback covers unsupported backends
  • model: qwen4exp: reduce number of graph splits (ggml-org#27880, merged) — regroup PLE embd lookup into the build_inp_embd split
  • kv-cells: stop the sequence scan once all sequences are seen (ggml-org#28011, merged) — n-gram prev-token lookup no longer scans all 256 sequences per cell; big long-context decode win
  • vulkan: top_k radix select for k >= 1024 for Qwen 3.8 Flash Next (ggml-org#28032, merged) — qwen4exp's QSA indexer hits k>1024 on 12 layers per decoded token past ~1K context, causing 12 CPU round trips/token on Vulkan; adds a radix-select shader plus a fused QSA indexer variant (get_rows + f16 mask + top_k). Carried as a cherry-pick of the upstream squash merge daef7b687 (supersedes the argsort-based vulkan: support TOP_K when k exceeds the workgroup limit ggml-org/llama.cpp#28062 we had here initially)

Benchmarks: Qwen3.8-Flash-Next UD-IQ1_S, -p 2048 -b 2048 -ub 2048 -t 128 -fa on -ngl 999 --load-mode dio

Vulkan — qvac-dev-strix-1 (Radeon 8060S, RADV gfx1151)

Test Baseline This branch Δ
pp2048 @ d0 388.46 ± 1.81 373.52 ± 1.45 −3.8%
tg128 @ d0 30.89 ± 0.02 32.17 ± 0.01 +4.1%
pp2048 @ d10000 317.18 ± 0.44 348.93 ± 0.68 +10.0%
tg128 @ d10000 20.16 ± 0.08 27.84 ± 0.09 +38.1%
pp2048 @ d20000 267.27 ± 1.04 308.52 ± 0.45 +15.4%
tg128 @ d20000 17.42 ± 0.10 25.81 ± 0.08 +48.2%

(pp @ d0 dip matches the upstream author's own 8060S measurement; the depth gains dominate.)

Metal — qvac-dev-mac-arm64 (Apple M3 Ultra 96GB)

Test Baseline This branch Δ
pp2048 @ d0 803.68 ± 1.36 795.49 ± 2.18 −1.0%
tg128 @ d0 24.24 ± 0.38 27.28 ± 0.62 +12.5%
pp2048 @ d10000 630.16 ± 0.89 624.92 ± 0.81 −0.8%
tg128 @ d10000 21.69 ± 0.37 27.21 ± 1.45 +25.5%
pp2048 @ d20000 519.60 ± 0.73 516.42 ± 0.67 −0.6%
tg128 @ d20000 19.80 ± 0.44 25.17 ± 0.99 +27.1%

CUDA — NVIDIA GB10 (128GB unified)

Adjacent warm runs (machine shows ~10% thermal drift between runs, so only back-to-back runs are comparable):

Test Baseline This branch Δ
pp2048 @ d0 681.75 ± 7.25 692.48 ± 22.74 +1.6%
tg128 @ d0 28.28 ± 0.65 29.23 ± 0.50 +3.4%
pp2048 @ d10000 631.68 ± 32.78 636.62 ± 6.31 +0.8%
tg128 @ d10000 25.48 ± 0.88 26.24 ± 1.15 +3.0%
pp2048 @ d20000 537.24 ± 8.17 531.40 ± 33.79 −1.1%
tg128 @ d20000 22.60 ± 0.10 25.07 ± 0.99 +10.9%

tg slowdown with context largely gone on all three backends (d20000 tg retention: Vulkan 56→80%, Metal 82→92%, CUDA 80→86%).

Test plan

  • test-backend-ops TOP_K/TOPK_QSA/GATED_DELTA_NET/LIGHTNING_INDEXER: Vulkan 457/457 (gfx1151 + GB10), CUDA 738/738, Metal 644/644 (incl. new qwen4exp large-k and QSA fusion shapes)
  • llama-completion smoke test: identical coherent output on Vulkan, CUDA, Metal
  • llama-bench fixed vs baseline at depths 0/10k/20k on Vulkan, Metal, CUDA (tables above)
  • 2-node RPC tensor-split rerun once the second Strix node is back

ggerganov and others added 3 commits August 31, 2026 12:03
for_each_token_in tested all LLAMA_MAX_SEQ sequences for every used cell,
while a cell almost always belongs to one. The scan now stops once the
cell's own sequences have been seen. Same visit order, same callback
arguments, so behaviour is unchanged.

get_prev_tokens is the only caller, so this affects the n-gram path.

RTX PRO 6000, Qwen3.8-Flash-Next UD-Q4_K_XL, fa on, warm runs:

  55k context    generation 56.3 -> 74.3 t/s
  132k context   generation 33.6 -> 50.9 t/s

Prompt processing is unchanged, the scan is amortised over the ubatch
there. The gain follows the number of used cells, so it grows with
context and is invisible on short prompts.
Comment thread ggml/src/ggml-vulkan/ggml-vulkan.cpp Outdated
static void ggml_vk_topk(ggml_backend_vk_context * ctx, vk_context& subctx, const ggml_tensor * src0, ggml_tensor * dst) {
uint32_t ncols = src0->ne[0];
uint32_t nrows = ggml_nrows(src0);
uint32_t k = dst->ne[0];

if (k > (1u << ctx->device->max_workgroup_size_log2)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This enters the large-k path on k alone, but the scratch it goes on to allocate is sized from nrows (scratch_sz + perm_sz at L16290), and the only thing bounding nrows is TOPK_LARGE_K_MAX_ROWS over in supports_op at L21380.

The two sites agree today, but only by a chain that isn't visible from either one: pipeline_topk_f32[i] is created only for i <= max_workgroup_size_log2, so !pipeline_topk_f32[min_pipeline] happens to imply k > 1u << max_workgroup_size_log2. Nothing states that, and if topk pipeline creation is ever relaxed, or the row bound lowered, this branch will size prealloc_x from an unbounded nrows with no guard. At pp2048 / 64k context that is a ~1 GB scratch plus a ~512 MB permutation, and unlike the mul_mat paths there is no GGML_ABORT("Requested preallocation size is too large") backstop.

Could the predicate be factored into one helper used by both, so the coupling is explicit?

static bool ggml_vk_topk_use_large_k(const vk_device & device, int64_t k, int64_t nrows) {
    return device->vulkan_memory_model &&
           (uint32_t)k > (1u << device->max_workgroup_size_log2) &&
           nrows <= TOPK_LARGE_K_MAX_ROWS;
}

plus a GGML_ASSERT on the row count at the top of ggml_vk_topk_large_k. The "k too large but too many rows" case can't reach the backend at all, so asserting is the right shape.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 0543b4ba3. supports_op now goes through ggml_vk_topk_can_use_large_k(), and ggml_vk_topk_large_k asserts vulkan_memory_model and nrows <= TOPK_LARGE_K_MAX_ROWS on entry.

One deliberate deviation from the sketch: the runtime dispatch in ggml_vk_topk still routes on k alone. Routing on the full predicate there would send a hypothetical k-large/rows-large node into the regular path — where pipeline_topk_f32[min_pipeline] doesn't exist — instead of hitting the assert. Since supports_op makes that shape unreachable on the backend, k-only routing + the assert is the failure shape we want. Comment added at the dispatch site saying so.

Verified on gfx1151: test-backend-ops -b Vulkan0 -o TOP_K,ARGSORT 549/549, with the nrows=33 cases still correctly unsupported (CPU fallback). Will propose the same change upstream on ggml-org#28062.

Comment thread ggml/src/ggml-vulkan/ggml-vulkan.cpp Outdated
uint32_t ncols_pad_log2 = (uint32_t)ceilf(log2f(float(ncols)));
uint32_t ncolsp2 = 1 << ncols_pad_log2;

bool use_small = ncols_pad_log2 <= ctx->device->max_workgroup_size_log2 &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The split leaves use_small computed twice from the same inputs: here it decides whether to allocate the prealloc_x scratch, and at L16188 it decides whether to bind that scratch or out_buf as binding 1.

The expressions match, so this is correct as written. But if they ever drift so the caller says small and the callee says large, binding 1 becomes dst_buf and argsort_large writes 2 * ncolsp2 * nrows ints of tmp_idx into a destination tensor holding only ncols * nrows ints, i.e. an overflow into whatever else lives in that backend buffer. That is a silent, data-dependent corruption rather than a crash.

Passing use_small into ggml_vk_argsort_rows (or returning it) removes the possibility for free, and also lets the callee drop its own pipeline_idx recompute.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 0543b4ba3. ggml_vk_argsort_use_small() is now the single definition of the predicate: ggml_vk_argsort uses it to size the scratch and passes the result into ggml_vk_argsort_rows, which selects binding 1 from the parameter instead of recomputing. Caller/callee drift is no longer possible.

ggml_vk_topk_large_k passes the helper's result too; it's always false there (k > 2^wg_log2 and k <= ncols implies ncols_pad_log2 > max_workgroup_size_log2), so behavior is unchanged.

Comment thread src/llama-context.cpp
@@ -250,10 +250,10 @@ llama_context::llama_context(

cparams.fused_gdn_ar = true;
cparams.fused_gdn_ch = true;
cparams.auto_fgdn = true;
cparams.auto_fgdn = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes fused_gdn_ar, fused_gdn_ch and fused_lid unconditionally true on every backend, and the PR was validated on Vulkan, CUDA and Metal only. GGML_OP_LIGHTNING_INDEXER is implemented in CPU, CUDA, Metal and Vulkan and nowhere else; GGML_OP_GATED_DELTA_NET coverage varies beyond those.

With the probe gone, a backend that lacks the op no longer falls back to the unfused decomposition (build_delta_net_autoregressive / build_delta_net_chunking in delta-net-base.cpp). Instead the graph keeps the fused node and ggml-backend schedules it on the CPU, so every GDN/LID layer becomes a round trip per token, and the "%s not supported, set to disabled" warning that used to surface this is no longer emitted.

Given the OpenCL work on this fork, could we get a llama-bench run on OpenCL before this lands? If those regress, keeping the probe and opting the three validated backends out of it would get the same win without the blast radius.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mechanism you describe is right: with the probe gone, a backend lacking the fused op keeps the fused node and ggml-backend schedules it on the CPU, and the "not supported, set to disabled" warning is no longer emitted.

One correction on the blast radius for this PR's target model: qwen4exp never emits GGML_OP_LIGHTNING_INDEXER. Its indexer is built from primitive ops (the rectify-and-sum over head dot products, see the comment at qwen4exp.cpp:576) — which is exactly why the Vulkan TOP_K fix in this PR matters to it. qwen4exp.cpp doesn't reference fused_lid at all, and its only use of fused_gdn_ar/ch is a shape check at :884. So for qwen4-next itself:

  • LID: no impact — the model never builds the fused op on any backend.
  • GDN: OpenCL and SYCL both implement GGML_OP_GATED_DELTA_NET in this tree (OpenCL: F32→F32, S_v ∈ {16,32,64,128}; qwen4exp runs S_v=128 since head_v_dim = ssm_d_state), so the fused node stays on-device there too.

The real LID exposure is deepseek4 / glm-dsa / deepseek32 on OpenCL and SYCL — those models do consult cparams.fused_lid, and both backends lack the op, so they'd take the CPU round trip per token where they previously got the unfused decomposition. That's upstream's accepted trade-off in ggml-org#27877 ("for backends that don't yet support these ops, there is CPU fallback"), but I'm happy to scope it: keep the probe and only skip it when every device in the context is CPU/CUDA/Metal/Vulkan. That keeps the init cost saving where we validated it without touching OpenCL/SYCL behavior for the DSA models.

On the OpenCL bench ask: I don't have an OpenCL-capable device on hand (validated here on Vulkan gfx1151, CUDA GB10, Metal M3 Ultra) — if you can point me at a box with the OpenCL backend running I'll do the before/after llama-bench run. Otherwise, want me to do the scoped-probe change instead?

…l-org#28032)

* vulkan: add top-k radix sort shader for k >= 1024

* add Qwen 3.8 Flash Next top-k tests

* add top-k qsa fusion

* clean up code
@amangupta-tether

Copy link
Copy Markdown
Author

Update: swapped the Vulkan TOP_K commit from the argsort-based ggml-org#28062 to the version that landed upstream, ggml-org#28032 (radix select for k >= 1024 + fused QSA indexer variant), cherry-picked as 65c7c96 from the upstream squash merge daef7b6.

This makes the two Vulkan review threads moot:

Re-validated on gfx1151 with the merged implementation: test-backend-ops -b Vulkan0 -o TOP_K,TOPK_QSA 457/457 (incl. the new QSA fusion cases), and the bench numbers improved further over the argsort version — tg128 @ d20000 25.81 vs 24.68 t/s, pp2048 @ d20000 308.5 vs 270.6 t/s (+15.4% over baseline now). The pp @ d0 dip (−3.8%) matches the upstream author's own 8060S measurement. PR body updated with the new table.

The llama-context.cpp thread (fused GDN/LID probe removal) is unaffected by this swap — still open on your call there.

@github-actions

Copy link
Copy Markdown

Review Status

Current Status: ❌ PENDING
Approvals so far: Member: 1

Pending reviews: Needs 1 Management or Team Lead.

@amangupta-tether
amangupta-tether merged commit 9d181fe into tetherto:temp-10297 Aug 31, 2026
53 of 58 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants