Skip to content

Speed up GLM-5.2 with fp8 kernels and one-graph MTP - #388

Open
vasilevklart wants to merge 159 commits into
mainfrom
glm/03-glm52-speed
Open

vasilevklart wants to merge 159 commits into
mainfrom
glm/03-glm52-speed

Conversation

@vasilevklart

@vasilevklart vasilevklart commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Problem. GLM-5.2 on main (#387) dequantizes its fp8 linears on every call and routes the MoE through the per-expert reference loop, which can't be captured, so decode runs eager and the model has no MTP.

Fix. kernels: auto, the default, keeps the fp8 linears fp8: w8a16 at decode sizes, FlashInfer's block-scale GEMM above. The same mode adds:

  • the fused fp8 MoE with its decode and router kernels;
  • the all-reduce fused into the next add + RMSNorm;
  • the last layer on the sampled rows at prefill.

kernels: reference is the op-by-op numerics anchor. configs/glm52_tp8_mtp.yaml adds greedy MTP k=3 as one captured graph per batch size; its plumbing stays model-local until a follow-up moves it into a shared draft-verify resource. components/mla.py becomes the MLA layer GLM-5.3 reuses (#390). A submodule's dynamo recompile limit (dynamo_recompile_limit()) now applies around its own compiled calls only.

8×H200, output tok/s at c M* vLLM SGLang M* + MTP vLLM + MTP SGLang + MTP
1 138.7 100.4 108.4 201.0 202.1 229.5
8 563.7 527.3 583.7 782.7 701.4 859.6
32 1,280.6 1,152.3 1,293.0 1,715.8 1,470.6 1,633.8
64 1,943.8 1,790.4 1,739.3 2,557.0 2,015.5 2,374.5
GSM8K-200 185 184 184 184 187 185

1024 in / 512 out, greedy, a fresh server per arm; vLLM 0.31 and SGLang 0.5.21 at TP8, 2048 context, bf16 KV, k=3.

Tests.

  • test_glm52_mtp_engine_cycle: the MTP stream equals plain decode bitwise.
  • test_glm52_fused_add_rmsnorm: fused wiring emits what unfused does.
  • test_glm52_moe_decode (GPU): the H200 tile table against the runner.
  • CPU Core on 517d41d65 (8,846 passed) fails no test that main (9f84f18cd) passes, by name; ruff is clean.
  • GPU sets on an H200 at the stack's top (91f303959) fail only the 22 tests that need an HF cache. CPU Core is on torch 2.10, pyproject's floor; the GPU runs on 2.12.1.

Not run yet. Qwen3.5-0.8B at c=32/64 against main, for this PR's engine changes (host KV plan, non-blocking copies, padding release).

AI assistance (Claude) was used for this PR.

@vasilevklart
vasilevklart added this pull request to stack #393 October 9, 2026 20:39
@vasilevklart vasilevklart changed the title glm/03 glm52 speed GLM-5.2/GLM-5.3 PR 3: GLM-5.2 speed: fp8 kernels, fused all-reduce norm, one-graph MTP Oct 9, 2026
@vasilevklart
vasilevklart marked this pull request as ready for review October 9, 2026 21:55
attn: MLA on a latent KV layout (#321). fused_moe: fp8 block-scale experts
(#270). worker: per-step token budget; api_server: role on the first token.
distributed: group timeout, compile_mode, symmetric-memory all-reduce.
GLM-5.2 at k=0: MLA with the DSA indexer, fp8 MoE, weight loader, configs.
Replaces #272. Open: the configs' new max_concurrent_requests caps.
A prompt of limit - 1 tokens failed its whole decode batch (the speculative
step overran the window), and a long-context prompt past index_topk failed
its prefill batch. process_prompt now refuses both; prepare_inputs agrees.
Byte-level BPE models emit each token's raw bytes, which can end inside a
character; decoding per chunk turned those into U+FFFD.
Per-token decode turned a character split across byte-level BPE tokens
into U+FFFD; the 10-07 GSM8K answers have them at ÷ and √.
As FlashInferManager since #243/#280: an eager plan staged into the one
wrapper per label while the last step's copy could still be queued, and a
captured step whose rows share a cached prefix overran the kv_indices buffer.
The conductor now builds it only for a model that sets prebuild_fused_moe.
As fused_experts_fp8: dynamo traced the launch path inline, which breaks
the graph and fails on a symbolic token count under torch 2.9.1.
An fp8 checkpoint whose quant config went undetected loaded clean with
unscaled e4m3 weights; a failed Hub fetch came back as a local path.
The runtime's ready order is handle order in Rust, and handles are recycled:
a large prefill could be passed over indefinitely. Backlogged rows count
too, since the batch builder tops a backlog up with fresh rows.
A non-numeric max_output_tokens from extra_body reached the conductor's
min() and took its message batch down; image or audio parts were decoded
and dropped. Both are 400s now.
Its logits kernel holds every token in one tile; past 128 the first launch
raised OutOfResources. The models never send more; now it says so.
top_k and min_p reach the sampler; a fused-kernel import failure is
logged; MSTAR_GLM52_GRAPH_COMPILE=0 also leaves uncaptured steps
uncompiled; the single-rank test config is dummy mode, not a 750 GB pull.
Chosen per plan, a captured bucket could re-plan into the other kernel
while the graph replayed the captured one on stale buffers.
from_hf_config read only rope_theta, so a YaRN or dynamic variant ran as plain RoPE, its frequencies and softmax scale wrong.
The CUDA op launched a 0-block grid unchecked; the latched error surfaced in the next unrelated kernel.
Each new test file with a CPU case, listed in test/cpu-core.txt.
A rank whose setup failed fell back to NCCL alone, and its peers' first
all-reduce waited on it forever. tp_allreduce_max_kb: 0 now turns it off
instead of reading as unset (512).
Built from tokenizer.json alone (transformers 4.x), the tokenizer had no
special tokens, so the detokenizer printed <|user|> at the end of replies.
components/mla.py ran its fused prep (mla_prep: q_a's norm, then kv_a's
norm, both RoPEs and the latent's cache write, in two kernels) only for a
forward that selects no rows. A selecting subclass that attends rows the
prep already wrote (_attends_written, _attend_written) now takes it too:
q_norm first, the selection from its q_c, then kv_write_q_rope and
attention over the selected slots. The kernels reproduce the unfused path
bit for bit.

Nothing here sets _attends_written, so every model keeps its path on this
branch; GLM-5.2's selecting attention takes it with #392's paged DSA.
decode.py's H200 table was swept at GLM-5.3's hidden 4096, and at
GLM-5.2's experts (hidden 6144, 256 experts of 256 at TP8) it is the slow
one at 1-6 and 9-10 tokens. A table swept at that shape on an H200, with
every call's weights read from HBM (the per-pair winner per token count up
to 8, the grouped kernels from 9), now serves that shape on every GPU but
the H100; any other shape keeps the H200 table, and the H100 keeps its own.

One layer's experts (routed and shared), CUDA-graph replay, us a call,
H200 table -> GLM-5.2's:

  tokens      1       2       4       5       6       8       9      10
          29.85   33.38   53.52   70.87   81.02   90.45  113.13  116.85
       -> 24.15   31.12   51.94   63.94   75.37   89.78  102.08  107.80

GLM-5.2-FP8 on 8x H200 (TP8, glm52_tp8.yaml), random 1024 in / 512 out,
ignore_eos, greedy, a fresh server per arm: c=1 131.3 -> 138.7 tok/s
(TPOT 7.48 -> 7.06 ms), c=8 555.1 -> 556.6. GSM8K-200 183 -> 184. Greedy
parity (8 prompts x 128 tokens): 3 of 8 identical, the others first
differing at tokens 7 to 71, since the new tiles sum in another order; a
second server whose decode MoE path was unchanged matched 8 of 8.

Tests: test_fused_moe_decode.py (the table pick by GPU name and shape).
…3 tokens on the H200

On an H200, FlashInfer's SM90 block-scale GEMM beats the w8a16 kernel from 3 tokens at
GLM-5.2's o_proj (N 6144, K 2048) and dense-layer down (N 6144, K 1536), where the shape
table kept w8a16 up to 32 tokens. Cold weights on one H200, us a call at 4 / 8 / 16 / 32
tokens:

  o_proj      w8a16 10.32 / 11.05 / 9.84 / 10.59   FlashInfer 8.01 / 7.99 / 8.09 / 9.28
  dense down  w8a16  9.27 /  9.28 / 8.74 /  9.55   FlashInfer 7.51 / 7.56 / 7.63 / 7.97

The new rows are keyed by GPU model, since the H100 and the H200 are both SM90. reserve()
resolves a device's limits once, at load and outside capture. They apply only where
FlashInfer's GEMM runs, so a GPU without it keeps w8a16 at these sizes instead of falling
through to cuBLAS's slower GEMM. Every other GPU and shape keeps its path. A 1x H100 shows
the same crossover (o_proj 10.53 -> 9.04 us at 3 tokens, 11.65 -> 9.09 at 8), but
GLM-5.2-FP8 does not fit 8x H100, so the H100 gets no row until it is measured end to end.

FlashInfer quantizes the activations to e4m3 per 128 columns, as dense_fp8 already does from
33 tokens, so outputs move at 3-32 tokens.

GLM-5.2-FP8 on 8x H200 (TP8, configs/glm52_tp8.yaml; random 1024 in / 512 out, greedy,
closed loop; a fresh server per arm, in the order base, this, this, base), tok/s:

  c     base             this             change   TPOT p50 (ms), mean of the rounds
  1      138.7 /  138.7   138.8 /  138.6    0.0%    7.06 -> 7.06
  8      556.6 /  556.8   564.2 /  563.1   +1.3%   13.76 -> 13.50
  32    1177.6 / 1270.8  1260.7 / 1280.6   +0.8%   23.42 -> 23.36  (second round)
  64    1924.7 / 1963.6  1919.1 / 1968.5    0.0%   29.82 -> 29.65

At c=32 both first-round servers stalled for up to 2.4 s, the base's more, so only the
second round is compared there. c=1 and c=64 are controls: at 1 token nothing changes,
and from 33 tokens both arms already run FlashInfer's GEMM. With MTP (one server each),
c=1 goes 197.3 -> 199.9 tok/s (+1.3%) and c=8 785.2 -> 792.1 (+0.9%). GSM8K-200 is 185
against 183, with 0 errors on both.

Tests: test_glm52_kernel_platform.py checks that the H200 takes FlashInfer from 3 tokens on
these shapes, and that an H100, an H200 NVL and an H200 without FlashInfer keep w8a16 up to
32 tokens. test_glm52_dense_fp8.py checks reserve()'s limits, and that FlashInfer stays
within the rounding bound at 3 and 8 tokens.
components/mla.py carried three options for Kimi K3, which no GLM model sets: an output gate
(mla_use_output_gate: g_proj and _gate), the latent norms' own eps (mla_latent_norm_eps), and
unrotated decoupled dims (mla_use_nope with qk_rope_head_dim > 0). GLM-5.2 rotates its decoupled
dims and GLM-5.3 has none; K3 is not in this launch, so the options and their tests move to the
K3 stack (refs/prep/k3/00-mla-k3-options). mla_use_nope stays for GLM-5.3 and now means no
decoupled dims: with qk_rope_head_dim above 0 it is refused.

Every GLM path is unchanged: with the gate off _gate returned its input, the eps fell back to
rms_norm_eps, and GLM-5.3's config already refuses mla_use_nope with rope dims.

Tests: test_mla_variants.py keeps GLM-5.3's layout and pins the new refusal; K3's reference and
gate tests leave with the options.
… the checkpoint

GLM-5.2 had nine kernel switches (moe_quant_kernel, moe_decode_kernel, moe_router_kernel,
moe_prefill_kernel, moe_fused_allreduce, dense_fp8, mla_fused_prep, fused_add_rmsnorm,
prefill_last_layer_rows), off by default and on in every serving yaml. Now:

- The MoE prefill kernels, which keep the W8A8 runner's tiles and K order (the same bits,
  test_glm52_moe_decode.py), run on every fused block, with no switch.
- The fp8 linears follow the checkpoint: the non-expert linears a block fp8 checkpoint stores
  in fp8 stay fp8 (dense_fp8 is now a property of the quantization config), and a bf16
  config, as the reduced test models are, takes the bf16 path.
- What changes numerics is one option, model_kwargs.kernels. "auto" (the default) is what
  the yamls turned on: the fused fp8 MoE with fused_moe's decode chain and router kernel
  (their per-GPU tables live in fused_moe/decode.py and dense_fp8.py), the MLA latent prep
  as two kernels, each all-reduce fused with the next add + RMSNorm and the MoE's partials
  reduced once, and the last layer's FFN on the sampled rows at prefill. "reference" is the
  numerics anchor, served eager: the per-expert dequant loop, the fp8 linears dequantized
  per call, the latent prep op by op, unfused all-reduces and norms, every prefill row
  through the last layer. "triton" (the fused MoE or an error) is gone: auto serves the
  reference loop, with a warning, where the fused kernel can't run.

The fused latent prep sits under auto, not beside the prefill kernels: its kernels have the
ops' bits, and plain decode keeps them (a reduced GLM-5.2 on an H200, round 9 without it
against this with it: 3/3 plain cases identical), but 3 of 7 MTP cases differ, in KV pages
(two captured cases) and in one eager case's LM-head bits, with every token the same.

A config that still names a retired switch is refused at startup, and a fp8 linear the
checkpoint doesn't store is named by the loader's missing-weight check. The serving yamls
drop the switches and serve what they served: GLM-5.2 bitwise, round 9 with the yamls'
switches against this with auto, 10/10 identical; round 9's defaults against this with
reference, 10/10. Every other model is unchanged: only GLM-5.2's config names kernels.

Tests: the GLM-5.2 kernel, MoE, fused-norm, MLA-prep and dense fp8 files ask for kernels
instead of the switches; the integration checkpoint stores the attention, shared-expert and
dense-MLP linears in fp8 as the real one does.
Comments and docstrings keep the why and lose the history. Tests that re-checked a
switch, a default or a log line, or copied production logic (the unused host verify
helpers, the loader's key inventory) are cut or merged into parametrized cases.
…d verify helpers

The two custom ops live in mstar/utils and serve GLM-5.3 too. mtp_greedy_verify and its
host twin had no caller outside tests: verify runs on the device in the step. The models
doc names glm52_tp8_mtp.yaml.
…piled calls

GLM-5.2's submodule raised torch._dynamo.config.recompile_limit for the whole thread (the
whole process on torch < 2.12), so every other node on a worker compiled under it. A submodule
now asks through NodeSubmodule.dynamo_recompile_limit(), and the engine and the graph runner
patch the limit around that submodule's compiled callables only. One that doesn't override
the hook gets its callables back unwrapped.
@dghosef dghosef self-assigned this Nov 2, 2026

@dghosef dghosef left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ty - a few things in my comments, mainly the one-rank verdict timeout, the fused MLA prep's kv_label and rope_interleave gaps, and measuring the engine changes on other models.

# prepare_inputs runs, so the last verdict's trim lands here. The verify and the
# drafts are argmaxes in the forward: no sampler.
for rid in request_ids:
self._apply_verdict(rid)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just make sure that once we get the actual spec decoding resource in (#1090), you move this onto it. Right now the MTP state is 8 per-request dicts and a seed slot pool in the model, the host spin-waits for each step's verdict outside check_stop (so MTP steps lose the async pre-plan overlap), and rejected rows are taken back through the new correct_len methods on KVManager/RopeManager. When that lands, can you delete all of this, correct_len included?

Comment thread mstar/model/glm52/submodules.py Resolved
return dense_fp8.linear(x, weight, scale, block_size, glu)


class Fp8Linear(nn.Module):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Non-blocking, just fyi - Fp8Linear re-does the column/row sharding and the reduce that ColumnParallelLinear/RowParallelLinear already do, and each call site picks one w/ a ternary. It also isn't LoraCapable, so LoRA can't target q_b_proj/o_proj on fp8 checkpoints. Longer term it'd be nice if the weight format were a quant argument on the existing parallel linears.

hidden_states = residual + hidden_states
return hidden_states + self.mlp(self.post_attention_layernorm(hidden_states))

def _forward_fused(self, hidden_states, residual, position_ids, rows):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This loop is a second copy of run_layers / forward_mlp_residual in mstar/model/components/decoder_layer.py, so a fix to one won't reach the other, and only GLM gets the sampled-rows trick. Can you open an issue for moving this onto run_layers (w/ position ids and a last-layer rows option)?

Comment thread mstar/model/components/mla.py Resolved
Comment thread mstar/engine/resources/kv/manager.py Resolved
Comment thread mstar/model/components/mla.py Resolved
Comment thread mstar/engine/__init__.py Resolved
Comment thread mstar/engine/resources/kv/manager.py Resolved
Comment thread mstar/model/glm52/components/moe.py Resolved
…ops the worker

A rank that timed out waiting for a verdict, or found its mailbox reused,
raised RuntimeError on its own: its batch failed while the other ranks went
on into the next all-reduce and hung. Every read follows the step's launch
(a pre-plan waits on the step's commit), so only a hung device or peer
delays a verdict, as with any collective. The overwrite check is a broken
invariant rather than a request's failure, so it raises SystemExit, as
micro_scheduler does for diverged TP ranks.
_write_fused_and_attend called write_slots() and the attention's run()
without the label, so a RoPE layer with kv_label set wrote and read the
default stream on the fused path. Both paths take it from one property now.
mla_prep's _rope turns the pairs (2i, 2i + 1), while the op-by-op path
follows PositionConfig.interleave, so a checkpoint with "rope_interleave":
false under kernels: auto had q_pe and k_pe rotated on the wrong pairs with
no error. RopeManager exposes interleave, and bind_resources keeps the ops
for NeoX pairing, as it does without a cos/sin table.
…a loop

The MoE prefill kernels run only on a fused block, and only kernels: auto
builds one (Glm52SparseMoeBlock.process_weights_after_loading).
…2.12

dynamo's config.patch is process-wide there, and pyproject still allows
torch 2.10; the limit stays per submodule only because a worker runs one
compiled call at a time.
Glm52SparseMoeBlock set NoAuxTcRouter._topk_op from outside. Whether the
kernel runs is known only on the loaded device, so the router's own load
hook takes it as an argument and imports the op. Glm52LLMSubmodule reads
its config, and an attribute __init__ sets, instead of getattr fallbacks,
and the GLM-5.2 MoE block reads config.kernels.
get_request_resource_configs built the sampler config from a fixed key
list without penalize_prompt, so a request that set it false with a
repetition_penalty still had its prompt penalized. It now passes with
top_k and min_p as a sampler knob with no config default (as #390 does
for GLM-5.3).

test_glm52_request_kwargs: every knob the sampler declares
(REAL_KNOB_RULES, INT_KNOB_RULES, BOOL_KNOBS) reaches the sampler config
or is refused at intake.
vasilevklart added a commit that referenced this pull request Nov 3, 2026
…t stops the worker

The same fix as GLM-5.2's (#388). A rank that timed out waiting for a
verdict, or found its mailbox reused, raised RuntimeError on its own: its
batch failed while the other ranks went on into the next all-reduce and
hung. Every read follows the step's launch (a pre-plan waits on the
step's commit), so only a hung device or peer delays a verdict, as with
any collective. The overwrite check is a broken invariant rather than a
request's failure, so it raises SystemExit, as micro_scheduler does for
diverged TP ranks.
Conflicts:
- kv/manager.py: main's plan states (#932, #1127): every eager packed
  step's addressing is built on the host, and decode takes the resident
  rows. #388's small-step host path and its thresholds go, with
  KVPlanOutput.max_span and test_kv_host_plan.py. correct_len now uses
  tokens_per_page, cache.copy_pages and StreamPages' del/extend, and
  refuses a cache with KV groups.
- glm52/submodules.py: main's TokenStop (#794) is the plain decode's stop.
  Under MTP, check_stop counts a verify step's tokens, and a prefill stop
  or one-token budget returns END_PARTITION, which replaces the
  ended_at_prefill latch. The prefill groups read PREFILL_TOKEN_BUCKETS.
- fused_moe/decode.py: main's kernel registry (#986). GLM-5.2's H200
  tiles move from _H200_GLM52 to kernels/tables/moe_decode/NVIDIA_H200.json,
  so they now apply on H200 only.
- mla.py, adding_models.rst: both sides' additions.
- test_glm52_dsa_engine.py, test_fused_moe_decode.py: #388's tests on the
  merged code.

Also, to fit main:
- GLM-5.2's per-step index upload goes through to_device_nonblocking, since
  PinnedStager no longer has to_device.
- prefill_token_buckets is a named Glm52Model argument, which the shipped-ladder
  test checks.
- #388's tests take main's capture_into_graph stream, the arena's
  owners/is_sealed, and swap the KV manager's arena (now a read-only Rust
  object) rather than patch it.
- test_glm52_capture_gpu's harness ends decode at Glm52Model.get_loop_caps, as
  the runtime does: since #857 check_stop stops on EOS only, so main's own
  copy of this test streams 15 tokens for 12 and fails.
vasilevklart added a commit that referenced this pull request Nov 3, 2026
Brings main in through #388's merge. Conflicts:
- recurrent/pool.py: main's one admit critical section, version bumps and
  per-dtype _foreach_zero_ cache (_slot_views), with #389's pre-planned
  reservation guard, the _release helper (now bumping the version) and
  per-slot blocks; #389's slot generator is renamed _slot_rows.
- runner.py: main's own refused-admit stage drop, which is #389's fix.
- convenience.py: main deletes RaggedAttentionCallable (#858); KDACallable stays.
- rid_table.py, adding_models.rst, test_admit_rollback.py: both sides.

Also, to fit main:
- KDA's eager layout upload copies through its stager into a fresh tensor,
  since PinnedStager no longer has to_device.
- #389's tests ingest a request once (main's worker does, #725).
- test_worker_node_metadata runs on the Rust graph runtime, main's only one,
  and stubs what main's Worker.__init__ reads.
- main's test_chunked_prefill engine fake sets _tp_follower_nodes, which the
  follower-refusal exit reads.

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants