Skip to content

quant: include MTP/NextN block when counting FFN layers (GLM-5.2)#24832

Closed
Deviad wants to merge 9 commits into
ggml-org:masterfrom
Deviad:feature/patch_used_to_create_mixed_quantization_of_glm5.2
Closed

quant: include MTP/NextN block when counting FFN layers (GLM-5.2)#24832
Deviad wants to merge 9 commits into
ggml-org:masterfrom
Deviad:feature/patch_used_to_create_mixed_quantization_of_glm5.2

Conversation

@Deviad

@Deviad Deviad commented Jun 20, 2026

Copy link
Copy Markdown

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 #24379

Overview

Additional information

Requirements

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
@Deviad
Deviad requested a review from ggerganov as a code owner June 20, 2026 09:07
@Deviad

Deviad commented Jun 20, 2026

Copy link
Copy Markdown
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"

CISC
CISC previously approved these changes Jun 20, 2026

@CISC CISC left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Update OP AI disclosure please.

Comment thread src/llama-quant.cpp Outdated
Deviad and others added 7 commits June 20, 2026 16:10
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.
@CISC
CISC dismissed their stale review June 21, 2026 15:31

unwarranted changes

@CISC

CISC commented Jun 21, 2026

Copy link
Copy Markdown
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.
@Deviad
Deviad requested review from a team as code owners June 24, 2026 19:49
@github-actions github-actions Bot added model Model specific server ggml changes relating to the ggml tensor library for machine learning Apple Metal https://en.wikipedia.org/wiki/Metal_(API) labels Jun 24, 2026
@CISC CISC closed this Jun 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Apple Metal https://en.wikipedia.org/wiki/Metal_(API) examples ggml changes relating to the ggml tensor library for machine learning model Model specific server

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Can't quantize Qwen 3.6 35B A3B finetune Misc. bug: llama-quantize error when quantizing MiMo V2.5 / GLM 5.1

2 participants