quant: include MTP/NextN block when counting FFN layers (GLM-5.2)#24832
Closed
Deviad wants to merge 9 commits into
Closed
quant: include MTP/NextN block when counting FFN layers (GLM-5.2)#24832Deviad wants to merge 9 commits into
Deviad wants to merge 9 commits into
Conversation
Fixes a quantization-time abort on models that carry a trailing
Multi-Token-Prediction / NextN block (e.g. GLM-5.2), where llama-quantize
fails with:
Bad layer 78 for tensor blk.78.ffn_down_shexp.weight. Must be in [0, 78)
Root cause
----------
In init_quantize_state_counters() the per-FFN layer counters were seeded
from hparams.n_layer():
qs.n_ffn_down = qs.n_ffn_gate = qs.n_ffn_up = n_layer();
But n_layer() is defined as:
n_layer() = n_layer_all - n_layer_nextn
i.e. it deliberately excludes the MTP/NextN block(s). For GLM-5.2 that
means n_layer_all = 79, n_layer_nextn = 1, so n_layer() = 78 and the
counters are set to 78.
The NextN block is still a real block in the file: it owns FFN tensors
named blk.78.ffn_{down,gate,up}_shexp.weight. When the quantizer reaches
those tensors, layer_info() parses the layer index out of the tensor
name (78) and bounds-checks it against the counter with
i_layer >= n_layer. Since 78 >= 78, it throws "Bad layer ...", aborting
the whole quantization.
Fix
---
Seed the FFN counters from n_layer_all instead of n_layer() so the
NextN block's layer index (== n_layer) is in range:
qs.n_ffn_down = qs.n_ffn_gate = qs.n_ffn_up = n_layer_all;
This only widens the valid [0, N) interval used for the name-based
bounds check; it does not change which tensors are selected for the
"more bits in the first/last eighth" heuristic in any way that affects
the standard (non-NextN) layers, and it lets the NextN FFN tensors be
quantized normally instead of aborting.
Refs: llama.cpp issue ggml-org#24379
Author
|
Here a couple of scripts I used. #!/usr/bin/env bash
# Build a fresh llama.cpp from master with the glm-dsa arch + quantize tool.
# The homebrew llama.cpp (v9200) predates GLM-5.2 support and cannot load glm-dsa.
set -euo pipefail
SRC="${LLAMA_SRC:-$HOME/projects/llama.cpp}"
BUILD="$SRC/build-metal"
JOBS="${JOBS:-$(sysctl -n hw.ncpu)}"
echo "==> llama.cpp source: $SRC"
if [[ ! -d "$SRC/.git" ]]; then
git clone --depth 1 https://github.com/ggml-org/llama.cpp "$SRC"
else
echo "==> updating existing clone to master"
git -C "$SRC" fetch --depth 1 origin master
git -C "$SRC" checkout master
git -C "$SRC" reset --hard origin/master
fi
# Verify the clone actually has GLM-5.2 support before building.
if ! grep -rq "GlmMoeDsa" "$SRC/conversion/glm.py" 2>/dev/null; then
echo "FATAL: $SRC/conversion/glm.py has no GlmMoeDsa converter." >&2
echo " GLM-5.2 support is missing. Check your checkout." >&2
exit 1
fi
echo "==> GlmMoeDsa converter present in source ✓"
echo "==> configuring cmake (Metal enabled, release) -> $BUILD"
cmake -S "$SRC" -B "$BUILD" \
-DCMAKE_BUILD_TYPE=Release \
-DGGML_METAL=ON \
-DLLAMA_CURL=OFF \
-DBUILD_SHARED_LIBS=OFF
echo "==> building llama-quantize + llama-gguf-split ($JOBS jobs)"
cmake --build "$BUILD" --config Release -j "$JOBS" \
--target llama-quantize llama-gguf-split
LQ="$BUILD/bin/llama-quantize"
echo "==> built: $LQ"
"$LQ" --help 2>&1 | head -1
# Sanity: confirm this binary recognizes glm-dsa.
if strings "$LQ" 2>/dev/null | grep -qi "glm.moe.dsa\|glm-dsa"; then
echo "==> glm-dsa arch recognized in binary ✓"
else
echo "WARNING: glm-dsa string not found in binary — quantize may still work" >&2
fi
echo "==> DONE. llama-quantize ready at: $LQ"#!/usr/bin/env bash
# Quantize GLM-5.2 to a custom mixed precision:
# routed-expert MLP weights -> 2-bit (IQ2_S)
# everything else -> 4-bit (IQ4_NL base, preserved)
#
# Source: the existing Unsloth UD-IQ4_NL (9 shards, 347 GB).
# Output: new sharded GGUF (~9 shards, est ~240-260 GB).
set -euo pipefail
LLAMA_SRC="${LLAMA_SRC:-$HOME/projects/llama.cpp}"
LQ="$LLAMA_SRC/build-metal/bin/llama-quantize"
IN_DIR="/Volumes/Data NVME/GLM-5.2-GGUF/UD-IQ4_NL"
IN_SHARD="$IN_DIR/GLM-5.2-UD-IQ4_NL-00001-of-00009.gguf"
# Unsloth's importance matrix (used to create the UD-IQ4_NL source). Required
# for IQ2_* quant types. Downloaded from unsloth/GLM-5.2-GGUF repo root.
IMATRIX="/Volumes/Data NVME/GLM-5.2-GGUF/imatrix_unsloth.gguf"
OUT_DIR="/Volumes/Data NVME/GLM-5.2-GGUF/GLM-5.2-mixed-IQ2S-experts-IQ4NL-rest"
OUT_PREFIX="$OUT_DIR/GLM-5.2-mixed"
TENSOR_TYPES="/Volumes/Data NVME/GLM-5.2-GGUF/glm52_tensor_types.txt"
# --- 2-bit variant: override here if you want a different one ---
# IQ2_S (2.56 bpw) <-- default, robust without imatrix
# IQ2_M (2.66 bpw) best 2-bit quality
# IQ2_XS (2.31 bpw) smaller; benefits from imatrix
# IQ2_XXS (2.06 bpw) smallest; benefits from imatrix
TWO_BIT="${TWO_BIT:-IQ2_S}"
BASE_BIT="${BASE_BIT:-IQ4_NL}"
NTHREADS="${NTHREADS:-28}"
if [[ ! -x "$LQ" ]]; then
echo "FATAL: $LQ not found. Run ./build_llamacpp.sh first." >&2
exit 1
fi
if [[ ! -f "$IN_SHARD" ]]; then
echo "FATAL: input shard not found: $IN_SHARD" >&2
exit 1
fi
# Regenerate the tensor-type file with the chosen 2-bit variant.
# IMPORTANT: llama.cpp applies tensor-type rules as regex_search() and FIRST
# MATCH WINS. Unsloth's imatrix has no entries for the MTP/NextN expert tensors
# in blk.78, so keep those at the high-precision base type and put those more
# specific rules before the generic routed-expert rules.
cat > "$TENSOR_TYPES" <<EOF
blk\\.78\\.ffn_down_exps=$BASE_BIT
blk\\.78\\.ffn_gate_exps=$BASE_BIT
blk\\.78\\.ffn_up_exps=$BASE_BIT
ffn_gate_exps=$TWO_BIT
ffn_up_exps=$TWO_BIT
ffn_down_exps=$TWO_BIT
EOF
mkdir -p "$OUT_DIR"
echo "==> source : $IN_DIR (9 shards, IQ4_NL)"
echo "==> output : $OUT_DIR"
if [[ ! -f "$IMATRIX" ]]; then
echo "FATAL: imatrix not found: $IMATRIX" >&2
echo " download: curl -L -o '$IMATRIX' https://huggingface.co/unsloth/GLM-5.2-GGUF/resolve/main/imatrix_unsloth.gguf_file" >&2
exit 1
fi
echo "==> mapping: experts(3 fragments) -> $TWO_BIT | rest -> $BASE_BIT"
echo "==> imatrix: $IMATRIX"
echo "==> threads: $NTHREADS keep-split: yes allow-requantize: yes"
echo ""
time "$LQ" \
--allow-requantize \
--keep-split \
--imatrix "$IMATRIX" \
--tensor-type-file "$TENSOR_TYPES" \
"$IN_SHARD" \
"$OUT_PREFIX.gguf" \
"$BASE_BIT" \
"$NTHREADS"
echo ""
echo "==> DONE. Output shards:"
ls -lh "$OUT_DIR"/*.gguf
du -sh "$OUT_DIR"
|
This was
linked to
issues
Jun 20, 2026
Co-authored-by: Sigbjørn Skjæret <1629204+CISC@users.noreply.github.com>
examples/trace-moe/trace-moe.cpp: - C++ tracer that captures ffn_moe_topk/ffn_moe_weights per (token, layer) - Outputs compact JSONL records + .meta.json sidecar (schema v1) - --trace-* flags pre-scanned from argv; strips -cnv/-st/--jinja/--chat-template-kwargs (LLAMA_EXAMPLE_COMMON rejects them; tracer tokenizes params.prompt verbatim) - Backpressure: bounded async queue with block/drop/sample modes - Eval callback pairs topk(I32)+weights(F32) per layer; per-token routing events with softmax router entropy - Readback: ALWAYS ggml_backend_tensor_get into fresh per-call buffer (never t->data directly even when is_host=true) — fixes Metal stale-read garbage during fast single-token generation decodes - Flat index via pending topk's n_used (weights tensor is 3D ne=[1,n_used,n_tokens] with degenerate dim0; bytes contiguous in [n_used,n_tokens] frame) examples/CMakeLists.txt: register trace-moe after eval-callback Validated against 232GB mixed GLM-5.2: 32 layers, 8 experts/event, entropy 2.5-3.0 bits, 0 bad records in prefill+generation, 0 dropped.
Add --trace-prompts <file.jsonl>: each line is a PromptSpec
{prompt, task_label, language, script, prompt_family, test_id}.
The model/context/sampler are loaded ONCE; for each prompt the tracer resets
per-prompt counters, reopens the writer, tokenizes+prefills+generates, writes
the .jsonl + .meta.json sidecar, then llama_memory_clear() wipes the KV cache
before the next prompt. Output naming <dir>/<test_id>-<language>.jsonl avoids
collisions when the same test_id is traced across languages.
Two bugs found and fixed while validating batched mode:
- TraceWriter.open() now resets stop=false before spawning the writer thread;
previously a previous close()'s stop=true made the new thread exit on an
empty queue, dropping every record of prompt N>1 (0 written).
- Per-prompt metadata (task_label/language/script/test_id) is now written into
st.cfg before tracing so render_record emits the correct per-prompt language
(records previously inherited the single cfg.language='en' for all prompts).
Validated: 7x7 multilingual study (49 prompts) -> 180457 records, 0 dropped,
75 layers, ~12 min (6.6x faster than the per-prompt-reload wrapper).
nlohmann/json (vendor) used for PromptSpec JSONL parsing.
Story 9 AC: the .meta.json sidecar previously wrote placeholder strings for
two critical provenance fields:
"command_line": "llama-trace-moe ...", # truncated placeholder
"prompt_sha256": "(see run log)", # placeholder, never computed
Replaced with real values plus several new model provenance fields:
command_line - real joined argv (was placeholder string)
prompt_sha256 - real SHA-256 of params.prompt (was '(see run log)')
model_sha256_prefix - first 1 MiB, 16 hex chars (new; cheap provenance)
model_size_bytes - per-shard size (was missing)
model_total_size_bytes - sum across all sibling shards (new; for GLM-5.2
multi-shard, this is 232 GiB; per-shard shard 1 looked misleadingly tiny
at 9.4 MiB because it only has the GGUF header)
started_at / ended_at - ISO 8601 UTC timestamps (was missing)
SHA-256 is a self-contained FIPS-180-4 impl in this file (~80 LoC) so there
is no external crypto dependency. Multi-shard total size globs sibling shards
via regex on the GGUF stem; single-shard models skip the field.
(prompt_sha256 hashes the UTF-8 bytes of params.prompt -- whether the prompt
came from -p or was loaded into params.prompt from -f, this is the actual
text the tokenizer saw verbatim; the tracer does not apply a chat template,
so chat-template hashing is not needed.)
Build verified warning-clean on macOS Metal backend.
Story 8 AC 8.3 (missing experts in compare reports): there is no public llama
API for n_expert_total. The hparams.n_expert field is private/experimental.
But the GGUF KV '<arch>.expert_count' is always written by llama_model_saver
and is unique per GGUF file (one arch per file). Read it via public API:
#include "gguf.h"
gguf_init_params params = { .no_alloc = true, .ctx = nullptr };
gguf_context * gctx = gguf_init_from_file(model_path, params);
// iterate n_kv keys, match suffix ".expert_count", read u32
For GLM-5.2 this returns 256 (matches the verified expert ID range 0..255
observed in real traces). Populated once at startup in main() since it's a
global per-model value (all MoE layers in one GGUF share the same
expert_count). Each routing record's existing 'n_expert' field (already
gated on n_expert_total > 0 in render_record) now emits per-event. Sidecar
also carries top-level n_expert_total.
Story 8 AC 8.4 (speed metrics): llama_perf_context(ctx) and
llama_perf_context_reset(ctx) are public (include/llama.h line ~1542-1544).
llama_perf_context_data holds t_p_eval_ms/t_eval_ms/n_p_eval/n_eval.
perf_reset zeroes prompt/gen perf but preserves t_load_us.
run_one_prompt now calls llama_perf_context_reset(ctx) at start of each
prompt (batched-mode timings don't leak across prompts) and reads
llama_perf_context(ctx) after the decode loop to compute:
perf_prompt_eval_per_sec = n_p_eval * 1000 / t_p_eval_ms
perf_gen_per_sec = n_eval * 1000 / t_eval_ms
Sidecar emits both per-sec rates plus raw perf_*_ms and perf_n_* counters
for downstream debugging.
Verified against real GLM-5.2 mixed GGUF (12-token smoke):
n_expert_total=256, perf_gen_per_sec=0.9171, perf_n_eval=1,
perf_n_prompt_eval=10, perf_prompt_eval_per_sec=6.2278
Each routing record now carries "n_expert":256. Build verified warning-clean
on macOS Metal backend.
Closes Story 8 ACs 8.3 and 8.4. With Phase 1 / Phase 2b already complete,
all actionable Phase 1+2 tracer ACs are now closed; the remaining 9 open
ACs are hard-gated future-phase work (Story 5 DSA: Phase 3 blocked on REAP37
IndexShare unblock; Story 6 activation summaries: Phase 4).
Implements the C++ tracer side of Story 6's activation-summary ACs: - AC 6.1 top-K channels per selected tensor: render_activation_record emits top_k_channels as [[channel_idx, magnitude], ...] sorted desc by |magnitude|, computed per-token via an O(n_channels log topk) min-heap (NOT an O(N²) partial_sort per token — 6144-channel prefill would dominate otherwise) - AC 6.2 norm/stat summaries per layer: l2_norm / mean / std / max_abs per token, computed in single forward pass over channels via Welford-equivalent sums - AC 6.3 sampled by token and layer: --trace-activation-stride N emits for every Nth layer only (default 2 → half volums), pairs with --trace-max-tokens for per-phase token budget - AC 6.4 schema distinguishes event types: event:"activation_summary" alongside existing event:"moe_topk" - AC 6.5 full activation dumps disabled by default: --trace-activations is opt-in; trace_cb_eval returns early when st->activation_stems is empty. New TraceConfig fields: trace_activations (comma-separated stems), trace_activation_topk (default 10), trace_activation_stride (default 2). CLI flags --trace-activations / --trace-activation-activation-topk / --trace-activation-stride pre-scanned by config_from_trace_flags so common_params_parse doesn't choke on unknown args. Sidecar (.meta.json) carries activation_stems / activation_topk / activation_stride when --trace-activations is set; absent otherwise so the analyzer can detect. trace_cb_eval dispatches: if is_activation_tensor(name, st.activation_stems, matched_stem) returns true, compute stats and push render_activation_record; do not fall through to the routing-event path. The two event types coexist in the same JSONL. is_activation_tensor predicate is tight: matches '<stem>-N' exactly where - stem is in the configured stems vector - N is an integer (avoids false positives like 'l_out_perm-3') Build verified warning-clean on macOS Metal. Verified end-to-end against real GLM-5.2 mixed GGUF: 4 prefill + 4 gen tokens with --trace-activations l_out --trace-activation-topk 5 --trace-activation-stride 4 → 2 routing records + 6 activation_summary records (1 per (layer, token) on layers 0, 4). Channel ggml-org#4386 came up top in both layer groups — first real semantic hint from bounded activation summarization on the real model. All 87 Python tests still pass.
Adds ShortGPT-style Block Influence scoring to the MoE tracer. BI = 1 - cos(h_in[t], h_out[t]) where h_in is the previous layer's l_out residual and h_out is the current layer's l_out residual, both vector copies for the same token. Implementation: - render_bi_record(): emits 'block_influence' JSONL record (schema v1) with cos_sim + bi_score fields, alongside activation_summary. - TraceState.prev_l_out_per_token: per-token previous-layer l_out residual cache (~24 KB/token at n_channels=6144). Cleared between prompts in run_one_prompt. - trace_cb_eval: when l_out fires for token t and layer N>0, look up prev_l_out_per_token[t]; if present (from layer N-1), compute cos_sim and emit BI record; always overwrite cache with current l_out for the next layer. Independent of --trace-activation-stride so BI is always captured when --trace-activations l_out is set; top-K activation_summary stays stride-gated. Memory cost: ~n_channels float per token currently in flight (~24 KB at n_channels=6144). Bound by n_ctx_per_token sets, not by trace record count. Phase 8 BI calibration traces (161-prompt multilingual suite) depend on this patch; 457,605 BI records captured across 161 prompts in 6.2 min wall. Used by analyze_bi_scores.py to rank layers by mean BI and emit layer-drop plans for prune_layers.py.
Member
|
Please revert latest changes and address #24832 (review) |
… + remediation Adds the DSA sparse-gather decode path (PLAN.md §7.N Part 1) in llama-graph.cpp: an opt-in, env-gated (LLAMA_DSA_SPARSE_GATHER=1) branch that gathers only the top_k KV rows selected by the indexer via ggml_get_rows and runs dense attention over that small subset (O(n_top_k·n_head) vs O(n_kv·n_head)). Default (env unset) is the unchanged masked-dense baseline. Decode-only (n_tokens==1); prefill keeps the dense masked path. Verified 1.28× faster at 53K decode (4.93 vs 3.85 tok/s) and 1.55× at short ctx, no correctness regression. See GLM52_SESSION_MEMORY.md. Also includes the prior glm-dsa forward-path override (models.h graph struct, llama-model.cpp KV-cache arm, llama-kv-cache.cpp) and zero-risk remediation cleanups: removed duplicate n_ff_exp/n_expert_shared hparam loads in glm-dsa.cpp, removed duplicate outer #define in ggml-metal.metal, added n_stream>0 assert, clarifying CMake comment, kv-cache + model loader fixes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes a quantization-time abort on models that carry a trailing Multi-Token-Prediction / NextN block (e.g. GLM-5.2), where llama-quantize fails with:
Root cause
In init_quantize_state_counters() the per-FFN layer counters were seeded from hparams.n_layer():
But n_layer() is defined as:
i.e. it deliberately excludes the MTP/NextN block(s). For GLM-5.2 that means n_layer_all = 79, n_layer_nextn = 1, so n_layer() = 78 and the counters are set to 78.
The NextN block is still a real block in the file: it owns FFN tensors named blk.78.ffn_{down,gate,up}_shexp.weight. When the quantizer reaches those tensors, layer_info() parses the layer index out of the tensor name (78) and bounds-checks it against the counter with i_layer >= n_layer. Since 78 >= 78, it throws "Bad layer ...", aborting the whole quantization.
Fix
Seed the FFN counters from n_layer_all instead of n_layer() so the NextN block's layer index (== n_layer) is in range:
This only widens the valid [0, N) interval used for the name-based bounds check; it does not change which tensors are selected for the "more bits in the first/last eighth" heuristic in any way that affects the standard (non-NextN) layers, and it lets the NextN FFN tensors be quantized normally instead of aborting.
Refs: llama.cpp issue #24379
Overview
Additional information
Requirements