Skip to content

fix(MODEL-TEXT-GLM4-MOE-LITE-ROUTER-F32): GLM's router is fp32 because Glm4MoE says so, not because a config key does - #2946

Merged
mudler merged 7 commits into
mainfrom
row/MODEL-TEXT-GLM4-MOE-LITE-ROUTER-F32
Sep 5, 2026
Merged

fix(MODEL-TEXT-GLM4-MOE-LITE-ROUTER-F32): GLM's router is fp32 because Glm4MoE says so, not because a config key does#2946
mudler merged 7 commits into
mainfrom
row/MODEL-TEXT-GLM4-MOE-LITE-ROUTER-F32

Conversation

@localai-org-maint-bot

@localai-org-maint-bot localai-org-maint-bot commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Glm4MoeLiteForCausalLM (GLM-4.7-Flash) computed its MoE router logits in
bf16. Upstream computes them in fp32, unconditionally, and it does so
because GLM's MoE block is a different class from DeepSeek-V2's — not because
of anything in the config.

The divergence, read at the pin the goldens were captured on (5559679229)

Upstream Glm4MoeLiteForCausalLM does not use DeepseekV2MoE. Its MoE block is
Glm4MoeLite, a bare subclass of Glm4MoE (glm4_moe_lite.py:86-87,
instantiated at :161-165), whose gate is
nn.Linear(hidden_size, n_routed_experts, bias=False, dtype=torch.float32)
(glm4_moe.py:141-146), fed hidden_states.to(dtype=torch.float32) (:218),
declared router_logits_dtype=torch.float32 (:205). No config key
participates.

DeepseekV2MoE resolves the same dtype from the config instead —
GateLinear(..., out_dtype=_get_moe_router_dtype(config))
(deepseek_v2.py:308-314, :123-133) — which returns fp32 only for
model_type == "glm_moe_dsa" or an explicit moe_router_dtype: "float32".

This port composes the DeepSeek-V2 forward and loader for GLM, so GLM's dtype
came from deepseek_v2_weights.cpp:332 and deepseek_v2.cpp:363 sized the logit
buffer from it. The published zai-org/GLM-4.7-Flash config.json declares no
moe_router_dtype and its model_type is glm4_moe_lite, so the flag resolved
false.

The change

ParseGlm4MoeLiteParams in the GLM registry TU composes
ParseDeepseekV2Params(config, allow_mtp_tail=true) and then sets
router_dtype_is_f32 = true, which is what Glm4MoeLite being Glm4MoE means
in a port that composes them. All three GLM registry hooks —
parse_config, load_weights, make_kv_cache — resolve through it, so the
params the forward reads cannot drift from the params the config hook validated.

ParseDeepseekV2Params is untouched and still does not read model_type, which
deepseek_v2_weights.cpp:321-330 argues for and is right about: that parser
serves DeepseekV2ForCausalLM. GLM-5.3 already carries its own answer the same
way (glm_moe_dsa.cpp:353).

Measured, not argued

deepseek_v2.h:164-168 says "a token gate cannot see this either way". That
holds for a store that is merely too WIDE. It does not hold for one that is too
NARROW in front of a top-k, and GLM routes top-4 of 64 with noaux_tc
sigmoid scoring plus an e_score_correction_bias, norm_topk_prob: true and
routed_scaling_factor: 1.8. New case T3 drives vt::MoeRouterTopK at exactly
that configuration:

bf16 router store changed the top-4 SET for 34/4096 tokens (0.830078%)

A changed SET is a different expert's weights running. It is bimodal, not a
tolerance. Two more measurements from the same file:

router dtype observable in the GLM forward: 6/16 seeds differ, worst |f32 - bf16| logit = 0.00100262
separating checkpoint seed = 61

RED and GREEN, both counts

Built -O0, CPU only (this host has no CUDA toolkit), at c796fea41.

Before the one-line repair, whole file in place:

[doctest] test cases:  5 |  2 passed | 3 failed | 0 skipped
[doctest] assertions: 23 | 20 passed |  3 failed

After:

[doctest] test cases:     5 |     5 passed | 0 failed | 0 skipped
[doctest] assertions: 16085 | 16085 passed | 0 failed

Mutations

Every one rebuilt (BUILD_RC=0 each time, so nothing here was caught by the
compiler instead of by a test) and restored, each restore verified by
sha256sum -c against a baseline taken before the first mutation.

# Mutation Result
M1 remove p.router_dtype_is_f32 = true; RED 5 | 2 passed | 3 failed, assertions: 23 | 20 passed | 3 failed
M2 delete the production loader call site weights.params = ParseGlm4MoeLiteParams(config); RED 5 | 4 passed | 1 failed, 2 assertions — only T4, reporting the registry's output as the bf16 arm
M3 remove the bf16 round trip T3 rests on RED, SET differences fall to 0/4096
M4 make T2's second arm f32 as well RED, 0/16 seeds differ, worst 0

M2 is the reachability result. T4 goes in through ModelRegistry::Load over a
synthetic GLM-4.7-Flash checkpoint written to disk and ModelRegistry::Forward
on its default configuration (gather_logits true, so the device-resident arm),
and compares the registry's own logits to the two arms built from the same bytes
by the same loader. Without the loader call site it reds while T1, T2 and T3 stay
green, which is exactly what that call site owes.

Regression

suite result
test_glm4_moe_lite_router_dtype 5/5, 16085 assertions
test_glm4_moe_lite_load 3/3, 15 assertions
test_deepseek_v2_forward 12/12, 2061 assertions
test_deepseek_v2_load 4/4, 14 assertions
test_ops_moe_router_grouped 14/14, 941 assertions

DeepseekV2ForCausalLM is byte-identical: ParseDeepseekV2Params is unchanged.

What this does NOT claim

It does not claim the SACRED gate is fixed. Glm4MoeLiteForCausalLM matches
the pinned oracle at 69 of 128 positions (#2839, PR #2906), and no assertion in
test_glm4_moe_lite_paged_engine.cpp is touched here. That gate compares a
frozen our_ids.npy, so no forward change can move it from this host at all;
only a re-capture on a GPU with the checkpoint can. No zai-org/GLM-4.7-Flash
snapshot exists on this box or on the NAS.

T3's 0.83% is per MoE layer per token on N(0,1) synthetic logits. Taken at face
value across GLM-4.7-Flash's 46 MoE layers it would put a changed selection on
roughly a third of token positions, which is the order of the observed 59
divergent positions — but the real logit distribution is not that one, and
nothing here measures the checkpoint. The spec records it under ## Owed O1.

Upstream anchors were read at 5559679229, the goldens' own pin and the PRIOR
parity pin, not at the active e126687a9a. One token gate has now run at the
active pin and passed (b55f63ed6, merged in here), but it is a different
model's battery and no Glm4MoeLiteForCausalLM golden has been re-captured
there. Owed as O5.

Preflight, and the two gates that are red for reasons this change did not cause

scripts/agent-preflight.sh --staged exits 1. Neither failure is caused by this
change, and the exit code is not being read as the verdict either way:

  • test_cpu_x86_llamacpp_floortest_a_contended_leg_is_discarded_and_never_summarised
    expected rc 2 and got 4 while the box was at load=75.28 76.77 62.18 from
    other sessions' builds; the harness discarded the contended leg and ran out of
    retries. Re-run standalone on this same tree: Ran 10 tests ... OK.
  • tools suitestest_oracle_pin.test_metadata_and_runtime_strings_differ_on_the_pin
    asserts VLLM_DISTRIBUTION_VERSION != VLLM_ORACLE_VERSION, and both now read
    0.28.1rc1.dev132+ge126687a9. Those constants come from .agents/upstream-sync.md
    through tools/bench/serve_low_common.py:107-108, neither of which this change
    touches, so it is a property of origin/main. Already open and owned as
    #2931.

Five gates SKIP for want of a cross-compiler, a CUDA toolkit or a CI-only input.
check-pr-size.py is one of them and was run by hand:
OK: every explicit path class is within its review budget.

origin/main was merged in at 15 commits (e23eeaa94) and the row's gate re-run
on the merged tree: identical counts, identical numbers. That merge falsified one
sentence of this pull request's own prose — the active-pin claim above — which is
repaired in a5b4830ea rather than left standing.

Filed on the way, owned rather than deferred

  • #2929neartie_gap_mnats.npy
    is identically zero in 12 of 18 golden directories while our_ids diverges
    from greedy_ids at 13 to 83 positions in each. At a first divergence our
    prefix equals the oracle's, so the oracle's teacher-forced argmax there IS the
    golden token and the gap must be positive. Established from the committed
    artifacts alone; owned under this spec's ## Owed O3.
  • #2930routed_scaling_factor
    is applied to the routing weights, where upstream applies it to the routed
    output. A recorded deviation whose only gate vehicle had a factor of 1.0, which
    made it bit-identical there; GLM's 1.8 makes it live. ## Owed O4.

Closes #2928.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [claude-code]

…ss property upstream, and our port reads it from a config key GLM does not ship

`Glm4MoeLiteForCausalLM`'s MoE block is `Glm4MoeLite`, a bare subclass of
`Glm4MoE`, whose gate is an fp32 `nn.Linear` fed fp32 activations with no config
key in the path (glm4_moe.py:141-146,205,218 at 5559679229). Our port composes
the DeepSeek-V2 block, whose router dtype comes from `moe_router_dtype`, and the
published GLM-4.7-Flash config.json does not declare it — so our router logits
round to bf16 in front of a top-4-of-64 `noaux_tc` selection.

The spec lands before the implementation so the commit order proves it, and it
records plainly that this host can neither run the repaired forward against the
oracle nor move the frozen `our_ids.npy` the SACRED gate compares.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [claude-code]
…e Glm4MoE says so, not because a config key does

`Glm4MoeLiteForCausalLM` composes the DeepSeek-V2 forward and loader in this
port, so its MoE router logit dtype came from `ParseDeepseekV2Params`, which
mirrors `_get_moe_router_dtype` and resolves fp32 only for
`model_type == "glm_moe_dsa"` or an explicit `moe_router_dtype: "float32"`. The
published `zai-org/GLM-4.7-Flash` config.json declares neither, so our router
logits were rounded to bf16 before the top-k.

Upstream `Glm4MoeLiteForCausalLM` does not use `DeepseekV2MoE` at all. Its MoE
block is `Glm4MoeLite`, a bare subclass of `Glm4MoE` (glm4_moe_lite.py:86-87,
:161-165 at 5559679229), whose gate is
`nn.Linear(hidden_size, n_routed_experts, bias=False, dtype=torch.float32)`
(glm4_moe.py:141-146) fed `hidden_states.to(dtype=torch.float32)` (:218) and
declared `router_logits_dtype=torch.float32` (:205). No config key participates.

`ParseGlm4MoeLiteParams` is the one place that says so, and all three GLM
registry hooks resolve through it, including the loader, so the params the
forward reads cannot drift from the params the config hook validated.

The measurement, not the argument: at GLM's real router configuration — 64
experts, top-4, `noaux_tc` sigmoid with the correction bias, `norm_topk_prob`,
`routed_scaling_factor` 1.8 — rounding the logits to bf16 changes the selected
top-4 SET for 34 of 4096 tokens. That is a different expert's weights running,
which no tolerance absorbs, and it is what `deepseek_v2.h:164-168`'s "a token
gate cannot see this either way" does not cover: that sentence is about a store
that is too WIDE.

This does not claim the 59-position SACRED gap is closed. No GLM-4.7-Flash
snapshot is on this host or the NAS, the committed `our_ids.npy` is frozen, and
the spec records both under `## Owed`.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [claude-code]
…ured, including the 34/4096 selection flips

The spec claimed the bf16 router store is a discrete selection error; the tests
now say by how much. At GLM's real router configuration the store changes the
top-4 SET for 34 of 4096 tokens, the dtype is observable through the forward at
6 of 16 fixed seeds, and the production load-and-forward path carries the f32
arm byte for byte.

The mutation table records that every mutation rebuilt clean, so none of them was
caught by the compiler rather than by a test, and that deleting the loader call
site reds only the reachability case.

It also names the revision the upstream anchors were read at, `5559679229`,
which is the goldens' pin and not the active `e126687a9a`, and owes the re-read
as O5 rather than letting the reader infer it.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [claude-code]
…om the committed artifacts, and say what it rules out

The per-prompt mismatch counts [2,6,6,12,12,11,10,0] looked like a distribution
with structure; they are almost entirely `16 - first divergent index`, because
after the first divergence the sequences barely re-converge. So the only real
variable is WHERE each prompt first diverges, and that localises without a
checkpoint.

The absolute positions are 19, 10, 20, 16, 10, 15, 12 — no shared page, block or
layer boundary, and no dependence on prompt length — which is evidence against a
positional or structural defect. No prompt diverges before generated index 4,
which a small numerical perturbation predicts and a structural bug does not, and
the one prompt that never diverges is the memorised quotation.

This supports the router-dtype repair without claiming it is the whole gap; O1
still owes the re-capture.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [claude-code]
…light gates instead of calling the run green

`scripts/agent-preflight.sh --staged` exits 1 here. One failure is the documented
load flake in the x86 floor harness, which passes standalone on this same tree
with 10 of 10 OK. The other is `test_oracle_pin` asserting that the distribution
and runtime version strings differ, which they no longer do; both constants come
from files this change does not touch, so it is a property of `origin/main` and
is already owned as #2931.

Recording the reasons beside the failures is what stops a later reader taking
"preflight exits 1" as a verdict on this change, and stops this session taking
the exit code as a pass.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [claude-code]
Fifteen commits, none of them touching `glm4_moe_lite_registry.cpp`,
`deepseek_v2_weights.cpp` or the GLM goldens. `tests/CMakeLists.txt` auto-merged
because the new registration and main's additions are in different blocks.

One of them bears directly on this row's prose and is the reason this merge is
read rather than taken: `b55f63ed6` records that a token gate HAS now run at the
active pin `e126687a9a`. The spec still says the upstream anchors here were read
at `5559679229`, which stays true and is what O5 owes; what changes is that the
pin is no longer entirely unmeasured, so the spec must not imply otherwise.

The row's gate is re-run on the merged tree, because a merge has falsified a
pull request's own prose in this repository before.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [claude-code]
…pec's own claim about the active pin, so repair it

The spec said no gate has run at `e126687a9a`. `b55f63ed6`, merged in the commit
before this one, records that one has: job 7386f034 on GB10, 2026-09-04,
`IDS mismatched_positions 0 of 96`, `TOKENGATE_VERDICT PASS`.

That gate is a different model's battery, so what it changes is the sentence and
not the obligation: no `Glm4MoeLiteForCausalLM` golden has been re-captured at
the active pin, and O5 still owes the re-read of `glm4_moe.py` there.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [claude-code]
@mudler
mudler merged commit a80f16c into main Sep 5, 2026
21 of 27 checks passed
mudler added a commit that referenced this pull request Sep 5, 2026
…and #2946

`main` advanced by one commit while this branch's gate ran --
`1043101d3 docs(ENG-ATTENTION-WINDOW): reopen W2 and correct a Now section that
claimed both sides`. It is a documentation correction on an unrelated row and
touches none of the three pull requests' files: neither `glm4_moe_lite*`, nor
the gfx1151 arm spec and its evidence directory, nor the portq reconcile
record. The merge is clean and nothing it brings in reads on what this branch
asserts.

THE GATE IS NOT GREEN, AND THIS IS WHAT IS RED.

`scripts/agent-preflight.sh` reported `1 gate(s) failed: test_cpu_x86_llamacpp_floor`,
in `test_a_contended_leg_is_discarded_and_never_summarised`, which asserts the
harness exits 2 and observed 4.

Those are two different stop conditions, not a near-miss.
`scripts/cpu-x86-llamacpp-floor.sh:336` exits 2 on `GIVING_UP too many discards`
after 24 attempts, which is what the case forces with `FOREIGN_MAX="-1"`. Line
`:127` exits 4 on `NO_QUIET_WINDOW`, meaning the box never got quiet enough to
START a leg. The case can therefore stop for a second legitimate reason it does
not admit.

THIS CHANGE DID NOT CAUSE IT, and that was established rather than assumed:

- `tests/scripts/test_cpu_x86_llamacpp_floor.py` is BYTE-IDENTICAL to
  `origin/main`; `git diff --stat origin/main HEAD -- <path>` is empty.
- `scripts/cpu-x86-llamacpp-floor.sh`, the harness the case actually executes,
  is BYTE-IDENTICAL to `origin/main` too.
- Two of the three pull requests touch no compiled code at all; the third
  touches `glm4_moe_lite_registry.cpp`, one new header and one new test.
- The whole file passes on rerun on this exact tree: `10 passed in 263.56s`.

THE CASE IS INTERMITTENT AND THE CAUSE IS NOT ESTABLISHED. My first reading was
that host load causes it: the failure carries the host's own load average,
`32.35 35.66 34.85`, taken while four sessions ran `agent-preflight.sh`
concurrently. The rerun refutes load as the cause -- it passed 10 of 10 at load
`60.46` falling to `45.73`, HIGHER than the load it failed at. So the mechanism
I first proposed does not survive its own control, and #2978 carries that
correction rather than the tidier story. The `builders` gate is ruled out:
`pgrep -x "no-such-process-name"` matches nothing and every captured line reads
`builders=0`.

What survives is enough to land on. The case is intermittent on a tree
byte-identical to `main` in both the test and the harness, so whatever the
trigger is, this change cannot be it. Nothing was weakened to get here: no
assertion deleted, no scope widened, and no retry-until-green -- the one failing
run and the eleven passing ones are all reported, and the gate is filed as #2978
rather than silenced.

`check-pr-size.py` is one of five gates preflight SKIPS because it cannot supply
`--base`/`--head` (#2826). Run by hand against `origin/main` it returns
`OK: every explicit path class is within its review budget`. The other four need
`--compile-commands` or `--vendored-root`; `tree-compiles` covered the two
translation units this change reaches.

FOLLOWING_AGENTS_PROTOCOL

Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5-1m [claude-code]
@localai-org-maint-bot

Copy link
Copy Markdown
Collaborator Author

Landed on main by local operator merge at f798f621d — this PR's own merge commit is a80f16cba, which carries the reasoning as its message.

The gate was NOT green and the landing commit says so rather than implying otherwise: test_cpu_x86_llamacpp_floor's contended-leg case failed once (exit 4 NO_QUIET_WINDOW against the asserted exit 2 GIVING_UP). That case is byte-identical to main, as is the harness it executes, and the whole file passed on rerun (10 passed in 263.56s) — at a HIGHER load than it failed at, which refutes the host-load explanation I first reached for. Tracked as #2978 with that correction; nothing was weakened and no gate was re-rolled until green.

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.

Glm4MoeLiteForCausalLM computes its MoE router in bf16; upstream Glm4MoE's gate is unconditionally fp32

2 participants