Skip to content

qwen4exp : QSA with chunking - #30815

Draft
sanmai wants to merge 33 commits into
ggml-org:masterfrom
sanmai:feat/QSA-indexer
Draft

sanmai wants to merge 33 commits into
ggml-org:masterfrom
sanmai:feat/QSA-indexer

Conversation

@sanmai

@sanmai sanmai commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Overview

This is just a prototype. No review is needed.

Additional information

Query chunking (2048 vs off) without kq_mask
CUDA0 compute buffer, ub 8192 about -2.4 GB 10957 -> 6885 MiB (-4.0 GiB)
CUDA0 compute buffer, ub 4096 not recorded 6112 -> 4076 MiB (-2.0 GiB)
Pinned host buffer, ub 8192 -4.6 GB on CPU 5526 -> 1430 MiB (-4.0 GiB)
Largest MoE cache, ub 8192 9000 -> 11500 (+2500) 11500 -> 15750 (+4250)
Largest MoE cache, ub 4096 14700 -> 16700 (+2000) 16700 -> 18700 (+2000)
#   ub    cache MiB      prefill t/s  decode t/s  VRAM peak MiB
#   4096  18700 (max)    1130-1197    48.3-54.7   32062
#   8192  15750 (max)    1740-1760    45.4-48.8   32050

Before 72e03e1:

  ub    chunk  cache MiB      prefill t/s  decode t/s  VRAM peak MiB
  2048  -      17500          786          50.6        30176
  2048  -      18500          796          53.8        31178
  2048  -      19000 (max)    804          53.5        31680
  2048  -      16200          767          48.5        28876
  2048  -      11500          711          38.4        24182
  4096  2048   15700          1167         47.3        31100
  4096  2048   16200          1186         47.5        31598
  4096  2048   16700 (max)    1180         48.5        32102
  4096  1024   15700 (max)    1167         45.4        31392
  4096  0      14700          1156         45.1        31066
  8192  2048   11500 (max)    1662         38.5        31892
  8192  1024   10500          1640         37.3        31968
  8192  0      9000           1593         32.5        31732
  8192  2048   18000 -c 131072  1753       52.1        31976

Requirements

am17an and others added 7 commits October 16, 2026 11:35
The selection mask and the indexer scores of a QSA layer are [n_kv, n_tokens]
and [n_pool, n_tokens]. Score, select and attend 2048 query tokens at a time,
so the chunks of a layer reuse one chunk worth of memory. The views of the
token inputs are shared by the layers, so each slice is uploaded once.

LLAMA_QSA_CHUNK sets the chunk size for tests, 0 disables the split.
A [n_kv, 1, 1, 1] mask applies to every token and gives a [n_kv, n_tokens]
result, so a selection that is causal on its own does not need a dense
[n_kv, n_tokens] mask as its source.
Like llm_graph_input_attn_kv, do not fill a mask that no op reads.
A token sees only the pools that end at or before it and the tail up to
itself, all of its own sequence, so every kept cell is already visible in
the kq_mask and the selection is causal on its own. Keep a zero row
instead of the kq_mask cells, then no op reads the dense [n_kv, n_tokens]
mask, and the graph does not allocate or upload it.
@github-actions github-actions Bot added model Model specific testing Everything test related ggml changes relating to the ggml tensor library for machine learning CUDA Related to the CUDA backend labels Oct 19, 2026
Master has the final PR 30640, which renames ggml_kpool_mask to
ggml_top_k_mask and broadcasts the mask natively; take its version of the
op and call it with the zero row from qwen4exp.
@sanmai

sanmai commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

@dagbdagb Replying here. Asked one of the agents to benchmark and find the best setting.

@am17an am17an left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is the main problem here at long context? i.e. uploading the kq mask?

@dagbdagb

Copy link
Copy Markdown

Not entirely sure if that question was directed at me or not.

If it is: The help-text for --moe-cache-mib N has 0 guidance for sizing N. Unless one is very deep into the matter or developed the feature, one can only resort to guess-work. Or automated testing. Certainly not the only such parameter in the README, but still.

@am17an

am17an commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

@dagbdagb it was not for you. Re the guidance, yes I will make some changes there soon

@sanmai

sanmai commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

These are ideas pulled straight from Strata, with the goal to reduce memory footprint of work buffers:

  • QSA query chunking (c391f0f) goes like this:

    • Take the first 2048 tokens of the batch.
    • Build the mask, only a quarter of full 8192 batch
    • Run the attention
    • Free that mask and reuse
    • Join the outputs, and since chunks attend to the same kv cache the result is same
  • Removal the kq_mask from QSA (2020bb6, 72e03e1) that frees about 2 GiB at ub 4096 and about 4 GiB at ub 8192, so we all can use a larger expert cache and get better overall perf at ubatch 8192

Shrinking pool_mask is also an option.

And there's a bug with CUDA top_k tie ordering - haven't looked into it.

ub Before #30640 Now │
ub 4096 1180 / 48.5 t/s at cache 16700 1160 / 50 t/s at cache 18700 │
ub 8192 1660 / 38.5 t/s at cache 11500 1750 / 47 t/s at cache 15500 │

1750 t/s pp is the major win here

@sanmai

sanmai commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

@am17an Feel free to slice and dice this thing into pieces as much as you want or need.

Comment thread src/models/qwen4exp.cpp Outdated
static int64_t qwen4exp_qsa_n_chunk() {
static const char * env = getenv("LLAMA_QSA_CHUNK");
static const int64_t n_chunk = env ? std::max<int64_t>(0, atoll(env)) : 2048;
return n_chunk;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

2048 was a sweet spot on 5090

at 1024 the launch tax starts to show up

@am17an

am17an commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Okay so this is because the kq_mask being the largest user of the compute buffer at these ub sizes. But do we need to allocate the kq_mask for this model since we're using sparse attention? cc @ServeurpersoCom if you have a good idea.

EDIT: maybe I'm not correct and need to measure again.

@sanmai

sanmai commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

all 12 attention layers are QSA so we should not need kq_mask

ggml_top_k_mask with a single keep row returns [n_kv, n_tokens], while
build_attn_qsa expects the kq_mask layout [n_kv, n_tokens/n_stream, 1, n_stream].
With several streams the shape assert failed at graph reserve.
@am17an

am17an commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

@dagbdagb maybe try the script in #30966

@ServeurpersoCom

ServeurpersoCom commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Okay so this is because the kq_mask being the largest user of the compute buffer at these ub sizes. But do we need to allocate the kq_mask for this model since we're using sparse attention? cc @ServeurpersoCom if you have a good idea.

You are right, it is not needed for qwen4exp. The QSA selection is already causal and sequence local by construction: pool_mask only keeps pools of the token's own sequence that end at or before its rank, the tail holds its own cells up to itself, and dead picks or n_kv sentinels are skipped by top_k_mask. So kq_mask just multiplies the kept cells by zero, and in the real GGUF all 12 attention layers are QSA (the other 36 are GDN), so nothing else reads it.

sanmai's numbers are correct, I get exactly the same on the real UD-Q4_K_XL: feeding top_k_mask a broadcast row of zeros instead of kq_mask takes the pinned host buffer from 5526 to 1430 MiB and the compute buffer down by 4 GiB at -c 262144 -ub 8192, which is the mask itself (262144 x 8192 x 2 bytes in F16). The logits at -c 16384 are byte identical to master.

If we want to go all the way, what remains is the dense [n_kv, n_tokens] selection mask FA consumes, which the CUDA sparse FA path already turns back into indices for the shapes it covers. The query chunking in this PR bounds it, and a possible follow-up would be letting flash_attn_ext take the selected cell indices directly, which would remove it for qwen4exp, glm5-next and the DSA models.

@sanmai

sanmai commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Just QSA without kq_mask for a pair of backends:

I extracted them because they pack the most punch (4 GB saved on GPU at ub 8192), but if that's a preference to keep this PR as a whole, I have nothing against it.

@am17an

am17an commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

I'm wondering how m-rope interacts with this whole thing. I don't have a good understanding of it at the moment in relation on how it interacts with the kq mask

@sanmai

sanmai commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Looking into the build failure.

Tests are passing on my end.

@ServeurpersoCom

Copy link
Copy Markdown
Contributor

I'm wondering how m-rope interacts with this whole thing. I don't have a good understanding of it at the moment in relation on how it interacts with the kq mask

M-RoPE only matters for image tokens, which share pos[0]. kq_mask orders them by (y, x) through is_2d_gt, while the k-pool layout orders a sequence by (pos, cell) in kpool_rank, so ties go by cell index. An image is normally written in token order into increasing cells, so both orders agree and the selection is the same with or without kq_mask; a single image test on the real model gives identical output.

They can only disagree when find_slot wraps around the ring in the middle of an image, so a later patch lands in a lower cell. That mismatch is already in the indexer on master: kq_mask only hides the half where the selection takes a later patch, it cannot bring back an earlier one the indexer dropped. The clean fix is to sort the k-pool layout by (pos, y, x), the ext positions are already read there for new_pool_pos, and then kq_mask is not needed in that case either.

@am17an

am17an commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Okay then this should we merge this?

@ServeurpersoCom

Copy link
Copy Markdown
Contributor

Okay then this should we merge this?

I would start with #30965, which isolates the kq_mask part. I tested that exact diff on the real UD-Q4_K_XL: logits byte identical to master at -c 16384, -4 GiB on both the compute and pinned host buffers at -c 262144 -ub 8192, and the new test-backend-ops cases pass. The chunking here bounds the selection mask but keeps it dense, so I would hold it until we know whether the index based FA mentioned above is the way to go.

@github-actions github-actions Bot added the Vulkan Issues specific to the Vulkan backend label Oct 24, 2026
@sanmai sanmai changed the title qwen4exp : QSA without the kq_mask + chunking qwen4exp : QSA with chunking Oct 25, 2026
@am17an

am17an commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

it looks it may help with large ubatch on dsv4.1/glm5.3 as well

PapyKahan added a commit to PapyKahan/llama.cpp that referenced this pull request Oct 28, 2026
Upstream's version is kept wherever it does what the branch did; the branch's stays where upstream has none:
- MoE cache: the constructor takes upstream's no_alloc flag beside the CPU share, buffers come from
  llama_memory_bufs::alloc_buf; phases and host mode as before.
- build_moe_ffn: the branch's experts lambda (cache slots, host mode, shared batches) with upstream's DeepSeek-4.1
  and Laguna changes.
- qwen4exp: the selection is built over the cache cells as upstream now does (ggml-org#30965, ggml-org#31243), the branch keeps
  its query chunks (ggml-org#30815's earlier version); upstream's hc_mix outputs and merged tensors.
- CUDA MMQ: the tile map of active MoE tiles also launches upstream's fused gate/up/GLU kernel (ggml-org#31188, ggml-org#29948);
  the fused paths zero the rows of negative ids as ggml_cuda_mul_mat_id does.
- CPU MUL_MAT_ID: upstream's -1 skip (ggml-org#26631) and the branch's two-pass grouping agree, negative ids are skipped.
- tests: the QSA chunk tests move with upstream's archs tests; a MUL_MAT_ID result marked by
  ggml_mul_mat_id_set_negative_ids runs on CUDA again, only unmarked ones are excluded ([TAG_MUL_MAT_ID_SKIP]).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P3vd8iuiuxiev7wMPyJmSf

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CUDA Related to the CUDA backend ggml changes relating to the ggml tensor library for machine learning model Model specific testing Everything test related Vulkan Issues specific to the Vulkan backend

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants