GLM-5.2 is a 744B-parameter MoE (256 routed experts, top-8, plus one shared
expert) built on DeepSeek Sparse Attention: Multi-head Latent Attention with
weight absorption, and a "lightning indexer" that decides which cached tokens
each query may attend to. Advertised context: 1M tokens. The GGUF architecture
id is glm-dsa. GLM-5.3-Flash (glm5next) runs through the same executor -
see its section below.
| Piece | Shape (GLM-5.2) | Notes |
|---|---|---|
| Attention | MLA, 64 query heads, 1 key head | q_lora_rank 2048, kv_lora_rank 512, n_embd_head_k/v_mla 256, n_rot 64 |
| KV cache | one 576-wide row per token per layer | kv_lora_rank + n_rot; the per-head K/V decompression is folded into the query (attn_k_b) and the output (attn_v_b) |
| DSA indexer | 32 heads x 128, top-k 2048 | scores every cached token; only the top-k survive into the attention mask |
| Indexer layers | 21 of 78 | layers 0,1,2 then every 4th from 6; the layers in between reuse the last full layer's selection |
| MoE | 256 experts, top-8, n_ff_exp 2048 |
sigmoid gating, a routing bias for SELECTION only, weight renormalisation, x2.5 routed scale |
| Dense layers | first 3 | plain SwiGLU with feed_forward_length 12288 |
| NextN / MTP | 1 trailing block | block_count includes it; the trunk graph runs block_count - nextn_predict_layers layers. Drives speculative decoding under --mtp-spec; unloaded otherwise |
| RoPE | NORM, base 8e6, no YaRN | applied to the 64-wide rope slice of Q and to the single K rope tail |
Attention softmax scale is 1/sqrt(n_embd_head_k_mla) = 1/16 — the decompressed
head size, not the 576-wide cache row.
Two implementations, both reproducing llama.cpp's src/models/glm-dsa.cpp:
-
GGML backends (
--backend ggml_cuda/ggml_vulkan/ggml_cpu/ggml_metal) run the native whole-model executor (TensorSharp.GGML.Native/ggml_ops_glm_dsa.cpp). It loads the split GGUF itself, layer-splits the weights across every visible GPU (226 GiB does not fit one card), owns the MLA and indexer caches on-device, and submits ONE ggml graph per ubatch throughggml_backend_sched, with a shape-keyed LRU graph cache so steady-state decode replays one allocated (and, on CUDA, captured) graph. -
--backend cpu(100% managed) and--backend cudarun the per-op path inTensorSharp.Models/Models/GlmDsa/*, built on the sharedOps/ManagedQuantizedOps/CudaQuantizedOpsstack. This is also the reference implementation the native executor is checked against;TS_GLM_NATIVE=0selects it on a GGML backend for an A/B.GLM-5.3-Flash (
glm5next) runs here too, but it is correct rather than fast -- see below.
Getting glm5next onto the pure-C# backend took four fixes, all of which were
silent or fatal rather than merely slow:
- NoPE MLA. GLM-5.3 sets
rope.dimension_count = 0: there is no rope half anywhere,n_nope == n_embd_head_k, and the compressed latent IS the whole cache row. The GLM-5.2 path splits every head into a NoPE and a RoPE part, so it narrowed a zero-width slice and threw.GlmDsaModel.Attentionnow mirrors thehp.n_rot == 0branches inggml_ops_glm_dsa.cpp: no pe half on the query or the key, no_kPeCacherow, no rope on either the attention or the DSA indexer, and the score is the absorbed term alone. - The MLA-absorbed layout check demanded
attn_k_b/attn_v_bon every trunk layer. GLM-5.3 is KDA-recurrent on most of its trunk and carries them on only 12 of 45 layers, starting at layer 3, so a valid checkpoint was rejected at layer 0. The requirement is now scoped to the full-attention layers. IQ2_XSandIQ4_XShad no managed support, so the loader expanded them to F32 -- 765 GB for this model. Loading never finished; it just grew. Both now have managed dequantizers (checked against ggml's own) and directx Q8_Kdot kernels.BackendType.Cpuwas the only backend missing fromCanUseFileMappedQuantizedWeights, so it alone copied every quantized tensor into fresh anonymous memory.
Load now reports Quantized: 103255 MB (103255 MB file-backed), F32: 983 MB and
finishes in ~48 s, most of it the page-cache prefault.
Performance. Measured on the 122-CPU box, 22-token prompt, 16 tokens out:
| prefill | decode | |
|---|---|---|
cpu, scalar i-quant dots |
0.9 | 0.4 |
cpu, AVX2 i-quant dots |
3.1 | 1.6 |
ggml_cpu, same file and box |
17.7 | 3.9 |
Roughly 89% of the time is the MoE expert path -- 8 experts x 3 matrices x 45
layers of small matmuls per token -- and it sits outside the Linear timing
bucket, so the built-in breakdown makes it look like "Other". The direct
IQ2_XS / IQ3_XXS dots removed the F32 expansion; vectorizing them
(VecDotIq2XsQ8KAvx2, VecDotIq3XxsQ8KAvx2, one ib32 per 256-bit lane, signs
applied to the ACTIVATION with VPSIGNB) is what closed most of the remaining gap.
Still ~5.7x off ggml_cpu on prefill and ~2.4x on decode.
Quality: close, and the token difference is a near-tie rather than a defect.
Comparing the PREFILL logits directly (TS_DUMP_LOGITS, which skips the warmup
forwards -- comparing those instead measures two executors on a throwaway token
and is meaningless):
- cosine 0.9567 over the 154880-wide vocabulary;
- native argmax
1986at 18.71 with16360at 18.60 -- 0.11 apart; - the managed path ranks them the other way and puts native's pick at rank 2.
That is why the greedy text differs (Simple arithmetic question, user wants a brief answer.</think>2+2 = against This is a simple arithmetic question. The user wants a brief answer. 2) while the reasoning is the same. At 2 bits the
per-op and fused executors pick different experts from small numerical
differences, the same effect that keeps TS_BATCHED_FUSED_DECODE off by default.
A cosine of 0.96 is consistent with that but does not prove it: it is lower than
the ~0.999 a higher-precision checkpoint would be expected to give, and no
higher-precision GLM-5.3 GGUF was available to use as a control. Treat the
managed path as a reference implementation to A/B against, not as bit-parity.
Split GGUFs are handled by GgufFile itself (split.count / -00001-of-000NN),
so every model in the repo can now be stored across several files.
Without --tp, a multi-GPU box already uses every card: the loader measures
each device's free VRAM and bin-packs the 78 layers across them, so device 0 runs
layers 0..k, hands the hidden state on, and so on. That is the default and there
is no flag for it — a 226 GiB checkpoint fits no other way. TS_GLM_NGPU caps how
many devices it uses, and if even the full set is not enough the load is refused
with the exact --n-cpu-moe N that would make it fit.
--tp N (or TENSORSHARP_TP_DEGREE) is the other mode: it runs every layer on
every one of N GPUs and splits the weights inside each layer, so decode reads
1/N of the weights per device instead of walking all of them in sequence. The split follows the
Megatron column/row pattern used by the rest of the repo:
| Piece | Split | Collective |
|---|---|---|
| Attention heads | column-parallel attn_q_b / attn_k_b / attn_v_b, row-parallel attn_output |
one all-reduce per layer |
| Routed experts | column-parallel ffn_gate_exps / ffn_up_exps, row-parallel ffn_down_exps — every expert is split row-wise, the experts are not divided between ranks |
one all-reduce per layer |
| Router, norms, indexer, shared expert, dense layers | replicated | none |
| MLA + indexer caches | replicated — every rank keeps its own full-length copy. The 576-wide MLA row is shared by all heads, so there is nothing head-shaped to shard; this is why --tp N multiplies the KV footprint by N and drops the fitted context |
none |
Splitting the expert hidden dimension rather than the expert ids is what
makes this work at all: ggml_mul_mat_id needs a token's selected expert ids to
be distinct, so an id-space split would have to invent a duplicate id for every
expert a rank does not own. Row-splitting keeps the router's global top-8 valid
on every rank, gives every rank an equal 1/N of the work no matter how the
routing skews, and cuts each rank's expert FLOPs by N as well as its memory.
A rank's strip of the down-projection cuts each ROW, so it is measured in whole
quantization blocks; a model whose expert hidden size is not N whole blocks
keeps its experts intact and splits only the heads (still exact, just slower).
TS_GLM_TP_SHARD selects the halves independently (1 = heads, 2 = experts,
3 = both) and TS_GLM_TP_OVERSUBSCRIBE=1 lets several ranks share one GPU,
which is how the split is checked for correctness on a single-GPU machine.
The native executor keeps every cache on the device, so a request's state is a
native slot — a full set of per-layer MLA and indexer caches plus its own
n_past — and binding a request to one is an active-slot switch that moves no
KV bytes (GlmDsaModel.PerSeqCache.cs, the same contract DeepSeek V4 uses).
Each slot's graphs are cached and captured independently, so concurrent
requests replay their own captured CUDA graphs instead of rebuilding or
replaying another request's baked cache addresses.
The token-batched paged path is deliberately not implemented: MLA stores one compressed row per token and the DSA indexer scores against that same contiguous history, so there is no paged-KV layout to batch over. What is implemented instead is a batched fused decode: one graph, one token from each of N sequences, where every projection, the router, the experts and the LM head run once over the batch and only the cache write, the indexer scoring and the softmax are built per token — so N concurrent requests read the weights once between them instead of N times. Measured on the 3-GPU box, four concurrent 200-token completions: 75.2 tok/s aggregate against 41.6 solo (1.81x), each stream at 18.8 tok/s.
TS_BATCHED_FUSED_DECODE=1 turns it on, and it is off by default here for the
same reason it is off everywhere else in the project. Batching changes the shape
of every GEMM, so CUDA picks different kernels and the result differs in the last
bits: the first divergence against the one-at-a-time path shows up at layer 1 at
2e-8 relative. In a dense model that would stay invisible, but 75 of these 78
layers pick 8 of 256 experts by a top-k over near-tied scores, so a last-bit
difference flips a marginal expert, and by the LM head the logits differ by
O(1) — on a 2-bit checkpoint that is a visibly different continuation, not a
rounding wobble. On the CPU backend, whose kernels do not switch on batch size,
batched and serial decode are bitwise identical.
Without the flag, concurrency still works and is exact: the engine interleaves whole-graph per-sequence forwards, and four concurrent completions come back byte-identical to running the same four prompts one after another. It just re-reads the weights once per sequence.
Below attention.indexer.top_k cached tokens the indexer cannot remove
anything — top-k over n_kv <= k keeps every cell — so TensorSharp skips the
scoring entirely and attends densely. That is the same function, computed
cheaply, and it is why short prompts pay nothing for DSA. The indexer KEYS are
still cached on those steps, because a later, longer step scores them.
Past top-k the graph builds the full indexer: rope, the Walsh-Hadamard rotation,
ggml_lightning_indexer (or an equivalent decomposition where the backend has
no kernel), ggml_top_k, and a mask that starts fully masked and is unmasked at
the selected positions before the causal mask is added back.
The Hadamard rotation is reproduced deliberately. It is an orthonormal involution applied to both sides of a dot product, so in exact arithmetic it cancels — but the indexer key cache is F16, and rotating before rounding spreads the error evenly across the 128 dimensions. Skipping it changed which tokens the top-k picked on a 2741-token prompt and broke token-for-token parity with llama.cpp; reproducing it restored 6/6.
GLM-5.2 ships a NextN block in the stock checkpoint — block_count is 79
and nextn_predict_layers is 1, so blk.78 is a complete glm-dsa decoder block
(MLA attention, the 256-expert sigmoid-gated MoE with its shared expert) wrapped
in the deepseek-family NextN wiring:
h_mtp = shared_head_norm( block( eh_proj( [ enorm(embed(t)) ; hnorm(h) ] ) ) )
logits = lm_head(h_mtp)
where h is the trunk's post-output_norm hidden state of the token before
t. The block predicts token t+1 from token t, so chaining it drafts a
window that the trunk then verifies in a single batched forward. Enable it with
--mtp-spec on either host; there is nothing to download.
# CLI — single-shot, chat REPL, or a multi-turn JSONL run
dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll \
--model models/GLM-5.2-UD-IQ2_XXS-00001-of-00006.gguf \
--backend ggml_cuda --n-cpu-moe 20 --mtp-spec --chat
# Server
dotnet TensorSharp.Server/bin/TensorSharp.Server.dll \
--model models/GLM-5.2-UD-IQ2_XXS-00001-of-00006.gguf \
--backend ggml_cuda --n-cpu-moe 20 --mtp-spec--mtp-draft N and --mtp-pmin X tune the window and the confidence gate on
both hosts (see Measured). Verification draws every emitted token
from a trunk row with whatever sampler the run configured — argmax under
--temperature 0, the chat sampler under --chat — so speculation never
changes which distribution a token comes from, only how many forward passes it
took to get there.
Three details are worth stating, because getting any of them wrong is silent:
- The draft block attends densely. It ships lightning-indexer weights
(
blk.78.indexer.*), but llama.cpp'sgraph_mtpbuilds the plain MLA attention input and never reads them — and the block has no indexer key cache to score against anyway. Running the indexer there would be a different model, not a faster one. - It borrows the trunk's embedding table and LM head. GLM-5.2 ships neither
nextn.embed_tokensnornextn.shared_head_head. Both are optional in llama.cpp too, and both are honoured when present. The borrowed head is why drafting is refused under--tp N > 1: the trunk head is column-parallel there, so the draft would read one rank's strip of the vocabulary. - The draft block's KV cache never needs a rollback. A catch-up always rewrites from the verified position forward, so rejected speculative rows are overwritten before anything reads them. And because glm-dsa has no recurrent state, a partially-rejected verify keeps the accepted prefix's KV in the trunk and only rewinds the position — no kept-prefix re-forward, which is the dominant rollback cost on a long context.
Opt-in for a reason. The block is a whole extra decoder layer — ~3 GiB at
IQ2_XXS — competing for the VRAM the loader sizes the context against, so the
native loader only pages it in when --mtp-spec (TS_MTP_SPEC) was set before
the model loaded. That is why the flag has to be on the command line rather than
toggled later, and why adding it to a command that already just fit can shorten
the context the loader settles on. TS_GLM_MTP=1 / 0 overrides either way for
an A/B.
2× RTX PRO 6000 Blackwell (97 GiB each), GLM-5.2-UD-IQ2_XXS, --n-cpu-moe 20,
21-token prompt, 160 tokens generated, greedy
(InferenceWeb.Tests/GlmDsaSpeculativeModelTests.cs). Each run takes the plain baseline
as the median of three, because the host-side expert matmul is the noisy half of
the comparison — and the whole benchmark was then repeated five times, because
one round is not enough to tell a 5% tuning effect from that noise.
| Configuration | Decode (5 runs) | vs plain | Draft acceptance | Drafted per verify |
|---|---|---|---|---|
| plain greedy | 17.96 / 18.33 / 20.42 / 20.37 / 18.56 tok/s | 1.00x | — | — |
--mtp-spec (the defaults: k=8, pMin 0.75) |
20.50 / 25.68 / 25.83 / 25.85 / 23.52 tok/s | 1.14 / 1.40 / 1.27 / 1.27 / 1.27x — median 1.27x | 93.8% | 1.59 |
--mtp-spec --mtp-draft 4 --mtp-pmin 0.55 |
22.35 / 26.99 / 25.86 / 26.81 / 25.89 tok/s | 1.24 / 1.47 / 1.27 / 1.32 / 1.39x — median 1.32x | 75.0% | 2.04 |
On tuning. A narrower window with a lower gate was best or tied-best in
every run (and in a --n-cpu-moe 34 variant), by ~4% on average — but never by
enough, and never consistently enough, to be worth hard-coding as a per-model
default: sweeping the gate alone at k=8 won three runs and lost three. The two
knobs interact, so sweep them together rather than one at a time. The runtime
cost governor in SpeculativeExecution measures the model/drafter pair
either way and parks drafting if it stops paying.
Why a narrower window helps at all: a verify amortizes unusually well here — the trunk reads its routed experts once for the whole window —
| Verify rows | Cost | vs a 1-row decode | Per token |
|---|---|---|---|
| 1 | 95.6 ms | 1.00x | 1.00x |
| 2 | 121.4 ms | 1.27x | 0.64x |
| 3 | 147.5 ms | 1.54x | 0.51x |
| 5 | 190.8 ms | 2.00x | 0.40x |
| 9 | 285.2 ms | 2.98x | 0.33x |
so an extra speculative row costs about a quarter of a decode step and is worth taking well below 50% expected acceptance. What the 0.75 gate does instead is cut the chain after one token (1.59 drafted per verify); lowering it lengthens the chain, and capping the window bounds what a chain that then gets rejected costs. Neither alone is reliably better than the defaults — together they are.
The break-even also moves with how much of the MoE is offloaded, so it is worth
knowing which side of it your host is on. At --n-cpu-moe 34 (84.4 GiB of
experts on the host):
| Configuration | Decode | vs plain |
|---|---|---|
| plain greedy | 12.57 tok/s | 1.00x |
| the defaults | 14.57 tok/s | 1.16x |
--mtp-draft 4 --mtp-pmin 0.55 |
14.78 tok/s | 1.18x |
Heavier offload slows the 1-row baseline more than it slows a wide verify, so the curve flattens (a 2-row verify is 1.16x a 1-row decode there, not 1.27x) and the configurations converge.
Every emitted token is drawn from a trunk row, so speculation cannot change which distribution a token comes from — only how many forward passes it took to get there. It does change the arithmetic: a K+1-row verify runs the trunk's matmuls at a different batch size than a 1-row decode, which selects different kernels and reduction orders.
On GLM-5.2 that is not invisible. At 2-bit with 256 experts at top-8, a last-bit
difference in a router logit changes which experts run, and 78 layers amplify
it. Measured over 140 verify rows against per-token decode: the top token
differs on 2.9% of rows (max |Δlogit| 2.6), so a long greedy run eventually
takes a different — equally valid — branch. This is the same effect the
tensor-parallelism section describes for --tp, and the
tests pin down where it does not come from:
- capturing the hidden state is free: an h-capturing forward at one token is bit-identical to a plain forward (max |Δ| exactly 0);
- driving the whole speculative loop with drafting suppressed — same cache bookkeeping, same catch-up calls, same rewinds — reproduces greedy exactly.
So the effect is the batch size, not the speculation. If you need a run to match
non-speculative greedy token for token, leave --mtp-spec off.
Measured on GLM-5.2-UD-IQ2_XXS (226 GiB) against llama.cpp b200-9731ad3 on the
same machine, feeding the RECORDED prompt token ids so the comparison isolates
the forward pass from tokenization (.parity/gen_ref_glm.py,
InferenceWeb.Tests/GlmDsaParityTests.cs):
| backend | prompts reproducing llama.cpp token-for-token |
|---|---|
ggml_cuda, 3 GPUs |
6/6 against llama.cpp on the same backend (5 short + one 2741-token prompt through the sparse path) |
ggml_cpu |
3/3 |
cpu (100% managed) |
1/1 |
On the same backend is the bar, and it has to be: on the 2741-token prompt
llama.cpp's own CPU and CUDA builds disagree at the fifth generated token
(1467 vs 8543, "The ... is a summary" either way), and TensorSharp
reproduces whichever one it is running on — CPU's answer on ggml_cpu, CUDA's
on ggml_cuda. The recorded golden was captured from a CPU llama-server, so
that one record reads as a mismatch when the check runs on GPU. The DSA
selection is what makes the prompt this sensitive: the indexer key cache is F16,
and at 2741 tokens two candidates in the top-2048 are close enough that a
last-bit difference in the score reorders them.
The tensor-parallel and batched-decode splits are checked the same way but on
the synthetic model, where they can be held to a much harder standard: on the
CPU backend --tp 1..4 and batched decode are bitwise identical to the
single-rank, one-at-a-time path, and on CUDA all seven head/expert split
combinations reproduce the golden continuation token for token
(--batched and TS_GLM_TP_SHARD in the parity harness).
InferenceWeb.Tests/GlmDsaTinyModelTests.cs covers the architecture without the
226 GiB download: it builds a deterministic 1.9 MB glm-dsa GGUF with the same
block shape (and top_k 8, so a 24-token prompt is already sparse) and checks
the greedy continuation against goldens captured from llama.cpp on that file.
3x RTX PRO 6000 Blackwell (97 GiB each), GLM-5.2-UD-IQ2_XXS, layer split, both
engines measured back to back in one session (llama-bench for llama.cpp, and
the parity harness's --bench, which reports the best of two repetitions the
same way):
| test | llama.cpp | TensorSharp (default n_ubatch 1024) |
TensorSharp (TS_GLM_UBATCH=2048) |
|---|---|---|---|
| pp128 | 276.5 t/s | 254.8 t/s | 264.4 t/s |
| pp512 | 695.4 t/s | 666.9 t/s | 659.6 t/s |
| pp2048 | 763.1 t/s | 918.9 t/s | 1145.8 t/s |
| pp4096 | 715.8 t/s | 864.7 t/s | 1048.7 t/s |
| tg64 | 42.2 t/s | 43.7 t/s | 43.9 t/s |
The crossover sits around a thousand prompt tokens, and the micro-batch is why: with 256 experts at top-8, a 512-token chunk routes only ~16 rows to each expert, so most of every expert-GEMM tile is padding and a bigger chunk buys more than anything else on the graph. Below that, a whole prefill is one small graph and the fixed cost per call — the managed hop, the input uploads, the 154880-wide logits copy back — is a visible fraction of it, which is where llama.cpp's few percent come from. Decode is memory-bound and lands a few percent ahead either way. Run-to-run spread on these numbers is about 4%.
Weight load is 218 GiB in ~37 s (5.9 GiB/s) from a warm page cache, using 16 reader threads across the six shards.
Same 3 GPUs, GLM-5.2-UD-IQ2_XXS, --tp 3 (heads + expert rows) against the
default layer split:
| test | layer split | --tp 3 |
|---|---|---|
| pp2048 | 896.8 t/s | 502.8 t/s |
| tg64 | 43.9 t/s | 16.2 t/s |
Two things to know before reaching for it. The first is exactness: splitting the
attention turns one GEMM into a sum of per-rank partials, so the residual differs
in the last bits, and — exactly as for batched decode above — 75 layers of
top-8-of-256 routing amplify that into a different continuation on a 2-bit
checkpoint. Against the recorded llama.cpp goldens the layer split reproduces
5 of the 6 recorded prompts and --tp 3 reproduces 3 of 6; the tokens that differ are the
near-tied ones. The second is speed, and the reason is the interconnect. Each
layer needs two all-reduces of the [6144, n_tokens] hidden state, and these
cards are PCIe-attached with no
NVLink: a 1024-token prefill chunk moves ~25 MB per crossing, 78 layers x 2
reductions deep, which is more time on the bus than the split saves in
arithmetic. Layer splitting moves the hidden state exactly twice per token, so
on this machine it wins on both counts. On an NVLink/NVSwitch host — where an
all-reduce is roughly an order of magnitude cheaper — the balance reverses; the
split itself is the same either way.
# 3 GPUs, layer split (the default: every visible GPU)
dotnet run --project TensorSharp.Cli -- --model GLM-5.2-UD-IQ2_XXS-00001-of-00006.gguf \
--backend ggml_cuda --prompt "Explain MLA in one paragraph."
# Fewer GPUs, or a specific count
TS_GLM_NGPU=2 dotnet run --project TensorSharp.Cli -- --model ... --backend ggml_cuda
# Tensor parallel across 3 GPUs instead of splitting the layers
dotnet run --project TensorSharp.Cli -- --model ... --backend ggml_cuda --tp 3
# Low VRAM: keep the routed experts (92% of the checkpoint) in system RAM
dotnet run --project TensorSharp.Cli -- --model ... --backend ggml_cuda --cpu-moe
dotnet run --project TensorSharp.Cli -- --model ... --backend ggml_cuda --n-cpu-moe 30Offload is a way to fit a checkpoint that does not fit, not a speed knob — when
the model already fits, moving experts to the host only adds bus round trips.
Same 3 GPUs, same run: default layer split pp2048 915.9 / tg64 43.9 tok/s,
--n-cpu-moe 30 94.7 / 16.4, --tp 3 505.6 / 17.6. Reach for
--n-cpu-moe when the alternative is not running at all.
Offload composes with tensor parallelism: --n-cpu-moe 30 --tp 2 loads, and the
host-resident layers keep their experts whole (splitting them would save no host
RAM and no host time, and a strided strip cannot be served in place from the GGUF
mapping — it would turn a mapped file into a 200 GiB private copy), so rank 0
evaluates those layers while the GPU-resident ones stay split. --n-cpu-moe 30
on its own reproduces llama.cpp 3/3.
The GGUF advertises 1,048,576 tokens. That is not a promise the caches fit: at
78 layers a 576-wide MLA row plus the indexer's is ~93 KiB per token, so a 1M
context is ~93 GiB of KV — a whole card's worth on top of the weights — before the graphs are
counted. So the advertised number is treated as a ceiling, not a request:
the loader sizes the context from the VRAM actually left after the weights land
(minus what the DSA masks and the LM head need for one n_ubatch graph) and
says what it picked. On the default layer split across the three cards above that
pick is 342,272 tokens, and --n-cpu-moe 30 raises it to 646,400. The line below
comes from a --tp 3 run, where every rank holds a full-length cache and the
pick therefore drops much further:
[glm] context 91136 tokens (the GGUF advertises 1048576): 18.3 GiB free per rank
after the weights, and the caches and graphs have to live in it.
MAX_CONTEXT turns that around: a context you name is a requirement, honoured
if it fits and refused with the numbers if it does not, rather than quietly
shrunk under you.
| Variable | Default | Meaning |
|---|---|---|
TS_GLM_NGPU |
0 (all) | GPUs to spread the layers over |
TS_GLM_UBATCH |
1024 | prefill micro-batch; 2048 is faster on long prompts if VRAM allows |
TS_GLM_THREADS |
min(cores, 32) | CPU-backend threads (the routed-expert matmul overrides this from --cpu-moe-threads) |
TS_GLM_NATIVE |
1 | 0 runs the managed per-op path on a GGML backend |
TS_GLM_FA |
1 | 0 disables flash attention (falls back to soft_max) |
TS_GLM_FUSED_LID |
1 | 0 builds the indexer out of primitives instead of ggml_lightning_indexer |
TS_GLM_OP_OFFLOAD |
auto | scheduler op-offload; off by default once any layer's experts are host-resident |
TS_GLM_VRAM_RESERVE_MB |
3072 | per-device headroom the layer split leaves for compute buffers |
TS_GLM_GRAPH_CACHE |
8 | cached built+allocated graphs |
TS_GLM_MOE_MMAP |
1 | 0 copies host-resident experts instead of mapping the GGUF |
TS_GLM_TP_SHARD |
3 | tensor-parallel split: 1 heads, 2 routed experts, 3 both |
TS_GLM_TP_OVERSUBSCRIBE |
0 | 1 lets tensor-parallel ranks share a GPU (correctness testing only) |
TS_GLM_BATCHED_DECODE |
1 | 0 makes the native side decline every batched decode, forcing the per-sequence path |
TS_GLM_TRACE |
— | layer list (or all) to dump per-layer activation sums, matching llama-eval-callback's layout |
TS_GLM_BD_DEBUG |
0 | 1 narrates each batched decode step (which slots, graph reused or rebuilt, how far it got) |
TS_GLM_TOPK |
1 | 0 attends densely even past the indexer top-k — an A/B for the DSA selection |
TS_GLM_NODES_PER_LAYER |
256 | graph node budget per layer per rank |
TS_GLM_LOAD_THREADS / TS_GLM_LOAD_CHUNK_MB |
16 / 64 | weight-load parallelism and chunk size |
[gMASK]<sop>[<|system|>Reasoning Effort: Max][tools]<|user|>...<|assistant|><think>
Thinking is opt-in (--think, /think on in the REPL, "think": true in an API
request), as on every other family here. Turning it on adds the
<|system|>Reasoning Effort: Max line and leaves the generation prompt's
<think> block open for the model to close; left off, the prompt emits
<think></think> so the model answers directly. Past turns' reasoning is always
dropped from the prompt, matching the template's clear_thinking default. Tool calls come back as
<tool_call>NAME<arg_key>k</arg_key><arg_value>v</arg_value>...</tool_call>,
one XML element per argument (values that were rendered with tojson are parsed
back into numbers / arrays / objects).
GLM-5.3-Flash is the hybrid successor: 320B parameters, 288 routed experts
(top-8, one shared, ×2.5 routed scale), 46 blocks = 45 trunk + 1 NextN. The
GGUF architecture id is glm5next, and it loads through the same native
executor (ggml_ops_glm_dsa.cpp) and the same GlmDsaModel — the MLA
attention, the MoE and the graph plumbing are shared with GLM-5.2, with four
architectural changes layered on top:
| Piece | Shape (GLM-5.3-Flash) | Notes |
|---|---|---|
| KDA linear attention | 34 of 45 trunk layers; 64 heads × 128 | attention.head_count_kv is a per-layer array: 0 = KDA, 1 = MLA. Short conv (kernel 4, persistent per-sequence tail), l2-normed q/k, per-CHANNEL decay gate bounded below multiplicatively (kda.gate_lower_bound −5), fused gated-delta-net recurrence with in-graph state commit |
| MLA + DSA layers | 11 of 45 (layers 3, 7, …, 43), NoPE | rope.dimension_count 0: no rope anywhere in the text tower, the 512-wide latent IS the cache row, softmax scale 1/√256 |
| Pooled indexer | every MLA layer, 4-cell pools, top-k 2048 | key + compressor gate cached as [key|gate] per cell; a softmax over the gates (plus a per-slot position embedding) compresses each pool; top-k over POOLS then expand to members, with the query's own trailing pool always attended. Dense below top_k + kpool − 1 = 2051 cached tokens |
| Sinkhorn hyper-connections | every layer, ×4 streams | the DeepSeek-V4 mHC recipe (fused ggml_dsv4_hc_pre/comb/post, 20 Sinkhorn iterations), embedding replicated ×4, head = UNWEIGHTED stream mean |
| SwiGLU clamp | all FFNs, limit 10 | up ∈ [−L, L], gate ∈ (−∞, L], before the activation — dense layers, shared expert and routed experts alike |
| Vision | mmproj-BF16.gguf (GLM-OCR ViT) |
see below |
The KDA recurrent state (conv tail + delta-net state, ~150 MB per sequence)
cannot be rewound, so a cached prefix is only reused when the new prompt
extends it exactly — the same contract as the Qwen 3.x GDN family — and
Reset wipes the state along with the position counter.
- Layer split across every visible GPU (the default): ~99 GiB of UD-Q2_K_XL loads across 2×96 GB in ~17 s warm.
--cpu-moe/--n-cpu-moe Nhost-resident experts: works (measured ~35–40 t/s decode with the first 10 layers' experts on the host).- Serving: per-sequence native slots, concurrent requests decode round-robin (the fused one-graph-per-step batched decode declines glm5next for now and the engine falls back automatically).
- Vision:
--image/ multi-image / multi-turn image sessions through the managedGlmNextVisionEncoder(the GLM-OCR ViT: RMS norms, fused QKV, per-head q/k RMS norms, 2D vision RoPE, SwiGLU-clamp MLP, 2×2 conv merger). All 24 blocks run as one device-resident GGML graph (TSGgml_GlmVisionEncoderF32); the projected embeddings override the<|image|>placeholder rows inside the native executor (TSGgml_GlmQueueVisionRows) — the text tower is NoPE, so image tokens need no MRoPE bookkeeping. - Not yet:
--tptensor parallelism (cleanly refused; use the layer split) and NextN/MTP speculation (llama.cpp asserts its glm5next MTP graph unimplemented too;--mtp-specprints a notice and serves standard decode).
2× RTX PRO 6000 Blackwell (96 GB), GLM-5.3-Flash-UD-Q2_K_XL (101 GiB), layer
split, flash attention on, both engines at n_ubatch 2048, back to back in one
session (llama.cpp build 2e0e57f / PR #27754 via llama-bench; TensorSharp via the parity harness --bench):
| test | llama.cpp | TensorSharp |
|---|---|---|
| pp2048 | 2070 t/s | 2014 t/s |
| pp16384 | 1690 t/s | 1692 t/s |
| pp32768 | 1483 t/s | 1446 t/s |
| tg64 | 36.6 t/s | 73.5 t/s |
Decode runs at 2.0× llama.cpp; prefill is within a few percent either way
(the same MoE tile-padding economics as GLM-5.2 apply, so TS_GLM_UBATCH=2048
is the setting to keep for long prompts). Greedy replay of llama.cpp goldens
reproduces the 2741-token long-context record — the pooled sparse-selection
path — token for token; short records flip on Q2-quant near-ties (llama.cpp's
own top-2 margin at a flip point is ~0.13 logits with the same candidate set).
GLM-5.3's template always reasons: the <|system|>Reasoning Effort: Max line
is unconditional, the generation prompt always opens <think>, and past
turns keep their reasoning (clear_thinking defaults to false). Tool calls
use the same XML element form as GLM-5.2. Images render as
<|begin_of_image|><|image|><|end_of_image|>, and the host expands
<|image|> to the merged-patch token count.
glm5next serves concurrent requests through the same native per-sequence slots
as GLM-5.2, plus a fused batched decode: one graph decodes one token for
each of 2-16 sequences per step, with per-token KDA recurrence against each
slot's own persistent state, per-token pooled-indexer scoring and per-token
attention, while the projections, hyper-connections, router, experts and the
LM head run once over the batch. Verified by a serial-vs-batched equality
harness (benchmarks/ParityHarness --batched): 3 concurrent sequences, every
step fused, token-for-token equal to serial decode.