Repository navigation
glm5-next: GLM-5.3-Flash support intial attempt - #1315
gopinath87607 wants to merge 30 commits into
Conversation
GLM-5.3-Flash is a 312B-total / 17B-active hybrid MoE: 45 trunk layers, 11 absorbed-NoPE-MLA
full-attention layers (every fourth) and 34 KDA linear-attention layers, all wrapped in
hyper-connections.
Three things in a glm5-next block have no analogue in this engine, and each one produces plausible
output when wrong rather than crashing:
* HYPER-CONNECTIONS own every residual. The sublayer's output is mixed back into four streams
through a Sinkhorn-normalised 4x4 matrix computed per token from a 24-wide projection with its
own base and scale. Ported from the reference's ggml_hc_pre/ggml_hc_post, not re-derived.
* KDA is a delta-rule recurrence with a per-head state, a depthwise causal convolution over three
SEPARATE q/k/v streams that nevertheless share one state (the reference concatenates their
filters before convolving), an L2 normalisation whose eps is a FLOOR on the norm rather than a
term inside the sum, and a decay gate bounded to (-5, 0).
* ABSORBED NoPE MLA. K and V are the same tensor: the cache holds the 512-wide kv_lora latent,
one row per token, and one token's value IS that latent. There is one KV head, no RoPE
(rope.dimension_count is 0), and the softmax scale is 1/sqrt(256) - the per-head width BEFORE
absorption - not 1/sqrt(512).
The router is not qwen4exp's either: sigmoid, with no softmax anywhere in the model, a bias that
steers SELECTION only, and weights that are the UN-BIASED probabilities normalised by their SUM.
ffn_gate_inp.weight is F32 here where qwen4exp's is BF16 - reading it as BF16 gives 288 plausible
logits and a different top-8.
New: five kernel files (glm_mhc, glm_kda, glm_delta, glm_attn, glm_elt), the layer order and its
buffer carve (glm_layer.{hpp,cpp}), expert-pool binding (glm_experts.{hpp,cpp}), architecture
detection (model_arch.cpp, arch.hpp), the projection helper layer.cpp and glm_layer.cpp now share
(gemv_util.{hpp,cpp}), and glm_parity - a ctest in which every case also computes a RIVAL reading
and requires the two to be told apart, because each of these choices yields well-formed output
when it is wrong.
Measured on 1x RTX 3060 12 GB, UD-IQ4_XS 5 shards, 319-token prompt, greedy: prefill 6.42 tok/s
at --prefill 1, 9.81 at 128 and 10.41 at 4096; decode 6.36 tok/s. The greedy ids are identical at
all three chunk sizes and equal to the reference captured before this work landed.
Not included: vision, MTP, speculative decoding, --batch and --gpu-stages stay out for this arch.
Co-Authored-By: Claude Code <noreply@anthropic.com>
|
Tried this branch on an AMD card on Windows, since nobody here has one. RX 7900 XTX (gfx1100), Ryzen 9 7900X3D, 64 GB, Windows 11 24H2, driver 32.0.31041.1004. Built with
One thing that tripped me up: the Ryzen iGPU shows up as HIP device 0 and the 7900 XTX as device 1. Without Running UD-IQ2_XXS end to end now (24 GB VRAM + 64 GB RAM), next to llama.cpp Vulkan with the same prompts. Will post numbers here when it's done. Notes: https://github.com/jerem91150/Strata/blob/glm53-hip/docs/GLM53_AMD_WINDOWS.md |
|
Numbers from the 7900 XTX as promised. UD-IQ2_XXS, 24 GB VRAM + 64 GB RAM, so Decode, 256 tokens:
To get past that, I tried the first family's hit/miss split for glm5-next on my fork. It keeps a per-layer set of experts in VRAM, computes them with Two smaller things came up on the way, possibly useful here:
Code is on https://github.com/jerem91150/Strata/tree/glm53-hip (last three commits), notes in docs/GLM53_AMD_WINDOWS.md. Happy to open a PR against this branch if any of it is wanted. |
… k-pool indexer Two phases of the "finish the GLM port" plan. **The SwiGLU clamp.** `glm_layer.cpp`, `glm_elt.cu` and `glm.hpp` all asserted that ik_llama.cpp reads `swiglu_clamp_exp` and never applies it. It applies it, and so does mainline llama.cpp: `swiglu_limit()` in ik_llama.cpp's `llama-model.h` has `LLM_ARCH_GLM5NEXT` in its allow-list and `llama-build-context.cpp` calls it on both the routed and the dense/shared path. Both oracles clamp and we did not, which is a correctness bug live on every FFN in the model. The arithmetic is `out = min(silu(gate), limit) * clamp(up, -limit, +limit)` - the SILU'S OUTPUT is what the limit caps, above only, and the up is capped on both sides. The comment's "clamp the raw gate before the silu" reading is a different function that agrees to 4.5e-4 at the model's own limit of 10 and is not what either reference computes. `swiglu_clamp_shexp` is added beside `swiglu_clamp_exp` and parsed the same way, and `NativeFmt` gains `swiglu_limit` - the one per-layer struct already threaded into every routed-expert kernel - filled from the geometry where the GLM layout is built. It defaults to 0 (no clamp), so the first family's packs are untouched. `native_expert_parity` gains a clamp arm that scales the activation until the fixture reaches all three sides, asserts that it did, and checks the kernel against a double transcription built from the same dequantized weights - at the model's limit and at 1.0, where the two readings of the clamp separate. At 10.0 it prints that they do not. **The DSA k-pool indexer**, ported from Project Maya's `glm_dsa.cu` + `glm_dsa.hpp` (an MIT fork of this repo) and re-checked against ik_llama.cpp's graph, which is the ladder oracle. Four kernels: the per-key-dim-element pool softmax, the relu-weighted score, the rank-formula selection with its cell expansion and tail, and the sparse attention over the selected cells with the -1 padding as the -inf mask. Our adaptation drops the fused de-absorption the original carries, because `attn_v_b` is quantized in our pack and stays with the existing band projection, and reads the engine's fp16 latent cache. `select_tail` is documented as a DISAGREEMENT between the oracles rather than a fact: ik_llama.cpp appends the incomplete tail whenever kpool > 1 and reads no key at all; upstream reads `attention.indexer.kpool_select_tail`, defaulting to false; and `l4.gguf`'s own metadata carries neither key. We follow ik, the oracle the ladder is measured against. `glm_parity` gains the four cases with the rivals that pin each trap: one softmax over the whole pool block instead of per key dim, the logits without `ape`, no relu, the relu moved outside the weighting, the tail flipped, ties to the higher index, cells in cell order, the scale from `kv_lora` instead of `qk_nope`, and the padding counted in the normaliser. 175 cases, 0 failures. Still to come: wiring the indexer into `mla_layer` behind an opt-in switch, the MTP draft layer, and the expert tier. The end-to-end ladder confirmation of the clamp needs a free card. Co-Authored-By: Claude Code <noreply@anthropic.com>
`STRATA_GLM_NO_CLAMP` makes the two limits read 0, which is the reference's own reading of an absent limit and therefore exactly "no clamp". It exists because a clamp that never binds is not a tested clamp: the shipped artifact's limit is 10.0, and on an ordinary prompt the gates rarely reach it - which is how the clamp's total absence survived a 30/32 ladder match. Measured on the l4 rung (4 layers), prompt "The capital of France is", greedy, 8 tokens, against the ik_llama.cpp oracle with the same metadata: limit 10 (shipped) both arms byte-identical, ids and --dump-logits exp=1, shexp=10 oracle `rael有关snowflakeyesterdayoringredient` 8/8 exp=10, shexp=1 oracle `raelites whooping少了少见的到的` 8/8 exp=1, shexp=1 oracle `rael also knowns'-'s` 8/8 exp=1, shexp=1, NO_CLAMP=1 back to the unclamped `raelitesaurus...` The last two lines are the evidence. The first says the switch works and the clamp is what moves the output; the three above it say each limit's own site matches the oracle when it binds - the routed experts through the CPU chain's `swiglu_limit`, the dense lead and the shared expert through the CUDA `ffn3`. At limit 10 they agree by construction: nothing binds, so the run confirmed the rest of the port and said nothing about the clamp. Co-Authored-By: Claude Code <noreply@anthropic.com>
The indexer's four kernels and its contract landed in f4d5319. This is the engine end of it: the per-layer pool-in-progress state, the four projections plus the key LayerNorm, the selection and the sparse attention, and the `--dsa` switch (refused by name on any other arch, which has its own indexer behind `--native-qsa-indexer`). Measured against ik_llama.cpp on l4.gguf, the ladder's 4-layer rung, with `indexer.top_k` patched 2048 -> 16 IN A COPY OF THE RUNG so the indexer binds from position ~16 instead of ~2048. Chunk 0 only - rows 256..510, 255 rows, `-c 512`, `--save-all-logits`: oracle dense vs ours dense mean KL 0.00032 argmax 249/255 top10 9.82 oracle --dsa vs ours --dsa mean KL 0.00081 argmax 248/255 top10 9.78 oracle dense vs ours --dsa mean KL 0.02583 <- crossed pairing oracle --dsa vs ours dense mean KL 0.02442 <- crossed pairing Our `--dsa` reproduces the oracle's `--dsa` to within the dense arm's own noise floor, and both crossed pairings are ~30x worse. Chunk 1 is deliberately excluded: the oracle scores each chunk as a sequence restarting at position 0 and kl_oracle.py maps it to our absolute positions. At the unpatched top_k 2048 and `-c 512` the indexer selects every visible pool, and both engines are then byte-identical to their own dense runs - ik's `--dsa` against its own no-`--dsa` compares equal over the whole file at `-b 512` and again at `-b 64` (PPL 91142.1138 in all four runs). That is the invariant the flag had to keep, and it is the reason the flag is safe to leave off. Two divergences from ik are documented in glm_dsa.hpp rather than coded, with the measurement that settled each: `select_tail` (the two oracles disagree by default and the model carries no key; we follow ik, which builds the tail unconditionally) and ik's third `n_sel` term `(n_kv - tail_cnt)/r`, which is a bound on a static graph shape and can only bind on the final cell of a full cache - the one row perplexity cannot score. glm_parity: 175 cases, 0 failures. l4 greedy ids unchanged (63003 63003 63003 ...). Co-Authored-By: Claude Code <noreply@anthropic.com>
…e oracle
`nextn_predict_layers = 1`, so the shipped artifact carries a 46th block: a full
MLA + MoE layer plus `nextn.eh_proj/enorm/hnorm/shared_head_norm`. Nothing read
it. It is now packed (`iq_pack.py --mtp`), loaded, carved, and run, and its
drafted token matches ik_llama.cpp on all four oracle prompts.
The block's shape: `h' = eh_proj(cat(enorm(emb(x) * clamp(pos,0,1)), hnorm(h)))`,
one MLA+MoE block, `shared_head_norm`, then the model's own output head. The
embedding half IS position-masked and the mask is applied BEFORE `enorm`, which
is why the first cell of a sequence contributes a constant and no token.
Four things in the engine assumed every layer index was a trunk layer, and each
had to be told about `n_layers`:
* `lay_n = g.n_layers + (n_nextn > 0 ? 1 : 0)` is now the one layer count the
expert layout, both expert sources and the GLM pool all get. They refuse a
count that disagrees with the layout, and the layout has the block's row.
* `NativeDense::set_draft_block` admits `blk.<n_layers>` for the upload and for
`served_names`, and `generate.cpp` sets it while the name set is built even
when the run will not run the block - the pack holds those rows either way and
`WeightTable::load` refuses a row with no bytes.
* the `--layer-split auto` search prices the block as the last stage's extra
layer, so a split cannot silently drop it.
* the GPU-expert-kernel gate is skipped for glm5-next. Measured on the shipped
artifact, the draft block's experts are Q3_K gate/up and Q4_K down (ggml types
11/12, shard 5 blk.45) against IQ3_S/Q6_K on the trunk, and neither type is in
`STRATA_GU_FMTS`/`STRATA_D_FMTS` - but on this arch every routed expert is
computed on the CPU and no GPU kernel ever sees one, so the gate refused the
whole model over a block it had no opinion about.
**The draft block's cache is COMPACTED: one row per draft step, not one per
position.** `mla_layer` gains `row0`/`vis0` (both defaulting to the dense
reading) and the block passes `n_written`/`n_written + 1`. Attending over
`pos + 1` rows walks rows the block never wrote, and a zeroed MLA row is not a
masked row - K and V are 0, so its score is exactly 0 and softmax gives it weight
`exp(0) = 1` against the one real row's `exp(s)`. MEASURED: with the dense
reading p1/p2/p3 still land (they are robust to it) and p4 drafts 6720 instead of
50473. This is also the reference's semantics - its draft context is a fresh
context, its cells are allocated in write order, and its mask leaves an
unallocated cell masked out.
**And the bug that made the block an FFN.** `glm_add_inplace` is `dst += src` and
the step's first call had its arguments the other way round, so the line that
reads `st.inp = attn(x) + inpSA` actually did `b.attn_out += st.inp` and left
`st.inp` at `inpSA`; the attention result was then overwritten by the next MoE
combine. The block still emitted a confident, plausible token - it was an FFN
over `inpSA` - so it read like an arithmetic bug inside the block and not a
dropped term. Found by zeroing `b.attn_out` by hand: bit-identical logits.
This is the file's only use of `glm_add_inplace`; the trunk writes its residual
through mHC instead, which is why nothing else caught it.
MEASURED, `--mtp-probe` on the 4-layer ladder rung, cold cache, one card
(RTX 3060), against `/home/gopi/glm-scratch/mtp-oracle/refs/{p1..p4}`:
prompt target_next draft oracle
p1 6196 72 72
p2 6013 11 11
p3 2421 7739 7739
p4 64555 50473 50473
The trunk's hidden state fed to the block was already right (max abs diff
0.0532 against the oracle's own dump, same RMS), so `target_next` matching was
not evidence about the block - it is the trunk head. `glm_parity` passes.
Not here yet, and the block is inert without it: the verify window. GLM's `--spec`
is still refused by name in `generate.cpp` (the first family's window is captured
layer PAIRS with its own state commit, which does not transfer), so `--mtp-probe`
measures the block and a run cannot yet accept what it drafts. The `--dsa`
indexer addresses the cache by absolute position and the block's is compacted, so
the combination is refused rather than silently mis-addressed.
Co-Authored-By: Claude Code <noreply@anthropic.com>
The previous commit's own comment claimed that a pack built without
`iq_pack.py --mtp` loads with an empty slot for the draft block - "on a pack
WITHOUT it the slot is simply empty (`bytes == 0`), which is exactly what a
dense-lead layer looks like". The contiguity walk does not do that: every
layer whose line is absent is left at its `~0ull` sentinel, and the walk
refuses the file. MEASURED: the shipped full pack, 45 rows (blk.0..blk.44,
packed before the block existed), refuses to start at all -
strata generate: native_experts.txt: layer 45 is missing or not contiguous
- because `lay_n` is `g.n_layers + 1` whenever the model declares a draft
block, and the row at that index only exists in an `--mtp` pack. Every
artifact that predates the block is unloadable.
An absent line is now an empty slot, stamped `offset = at` with the `bytes`
and `NativeFmt` a default-constructed row already has, so the walk advances by
nothing over it and the row is indistinguishable from a dense-lead one. Only
a TRAILING run of absent rows is accepted: the file is written in order, so a
row after a gap means the file is damaged, and forgiving a gap would silently
zero a trunk layer's experts.
`ctest -R expert_layout|file_expert_source|native_expert_parity` 13/13.
The full model now loads and serves on all four cards: 6.8 tok/s decode on a
warm request, against 4.8 tok/s logged for this config before the block
existed.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Brings the GLM-5.3-Flash port up to v0.1.40.3 (265 commits) and clears the conflicts PR #1315 was showing as `dirty`. Five files conflicted, and every one of them is a place where our branch generalised something upstream also touched: * `native_dense.{hpp,cpp}` - ours adds `set_draft_block` (the model's MTP block past the trunk) and upstream adds `weight_bytes_for` (the split search's byte walk, #1238). Both are kept. `weight_bytes_for` had to be taught the arch and the trunk depth the way `served_names` reads them, because `eligible` takes them and now also admits the draft block - without that it prices a different set of matrices than `load` allocates. The shard-metadata guard keeps OUR version: upstream's `else if` fires for any later shard, ours checks the family first and then the split metadata, which is what the GLM-5.3-Flash split needs (shards 2-5 name `glm5next` and carry none of the model's keys). * `pool.cpp` - `run_split_multi_native` now sizes its rows from the layer's own `f.n_ff`/`f.n_embd` and its task count from the worker count, rather than the first family's compile-time `FF`/`H` and `phase_tasks`. Ours is the wider change and the reason the native multi path can run a 2048-row expert at all. * `generate.cpp` - two of the three hunks are the same fix on both sides: the stage's remaining room. Upstream factored it into `stage_left` and subtracted the weight carve there; ours inlined `session_bytes` with GLM's chunk arena as a sixth argument. The helper is kept and made GLM-aware, so a stage's room is now its live VRAM less its session (including the chunk) less its own carve. The third is the `GEN` line parser. Ours refactored it into `parse_gen_line` so glm5-next's own loop reads the same line; upstream grew a `pin=N` key in the inline copy meanwhile. Taking the refactor meant re-adding `pin` to `GenKeys` and the parser - without that, `pin=` would have been silently dropped and the serve loop's pinning would have lost its input. * `server.py` - ours derives the stop set from the pack's `eog_ids` (a literal `encode("<|im_end|>")` is SIX tokens on GLM and one of them is `<`, which cut replies at the first less-than sign); upstream adds `tool_call_recovery`. Both. `tests/core/file_expert_source_test.cpp` follows `expert_layout_load`'s geometry arguments, which our branch added. Build is clean. `glm_parity` passes, and the l4 rung's greedy ids are unchanged against the oracle (`6196 3611 42269 99667 73895 63003...`, 90.9 tok/s decode, 111.7 tok/s prefill) - the trunk is not touched by this merge. Four ctest cases fail on this machine and none of them is ours: `expert_multi_test` (no AVX512 - this is a Xeon E5-2680 v4), `ple_parity` (wants a Q2_0 GGUF that is not present), `pdl_parity` (PDL is unsupported on sm_120) and `qsa_select_bench`. Our six commits never touch `qsa_*`, `pdl_*`, `ple_parity` or `expert_multi_*`. Co-Authored-By: Claude Code <noreply@anthropic.com>
glm5-next's chat template opens the thinking block whatever it is told: its
generation prompt is always `<|assistant|><think>` (tokenizer/chat_template.jinja:256),
it tests no `enable_thinking` at all, and an effort outside low|high becomes
`max` (line 2). So `reasoning_effort: "none"` - which the front ends map to
`enable_thinking: False` - thought at full length.
When thinking is off and the rendered prompt ends on a bare `<think>`, close
the block empty. That is the form the same template writes for a past turn
that did not think (`{{ '<think></think>' }}`, line 152), so it asks for what
the model already produces itself.
The trailing-tag test keeps this to that template: another family closes its own
block there and renders as it always did (the default template's thinking-off
tail is `<think>\n\n</think>\n\n`, serve/chat_template.jinja:179). The tag is
appended before `unmark_think_literals`, where it holds no mark and so is
neither one of the spans encoded as text nor able to move them.
serve/test_server.py: ThinkingOffClosesTheBlock, with a GLM-shaped template in
a temp file - five cases, seven assertions of which fail without the fix.
Co-Authored-By: Claude Code <noreply@anthropic.com>
A token routes 8 experts over 42 MoE layers - 3.92 GB read on the host every
token - and that read, not the arithmetic, is what a decode step spends its
time on. This puts a per-layer set of experts in VRAM, computes those on the
card, and leaves the CPU pool only the ones the card does not hold.
--glm-gpu-experts N N MiB for the tier; 0 = all free less the reserve
(STRATA_GLM_GPU_RESERVE_MIB, 2048 by default); -1 off
The hits are queued before the pool runs, so the card computes them while this
thread computes the misses, and only the misses' rows cross PCIe. The slot
quota is per MoE layer - `budget / (moe_layers * expert_bytes)`, capped at
n_expert - because arrival-ordered admission fills the whole cache inside the
first layers it sees (measured on the first family: 2.97% of slots in use
against 70.4% for a per-layer quota). Eviction is free slot first, then at
most one swap per layer per token and only when the newcomer's hit count is at
least the victim's + 2; counts halve every 4096 tokens.
The one thing this does differently from the reference it was read from
(jerem91150's 1f0cc02): that sized the slots over the WHOLE model, which under
our layer split spends three quarters of a 4-way run's VRAM on layers the card
never computes and shrinks every quota 4x. Here a stage sizes its own
[layer_lo, layer_hi) against its own card's free memory, so each card gets the
quota a whole-model sizing would have cut four ways.
Not on by default, and a first family never reads the flag: the tier is built
only for Arch::Glm5Next, sized after every weight, session and per-token buffer
so `device_free_bytes()` is a question about what is really left. Prefill is
untouched - a chunk still routes every token through the pool.
STRATA_GLM_GPU_CHECK=1 recomputes every hit on the CPU and prints
max |gpu - cpu|, the only check that says the slot holds the expert it is named
for; about 2% of the largest value is expected (q8_1 vs q8_K activations).
src/kernels/cuda/iq_kernels.cu gains the IQ down types in STRATA_D_FMTS, which
the grouped kernel needs for this pack's IQ4_XS down rows; Fmt<16..22>::dot
already existed and `native_expert_supported` only gates on gu_qk/d_qk.
Co-Authored-By: Claude Code <noreply@anthropic.com>
… one wrong `STRATA_GLM_GPU_CHECK=1` has recomputed every VRAM-tier hit on the CPU and printed the per-layer maximum since the tier landed. A maximum over k*n_embd values is the error's TAIL, though, and it reads as the error itself; the mean and the rms over the same set are what say how big the difference typically is, so print those too and the two ratios. On the 4-way rig a hit differs from the CPU pool by a mean of 1.6e-2 of the row's rms, worst 2.8e-1 - the same 1.5e-2 relative `native_expert_parity` records for this path's activation rounding. Then make the check DO something. A slot holding the wrong expert produces a plausible token, which is exactly what this env var exists to catch, and a printed number nobody reads catches nothing. It now fails the request when a hit differs from the CPU's by more than the row's own rms. That bar is measured, not assumed: on the rig above (5070 layer-checks, two generations) the correct slot's max/rms had a median of 9.6e-2 and a worst of 2.8e-1, while a wrong slot - the card's hit read against the CPU's row for a DIFFERENT expert of the same layer, measured on the same data - ran from 2.2 to 5.6. The bar has 3.6x of headroom above the worst correct layer and 2.2x of margin below the best wrong one. `=2` prints that wrong-slot figure as it goes, so the bar can be re-derived rather than taken on faith. docs/DETAILS.md gains `## GLM-5.3-Flash (glm5-next): the switches it adds` - --dsa, --mtp-block and --glm-gpu-experts with the tier's measured table (+43% decode on 6.7 -> 9.6 tok/s, prefill unchanged, 49.7% of routed-expert work on the cards), the not-bit-identical note and its measured size, and the check's bar and its gap. `--glm-gpu-experts` also gets its line in --help. Co-Authored-By: Claude Code <noreply@anthropic.com>
…refill
A chunked GLM prefill hands `glm_block_layer_pre` one token at a time, and every
projection inside it reads its weight matrix once per call. Those reads are not
a rounding error: on four cards (layers 0-11 / 12-23 / 24-35 / 36-44, CUDA0..3,
UD-IQ4_XS, 344-token prompt, chunk 256) a chunk spent 17.35 s on the card against
11.87 s in the CPU pool, and the card's time was strictly proportional to tokens -
12.6 ms a token a stage at chunk 256, 12.8 at chunk 88, no amortization at all.
The dense native weights are 2322.89 MiB of projections, so a 1-column GEMV per
token was reading ~7.16 GiB a token at about 140 GB/s on a card that does ~360.
So `pre` now takes a group of up to `GLM_MAX_NTOK` (8, the widest `native_mmvq`
takes) columns, and the weight is read once for all of them. Nothing about the
arithmetic changes: every kernel in `pre` already took an `ntok`, and the group
entry point is `glm_block_layer_pre` itself - the two norms that were written
`rows = 1` take `b.ntok` now, and the router, whose weights are 4.7 MB of F32
against the layer's ~160 MB of quantized ones, stays a per-token loop on purpose.
Three things had to be true for a group to be safe, and the first was a latent bug:
* `glm_buffers_bytes` carved each token's slot as `ntok * align16(field)`. That
pads every token's slot to 16 bytes, so a field whose one-token size is not a
multiple of 16 - `pre`/`post` at `hc * 4 = 12`, `comb` at 36, `idx_pos` at 4 - is
NOT contiguous across tokens, and every kernel here reads column `j` at
`base + j * width` and nothing else. It is `align16(ntok * field)` now. The
two agree at `ntok == 1`, which is why the decode path never saw it.
* The router's rows (`logits`, `ids`, `weights`) live in the Qwen path's
`MoEBuffers`, carved at one token, so `GlmChunkBuffers` carries the three of
them per token and `glm_chunk_group_view` points a group's `mb` at them.
* `glm_block_layer_pre` refused `b.ntok != 1` by name. It is the group entry
point now; a group of one still uses the session's own scratch, so `--prefill 1`
and the decode step take the byte-for-byte path they took before.
Measured, chunk 256, 344-token prompt, the same binary either way: prefill
31.4 s -> 19.6 s (1.60x, 10.9 -> 17.6 tok/s). The card's share of a chunk went
18.98 s -> 7.25 s; the pool is unchanged at 11.87 s and is now the larger half.
Per token, at a group width of 8: `hc_read(attn)` 0.134 -> 0.024 ms, the attention
mixer 0.873 -> 0.372, `ffn3` 0.231 -> 0.055, `ffn norm + quantize` 0.051 -> 0.0099,
and the router 0.026 -> 0.024, which is what "the router rides along" means.
Correctness: `STRATA_GLM_NO_GROUP=1` pins the group to one token. A 48-token
greedy completion is byte-identical with it on and off, and identical across a
repeat of the on-arm, so the engine is deterministic and the comparison has power.
The instrumentation that found all this is behind `STRATA_GLM_PREFILL_TIME=1` and
costs a normal run nothing: a per-chunk line splitting card / pool / post / dense
lead, and a per-section breakdown of `pre` from CUDA events at six boundaries -
events and not wall clock, because `pre` only enqueues, so a `printf` around the
calls would report the launch cost and nothing else. That first line is what
turned "the pool is the bottleneck" (the guess) into "38% pool, 56% card" (the
measurement), and it is what made the group worth writing at all.
Co-Authored-By: Claude Code <noreply@anthropic.com>
The GLM section lists the switches the architecture adds; the group prefill is not a switch, so it needs its own paragraph. It also moves the prompt-reading column of the expert-tier table, which is re-measured here rather than left stale: 344 tokens in 20 s without the tier and 19 s with it, against 31 s before.
`STRATA_GLM_PREFILL_TIME=1` kept a single file-scope pool of `cudaEvent_t`s, and a
layer split is ONE PROCESS ACROSS SEVERAL CARDS - the engine `cudaSetDevice`s per
stage. An event belongs to the context that created it, so stages 2, 3 and 4
recorded device 0's events on their own streams and got
`cudaErrorInvalidResourceHandle`. `section_mark` turned the timer off rather than
report garbage, which is why only stage 1 ever printed a section block; the worse
half was that the failure also latches a CUDA error, and the next unrelated
`check_launch` printed it as if its own kernel had failed:
glm_hc_norm_bf16 launch: invalid resource handle
The pool is indexed by device now. On the same run all four stages report (516,
516, 516, 387 pre calls, 8.0 tokens each) and the `invalid resource handle` line
is gone.
While here, the report said "layer-tokens" for what a group entry point made
"calls": at a group width of 8 the per-line figure read eight times too large. It
now prints both - 3.029 ms a call and 0.379 ms a token on stage 1's attention
mixer, which is the same 0.372-0.379 ms a token the width-1 arm divides to, so the
two arms finally compare.
The 128 came from a modelled byte curve (`glm-analysis/prefill_amortize.py` over a 516-token router trace): 5.6x fewer expert bytes a token at 128, 9.3x at 256, and "past 128 the curve flattens because MAXT (8) starts splitting one expert's tokens into several jobs". The per-token BYTES do flatten there. What the model missed is that a chunk also pays a FIXED cost: it must read every expert its tokens touch before it can serve any of them, and 88 tokens already touch 263 of the 288 experts. A bigger chunk does not read fewer bytes a token - it pays that fixed cost fewer times. Nothing in the engine measured that until the chunk was swept end to end. Four-card rig, UD-IQ4_XS, 16K context, no other flags: | read | 128 | 512 | 2048 | 4096 | |---|---|---|---|---| | 1334-token prompt | 80.2 s | 71.8 s | 67.7 s | 67.5 s | | 344-token prompt | 20.0 s | 18.6 s | 18.6 s | 18.6 s | | session rows | 30.2 MiB | 120.6 MiB | 482.5 MiB | 965.1 MiB | (At 344 and at 4096 the prompt fits one chunk, so those columns are the same run: what the column really shows is the cost of the chunks the prompt does NOT fill.) 512 is the value point: 12% of a long prompt's read for +90 MiB, under 2% of what the smallest card in that rig has free after its weights. 2048 buys another 6% for +362 MiB more, and that memory is the expert tier's, which is the decode lever. Measured on the default path itself, after the change: 344 tokens in 18.5 s (was 20.5) and 1334 tokens in 71.7 s (was 80.7), with the engine reporting "512 tokens a chunk (120.6 MiB of session rows, 64.0 MiB of expert output an H2D)".
The switches list said nothing about the chunk, which is now the default's most measurable number: 512, against the 128 that a modelled byte curve had chosen.
`glm_mtp_step` is single-token by construction - `embed_row` of one `token`, the block's own `st.emb`/`st.cat` scratch, `copy_dev(b.cur, st.inp, n)` - so the `1`s in its two norms are correct as written. But the group carve is now a `GlmBuffers` with `ntok == 8`, and a caller that handed the draft block one would get the first token normalized and the rest reading whatever the previous call left in `st.*`: a finite, fluent, wrong draft. Naming the refusal is what the trunk block did before it became the group entry point, and it is the cheaper half of the pair.
…ays so Measured on the 4-card rig (2x RTX 3060 12 GB + 2x RTX 5060 8 GB, auto split 0-11/12-23/24-35/36-44), UD-IQ4_XS at /home/gopi/glm-packs/full, a 5,294-token prompt: 350.1 s at --prefill 1024, 344.3 at 2048, 350.8 at 4096 - 15.1, 15.4, 15.1 tokens a second, a 2% spread - while the session rows grow 241.3 / 482.5 / 965.1 MiB. So the fixed cost a chunk pays is spent by 1024 on a prompt this long: past it a bigger chunk buys nothing and costs the memory the expert tier wants. 8192 is not a bigger chunk - it is clamped to the pool's 4096, silently. --prefill's help said only the first family's story. On glm5-next there is no expert-cache buffer to lend, so auto and no flag both mean 512, every CHUNK is clamped to 4096, and --prefill 1 is the one-token-at-a-time control arm. Co-Authored-By: Claude Code <noreply@anthropic.com>
Reading the tree back for the PR turned up four claims that stopped being true when `--glm-gpu-experts` landed. `--expert-cache-remote` was refused with "nothing puts a routed expert in VRAM on this arch", and `--no-pool` with "nothing else computes them". Both refusals stand - the helper caches are the first family's, and without the pool the card computes only the tier it was asked to hold, which is half the experts a token routes - but neither reason was the reason any more. The comment over the split search said glm5-next "has no expert cache ... and never enter VRAM": what it has none of is an expert PROFILE, which is what that search scores, and its tier is sized per stage against the card's own free memory. Same claim in the carve's comment, where what the carve frees is what the tier gets. `pool.cpp` named `STRATA_SLICE_MT` as the switch that decides the sliced multi-token path. The switch is `STRATA_NO_SLICE_MT`, and it is on by default - the opt-in spelling is left over from an earlier iteration and is read nowhere. docs/DETAILS.md gains the A/B and measurement switches in one paragraph: STRATA_GLM_NO_CLAMP, STRATA_GLM_NO_GROUP, STRATA_NO_SLICE_MT, STRATA_SLICE_MT_DOWN and STRATA_MTP_DUMP_H, each with the measurement it exists for. Co-Authored-By: Claude Code <noreply@anthropic.com>
The two band loops in `mla_layer` - step 3's absorption and step 6's de-absorption - walked one (token, head) pair at a time. `nsys` on one 128-token chunk of the 4-way split: 180,224 `ncols == 1` GEMVs, each with its own `native_quantize_q8_1`, so 360,448 of the chunk's 434,144 kernel launches - 83.0% - at 2.18 us and 1.21 us a call, for 136 KiB of weights read. A band is per HEAD but is the same weight rows for every token, so the only thing between it and one batched call was the layout: `qfull`/`kqv` are token-major, and `project_rows` wants a batch's columns contiguous and `w` apart. One head-major transpose per loop - `glm_heads_major`, `[t][h][w] <-> [h][t][w]`, its own inverse - makes the group the inner axis, and the result is transposed back. 22,528 `ncols = 8` calls now, so 45,056 launches - a layer's band tensor is 1,024 calls where it was 8,192 at one column a call, and the census weighs both sides of it: one stage's 128-token chunk went from 51,216 calls to 8,208 over the same 65,664 columns, and the band tensors that were 8,192 calls at one column a call no longer reach its top fourteen. Measured on the 4-card rig (`UD-IQ4_XS`, 1,334-token prompt, `--prefill 512`): 71.9 s -> 65.9 s, a single run each. That is what the nsys figures predict - 528 ms a chunk, 11 chunks, 5.8 s - and the run gave 6.0. Fourteen more arms fill the curve in: 74 / 65 / 62 / 62 s at `--prefill` 128 / 512 / 2048 / 4096 on the 1,334-token prompt (from 80.2 / 71.8 / 67.7 / 67.5), 325 / 318 / 317 s at 1024 / 2048 / 4096 on a 5,294-token one (from 350.1 / 344.3 / 350.8), and 19 / 17 / 17 / 17 s on a 344-token one. The group arm at a fixed `--prefill 256` is what says this is the group and not the kernel: group of one 32 s, group of eight 18 s, with the two chunks' card time 17.57 s -> 4.97 s and the CPU pool's the same to 1% (11.95 vs 11.86 s) - the pool is now the larger half of a prefill. The output does not move: in `multi_exact` (the default) every column of a batched call is bitwise equal to the single-column call it replaced (`mmvq_multi_parity`: 148,480 outputs, 0 differ, its control finding 63,830 at `multi_exact` off), and 48 greedy ids at `--prefill 512` are byte-identical to the pre-change binary. The fix needed two things to be visible at all: - A projection census behind `STRATA_GLM_PREFILL_TIME`: every top-level `gemv_quantized` call by path (native batched, canonical batched, canonical single) and the top fourteen tensors by weight bytes, with the columns a call carried and the bytes a batched path would have divided. `w.bytes` is zero for a native tensor, so the size comes from the GGUF layout the kernel itself uses; counted at the top level only, so the canonical loop's per-column re-entry is not reported as `ncols` calls. One stage-chunk of the 4-way split: 8,208 calls over 65,664 columns, 0 MiB of it repeated per column. - `STRATA_GLM_GROUP=N`, the group width inside 1..8, so what a group BUYS can be measured rather than assumed. The card's cost for a 128-token chunk against `N` = 1 / 4 / 8: 6.45 / 2.31 / 1.77 s, against stage 0's 294,474 / 73,618 / 36,809 MiB. The bytes are exactly /4 then /2 - what a read amortized over the group looks like - while the time stops falling in step, which is where the width stops. `STRATA_GLM_NO_GROUP=1` still pins it to one and is kept, because a lot of measurements already name it. And `STRATA_GLM_POOL_TIME=1`: one line a layer a chunk from the expert pool - the routed pairs, the distinct experts the grouping reached, the jobs the `MAXT`-token cut made of them, the bytes read against the ideal (so the cut's cost is the ratio), and the pool's own milliseconds split gate/up, quantize and down. The pool is now the larger half of a prefill and nothing printed its internals. One claim corrected, because measuring the chunk sizes falsified it. `native_expert.cpp` said the text was identical at chunk 1, 8, 32, 128 and 512, and that is not generalisable: on a 344-token prompt `--prefill` 1, 128, 256 and 512 give four arms that each reproduce themselves byte for byte and disagree from the first token. `STRATA_NO_SLICE_MT=1` makes chunks 1, 128 and 512 byte-identical, which is what pins the cause on the multi-token CPU expert kernels' chunk-dependent rounding rather than on the bands or the cards. The comment now says what was measured instead of what held on the one prompt it was first measured on. `glm_parity --selftest`: 191 cases, 0 failures, sixteen of them the new head/token transpose - four shapes, checked value by value rather than by multiset, with the round trip and two rivals that are themselves permutations. Co-Authored-By: Claude Code <noreply@anthropic.com>
…turns The chunked prompt call was `for i in stages` with a D2H and a `cudaStreamSynchronize` at every boundary, all on the prompt's own thread: the four cards took turns and three of them were idle at any instant. The first family pipelines this (upstream `44bd7ad`), and the shape here is that shape - a thread a stage, two pinned buffers a boundary, and a successor launched against the buffer its predecessor has just filled. Stage 0 stays on this thread: its input is the chunk's own embeddings, which this loop writes into stage 0's residual immediately before the call and which stage 0's previous chunk is still reading until its own D2H is done. Measured on this box, ONE binary, `STRATA_GLM_NO_PIPE` selecting the schedule, a 5,294-token prompt in 512-token chunks over the 4-way split (2x RTX 3060, 2x RTX 5060, `UD-IQ4_XS`): pipelined 179,129 ms 29.6 tok/s serial 337,021 ms 15.7 tok/s 1.881x the two arms' greedy ids are byte-identical At 15,964 tokens the same split reads in 659,775 ms, 24.2 tok/s, against a serial prediction of ~1,662 s - so the gain grows with context, which is the point: what the pipeline hides is card time, and card time is what grows. The serial arm's own stage totals say the same thing: pool 32.5 / 44.5 / 45.5 / 33.9 s (156.4 s) and card 51.8 / 56.0 / 38.6 / 27.1 s (173.6 s), against a 336.3 s wall - the sum of the two. Pipelined, the wall is 179.1 s, 22.7 s above the pool. **The pool is the floor and that is not a scheduling problem.** `GlmExpertPool` is ONE object on ONE set of cores, so overlapping the stages cannot divide its 156.4 s: 1.88x is close to what is reachable at this context, and the ceiling moves up with the prompt because the cards' share does. Three things the stages' threads forced, each of which was a measured failure first: - `GlmExpertPool::run` now holds a mutex for the duration of a call. Its members - the batch, the job array, the per-job scratch, the epoch - are a host-thread protocol that was never re-entrant, because every caller used to be the token loop, one layer at a time. Two stages reach a MoE layer at the same instant now and both would write `mjobs_`. - `proj_stats()` and `section_timer()` are `thread_local`. Both were process-wide records: one `unordered_map` four threads incremented at once, and a lazy `resize` four threads could grow at once. A measurement that the measurement corrupts, and a table a thread is what each stage's own chunk expects to print. - **the pipe is built for one request and torn down at the end of it.** It used to be built once beside the serve loop and left standing, which hung the second request: `finish` had already set `done`, so the workers had left their loops and been joined and the `== nullptr` test that builds one could not re-spawn them; `jobs` still held the previous request's entries, so new chunks were numbered after them; and `last_done`, which `finish` waits on, started the new request at the old count, so the wait for `jobs.size()` could never be satisfied. Measured: request 1 fine in 22.7 s, request 2 timed out at 180 s with "the engine reported an error: I/O operation on closed file". After the teardown, four sequential requests all complete. It also stops the pinned hand-offs leaking - `cudaHostAlloc` has no owner, so every pipe leaked `(stages - 1) * 2 * hand_bytes`, 192 MiB a request on this split - hence the destructor that frees them. Two things the pipeline's own shape needed, both of which look like details and are not: - **each worker is pinned to its stage and walks the chunks in order.** The obvious one-queue shape, where whichever worker is idle claims the next chunk, deadlocks: a worker's jobs are not interchangeable, because stage `s` can only do chunk `j` once stage `s-1` has handed `j` over, so the stage-3 thread can claim chunk 0 and wait forever at boundary 2 for a hand-off only the stage-1 thread can make - and that thread is idle, because the chunk it would have taken is already claimed. Measured on the first run of this code: four chunk summaries and then nothing, at 0% CPU. The back-pressure then falls out of the two-slot boundaries. - `take` stops on `abort` and NOT on `done`. Stages run up to six chunks behind the submitter, so ending the request on `done` killed a stage that was still owed a hand-off and reported it as a failure - measured: "the chunk pipeline stopped before chunk 2 could cross boundary 2", on a five-chunk prompt that had submitted all five. `finish` waits for every published chunk to reach the last stage before it sets `done`. The heartbeat now reports the tokens the SUBMITTER has handed over, not the last stage's count. The last stage's count is a constant lag behind, and the lag is not cosmetic: the server derives its silence deadline from the position this line carries (`server.py:1488`, `allow = max(silence, PP_SLACK * chunk / rate)`, `PP_SLACK` 3.0), so a position stalled while a chunk takes ~100 s is read as a stalled engine. A final `PP` after the drain now carries the whole prompt's time and rate, on every exit - the loop's own last line is inside the loop, which a cancelled read leaves early. `glm_parity --selftest`: 191 cases, 0 failures. Co-Authored-By: Claude Code <noreply@anthropic.com>
…d a counting sort `glm_dsa_select` is launched once per query token with `n_pools` = the pools so far, so at 16k context ONE THREAD walked every visible pool once per ranked pool: 4,096^2 = 16.8M comparisons, 512 times a chunk of 512, on all 11 MLA layers. One stage-chunk's `attn` section was 210.0 s of a 210.8 s card time against 21.3 s for the same chunk on the dense path - a 10x regression whose whole cause was that loop, under a comment saying a radix top-k "would only pay at prefill lengths", which is what this model's prefill is. The boundary is now four MSD radix passes over a monotone unsigned float key, and the picks are ordered by a stable LSD radix counting sort: stability is what carries the tie rule, since the picks are collected in index order and equal keys then come out lower index first, which is the reference's tie-break. `top_pools` above DS_MAX_TOP (512, exactly this model's `idx_top_k / idx_kpool`) keeps the rank formula - correct at any width, refused rather than truncated, as this file does elsewhere. Measured on the 15,964-token prompt, 512-token chunks, 4-way split, the four cards summed: the `attn` section's slope over chunks 6..30 goes from 1.764 s a chunk to 0.112, 15.8x flatter, and end to end `--dsa` reads in 539.5 s against the dense arm's 659.0 s, with the two arms' greedy ids byte-identical. The rewrite was wrong on the one path `glm_parity` had no case for: "fewer visible pools than selections", which is every query below 2,048 cells and so every decode. The first version wrote those cells in POOL order - "all of them are selected" read as "so the order does not matter" - and the rank formula it replaced ranked them too (every rank is below `top_pools` there), so the header's descending-score order was broken on the ordinary path while every fixture stayed green. Found by reading the contract against the code. `glm_parity` gains a sweep rather than one more fixture: every (pool count, selection width) pair straddling the budget on both sides, against score vectors full of equal values, at a `pos` putting `n_vis` under, at and over the width, tail on and off - 2,784 fixtures, 1,376 below the budget and 1,424 with a shared boundary value, all exact against the transcription of the rank formula. Plus the deliberately written-out case, whose expected row's first twelve cells are spelled out rather than only compared, so a pool-order emission fails on the first one. 202 cases, 0 failures. Co-Authored-By: Claude Code <noreply@anthropic.com>
…w is gone
`mla_attn_kernel` ran one 128-thread block per (head, token) and reduced the 512-wide dot
with a full block reduction - seven `__syncthreads()` for every row of the cache. Timed on
its own, a call cost 0.68 us a cache row, dead linear in `n_kv`: 1 KiB walked in 0.68 us is
~1.5 GB/s against the card's 391 GB/s measured D2D roofline. It was the one section that
did not amortize with the group width, and at a 4096-token chunk that is 4096 launches of a
64-block grid a layer.
The batched kernel puts one warp on a (head, query) pair, 16 latent slots a lane, and
reduces with `__shfl_xor_sync` - no barrier at all. Its rescale is lazy: a row that does not
raise the running maximum takes the branch where `corr` and `p` are both 1, so the 512-wide
rescale runs O(log n_kv) times instead of on every row. The warps of a block share a query,
so the eight reads of a cache row are one L1 miss and seven hits.
The mask arithmetic is what lets one call replace `ntok`: per token the old path passed
`n_kv = vis0 + t` and `pos_base = abs_pos + t`, giving each query the limit `abs_pos + t + 1`.
Called once for the group with `pos_base = abs_pos` and `n_kv = vis0 + ntok - 1`, the
kernel's `min(pos_base + t + 1, n_kv)` returns exactly the same limit for every `t`, because
the chunk path has `vis0 = abs_pos + 1`.
That also removes both transposes from the dense path's step 5 and 6: `band_out` is already
`[h][t][kvl]`, the layout the kernel's contract asks for, and it writes `heads_in` the same
way. `qabs` and `kqv` remain, `--dsa` only - its selection is a property of one query, so
it keeps the per-token walk and the two transposes with it.
Measured, 4-way split, chunk 4096, UD-IQ4_XS:
* `glm_parity --selftest`: 208 cases, 0 failures. Two non-square cases (T > n_head, and
T and n_head coprime) were added, because every previous T > 1 case had T == n_head and
that cannot tell head-major from token-major - the layouts coincide at T == 1, which is
why the engine never exposed this.
* standalone, same-conditions A/B with the T=1 control row identical in both builds: the
T=8 case goes 2400.6 ms -> 489.2 ms per layer per 4096-token chunk, 4.91x.
* in situ, layers 0..11, 8 tokens a call: the attention section goes 21.757 s -> 12.639 s
over 6144 pre calls. Every other section is unchanged to within noise.
* end to end, kernel-only A/B, same prompt: 199.2 s / 26.6 tok/s -> 170.9 s / 31.0 tok/s,
1.166x, both arms emitting the same token.
Co-Authored-By: Claude Code <noreply@anthropic.com>
…issing
The pool is one object on one set of cores, so a four-way layer split cannot divide it: on
the 5,294-token read it is 16.9 + 32.5 + 31.5 + 26.4 = 107.3 s of the stages' 162.6 s, and
that is the share the cards can take over. A chunk streams every expert it routes to
through the stage's own slots instead.
`native_expert_grouped` was already multi-token - each entry carries its own token and its
own output row - so `run_chunk` is `run_hits` with the plan built from the chunk's routing
and the buffers sized for T tokens rather than one. `c.cur`, `c.ids` and `c.parts` are
device tensors already, so the hand-off is the ids alone: the `x` copy down and the 134 MiB
result copy back exist only because the CPU pool is the consumer.
**And the gate it was missing.** `STRATA_GLM_GPU_CHECK` covered the decode path's hits and
nothing covered `run_chunk`, although its own header claimed it did - and a chunk is 12,096
slot decisions a layer instead of 8, all of them silent when they are wrong, because a slot
table right about indices and wrong about bytes still produces a plausible token. It walks
the plan with a stride and recomputes each sampled entry on the CPU: a full walk is 103 GMAC
a layer, about six minutes at the pool's 0.28 TMAC/s, which is the very cost this path
exists to avoid. `STRATA_GLM_GPU_CHECK_N` sets the sample (16 a layer by default).
Measured, 4-way split, 5,294-token prompt, UD-IQ4_XS, chunk 4096 unless stated:
* the gate passes on all 42 MoE layers, 462 layer-checks: max/rms median 1.09e-1, worst
2.94e-1, against the decode path's 1.0 bar.
* and the bar separates on this path's own data rather than only on the decode path's:
the wrong-slot self-test (`=2`) runs 5.19 to 6.35, a 17.6x to 294.6x gap over the worst
correct entry, median 51x.
* throughput, which is conditional and is stated as such: chunk 4096 pool 27.1 tok/s ->
tier 31.0; chunk 512 pool 28.8 -> tier 15.1. The tier wins only where it amortizes.
* the reason is in its own per-layer line. A chunk streams ~1.6-2.6 GiB a layer whatever
its size, so the cost is per CHUNK: the card's own work is 0.25-0.52 s a layer and the
host assembly of those blobs is 0.35-1.17 s at 2.3-5.3 GB/s. At 512 tokens the same
copy feeds 8x fewer tokens. The tier can never win on memory traffic - both paths read
each blob exactly once - it can only win on compute, and today the copy is larger than
the compute it feeds.
Off unless `--glm-gpu-experts N` is given, so every run that does not ask for it is
bit-identical to before this existed.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Two conflicts, both the same shape: each side added a new member at the same spot, and
neither contradicts the other. Both sides are kept.
* `expert_source.hpp` / `expert_source.cpp` - upstream added the pp-opt batched
`copy_blobs` (unbuffered, one read_direct batch for a run of neighbours) and the
STRATA_STAGE_PIN stage buffers; this branch added `slices`, which hands a GGUF-in-place
expert's three roles over as pointers instead of assembling a blob. `FileExpertSource`
now declares both; nothing in either implementation touches the other.
The rest of this branch's changes to those files - the zero-blob tolerance for glm5-next's
dense-lead layers, in `open` and in `check_experts_gguf` - auto-merged intact.
Built and checked on the merged tree: 99 of 102 ctest cases pass, glm_parity 208/0. The
three failures are not from the merge - `ple_parity` wants a Qwen Q2_0 fixture that is not on
this box, and `qsa_select_bench` / `pdl_parity` fail with the identical messages on pristine
origin/main built from this same config (a clean worktree at fb58e0d was built and run to
establish that).
Co-Authored-By: Claude Code <noreply@anthropic.com>
|
Would be great if we can have a MOE focused inference engine to run all forms of MOE |
…nce a group
glm5-next enters its `pre` section once per token GROUP, and the group has been bounded by what
`native_mmvq` takes: `NATIVE_MMVQ_MAX_NCOLS` = 8. A 4,096-token chunk therefore enters it 512
times and reads each dense weight matrix 512 times - the section's own report
(`STRATA_GLM_PREFILL_TIME=1`) puts a stage at 722 GB of reads on 1.4 GB of weights, ~55 us a call
- and the eight columns the group exists for only ever divide that read by eight. Two routes off
the same native bytes, both behind the cap that was already there:
* `prefill::mmq::dense` - ONE MMQ over ALL `T` rows of one matrix. `Product` is shaped for a
group of experts (`bounds` says which activation rows each owns, `ids` remaps the output
rows); a dense matrix is the degenerate case, one matrix owning every row, and it is the case
that decides the prompt. It is called from `gemv_quantized` when a NATIVE tensor is handed
more columns than `native_mmvq` takes. It returns false - having written nothing - on an
uncovered `ggml_type`, on a `K` that is not a whole number of 256-value chunks (MMQ loads the
weights in those), on an [N, K] the card has no tile for, or on an allocation failure; the
per-column walk below then takes the tensor exactly as before. A canonical tensor is still
refused outright: it has no such route.
* `STRATA_GLM_GROUP_MAX` / `glm_group_max()` - the width the session actually carves for, which
is `GLM_MAX_NTOK` unless the variable names a wider one. Capped at `kGlmGroupMaxNtok` = 512,
and the cap is not a free choice: `glm_heads_major` launches a grid whose y is `n_head * T` and
refuses past 65535, so 64 heads put that wall at 1023. Every carve site asks the one function,
so the carve is always the width the loop is about to use.
The narrow path is untouched, deliberately. MMQ rounds the activations into its own q8_1 layout
and accumulates per K-tile while `native_mmvq` keeps one accumulator a row, so the two are not
bit-identical - and the group of eight is the arm every measurement on this branch was taken
against. Only past the cap, where the alternative is a per-column walk, is the trade made.
`strata_engine` now links `strata_mmq` (both the CUDA and the HIP branch), because `mmq::dense` is
called from `glm_layer.cpp`, which is in `strata_engine`; declared inside `STRATA_NATIVE_EXPERTS`,
where the GGML sources are.
Co-Authored-By: Claude Code <noreply@anthropic.com>
… since the port glm5-next stamps `swiglu_clamp_exp` 10.0 on every layer's routed experts. The CPU path has carried it from the beginning (`cpu::NativeFmt::swiglu_limit`, applied by `native_gu_rows` and the multi- token row kernels), but the GPU's SwiGLU computed `silu(gate) * up` with no limit at all - so a card and the pool disagreed on exactly the rows whose gates or ups clear 10, on the same bytes. Measured on a 512-token chunk, layer 29: 9 of the 15 entries routing expert 107 read mean/rms 1.25 against the pool, where a WRONG EXPERT reads 0.80 - the card was computing a different function on the right bytes, and at a figure that a wrong-slot check tuned to catch a wrong expert would have been happy to pass. The other 4,087 entries of that layer matched at 2.4e-02, and the same chunk with the pool's own clamp switched off (`STRATA_GLM_NO_CLAMP`) passed on every layer, which is what named it. `min(silu(gate), lim) * clamp(up, -lim, +lim)`: the SILU'S OUTPUT is what is capped, above only, and the up is capped on both sides of its raw value - clamping the raw gate instead is the misreading `glm_elt.cu`'s header calls out, and the CPU's own path spells out the same two lines. `lim <= 1e-6` is the reference's own guard and is how this file spells "no clamp", so a pack whose geometry leaves the key at 0 (every family but glm5-next) computes arithmetic that is bit for bit what it computed before the field existed. The limit travels in `NativeExpertLayout` (default 0.0), stamped from the pack's own `swiglu_clamp_exp` by `expert_layout()`, and is threaded through `swiglu_entries_kernel`, `swiglu_q8_1_entries_kernel`, the fused `native_gu_fused_kernel` and `s26_launch_l`. The two scattered-expert paths were building their layout without the limit too (`peer_experts.cpp`, `remote_experts.cpp`) - the same disagreement, on cards that have their own - and they now take it from the same `expert_layout()` the pool is built from, so the two arms of a run cannot read `swiglu_clamp_exp` two ways again. Co-Authored-By: Claude Code <noreply@anthropic.com>
… and cannot hold a stale slot
The tier's prefill path landed in the previous commit with a staging ring between the source and the
card: every expert was assembled in host RAM and copied again into a ring buffer, so the host was
the feed and the DMA took turns with the tensor work. This is the rest of it - the feed, the wave,
and the three ways a slot table could be wrong in silence.
**The direct feed, which is the measured win.** The source can hand an expert's own bytes over
instead of a blob this process assembled: a GGUF-in-place pack gives three ranges (gate, up, down)
and a source whose experts live in registered memory gives one. A wave then DMAs them straight
into the slot and the host copies nothing at all, so no ring is allocated either - which is why a
direct job carries `kNoRing`: `ring_size` is 0 and `stage_ev_` is empty, and naming buffer 0 anyway
is a division by zero and an index off the end of a vector. Measured on the 5,304-token prompt,
chunk 4096, 4-way split, `STRATA_GLM_GPU_CHECK=1`, per-wave instrument OFF for both:
* staged ring, resident arena source 101.8 s / 52.1 tok/s
* staged ring, mmap source 103.3 s / 51.4 tok/s
* direct slices, mmap source 80.7 s / 65.7 tok/s <- 1.26x over the staged ring
* and the instrument is not free: the same direct arm with `STRATA_GLM_GPU_CHUNK_TIME=1` is
102.3 s / 51.8 tok/s, because its per-wave `cudaStreamSynchronize` is exactly the overlap the
change exists to create. Every T=1 figure on this branch is a floor, not the schedule.
**Two streams and a two-half wave.** A wave's copies run on their own stream, released to the
caller's by one event, so the DMA of wave n+1 overlaps the kernel of wave n instead of queueing
behind it. A wave's slots are split in two halves and consecutive waves take turns between them,
so the only edge left is against the wave that used the same half two waves back (`dma_ev_`,
`dma_ev2_`). Measured standalone on a 5060 (`bw/wave.cu`, 283 blobs of 11.66 MB into 13 slots):
the expert tier's per-layer wall 1.19 s -> 0.55 s with the instrument on, 1.19 -> 0.36 s without.
Same bytes, same kernels; only when the copies happen changed.
**The plan is a ring, and one copy of it is a correctness bug.** The per-wave plan lives in PINNED
host buffers, so the `cudaMemcpyAsync` that uploads it is a real DMA and the host does not wait for
it - a single copy is refilled for wave n+1 while wave n's upload is still in flight, and the card
then computes wave n against wave n+1's rows. Measured: `STRATA_GLM_GPU_CHECK=1` on a 512-token
chunk read max/rms **10.66** on layer 3 with the per-wave instrument off (the bar is 1.0, a correct
slot is ~0.2) and 0.10-0.19 on every layer with it on, whose per-wave sync was the only thing
draining the upload. `kPlanRing` = 8, gated by an event a copy; the host runs two or three waves
ahead, not eight.
**The three silent failures in the slot table**, all of them found by the check rather than by a bad
token, and all of them now impossible by construction:
* `admit`'s LFU scan read `cnt[owner_[v]]` for a slot whose owner was -1, i.e. `cnt[-1]`, and a
freed slot could then be recorded against a `slot_` entry one before the layer's own array -
the layer loop failing at layer 6 with "the VRAM tier's slot holds the wrong expert". The scan
now takes a vacant slot first and skips negative owners.
* `run_chunk` filled slots and never advanced `next_[layer]`, so the next decode blind-took a slot
the chunk had filled without clearing the displaced expert's `slot_` entry.
* and the plan ring above, which produces the same class of symptom from a different direction.
`STRATA_GLM_GPU_SLOT_CHECK=1` walks `slot_[layer * n_expert + e]` against `owner_` at the top of
the decode's `run_hits` and at the end of every chunk, and fails the request naming the layer,
the slot and both experts; it is a few hundred comparisons a layer a call.
**The chunk's own gate now measures what it can separate.** `kChunkCheckBar` is 0.5, not the decode
path's 1.0: the gate is the worst ENTRY's mean over its own row's rms, and on this path the worst
column of a right entry reads 3.08 against a wrong-slot figure of 6.93 - a 2.3x separation with the
old bar sitting inside it. A poison scan catches rows the card never wrote (the buffer is filled
with a value no expert can produce, and a row still holding it after the layer is a wrong slot seen
from the outside, whole-buffer, 67 MB at T=512); the check records which wave carried each entry so
a failure says "the last entry of wave 19, which ends at plan index 812" rather than "entry 812 of
4,096 is wrong"; and the card's own q8_1 activations are kept for the one case the pool cannot
reproduce a row from.
**The VRAM reserve drops from 2048 MiB to 512.** Sampling all four cards once a second through a
whole run: every card ended with 2473-2580 MiB free, of which 2048 was this reserve and the rest the
rounding of the budget into whole slots, and the ENTIRE chunk path - plan, activations, scratch -
moved the card by 2 MiB peak to peak. 512 MiB is 250x the largest excursion a real run showed, and
the 1.5 GiB it hands back is what pays for the second half of the chunk's slots, which is what lets
a wave's copies run under the previous wave's kernel. `STRATA_GLM_GPU_RESERVE_MIB` still sets it.
**And the pinned host arena is now opt-in, because as a default it measured a 2.4x regression** -
245.7 s / 21.6 tok/s against 103.3 s / 51.4 tok/s for the same binary with the knob at 0, on the
prompt this was built for. Pinned beats pageable 2.2x in the probe, so the bytes are not the cost;
the page cache is, because every registered byte is one the kernel can no longer hand to the pool's
own reads. Unset is off; `STRATA_GLM_GPU_PIN_GIB=<n>` is the opt-in, and the regression is written
down where the knob is.
Co-Authored-By: Claude Code <noreply@anthropic.com>
…t was bought with DETAILS.md is the place a measured number is supposed to end up, so the three commits before this one owe it an entry each. **The reserve is 512 MiB, not 2,048.** It was 2,048 by assertion; sampling all four cards once a second through a whole run says every card ended with 2,473-2,580 MiB free, of which 2,048 was the reserve and the rest the rounding of a budget into whole slots, and the ENTIRE chunk path - plan, activations, scratch - moved a card by 2 MiB peak to peak. The 1.5 GiB it hands back is the second half of a chunk's slots, which is what lets a wave's copies run under the previous wave's kernel. A card with no room for the model and the reserve refuses rather than degrades, and that refusal is now written down with the single-card numbers that produced it (19.3 tok/s prefill, 4.6 decode, the card busy 27% of a chunk against the pool's 15.0 s). **The direct feed, its two streams, and the plan ring.** A chunk asks the source for an expert's own bytes - three ranges from a GGUF-in-place pack, one from a source whose experts are in registered memory - and DMAs them straight into the slot, so the host copies nothing and no ring is allocated. Measured at chunk 4096 on the 5,304-token prompt, per-wave instrument off for both: 103.3 s / 51.4 tok/s staged against 80.7 s / 65.7 tok/s direct. The shape's two correctness details are here too, because both are silent: the per-wave plan lives in pinned host buffers whose upload is a real DMA the host does not wait for (one copy of it read max/rms 10.66 on layer 3 with the instrument off, 0.10-0.19 with it on), and a direct job must name no ring buffer, because there is none. **`STRATA_GLM_GPU_SLOT_CHECK=1`**, the invariant that caught the two stale tables, and **the chunk path's own arm list** - `GROUP_MAX`, `CHUNK_DIRECT`, `CHECK_FROM`, `CHUNK_TIME` (with the 27% it costs) and `PIN_GIB`. **`STRATA_GLM_GPU_PIN_GIB` is off unless set, and the reason is a measurement**: as an auto-sized default the private pinned arena read 245.7 s / 21.6 tok/s against 103.3 s / 51.4 tok/s for the same binary with the knob at 0, a 2.4x regression, because every registered byte is one the kernel can no longer hand to the pool's own reads of the file mapping. Pinned beats pageable 2.2x in the probe, so the bytes are not the cost; the page cache is. Co-Authored-By: Claude Code <noreply@anthropic.com>
…lows the source `STRATA_GLM_GPU_CHECK` was written for the decode path, where a hit is 8 experts a layer and recomputing one on the CPU is free next to the pool's own work. On the chunk path it is not: it calls the pool once per sampled entry - 16 a layer by default - and each of those calls reads a whole expert, 11.67 MB, from the source. That is 187 MB a layer and 7.8 GB over a 4,096-token chunk, one entry at a time, on the thread that feeds the next layer. Measured on the 5,304-token prompt, chunk 4,096, 4-way, per-wave instrument off, the same configuration either side of the one switch: * check off 80.7 s / 65.8 tok/s * check on 280.3 s / 18.9 tok/s and the card's own time did not move for it - a 4,096-token chunk over layers 0..11 is 7.03 s of card in the slow arm against 7.83 s in the fast one. The disk read 126 MB/s while the slow arm ran and the check's own pace was 2.4 s a layer, which is that rate against that read. With the source in registered memory rather than a cold file mapping the same check is cheap - 85.2 s for a checked direct-feed run at the same chunk - so the price is the source's, not the sample's. Written down where the flag is, because a timing arm that leaves it on is measuring the checker. Co-Authored-By: Claude Code <noreply@anthropic.com>
|
hey guys i have thrown some works share your feed backs and correction to this pr. i think i am still unable to finis this pr so i am stopping here for while i will work on this later after some time. |
|
Follow-up to my numbers from the 7 October, for whoever picks this up again: same machine (native Windows 11, RX 7900 XTX 24 GB, Ryzen 9 7900X3D, 64 GB RAM, Unsloth UD-IQ2_XXS), now with Project Maya next to it, since it grew out of Strata and runs the same model. To be clear about where this comes from: it is one piece of work done across Strata and Maya together. I run both on this box, and things moved both ways. The VRAM tier I proposed here (1f0cc02) and Strata's Windows HIP build script went into my Maya build; what I learned on Maya (below) is what I bring back here.
Your chunk path from this morning should close most of the prompt gap; the 19.3 tok/s single-card figure in DETAILS.md is the number to beat. Decode is already on par. Two things from the Maya side that may save time here:
When this moves again I can run it on the 7900 XTX under Windows. |
Three files conflicted; each was resolved by origin rather than by side, and the resolution then exposed two build breaks that main's post-fork tests carry. serve/test_server.py The import block only: the union of both sides (frontend's effort_kwargs/literal_tags/think-literal helpers and our CTX_SLACK, EngineNonFinite, PP_DONE_TAIL, prompt_progress, request_timings and parse_gen_line's symbols). Both sides' new names are present in the merged modules. src/kernels/cpu/native_expert.cpp Ours is the dispatch structure (gu_multi_kind/down_multi_kind and their four call sites); main's AVX-1 expert tier is wired into it as kind 1 rather than kept as a second table. iq128_on()/iq128_supported() take the branches no wider tier covers, so a CPU with AVX but no AVX2 keeps working, and STRATA_NO_IQ128 still turns it off. The AVX-1 kernel had no SwiGLU clamp, which the wider tiers have had: on the ISA floor this PR supports, glm5-next's swiglu_clamp_exp would have been dropped. `limit` is now a parameter of iq128_gu_rows and of gu_rows_nt's eight cases, clamped as the AVX-2/512 kernels clamp (silu above only, up on both sides, <= 1e-6 is no clamp). iq_avx1_parity passes 0.0f, which is what its reference does; native_expert_parity's clamp arms now cover the tier's kernel too. src/program/generate.cpp Five hunks. Ours on the stdin thread (ServeStdin sin; sin.stop/sin.next - main's inline block is unchanged from the fork point) and on request parsing (parse_gen_line + GenKeys). Main's two new request keys since the fork - logit_bias and aux_cpus - are folded into GenKeys rather than dropped, with the serve loop keeping main's batching refusal for a bias. Main's pp_exact = false until the window is in, and its non-finite finish path, are kept as written. req_pcie_frac is no longer const: main's PcieAuto writes the refined share back to it. Tests: clean CUDA build (nvcc 13.2, arch 86;120), 20/20 CPU tests including iq_avx1_parity, iq_avx2_parity, native_expert_parity_* and split_lend_complement_test, `native_expert_parity --synthetic q4_K/q8_0` 0 failures, and `python3 -m unittest serve.test_server` 301 tests OK. The GPU parity suite beyond sampler/rope/elementwise is not run here: the cards are held by a model server. HIP and SYCL are not built: this machine has no hipcc or icpx. This box is AVX2 without AVX-512 or AVX1-only parts, so the AVX-1 tier compiles but is not exercised end to end here. Co-Authored-By: Claude Code <noreply@anthropic.com>
|
hey guys since https://github.com/mw00/project-maya has better prefill version and bit more support i would like to focus on some other model so thanks for all the test and report please keep on supporting it i am closing this pr for now. |
Add GLM-5.3-Flash (
glm5-next) supportGLM-5.3-Flash is a 312B-parameter / 17B-active hybrid MoE. Its trunk is 45 layers: 11 absorbed-NoPE-MLA
full-attention layers (every fourth) and 34 KDA linear-attention layers, all wrapped in hyper-connections. In the
quantisation this was developed against (
UD-IQ4_XS, 5 shards) the dense weights stream about 7.5 GiB per token, soit runs on one card but not quickly.
This adds the architecture end to end — geometry detection, the GGUF tensor map, five new kernel files, the
host-side layer order, expert-pool binding — plus everything the model carries that the first port did not run: the
SwiGLU clamp both references apply, the DSA k-pool indexer, the model's own MTP draft block, a VRAM tier for the
routed experts, and a chunked prefill that reads each projection once per group of tokens.
24 commits on
glm5-next-support(22 of them its own, plus a merge), started from v0.1.40.3 and now merged withtoday's
main(v0.1.40.4-126-gfb58e0d). It merges cleanly — checked withgit merge-tree, which reports atree and no conflict list. The merge had two conflicts, both the same shape and neither a real disagreement: each
side added a member to
FileExpertSourceat the same spot, so both are kept (copy_blobsfrom upstream,slicesfrom here), and the branch's zero-blob tolerance for glm5-next's dense-lead layers auto-merged intact.
What a glm5-next block has that nothing else in the engine does
Each of these is here rather than bent into an existing family, because each one produces plausible output when
wrong rather than crashing:
Sinkhorn-normalised 4x4 matrix computed per token from a 24-wide projection with its own base/scale. Ported from
the reference's
hc_pre/hc_post, not re-derived.q/k/v streams that nevertheless share one state (the reference concatenates their filters before convolving), an
L2 normalisation whose epsilon is a floor on the norm rather than a term inside the sum, and a decay gate
bounded to
(-5, 0).kv_loralatent, one row pertoken, and one token's value is that latent. There is one KV head, no RoPE (
rope.dimension_countis 0), thequery is absorbed into latent space, and the softmax scale is
1/sqrt(256)— the per-head width beforeabsorption — not
1/sqrt(512).The router is not qwen4exp's
The weights are the un-biased probabilities, normalised by their sum rather than their max, and
scale(2.5) is applied after.
ffn_gate_inp.weightis F32 here, where qwen4exp's is BF16 — reading it as BF16 gives 288plausible logits and a different top-8.
Running it
The routed experts are served from a pack built by
tools/iq_pack.py(they live innative_experts.txt), and theengine reads the architecture out of that pack. Add
--mtpto the pack command for the draft block (below); a packbuilt without it still loads.
The switches this adds
New engine flags
--glm-gpu-experts N— keepNMiB of the routed experts in VRAM and compute those on the card.0meansevery free byte less
STRATA_GLM_GPU_RESERVE_MIB;-1(the default) is off. Inert on another architecture —the first family never reads the flag. Refused nowhere, because it has nothing to refuse.
--dsa— attend the 2,048 cache cells the DSA k-pool indexer selects, instead of the whole cache. Refused byname on another architecture, which has its own indexer behind
--native-qsa-indexer.--mtp-block— load the model's own draft block (thenextn.*tensors past the trunk) and run it. Needs apack built with
iq_pack.py --mtp. Refused by name on another architecture.--mtp-probe— measure only: read the prompt, run the head, run ONE draft step, printMTP <draft id>andexit. Implies
--serve.tools/iq_pack.py --mtp(pack side) — write the draft block's rows,blk.45.*andnextn.*.New environment variables
STRATA_GLM_GPU_RESERVE_MIB— the VRAM the tier leaves alone when--glm-gpu-experts 0. 512 MiB bydefault (it was 2,048): sampling all four cards once a second through a whole run, every card ended with
2,473-2,580 MiB free, of which 2,048 was this reserve and the rest the rounding of the budget into whole slots,
and the entire chunk path — plan, activations, scratch — moved the card by 2 MiB peak to peak. The 1.5 GiB
it hands back pays for the second half of a chunk's slots, which is what lets a wave's copies run under the
previous wave's kernel.
STRATA_GLM_GPU_CHECK=1— recompute every VRAM-tier hit on the CPU, print the per-layer mean and rms, andfail the request when a hit differs from the CPU's by more than that row's own rms. It covers both paths:
the decode path's hits entry by entry, and
run_chunk's whole plan, sampled with a stride.=2also prints thewrong-slot figure as it goes, so the bar can be re-derived rather than taken on faith.
STRATA_GLM_GPU_CHECK_N— how many entries a layer the chunk path's check recomputes, 16 by default. It hasto sample: a full walk of a 512-token chunk is 103 GMAC a layer, about six minutes at the pool's measured
0.28 TMAC/s, which is the very cost the card path exists to avoid.
STRATA_GLM_GPU_CHECK_FROM=<layer>— start the per-layer check at that layer instead of 0. The check iswhat fails a request when a slot is wrong, and it costs the CPU time it takes to recompute the sample; a run
that has proved the layers below can skip them, and it says so once in the log.
STRATA_GLM_GPU_SLOT_CHECK=1— walk the slot table's own invariant (slot_[layer * n_expert + e]againstowner_[s]) at the top of the decode'srun_hitsand at the end of every chunk, and fail the request naming thelayer, the slot and both experts. A few hundred comparisons a layer a call. It is what caught the stale table
behind "layer 6 holds the wrong expert", which no per-layer value check can see one token before it bites.
STRATA_GLM_GPU_CHUNK_DIRECT=0— put the staging ring back in front of the source for a chunk. Default isdirect (the source's own bytes DMA'd into the slot, the host copying nothing);
=0is the A/B arm that measureswhat the copy costs, and it is worth 1.26×.
STRATA_GLM_GPU_CHUNK_THREADS=<n>— threads that assemble one wave's blobs. Only meaningful on the stagedpath; the direct path has nothing to assemble.
STRATA_GLM_GPU_PIN_GIB=<GiB>— off unless set. Reserve that much RAM as one registered (pinned)anonymous mapping a MoE layer, so a chunk DMAs straight out of it instead of through the driver's staging buffer
for a pageable file mapping. The probe says pinned is 2.2× faster than pageable and blocks the caller for none
of the transfer — and as an auto-sized default it measured a 2.4× regression (245.7 s / 21.6 tok/s against
103.3 s / 51.4 tok/s for the same binary with the knob at 0), because every registered byte is one the kernel
can no longer hand to the pool's own reads of the file mapping. Opt-in until that has an explanation.
STRATA_GLM_GROUP_MAX=<n>— the token-group width a chunked prefill carves for and runs at,8(theNATIVE_MMVQ_MAX_NCOLScap) unless set, clamped tokGlmGroupMaxNtok= 512. Past the cap a native projectiongoes through
prefill::mmq::dense— one GEMM over the whole group — instead of the per-column walk.STRATA_GLM_NO_CLAMP— make both SwiGLU limits read 0, which is exactly "no clamp". The A/B arm that provesthe clamp is what moves the output.
STRATA_GLM_NO_GROUP— pin prefill'spreto one token instead of a group of eight. The A/B arm that provesthe group is bit-exact.
STRATA_GLM_NO_PIPE— run the chunked prompt's stages in order on the prompt's own thread, the schedule thepipeline replaced, so one binary can measure the two against each other. The two arms must produce the same
tokens, and on a 5,294-token prompt they do, byte for byte; the schedule is the only difference between them.
STRATA_GLM_GROUP=N— pick the group width yourself,1..8, which is the arm that measures what a groupbuys: the card's per-token cost against
Nsays whether a weight read is amortized or repeated. Measured ona 128-token chunk at 1 / 4 / 8: the whole model's card time 6.45 / 2.31 / 1.77 s, and stage 0's weight bytes
294,474 / 73,618 / 36,809 MiB. The bytes are exactly ÷4 then ÷2 — what a read amortized over the group looks
like — while the time stops falling in step once the launch bill is no longer the larger half. Nothing outside
1..8is accepted;STRATA_GLM_NO_GROUP=1is the same thing pinned to one, kept because measurements alreadyname it.
STRATA_GLM_PREFILL_TIME=1— print where a chunk's time goes: one line splitting the card, the CPU pool,postand the dense lead, then six sections ofpre, from CUDA events. On stderr, and at no cost to a run thatdoes not set it. It also prints a projection census — every top-level
gemv_quantizedcall by path (nativebatched, canonical batched, canonical single) and the top fourteen tensors by weight bytes, with the column
count and the columns a call carried. That is what said the bands were the bill, and it reports the same number
the fix removed: 8,208 calls over 65,664 columns for one stage-chunk, 0 MiB of it repeated per column.
STRATA_GLM_POOL_TIME=1— one line a layer a chunk from the expert pool: the routed pairs, the distinctexperts the grouping reached, the jobs the
MAXT-token cut made of them, the ideal bytes against the bytes read(so the cut's cost is the ratio), and the pool's own milliseconds split gate/up, quantize and down.
STRATA_MTP_DUMP_H=<path>— write the hidden state the draft block is fed, as raw f32, to diff against theoracle's own dump.
STRATA_NO_SLICE_MT— put the CPU pool's multi-token row kernels back to the per-token dot. Bit-identical to--prefill 1at every chunk size (verified at 1, 128 and 512), and 2.3x slower on the pool. It is also theswitch that makes greedy output independent of
--prefill; see Known gaps.STRATA_SLICE_MT_DOWN— opt in the down-rows half of the same kernels. About 4% here; off by default becausethe engine's own dispatch deliberately does not name IQ4_XS for it.
Flags that mean something different on this arch
--prefill CHUNK— a chunk reads each projection's weights once per group of eight tokens, not once pertoken.
autoand no flag both mean 512 (glm5-next has no expert-cache buffers to lend, which is what thefirst family's
autoscans), anyCHUNKis clamped to the pool's own limit of 4096 silently, and--prefill 1is the one-token-at-a-time control arm.--trim-stage-weights/STRATA_STAGE_TRIM— forced on. Without the carve every stage loads all 7,662 MiBof dense projections, which does not fit an 8 GB card at all.
--layer-split auto— its own placement search, not the first family's expert-profile search, and it pricesthe draft block as the last stage's extra layer.
--batch/ concurrent requests — one request at a time: the KDA layers are a recurrence and the MLA cache isone array indexed by absolute position, so there is no slot form to share.
--spec— refused by name; there is no verify window on this arch yet (see Known gaps). A native packnormally requires
--spec >= 2, and glm5-next does not.Refused by name, because the flag has no meaning here
--spec,--gpu-stages(no captured layer graphs),--no-pool(the pool computes every expert the card's tierdoes not hold),
--expert-cache-remoteand anautoexpert profile (the first family's helper caches; this arch'stier is
--glm-gpu-experts), and--mtp(the first family's drafter — use--mtp-block).Serve
reasoning_effort: "none"(which the front ends map toenable_thinking: False) now closes glm5-next's thinkingblock. Its chat template opens the block whatever it is told and tests no
enable_thinkingat all, so thinking offused to think at full length; the empty block is the shape that template writes for a past turn that did not think.
Measured
2 × Xeon E5-2680 v4, 2 × RTX 3060 12 GB + 2 × RTX 5060 8 GB (auto split 0-11 / 12-23 / 24-35 / 36-44),
UD-IQ4_XS, one request at a time, greedy, 16K context.Decode, one card: 4.5 tok/s (222.6 ms/token) — the dense weights stream ~7.5 GiB per token, of which KDA's
q/k/v and output projections are 4.84 GiB (64%); the matvec kernels run at 319-325 GB/s, about 88% of the card's
peak, so this is bandwidth-saturated rather than launch-latency bound.
The VRAM expert tier (
--glm-gpu-experts 0, all four cards): +43% decode, 6.7 → 9.6 tok/s, promptreading unchanged at 19-20 s. The tier took 19.1 GiB across the four cards — 61 / 53 / 22 / 30 slots a MoE layer,
1,719 of the model's 12,096 (layer, expert) pairs — and served 49.7% of the routed-expert work on the cards.
It is not bit-identical to the CPU pool: a hit differs from what the CPU would have produced by a mean of 1.6e-2 of
the row's rms (worst single value 2.8e-1), the same 1.5e-2 relative the engine's
native_expert_parityrecords forthis path's activation rounding.
STRATA_GLM_GPU_CHECKexists because a slot holding the wrong expert alsoproduces a plausible token: on 5,070 layer-checks the correct slot's worst layer was 0.28 rms while a wrong slot
ran 2.2-5.6, a 7.9x gap around the bar.
Chunked prefill, grouping eight tokens: a 344-token prompt reads in 32 s → 18 s (10.8 → 19.1 tok/s), one
run an arm,
--prefill 256. The card's share of the prompt's two chunks went 17.57 → 4.97 s, a factor of 3.5,while the CPU pool's is the same to 1% (11.95 vs 11.86 s) — the pool is now the larger half of a prefill, 11.9 s of
the 17.2 s those chunks take in all. A group of one is the arm that shows the band change is only the group: same
code path at width 1, 32 s where the pre-change binary read 31 s.
The absorbed-MLA bands were the chunk's launch bill, and are not any more.
nsyson one 128-token chunk ofthe same split: the two band loops of the MLA layers (step 3 and step 6 of
mla_layer) issued 180,224ncols == 1GEMVs, each with its ownnative_quantize_q8_1— 360,448 of the chunk's 434,144 kernel launches,83.0%, at 2.18 µs and 1.21 µs a call for 136 KiB of weights read (512 rows of a 256-wide Q8_0 fold; the count is
exact, 128 tokens × 11 MLA layers × 64 heads × 2 tensors). A band is per HEAD but is the same weight rows for
every token, so the only thing between it and one batched call was the layout:
qfull/kqvare token-major andproject_rowswants a batch's columns contiguous. One head-major transpose per loop (glm_heads_major,[t][h][w] ↔ [h][t][w], its own inverse) makes them 22,528ncols = 8calls — 45,056 launches — a layer'sband tensor is 1,024 calls where it was 8,192 at one column a call. The census weighs both sides: one stage's
128-token chunk went from 51,216 calls to 8,208 over the same 65,664 columns, and a band tensor of one MLA
layer (1,088 MiB in 8,192 calls at one column a call) does not reach its top fourteen any more. On the 1,334-token
prompt at
--prefill 512: 71.9 s → 65.9 s, single runseach, against a 528 ms-a-chunk prediction from the nsys figures — 11 chunks, so 5.8 s, and the run gave 6.0. The
chunk curve below, measured the same way on the same prompt, reads 71.8 s → 65 s at that chunk size. The
output does not move: in
multi_exact(the default) every column of a batched call is bitwise equal to thesingle-column call it replaced —
mmvq_multi_parity, 148,480 outputs, 0 differ, and its control finds 63,830 atmulti_exactoff — and 48 greedy ids at--prefill 512are byte-identical to the pre-change binary.Chunk size. A chunk must read every expert its tokens touch before it can serve any of them, and 88 tokens
already touch 263 of the model's 288 experts, so a bigger chunk does not read fewer bytes per token — it pays that
fixed cost fewer times. 1,334-token prompt: 74 s at
--prefill 128, 65 at 512, 62 at 2048 and 62 at 4096. 5,294-tokenprompt: 325 s at 1024, 318 at 2048, 317 at 4096 — flat within 3%, so past 1024 on a long prompt a bigger
chunk buys nothing, while the session rows grow 241.3 / 482.5 / 965.1 MiB. That is why the default is 512 and not
something larger.
Layer split. A split costs prefill nothing at that chunk size: 9.7 tok/s on one card, 10.4 on four, 2,615-token
prompt at
--prefill 4096.The chunked prompt pipelines its stages. The chunk call used to be
for i in stageswith a D2H and acudaStreamSynchronizeat every boundary, all on the prompt's own thread — the four cards took turns and threewere idle at any instant. A stage is a thread now, with two pinned buffers a boundary, so a stage waits on its own
boundary instead of on the whole chain. One binary,
STRATA_GLM_NO_PIPEpicking the schedule, 5,294-token promptin 512-token chunks: 179,129 ms (29.6 tok/s) pipelined against 337,021 ms (15.7 tok/s) serial — 1.881×, and the
two arms' greedy ids are byte-identical. At 15,964 tokens the same split reads in 659,775 ms (24.2 tok/s) against a
serial prediction of ~1,662 s, so the factor grows with the prompt.
The CPU pool is the floor, and that is not a scheduling problem. The serial arm's own stage totals are pool
32.5 / 44.5 / 45.5 / 33.9 s (156.4 s) and card 51.8 / 56.0 / 38.6 / 27.1 s (173.6 s) against a 336.3 s wall, which
is their sum; pipelined, the wall is 179.1 s, 22.7 s above the pool.
GlmExpertPoolis one object on one setof cores, so overlapping the stages cannot divide those 156.4 s — 1.88× is most of what is reachable at this
context, and the ceiling rises with the prompt because the cards' share of the work is what grows.
The growth is the attention, and it is entirely in the card's
pre. Per stage, over the 5,294-token run's ten512-token context steps, the card's
prephase grows by 0.668 / 0.656 / 0.398 / 0.264 s a step (1.986 s in all),while
postand the dense lead stay flat. The 15,964-token run measures 0.645 / 0.638 / 0.394 / 0.261 — 1.938 s astep, within 3.5% of the same fit over three times the range, under a different schedule. A chunk's only
context-dependent work in
preis the MLA attention over the cache, so a prompt's card time is quadratic in itslength: the 99,181-token prompt this was measured for projects to ~37,800 s of card time, 96% of it that growth.
That is what
--dsaexists to cut, and it does: the same 15,964-token prompt, same binary, same split,--dsareads in 539.5 s (29.6 tok/s) against the dense arm's 659.0 s (24.2 tok/s) — 1.22×, and the two arms' greedy
ids are byte-identical over the 8 tokens they were asked for — a match worth exactly what 8 tokens are worth, and
the verification section says why.
The per-chunk picture is the honest one, because
--dsapays a cost the dense path does not: the indexer runs atevery chunk while the sparsity only starts paying once more than 2,048 cells are visible. Summed over the four
cards, the
attnsection runs 1,046.9 s dense against 713.0 s for--dsaover the prompt's 31 chunks — and theslopes are the result, 1.764 s a chunk dense against 0.112 s, 15.8× flatter.
--dsais slower until chunk10: at chunk 4 it is 22.6 s against 13.0, because the indexer is by then selecting over pools while the attention
is still capped at 2,051 cells and dense is only reading 2,560. By the last chunk the dense
attnis 96.4% ofits card time against 91.7%, and it is that slope applied 194 more times that makes a 99,181-token prompt
project to hours.
The selection was the load-bearing part of that, and it was O(n_vis²) in a single thread.
glm_dsa_selectislaunched once per query token with
n_pools= the pools so far, so at 16k context one thread walked every visiblepool once per ranked pool: 4,096² = 16.8M comparisons, 512 times a chunk, on all 11 MLA layers. One stage-chunk's
attnwas 210.0 s of a 210.8 s card time against 21.3 s for the same chunk dense — a 10× regression whosewhole cause was one loop, and the comment above it said a radix top-k "would only pay at prefill lengths", which
is exactly what this model's prefill is. It is now an MSD radix pass for the
top_pools-th largest score and astable radix sort of the picks, and at the prompt's last chunk the four cards'
attnis 26.9 s against dense's58.2 — the collapse in the slope, not a constant taken off it.
The MLA kernel's own shape, and the tier that now serves prefill
Both landed after the numbers above, measured the same way: 4-way split,
UD-IQ4_XS, the 5,294-token prompt,greedy, chunk 4096 unless stated.
The attention kernel was the one section that did not amortize with the group width.
mla_attn_kernelran one128-thread block per (head, token) and reduced the 512-wide dot with a full block reduction — seven
__syncthreads()for every row of the cache. Timed on its own it cost 0.68 µs a cache row, dead linear inn_kv:1 KiB in 0.68 µs is ~1.5 GB/s against the card's 391 GB/s measured D2D roofline. At a 4096-token chunk that is
4,096 launches of a 64-block grid a layer, which is why it grew while nothing else in
predid. The replacementputs one warp on a (head, query) pair, 16 latent slots a lane, and reduces with
__shfl_xor_sync— no barrier —with a lazy rescale that runs O(log n_kv) times instead of once a row. Standalone, same-conditions A/B with the
T=1 control row identical in both builds: 2400.6 ms → 489.2 ms per layer per 4096-token chunk, 4.91×. In
situ, layers 0..11 at 8 tokens a call, the attention section goes 21.757 s → 12.639 s over 6,144 pre calls,
every other section unchanged to within noise. End to end, kernel-only A/B: 199.2 s / 26.6 tok/s → 170.9 s /
31.0 tok/s, 1.166×, both arms emitting the same token. It also deletes both transposes from the dense path's
steps 5 and 6, because the kernel's contract is already the layout
band_outholds.The VRAM expert tier serves prefill too, and that result is conditional.
--glm-gpu-experts Nnow streams everyexpert a chunk routes to through the stage's own slots — the only change that can take the pool off the critical
path, since 107.3 s of the four stages' 162.6 s at chunk 4096 is the pool. It wins by amortization and loses
without it:
A chunk streams ~1.6-2.6 GiB a layer whatever its size, so the tier's cost is per chunk: the card's own work is
0.25-0.52 s a layer and the host assembly of those blobs is 0.35-1.17 s at 2.3-5.3 GB/s. At 512 tokens the same
copy feeds eight times fewer. The tier cannot win on memory traffic — both paths read each blob exactly once —
so it can only win on compute, and today the copy is larger than the compute it feeds.
And it had no correctness gate, though it claimed to have one.
STRATA_GLM_GPU_CHECKcovered the decode path'shits and nothing covered
run_chunk, thoughglm_gpu_experts.hppsaid it was "the gate, and it is the same one".A chunk is 12,096 slot decisions a layer instead of 8, all of them silent when wrong, because a slot table that is
right about indices and wrong about bytes still produces a plausible token. It now walks the plan with a stride and
recomputes each sampled entry on the CPU — a full walk is 103 GMAC a layer, about six minutes at the pool's
0.28 TMAC/s, which is the very cost the card path exists to avoid — with
STRATA_GLM_GPU_CHECK_Nsetting thesample. On all 42 MoE layers, 462 layer-checks: max/rms median 1.09e-1, worst 2.94e-1, against the 1.0 bar.
And the
=2wrong-slot self-test says the bar separates on this path's own data rather than only on the decodepath's: 5.19 to 6.35 wrong against a worst correct entry of 2.94e-1, a 17.6× to 294.6× gap, median 51×.
What feeding a chunk costs, and the three ways the slot table was wrong
All of it on the same rig, the 5,304-token prompt, chunk 4096, 4-way split,
UD-IQ4_XS, greedy, one run an arm.The feed: the source's own bytes, not a blob this process assembled. The chunk path began with a staging ring
— the source's blob assembled in host RAM, copied into the ring, then DMA'd to the slot — which makes the host the
feed and the DMA take turns with the tensor work. The source can hand an expert's own bytes over instead: a
GGUF-in-place pack gives three ranges (gate, up, down), a source whose experts live in registered memory gives
one. Nothing is assembled, so no ring is allocated at all, and the host copies nothing.
1.26× over the staged ring, and that is the pair the change is for: same chunk, same split, same prompt, and the
per-layer check off on both. In the staged path the host assembles the blob in RAM whichever source it came
from, so af-pin0's 103.3 s against af-stg2's 101.8 s is the source not mattering — the size of the copy in front
of the DMA does.
And the per-layer check is an instrument with a price at chunk scale, which is not a detail.
af-dir3isaf-dir2's configuration withSTRATA_GLM_GPU_CHECK=1— the per-layer check, nothing else. It reads 280.3 sagainst 80.7 s, a 3.5× slower run, with the card's own time unchanged (a 4,096-token chunk over layers 0..11 is
7.03 s of card in the slow arm against 7.83 s of card in the fast one). The check calls the CPU pool once per sampled entry — 16 entries a layer — and
each of those calls reads a whole expert, 11.67 MB, from the source: 187 MB a layer, 7.8 GB over a chunk, and the
host reads them one at a time. The disk was measured at 126 MB/s while that arm ran, and its own pace was
2.4 s a layer (104 layer-checks in 250 s), which is that rate against that read. When the source is the file
mapping and the DMA is streaming the same file through the page cache, those reads are not free; when the source
is registered memory the same check is cheap — af-arbd1's 85.3 s is a checked run at a per-wave instrument that
costs 27% on its own. Timing arms are
=0; the check is the correctness arm and it is slow by design.And the per-wave instrument is not free. The same direct arm with
STRATA_GLM_GPU_CHUNK_TIME=1reads102.3 s / 51.8 tok/s, because the instrument's per-wave
cudaStreamSynchronizeis exactly the overlap the changeexists to create. Every T=1 figure in this PR is a floor rather than a schedule; the honest ones are T=0.
The wave: two streams, halves, and an event. A wave's copies run on their own stream, released to the
caller's by one event, and a wave's slots are split in two halves with consecutive waves taking turns between
them — so the only edge left is against the wave that used the same half two waves back. Measured standalone on a
5060 (
bw/wave.cu, 283 blobs of 11.66 MB into 13 slots): the expert tier's per-layer wall 1.19 s → 0.55 swith the instrument on and → 0.36 s without it. Same bytes, same kernels; only when the copies happen.
The plan needed to be a ring, and one copy of it was a correctness bug. The per-wave plan lives in pinned
host buffers, so the copy that uploads it is a real DMA and the host does not wait for it: a single copy is
refilled for wave n+1 while wave n's upload is still in flight, and the card then computes wave n against wave
n+1's rows and slots. Measured:
STRATA_GLM_GPU_CHECK=1on a 512-token chunk read max/rms 10.66 on layer 3with the per-wave instrument off — the bar is 1.0 and a correct slot is ~0.2 — and 0.10-0.19 on every layer with
it on, whose per-wave sync was the only thing draining the upload. It is
kPlanRing= 8 copies deep now, gated byan event a copy; the host runs two or three waves ahead, not eight.
Two more ways the table went wrong, both found by a check rather than by a bad token.
admit's LFU scan readcnt[owner_[v]]for a slot whose owner was −1 — i.e.cnt[-1]— and could then record a freed slot against aslot_entry one before the layer's own array; it now takes a vacant slot first and skips negative owners.And
run_chunkfilled slots without ever advancingnext_[layer], so the next decode blind-took a slot the chunkhad filled, without clearing the displaced expert's
slot_entry. Both are the same symptom from differentdirections: a slot table right about indices and wrong about bytes, which produces a plausible token.
The gate had a bar to re-derive, not to inherit.
kChunkCheckBaris 0.5 against the decode path's 1.0,because the chunk gate is the worst entry's mean over its own row's rms and on this path the worst column of a
right entry reads 3.08 against a wrong-slot figure of 6.93 — a 2.3× separation with the old bar sitting inside it.
A poison pass catches rows the card never wrote at all (the buffer is filled with a value no expert can produce;
a row still holding it after the layer is a wrong slot seen from the outside, whole-buffer, 67 MB at T=512), the
check records which wave carried each entry so a failure reads "the last entry of wave 19, which ends at plan
index 812" instead of "entry 812 of 4,096", and the card's own q8_1 activations are kept for the one case a row
cannot be reproduced from the pool.
One card cannot run this shape at all, and that is worth knowing before the tier is judged. On a single RTX
3060 the model does not fit at a 16K context — the native head's own upload failed with 428 MiB of 11,911 MiB
free — and at 6,144 tokens it loads with under 512 MiB to spare, which is no VRAM tier at all: the engine
refuses to make one and every routed expert falls back to the pool. In that shape a 512-token chunk is
15.0 s of CPU pool against 5.6 s of card (20.85 s in all): the card is busy 27% of the chunk. Prefill reads
19.3 tok/s against 65.7 on four cards, decode 4.6 tok/s. The tier is not what makes prefill slow there — the
pool is, and one card has no room to take any of it.
Verification
The ladder.
/home/gopi/glm-ladderholds truncated rungs of the same GGUF (4 layers is the smallest thatruns) and a tagged-dump comparator against the ik_llama.cpp oracle. 30 of 32 tensor checks matched on the shipped
model before this branch's later work, and the arms below are the ones that changed output.
The SwiGLU clamp, which the port asserted neither reference applies — both do:
swiglu_limit()hasLLM_ARCH_GLM5NEXTin its allow-list in ik_llama.cpp, and mainline applies it too. Four arms on the l4 rungagainst the oracle with the same metadata, limits set to 1 so that the clamp binds:
--dump-logits.rael有关snowflakeyesterdayoringredient,ours 8/8.
raelites whooping少了少见的到的, ours 8/8.rael also knowns'-'s, ours 8/8.STRATA_GLM_NO_CLAMP=1: back to the unclamped string, ours 8/8.The first one is why the clamp's total absence survived the original ladder match: at the model's own limit of 10
nothing binds. The last four are the evidence — each limit's own site matches the oracle when the clamp is made to
bite.
The DSA indexer, on l4 with
indexer.top_kpatched 2048 → 16 in a copy of the rung so that the indexer bindsfrom position ~16 (chunk 0, rows 256-510,
-c 512,--save-all-logits), mean KL / argmax / top-10:--dsavs ours--dsa: 0.00081, 248 of 255, 9.78--dsa, and the reverse: 0.02583 and 0.02442 — the two crossed pairings, ~30x worseAt the unpatched
top_k2048 with-c 512the indexer selects every visible pool, so its cell set is the denseone. In ik that makes the flag a no-op outright — its
--dsaagainst its own no---dsacompares equal overthe whole file at
-b 512and again at-b 64, PPL 91142.1138 — because the two paths there are the sameattention kernel over a different cell list, which with all pools selected in cell order is the same list.
In this engine it is not a no-op, and the reason is in the kernels rather than in the selection. Ours are two
implementations:
glm_mla_attnis an online-softmax kernel (a tree-reduced dot per cell, a running max and arescaled accumulator in registers), while
glm_dsa_attnis a two-pass one (one thread summing a cell's wholelatent serially, then a max and a normalise pass, matching
ggml_soft_max). Same cells, different sums, so theoutputs agree to rounding and not bit for bit. Measured on the shipped 4-way split at 344 tokens, greedy:
--dsareadsA mixture-of-experts (MoEwhere dense reads# Mixture-of-Experts Routing in— a near-tiebetween two plausible openings, flipped by accumulation order. That is the honest version of "the indexer is a
no-op below the budget": the selection is, and the engine is not, and one engine run at a diverging length says
nothing about which of the two is right. The oracle pairing above is what says that.
The selection rewrite was wrong on the branch the harness had no case for, and the harness is why. What
glm_paritycould not see is which path it was testing: theDsaFixfixture shows 4 visible pools against 2selections and the wide case 300 against 32, so the "fewer visible pools than selections" path had no case at
all — and that is the path every query below 2,048 cells takes, which is every decode. The rewrite's first
version wrote the cells in pool order there, reading "all of them are selected" as "so the order does not
matter". It does: the header's descending-score order is a separate promise, and the first attempt broke it.
Two things came out of chasing it, and the second is the more useful one.
The bug was found by reading, not by the run that prompted the reading. Fixing the order changed the 344-token
--dsaoutput not at all — the divergence from dense there is the two attention kernels, above — so that runneither convicted nor cleared the pool-order version. What convicts it is the contract: the rank formula it
replaced ranked every pool below the budget too (
rank < top_poolsis true for all of them), so ascending poolorder was wrong regardless of whether 8 greedy tokens could see it.
And the missing coverage is now a sweep rather than a case: every (pool count, selection width) pair
straddling the budget on both sides, against score vectors full of equal values, at a
posthat putsn_visunder, at and over the width, tail on and off — 2,784 fixtures, 1,376 of them below the budget and 1,424 with a
shared boundary value, all exact against the transcription of the rank formula. Plus the one deliberately
written-out case, whose expected row's first twelve cells are spelled out rather than only compared, so a
pool-order emission fails on the first one. The full suite is 208 cases.
The MTP draft block: its drafted token matches ik_llama.cpp on all four oracle prompts. The trap it had to
avoid:
is_qsa_layer(g, 45)is45 % 4 == 3→ false, so the existing predicate makes blk.45 a KDA layer, and ithas MLA tensors and no
ssm_*at all.The group prefill is bit-exact at a fixed chunk size: 48 greedy ids byte-identical with
STRATA_GLM_NO_GROUP=1on and off, and identical across a repeat of the same arm, so the engine is deterministicand the comparison has power. A later revision of this section also claimed the ids were identical at chunk 1, 128
and 4096; that is wrong, and the counterexample is below.
The chunk size moves a token, and the CPU expert kernels are what moves it. One 344-token prompt, greedy, on
the shipped 4-way split — where 512 and 4096 are the same arm, because 344 tokens fit in one chunk either way:
--prefillIn a mixture-of-experts (MoE) transformer, each MoE layer reA mixture-of-experts (MoE) layer replaces the single FFN bloA mixture-of-experts (MoE) layer replaces the single FFN blo— diverges from 128 later in the same 48 tokens# Mixture-of-Experts Routing in Transformer LayersThe 128 and 512 arms were each run twice and reproduce themselves byte for byte, so this is a difference in the
engine and not sampling. It is not the bands and not the cards:
STRATA_NO_SLICE_MT=1, which puts the routedexperts back on ggml-cpu's per-token dot, makes chunks 1, 128 and 512 byte-identical — so the cause is
measured rather than inferred. The multi-token
CPU expert kernels (
native_gu_rows_slice, andgu_rows_nt/dot_rows_ntunder it) take their shape from thenumber of tokens sharing a job, and a job's composition is a function of how many tokens in the chunk routed to
that expert. They round ~3e-8 away from the per-token dot; glm5-next's q8_0 quantization of
hbetween the twoprojections occasionally turns that into ~1% on a row, up to 1.76 on a logit. The switch buys 2.3x on the pool and
on the short prompt it was first measured on the text was unchanged, so it stays on by default; making the output
chunk-invariant costs that 2.3x, and the default 512 is the chunk the ladder above was validated in.
Tests.
glm_parity --selftestis wired into ctest: 208 cases, 0 failures. Sixteen of the new ones are thehead/token transpose —
test_heads_majorat four shapes ((3,2,8),(2,5,4),(4,4,3)and the model's own(64,8,256)), each checked value by value rather than by multiset, with the round trip and two rivals that arethemselves permutations; both counts are above 1 in every case, because at
ntok == 1the move IS the identityand the rival would be the right answer. Every case is a double-precision
transcription of the reference plus a rival reading that must disagree, because each of these choices (the
2on
post, the end of the Sinkhorn loop, the L2 floor, the sign nesting in the decay gate, one softmax over thewhole DSA pool block instead of per key-dim, the scale from
kv_lorainstead ofqk_nope, the tail appended ornot, ties to the higher index) yields well-formed output when wrong.
native_expert_paritygains an arm thatscales the activation until the SwiGLU fixture reaches all three sides of the clamp, asserts that it did, and
checks the kernel against a double transcription at the model's limit and at 1.0.
ctest -R "glm|native_expert_parity|expert_layout|file_expert_source"— 14/14 pass.The whole suite on the merged tree: 102 cases, 99 pass, 3 skipped, 3 fail. The three failures are not from
this branch, and this time that is measured against upstream rather than argued: a separate worktree was built at
origin/main(fb58e0d, the exact commit this branch was merged with) from the same CMake configuration, andqsa_select_bench(warm: unspecified launch failure) andpdl_parity(pdl_supported() = 0on thissm_120 RTX 5060, then
CUDA: edges: invalid argument) fail there with byte-identical messages. Both weretouched by upstream's own commits since this branch forked (
dfa26c1tightened the qsa accuracy floor to 1e-5;c788e2areworked the PDL prefetch below sm_70), and neither is a file this branch touches.ple_paritywants a QwenQ2_0fixture that is not on this box:required PLE table is missing or incompatible: ../../Q2_0/Qwen3.8-Flash-Next-GSQ-RCO-Q2_0-00002-of-00002.gguf.Also worth knowing before reading those two: the build directory here is configured
CMAKE_CUDA_ARCHITECTURES=75while the cards are sm_86 and sm_120, so the GPU-level failures may be as much aproperty of this build as of the tests. No GLM target is among the failures.
Not in this PR
--batchand concurrent requests. The KDA layers are a recurrence and the MLA cache is one linear arrayindexed by absolute position, so there is no slot form to share across requests.
Known gaps
them, which is why
--specis refused rather than defaulted.--mtp-blockis a measurement switch;--mtp-probeis what turns it into a number. The first family's window does not transfer — it is captured layerpairs,
expert_pool_dispatch_multiand a state commit with its own layout.glm_mtp_stepreturns an error ifb.ntok != 1): the block is a single-token step and a chunked prefill would otherwise hand it a group.--prefill, on a long enough prompt.--prefill1, 128, 256 and 512 each give adifferent completion of the same 344-token prompt, deterministically, because the CPU expert kernels' shape
follows the chunk (see "The chunk size moves a token" in Verification).
STRATA_NO_SLICE_MT=1makes the outputchunk-invariant at 2.3x on the pool; the default 512 is the chunk the ladder was validated in, and the card
side is not implicated. Closing it properly means making the multi-token expert kernel bit-identical to
ggml-cpu's per-token dot, which is the 2.3x, so it is a trade rather than a bug to fix.
Measured with the staging ring,
--glm-gpu-experts Nmoves that share to the cards at--prefill 4096(27.1 → 31.0 tok/s) and loses 2× at
--prefill 512(28.8 → 15.1), because the tier's cost is per chunk ratherthan per token — the direct feed above is what turns the 4096 figure into 65.7 tok/s, and the 512 arm has not
been re-measured since. The pool is not RAM-bandwidth-bound and not NUMA-bound (confining it
to one node halved the cores and doubled its time); it is bound by per-token row decode. This machine is a
Broadwell Xeon with AVX-2 only, so this split is pessimistic for the pool — on a Zen 4 or Ice Lake box the card
would be the bigger share.
select_tailis a disagreement between the two oracles, documented inglm_dsa.hppratherthan coded as a fact: ik_llama.cpp appends the incomplete tail whenever
kpool > 1and reads no key at all,upstream reads
attention.indexer.kpool_select_tailand defaults it to false, and this model's metadata carriesneither key. We follow ik, the oracle the ladder is measured against.
registered anonymous mapping makes each DMA 2.2× faster on the probe and blocks the caller for none of it, but
it takes the bytes out of the page cache that the pool's own reads of the file mapping depend on; measured
245.7 s / 21.6 tok/s against 103.3 s / 51.4 tok/s for the same binary with the knob at 0.
STRATA_GLM_GPU_PIN_GIBis the opt-in and the regression is written down where the knob is. It is not needed for the win: the direct
feed's 65.7 tok/s is a file mapping's slices.
ranges and a source whose experts live in registered memory gives one; anything else falls back to the staging
ring, which is the 51.4-52.1 tok/s arms rather than a failure. Checked against
copy_blobfor every layer atinit before it is armed.
of 11,911 MiB free) and at 6,144 tokens there is under 512 MiB to spare, so
--glm-gpu-expertsrefuses andevery routed expert falls back to the pool — 19.3 tok/s prefill on one 3060 against 65.7 on four.
geometry_keyhas noarchslot (conversation_state.cpp). It is an 18-element on-disk array writtenpositionally with a hard-coded
18 * 8, so widening it is a format break that wants a version gate; as it standsa GLM conversation checkpoint is not distinguishable from a Qwen one by geometry alone.
🤖 Generated with Claude Code