Skip to content

glm5-next: GLM-5.3-Flash support intial attempt - #1315

Closed
gopinath87607 wants to merge 30 commits into
Niko1221:mainfrom
gopinath87607:glm5-next-support
Closed

gopinath87607 wants to merge 30 commits into
Niko1221:mainfrom
gopinath87607:glm5-next-support

Conversation

@gopinath87607

@gopinath87607 gopinath87607 commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Add GLM-5.3-Flash (glm5-next) support

GLM-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, so
it 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 with
today's main (v0.1.40.4-126-gfb58e0d). It merges cleanly — checked with git merge-tree, which reports a
tree and no conflict list. The merge had two conflicts, both the same shape and neither a real disagreement: each
side added a member to FileExpertSource at the same spot, so both are kept (copy_blobs from upstream, slices
from 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:

  • Hyper-connections (mHC) 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/scale. Ported from
    the reference's hc_pre/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 epsilon 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), the
    query is absorbed into latent space, and the softmax scale is 1/sqrt(256) — the per-head width before
    absorption — not 1/sqrt(512).

The router is not qwen4exp's

probs  = sigmoid(logits)          <- no softmax anywhere in the model
select = probs + bias             <- the bias steers SELECTION only
ids    = top_k(select, k)         <- ties by ascending index
w      = probs[ids]; w /= sum(w); w *= scale

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.weight is F32 here, where qwen4exp's is BF16 — reading it as BF16 gives 288
plausible logits and a different top-8.

Running it

The routed experts are served from a pack built by tools/iq_pack.py (they live in native_experts.txt), and the
engine reads the architecture out of that pack. Add --mtp to the pack command for the draft block (below); a pack
built without it still loads.

strata --serve \
  --pack            <pack dir>          # tools/iq_pack.py
  --native          <model>-00001-of-00005.gguf   # the dense tensors, served natively
  --mmap-experts
  --max-context     16384 \
  --prefill         4096 \
  --glm-gpu-experts 0                   # optional: the VRAM expert tier

The switches this adds

New engine flags

  • --glm-gpu-experts N — keep N MiB of the routed experts in VRAM and compute those on the card. 0 means
    every 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 by
    name on another architecture, which has its own indexer behind --native-qsa-indexer.
  • --mtp-block — load the model's own draft block (the nextn.* tensors past the trunk) and run it. Needs a
    pack 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, print MTP <draft id> and
    exit. Implies --serve.
  • tools/iq_pack.py --mtp (pack side) — write the draft block's rows, blk.45.* and nextn.*.

New environment variables

  • STRATA_GLM_GPU_RESERVE_MIB — the VRAM the tier leaves alone when --glm-gpu-experts 0. 512 MiB by
    default (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, and
    fail 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. =2 also prints the
    wrong-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 has
    to 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 is
    what 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] against
    owner_[s]) at the top of the decode's run_hits and at the end of every chunk, and fail the request naming the
    layer, 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 is
    direct (the source's own bytes DMA'd into the slot, the host copying nothing); =0 is the A/B arm that measures
    what 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 staged
    path; 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 (the
    NATIVE_MMVQ_MAX_NCOLS cap) unless set, clamped to kGlmGroupMaxNtok = 512. Past the cap a native projection
    goes 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 proves
    the clamp is what moves the output.
  • STRATA_GLM_NO_GROUP — pin prefill's pre to one token instead of a group of eight. The A/B arm that proves
    the group is bit-exact.
  • STRATA_GLM_NO_PIPE — run the chunked prompt's stages in order on the prompt's own thread, the schedule the
    pipeline 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 group
    buys: the card's per-token cost against N says whether a weight read is amortized or repeated. Measured on
    a 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..8 is accepted; STRATA_GLM_NO_GROUP=1 is the same thing pinned to one, kept because measurements already
    name it.
  • STRATA_GLM_PREFILL_TIME=1 — print where a chunk's time goes: one line splitting the card, the CPU pool,
    post and the dense lead, then six sections of pre, from CUDA events. On stderr, and at no cost to a run that
    does not set it. It also prints a projection census — every top-level gemv_quantized call by path (native
    batched, 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 distinct
    experts 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 the
    oracle'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 1 at every chunk size (verified at 1, 128 and 512), and 2.3x slower on the pool. It is also the
    switch 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 because
    the 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 per
    token. auto and no flag both mean 512 (glm5-next has no expert-cache buffers to lend, which is what the
    first family's auto scans), any CHUNK is clamped to the pool's own limit of 4096 silently, and
    --prefill 1 is 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 MiB
    of 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 prices
    the 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 is
    one 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 pack
    normally 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 tier
does not hold), --expert-cache-remote and an auto expert profile (the first family's helper caches; this arch's
tier is --glm-gpu-experts), and --mtp (the first family's drafter — use --mtp-block).

Serve

reasoning_effort: "none" (which the front ends map to enable_thinking: False) now closes glm5-next's thinking
block. Its chat template opens the block whatever it is told and tests no enable_thinking at all, so thinking off
used 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, prompt
reading 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_parity records for
this path's activation rounding. STRATA_GLM_GPU_CHECK exists because a slot holding the wrong expert also
produces 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. nsys on one 128-token chunk of
the same split: the two band loops of the MLA layers (step 3 and step 6 of mla_layer) issued 180,224
ncols == 1 GEMVs, each with its own native_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/kqv are token-major and
project_rows wants 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,528 ncols = 8 calls — 45,056 launches — a layer's
band 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 runs
each, 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 the
single-column call it replaced — mmvq_multi_parity, 148,480 outputs, 0 differ, and its control finds 63,830 at
multi_exact off — and 48 greedy ids at --prefill 512 are 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-token
prompt: 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 stages with a D2H and a
cudaStreamSynchronize at every boundary, all on the prompt's own thread — the four cards took turns and three
were 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_PIPE picking the schedule, 5,294-token prompt
in 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. GlmExpertPool is one object on one set
of 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 ten
512-token context steps, the card's pre phase grows by 0.668 / 0.656 / 0.398 / 0.264 s a step (1.986 s in all),
while post and the dense lead stay flat. The 15,964-token run measures 0.645 / 0.638 / 0.394 / 0.261 — 1.938 s a
step, within 3.5% of the same fit over three times the range, under a different schedule. A chunk's only
context-dependent work in pre is the MLA attention over the cache, so a prompt's card time is quadratic in its
length: 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 --dsa exists to cut, and it does: the same 15,964-token prompt, same binary, same split, --dsa
reads 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 --dsa pays a cost the dense path does not: the indexer runs at
every chunk while the sparsity only starts paying once more than 2,048 cells are visible. Summed over the four
cards, the attn section runs 1,046.9 s dense against 713.0 s for --dsa over the prompt's 31 chunks — and the
slopes are the result, 1.764 s a chunk dense against 0.112 s, 15.8× flatter. --dsa is slower until chunk
10: 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 attn is 96.4% of
its 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_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² = 16.8M comparisons, 512 times a chunk, on all 11 MLA layers. One stage-chunk's
attn was 210.0 s of a 210.8 s card time against 21.3 s for the same chunk dense — a 10× regression whose
whole 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 a
stable radix sort of the picks, and at the prompt's last chunk the four cards' attn is 26.9 s against dense's
58.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_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 it cost 0.68 µs a cache row, dead linear in n_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 pre did. The replacement
puts 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_out holds.

The VRAM expert tier serves prefill too, and that result is conditional. --glm-gpu-experts N now streams every
expert 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:

chunk CPU pool VRAM tier
4096 27.1 tok/s 31.0 tok/s
512 28.8 tok/s 15.1 tok/s

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_CHECK covered the decode path's
hits and nothing covered run_chunk, though glm_gpu_experts.hpp said 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_N setting the
sample. 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 =2 wrong-slot self-test says the bar separates on this path's own data rather than only on the decode
path'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.

arm feed source per-layer check wall prefill
af-pin0 staged ring file mapping on 103.3 s 51.4 tok/s
af-stg2 staged ring resident arena off 101.8 s 52.1 tok/s
af-dir2 direct slices file mapping off 80.7 s 65.7 tok/s
af-dir3 direct slices file mapping on 280.3 s 18.9 tok/s
af-arbd1 direct slices resident arena on, per-wave instrument on 85.3 s 62.2 tok/s

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-dir3 is
af-dir2's configuration with STRATA_GLM_GPU_CHECK=1 — the per-layer check, nothing else. It reads 280.3 s
against 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=1 reads
102.3 s / 51.8 tok/s, because the instrument's per-wave cudaStreamSynchronize is exactly the overlap the change
exists 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 s
with 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=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 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 by
an 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 read
cnt[owner_[v]] for a slot whose owner was −1 — i.e. cnt[-1] — and could then record a freed slot against a
slot_ entry one before the layer's own array; it now takes a vacant slot first and skips negative owners.
And run_chunk filled slots without ever advancing next_[layer], so the next decode blind-took a slot the chunk
had filled, without clearing the displaced expert's slot_ entry. Both are the same symptom from different
directions: 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. kChunkCheckBar is 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-ladder holds truncated rungs of the same GGUF (4 layers is the smallest that
runs) 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() has
LLM_ARCH_GLM5NEXT in its allow-list in ik_llama.cpp, and mainline applies it too. Four arms on the l4 rung
against the oracle with the same metadata, limits set to 1 so that the clamp binds:

  • limits 10 / 10 (the shipped artifact): nothing binds, so the arms are byte-identical — ids and --dump-logits.
  • limits 1 / 10 (the routed experts clamp, the dense lead does not): oracle rael有关snowflakeyesterdayoringredient,
    ours 8/8.
  • limits 10 / 1 (the dense lead and the shared expert clamp, the routed experts do not): oracle
    raelites whooping少了少见的到的, ours 8/8.
  • limits 1 / 1: oracle rael also knowns'-'s, ours 8/8.
  • limits 1 / 1 with 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_k patched 2048 → 16 in a copy of the rung so that the indexer binds
from position ~16 (chunk 0, rows 256-510, -c 512, --save-all-logits), mean KL / argmax / top-10:

  • oracle dense vs ours dense: 0.00032, 249 of 255, 9.82
  • oracle --dsa vs ours --dsa: 0.00081, 248 of 255, 9.78
  • oracle dense vs ours --dsa, and the reverse: 0.02583 and 0.02442 — the two crossed pairings, ~30x worse

At the unpatched top_k 2048 with -c 512 the indexer selects every visible pool, so its cell set is the dense
one. In ik that makes the flag a no-op outright — its --dsa against its own no---dsa compares equal over
the whole file at -b 512 and again at -b 64, PPL 91142.1138 — because the two paths there are the same
attention 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_attn is an online-softmax kernel (a tree-reduced dot per cell, a running max and a
rescaled accumulator in registers), while glm_dsa_attn is a two-pass one (one thread summing a cell's whole
latent serially, then a max and a normalise pass, matching ggml_soft_max). Same cells, different sums, so the
outputs agree to rounding and not bit for bit. Measured on the shipped 4-way split at 344 tokens, greedy:
--dsa reads A mixture-of-experts (MoE where dense reads # Mixture-of-Experts Routing in — a near-tie
between 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_parity could not see is which path it was testing: the DsaFix fixture shows 4 visible pools against 2
selections 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
--dsa output not at all — the divergence from dense there is the two attention kernels, above — so that run
neither 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_pools is true for all of them), so ascending pool
order 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 pos that puts n_vis
under, 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) is 45 % 4 == 3 → false, so the existing predicate makes blk.45 a KDA layer, and it
has 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=1 on and off, and identical across a repeat of the same arm, so the engine is deterministic
and 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:

--prefill first tokens
1 In a mixture-of-experts (MoE) transformer, each MoE layer re
128 A mixture-of-experts (MoE) layer replaces the single FFN blo
256 A mixture-of-experts (MoE) layer replaces the single FFN blo — diverges from 128 later in the same 48 tokens
512 # Mixture-of-Experts Routing in Transformer Layers

The 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 routed
experts 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, and gu_rows_nt/dot_rows_nt under it) take their shape from the
number 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 h between the two
projections 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 --selftest is wired into ctest: 208 cases, 0 failures. Sixteen of the new ones are the
head/token transpose — test_heads_major at 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 are
themselves permutations; both counts are above 1 in every case, because at ntok == 1 the move IS the identity
and 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 2
on post, the end of the Sinkhorn loop, the L2 floor, the sign nesting in the decay gate, one softmax over the
whole DSA pool block instead of per key-dim, the scale from kv_lora instead of qk_nope, the tail appended or
not, ties to the higher index) yields well-formed output when wrong. native_expert_parity gains an arm that
scales 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, and

  • qsa_select_bench (warm: unspecified launch failure) and pdl_parity (pdl_supported() = 0 on this
    sm_120 RTX 5060, then CUDA: edges: invalid argument) fail there with byte-identical messages. Both were
    touched by upstream's own commits since this branch forked (dfa26c1 tightened the qsa accuracy floor to 1e-5;
    c788e2a reworked the PDL prefetch below sm_70), and neither is a file this branch touches.
  • ple_parity wants a Qwen Q2_0 fixture 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=75 while the cards are sm_86 and sm_120, so the GPU-level failures may be as much a
property of this build as of the tests. No GLM target is among the failures.

Not in this PR

  • Vision. Text generation only.
  • --batch and concurrent requests. The KDA layers are a recurrence and the MLA cache is one linear array
    indexed by absolute position, so there is no slot form to share across requests.

Known gaps

  • There is no verify window yet, so the MTP block is inert in a real run. It drafts tokens and nothing checks
    them, which is why --spec is refused rather than defaulted. --mtp-block is a measurement switch;
    --mtp-probe is what turns it into a number. The first family's window does not transfer — it is captured layer
    pairs, expert_pool_dispatch_multi and a state commit with its own layout.
  • The MTP step refuses a multi-token scratch rather than half-reading one (glm_mtp_step returns an error if
    b.ntok != 1): the block is a single-token step and a chunked prefill would otherwise hand it a group.
  • Greedy output depends on --prefill, on a long enough prompt. --prefill 1, 128, 256 and 512 each give a
    different 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=1 makes the output
    chunk-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.
  • The CPU pool is ~60% of a prefill chunk now, and the tier that takes it over only pays at a large chunk.
    Measured with the staging ring, --glm-gpu-experts N moves 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 rather
    than 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.
  • The DSA indexer's select_tail is a disagreement between the two oracles, documented in glm_dsa.hpp rather
    than coded as 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 and defaults it to false, and this model's metadata carries
    neither key. We follow ik, the oracle the ladder is measured against.
  • The RAM arena is off by default because as a default it lost 2.4×. Pinning a MoE layer's bytes into a
    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_GIB
    is 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.
  • The direct feed needs a source that can hand over an expert's own bytes. A GGUF-in-place pack gives three
    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_blob for every layer at
    init before it is armed.
  • A 12 GB card gets no tier at all on this model. At a 16K context the native head's own upload fails (428 MiB
    of 11,911 MiB free) and at 6,144 tokens there is under 512 MiB to spare, so --glm-gpu-experts refuses and
    every routed expert falls back to the pool — 19.3 tok/s prefill on one 3060 against 65.7 on four.
  • geometry_key has no arch slot (conversation_state.cpp). It is an 18-element on-disk array written
    positionally with a hard-coded 18 * 8, so widening it is a format break that wants a version gate; as it stands
    a GLM conversation checkpoint is not distinguishable from a Qwen one by geometry alone.

🤖 Generated with Claude Code

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>
@gopinath87607 gopinath87607 changed the title glm5-next: GLM-5.3-Flash support{prefill really sucks } glm5-next: GLM-5.3-Flash support {intial attempt & prefill really sucks } Oct 7, 2026
@gopinath87607 gopinath87607 changed the title glm5-next: GLM-5.3-Flash support {intial attempt & prefill really sucks } glm5-next: GLM-5.3-Flash support {intial attempt & prefill really sucks avoid testing its not ready } Oct 7, 2026
@jerem91150

jerem91150 commented Oct 7, 2026 •

Copy link
Copy Markdown

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 tools\hip\build_windows.bat tests and STRATA_HIP_ARCHS=gfx1100 (TheRock 10.2.0a20260930). No source changes needed, just the usual unused hipError_t warnings, nothing in the glm files.

glm_parity --selftest passes 115/115 on the 7900 XTX.

One thing that tripped me up: the Ryzen iGPU shows up as HIP device 0 and the 7900 XTX as device 1. Without HIP_VISIBLE_DEVICES=1 the test runs on the iGPU, every glm kernel fails with device kernel image is invalid and you get 50 failures out of 115. Looks like broken math, isn't. The engine is fine since setup passes "gpu": 1. Might be worth having the tests pick the first device that matches the build arch, or at least print which one they use.

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

@jerem91150

Copy link
Copy Markdown

Numbers from the 7900 XTX as promised. UD-IQ2_XXS, 24 GB VRAM + 64 GB RAM, so --mmap-experts (92 GiB of experts) and --prefill 1 (the chunked read wants sliceable experts, and IQ2_XXS gate/up aren't).

Decode, 256 tokens:

  • llama.cpp Vulkan on the same file, 5 layers of experts on the card: 4.2 to 4.7 tok/s
  • this PR as is, experts on the CPU: 2.5 tok/s

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 native_expert_grouped, and only sends the misses to the CPU pool, with LFU deciding who stays. With 13.8 GiB of slots it warms up to about 71% hits and 4.8 to 5.9 tok/s. I checked every hit against the CPU pool, and the gap is ~2% of the largest value, which is just q8_1 vs q8_K rounding.

Two smaller things came up on the way, possibly useful here:

  • Unsloth's UD-IQ2_XXS has IQ3_XXS downs, which STRATA_D_FMTS doesn't list, so the engine refuses it at startup. Adding the IQ2/IQ3 types there works, since launch_down is generic.
  • GLM's template always opens <think> and maps any effort it doesn't know to max. So reasoning_effort: "none" gives full-length thinking. I close the block empty in encode_prompt when thinking is off.

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.

Gopi and others added 17 commits October 8, 2026 07:21
… 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>
Gopi and others added 6 commits October 8, 2026 15:55
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>
@gopinath87607 gopinath87607 changed the title glm5-next: GLM-5.3-Flash support {intial attempt & prefill really sucks avoid testing its not ready } glm5-next: GLM-5.3-Flash support intial attempt Oct 8, 2026
@xyzzing

xyzzing commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Would be great if we can have a MOE focused inference engine to run all forms of MOE

Gopi and others added 5 commits October 9, 2026 12:44
…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>
@gopinath87607

Copy link
Copy Markdown
Contributor Author

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.

@jerem91150

Copy link
Copy Markdown

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.

GLM-5.3-Flash, one card this PR (my build of 7 Oct, --prefill 1, --mmap-experts, VRAM tier) Maya v1.0.16
prompt read token at a time (the chunked read wanted sliceable experts) 27K tokens in 46 to 59 s
decode up to 5.9 tok/s 5.5 tok/s median (3.5 to 8)

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:

  • Numbers in long prompts. Maya split GLM prompts with qwen35's one-digit rule until v1.0.15, and from ~12K tokens on numbers came back wrong (240 read as 2400). This PR already reads the pack's pre (glm4, digits by three), so it is fine; worth a test with a long prompt full of numbers all the same.
  • Disk reads on Windows. Each read seems to carry a fixed cost there: Maya's prompt reader went from 1.66 to 4.27 GB/s with 16 reads in flight and one chunk per expert part instead of 8.

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>
@gopinath87607

Copy link
Copy Markdown
Contributor Author

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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants