minfer Debug Dump Mechanism

Feature: --features debug_dump Controlled by: MINFER_DUMP_DIR environment variable

Overview

The debug_dump feature writes raw f32 binary files of hidden states and logits at specific points during inference. Combined with the scripts/compare_layers.py tool (which compares against llama.cpp reference dumps), this enables precise layer-by-layer numerical debugging.

Performance: When not enabled (cargo build --release), all dump code is eliminated at compile time — zero instructions, zero branches. When enabled, overhead is one OnceLock env var read + file write per dump point.


Dump Points

│  forward pass
│
├─ ⓪ minfer_dump_prompt.txt ─────────────────────────────── text dump, NOT a hidden state
│     file: minfer_dump_prompt.txt
│     dump: rendered chat template prompt (for validation against llama prompt)
│
├─ ① embed_out ─────────────────────────────────────────── token_embd (Q5_0 dequant) → hidden
│     file: minfer_dump_embed_out.f32
│     shape: [nt * ne]   (nt = num tokens, ne = hidden dim)
│     dump: embedding lookup output, before any transformer layers
│
└─  for each layer N (0..23):
      │
      ├─ ⑥ layer0_bn ────────────────────────────── RMSNorm(hidden, attn_norm) → bn
      │     file: minfer_dump_layer0_bn.f32  (only layer 0)
      │     shape: [nt * ne]
      │     dump: RMSNorm output, verification target for verify_rmsnorm.py
      │
      ├─ WQ matmul: bn × WQ → bq  [Q5_0 dequant]
      │     dump ⑧: minfer_dump_layer0_bq.f32 (first 32 values)
      │     verification target for verify_matmul.py
      │
      ├─ add_bias(bq) / add_bias(bk) / add_bias(bv)
      ├─ RoPE(bq, bk)
      │     dump ⑫: minfer_dump_layer0_bq_rope.f32 (bq after RoPE)
      │
      ├─ store KV cache
      ├─ GQA attention (f32)
      │     dump ⑬: minfer_dump_layer0_ba.f32 (attention output)
      │
      ├─ WO matmul: ba × WO → bn                            [Q5_0 dequant]
      ├─ residual: hidden += bn
      │
      ├─ ② layer{N}_attn_out ──────────────────────────── attention output
      │     file: minfer_dump_layer{N}_attn_out.f32
      │     shape: [nt * ne]
      │     dump: hidden state after attention branch, before FFN
      │
      ├─ RMSNorm(hidden, ffn_norm) → ffn_in
      ├─ gate/up matmul: ffn_in × Wg/Wu → bg, bf           [Q5_0 dequant × 2]
      │     dump ⑨: minfer_dump_layer0_bg.f32 (first 32 values)
      │
      ├─ SwiGLU: silu(bg) * bf → bg
      │     dump ⑭: minfer_dump_layer0_swiglu.f32 (bg after SwiGLU)
      │
      ├─ down matmul: bg × Wd → bn                          [Q5_0 / Q4_K / Q6_K dequant]
      │     dump ⑩: minfer_dump_layer0_fd.f32 (first 32 values)
      │
      ├─ residual: hidden += bn
      │
      └─ ③ layer{N}_out ───────────────────────────────── FFN output
            file: minfer_dump_layer{N}_out.f32
            shape: [nt * ne]
            dump: hidden state after full layer (attention + FFN + residuals)

   after all layers:

   ├─ RMSNorm(hidden, output_norm) → bn
   ├─ LM head: bn × output.weight → logits                 [Q8_0 matmul]
   ├─ add output_bias
   │
   ├─ ⑤ last_norm ────────────────────────────────────── post-RMSNorm hidden
   │     file: minfer_dump_last_norm.f32
   │
   └─ ④ logits ────────────────────────────────────────── final logits
         file: minfer_dump_logits.f32
         shape: [nt * n_vocab]
         dump: raw logits before sampling


   ─── Utility dumps (not layer-specific) ───

   ⑦ minfer_dump_q8_quant_verify.txt ──────────────────── Q8_0 quantize
         fired once on first quantize_row_q8_0_buf call
         dump: amax, d, x[0], x[1], x[16], q[0], q[1], q[16]
         verification target for verify_q8_quant.py

minfer Side

Feature flag

# Cargo.toml
[features]
debug_dump = []

Core module: src/dump.rs

#![allow(unused)]
fn main() {
// For float arrays (hidden states, logits)
pub fn maybe_dump(name: &str, data: &[f32])

// For text (prompts)
pub fn maybe_dump_text(name: &str, text: &str)
}
  • Both controlled by MINFER_DUMP_DIR env var
  • If not set → no-op (returns immediately)
  • If set → maybe_dump writes raw f32 bytes to {MINFER_DUMP_DIR}/{name}.f32
  • If set → maybe_dump_text writes UTF-8 string to {MINFER_DUMP_DIR}/{name}.txt
  • Uses OnceLock to cache the env var check (read once, reused)

Build & run

# Normal build — zero overhead, dump code eliminated at compile time
cargo build --release

# Debug build — dump enabled
cargo build --release --features debug_dump

# Run with CPU path + dump
MINFER_DISABLE_MPS=1 MINFER_DUMP_DIR=/tmp \
  cargo run --release --features debug_dump -- <model> "Hello"

Python Side

scripts/dump_llama_ref.py

Generates llama.cpp reference hidden states for comparison.

# Bare text (no chat template wrapping)
uv run python -m scripts.dump_llama_ref \
  --model <path-to-gguf> \
  --prompt "Hello" \
  --output ./llama_ref

# With chat template — reads tokenizer.chat_template from GGUF,
# renders with Jinja2, producing the same prompt minfer would use
uv run python -m scripts.dump_llama_ref \
  --model <path-to-gguf> \
  --prompt "Hello" --chat \
  --output ./llama_ref

Output (per layer):

FileShapeContent
layer{N}_hidden_states.npy[hidden_dim]Last-token hidden state after N layers
logits_prefill.npy[vocab_size]Final logits
token_ids.npy[seq_len]Input token IDs
prompt.txt—Rendered prompt text (for validation against minfer's minfer_dump_prompt.txt)

How it works: Uses the "truncated model" technique — creates a fake GGUF with block_count=N (metadata only, zero-copy weights), runs llama.eval(), and extracts the embedding output via llama_get_embeddings(). Since a decoder-only transformer is feed-forward, the first N layers produce identical outputs to a full model.

When --chat is specified, the script reads tokenizer.chat_template from GGUF metadata, renders it with Jinja2 using messages=[{"role":"user", "content": prompt}] and add_generation_prompt=True, producing a prompt identical to minfer's chat template rendering.

scripts/compare_layers.py

Compares minfer dumps against llama.cpp reference.

uv run python -m scripts.compare_layers \
  --llama-dir ./llama_ref \
  --minfer-dir /tmp \
  --hidden-dim 896

Layer mapping: llama layer N (N=1..24) = minfer layer N-1 (0..23). Both dump the hidden state AFTER the corresponding layer's computation.

Prompt validation: Before layer comparison, the script reads prompt.txt (from llama reference) and minfer_dump_prompt.txt (from minfer dump). If both exist and differ, the script immediately aborts with PROMPT MISMATCH — comparison aborted and sys.exit(1).

Output: Per-layer:

ColumnMeaning
minfer RMSRMS of minfer's hidden state
llama RMSRMS of llama.cpp's hidden state
ratioRMS ratio (should be ~1.000)
cosCosine similarity (≥0.999 = match)

Plus logits comparison with top token and cosine.


Full Workflow

# Step 1: Generate llama.cpp reference (one-time, ~20 min for 24-layer model)
#         --chat ensures the SAME prompt as minfer (rendered from tokenizer.chat_template)
uv run python -m scripts.dump_llama_ref \
  --model ~/.cache/minfer/models/hf/Qwen/Qwen2.5-0.5B-Instruct-GGUF/qwen2.5-0.5b-instruct-q4_k_m.gguf \
  --prompt "Hello" --chat --output ./llama_ref

# Step 2: Run minfer with debug dump (CPU path)
#         MINFER_DUMP_DIR enables all dump points including prompt
MINFER_DISABLE_MPS=1 MINFER_DUMP_DIR=/tmp \
  cargo run --release --features debug_dump \
  -- <model> "Hello"

# Step 3: Compare
#         Script validates prompt files match before comparing layers
uv run python -m scripts.compare_layers \
  --llama-dir ./llama_ref --minfer-dir /tmp --hidden-dim 896

Diagnostic Logic

Given compare_layers.py output showing first divergence at some layer, use the sequence below to isolate the bug:

Divergence patternLlama refMinfer dumpDiagnosis
Prompt mismatchprompt.txtminfer_dump_prompt.txtTemplate rendering or tokenizer differs between llama and minfer
embed_out already divergedlayer1_hiddenembed_outQ5_0 embedding dequant is wrong
layerN_out diverged, layerN_attn_out oklayer{N}_hidden vs layer{N-1}_hiddenattn_out vs layer_outFFN matmul is wrong (gate/up/down Q5_0 dequant)
layerN_attn_out already divergedlayer{N-1}_hiddenlayer{N-1}_out vs layer{N}_attn_outAttention matmul is wrong (Q/K/V/WO Q5_0 dequant)
24 layers all match, logits divergelayer24_hiddenlayer23_outoutput.weight (Q8_0) matmul is wrong
All cosine ≥ 0.999, logits match——Bug is elsewhere: sampler, or model architecture hparams mismatch

Layer numbering

llama.cpp dumpminfer dumpComputation
layer1_hidden_states.npyminfer_dump_layer0_out.f32Hidden after layer 0 (attention + FFN)
layerN_hidden_states.npyminfer_dump_layer{N-1}_out.f32Hidden after layer N-1
logits_prefill.npyminfer_dump_logits.f32Final logits

Path Verification Status (2026-07-28)

All CPU inference paths verified correct through automated cross-validation against gguf.quants.dequantize() (validated against llama.cpp C reference in gguf-py/tests/test_quants.py).

Verification Results

#PathScriptMethodResult
1Q5_0 embedding dequantverify_q5_embed.pyvs minfer_dump_embed_out.f32✅ exact match (8 values identical)
2RMSNormverify_rmsnorm.py --comparevs minfer_dump_layer0_bn.f32✅ cosine = 1.0000000000
3Q8_0 quantizationverify_q8_quant.pyvs minfer_dump_q8_quant_verify.txt✅ q[0]=q[1]=q[16] identical
4Q4_K scalar dot producttest_q4k_dot_simpleunit test✅ passing
5Q8_0 scalar dot producttest_q8k_dot_simpleunit test✅ passing
6Q6_K scalar dot productreference_dot_q6kunit test✅ passing
7Row stride (all tensors)dump_tensors.py vs matmul wsmanual✅ correct
⑧WQ matmul outputverify_matmul.py vs bq dumpdot product✅ cos = 1.0000000000
⑨FFN gate matmul outputverify_matmul.py vs bg dumpdot product✅ cos = 1.0000000000
⑩FFN down matmul outputverify_matmul.py vs fd dumpSwiGLU + dot✅ cos = 0.9999848730
⑫RoPE rotationverify_rope.py vs bq_rope dumpfreq + sin/cos✅ cos = 0.9999999942
⑬GQA attention (nkv=1)verify_attention.py vs ba dumpV lookup✅ cos = 0.9999839613
⑭SwiGLU activationmanual vs bg dumpsilu×up✅ plausible

Despite all 14 paths being verified correct, the Q5_K_M model still produces garbled output. Model file confirmed working with llama-cli. Root cause is a subtle integration issue not captured by individual verification.

Diagnostic Status (2026-07-29)

ItemStatusMethod
Q5_0 dequant formula✅gguf.quants.dequantize() (C-validated)
Weight tensor layout✅Raw bytes match Python
RMSNorm✅cosine = 1.0 against reference
Q8_0 quantization✅amax/d/q values match
Unit tests (Q4_K/Q8_0/Q6_K)✅all passing
Row stride✅formula matches GGUF physical layout
Matmul outputs (WQ/gate/down)✅cosine = 1.0
RoPE✅cosine = 0.9999999942
GQA attention✅cosine = 0.9999839613
SwiGLU✅values plausible
Residual connections✅fd contribution matches exactly
Model metadata comparison✅Q4_0 vs Q5_K_M: identical architecture
llama-cli on Q5_K_M✅produces correct output
Per-layer verification⚪inconsistent due to f32/f64 precision diff
Cross-model per-layer (Q4 vs Q5)⚪weights differ, cannot compare
KV cache integration⬜not verified
Generation loop interaction⬜not verified

Verification Method

gguf.quants.dequantize() ── GGUF file ──→ Python reference values
        │                                       │
        │ (validated against C)                  │ compare
        │                                       │
minfer's own computation  ── forward pass ──→ minfer dump (.f32 / .txt)

The Python side uses gguf.quants.dequantize() from gguf-py, which is the same implementation validated in llama.cpp's test_quants.py (quantize + dequant must be bit-exact against the C reference). This eliminates the "Python formula might be wrong" concern — the verification chain traces back to llama.cpp's C implementation.

Row Stride Verification (2026-07-28)

The hypothesis that matmul ws (computed as (id/blck_size)*type_size) diverges from the GGUF physical row stride was tested and disproven:

TensorTypeShapeGGUF row strideMatmul wsMatch
blk.0.ffn_downQ6_K[4864,896]3,9903,990✅
blk.11.ffn_downQ4_K[4864,896]2,7362,736✅
blk.0.ffn_gateQ5_0[896,4864]616616✅
blk.0.attn_qQ5_0[896,896]616616✅

The formulas are inherently consistent: both derive from (ne[0]/blck_size)*type_size.

Matmul Output Verification (2026-07-28)

All three matmul types verified correct using full forward-computation in Python, including bias and SwiGLU:

TensorTypeShapeCosineMethod
WQQ5_0[896,896]1.0000000000RMSNorm + bias
FFN gateQ5_0[896,4864]1.0000000000post-attn RMSNorm
FFN downQ6_K[4864,896]0.9999848730SwiGLU + dot product

Despite all 10 paths being verified correct, the Q5_K_M model still produces garbled output. Remaining unverified: RoPE, GQA attention, SwiGLU, KV cache, residual connections. Root cause remains unidentified.

Verification Scripts

The verification scripts in scripts/verify_*.py provide standalone Python reference implementations for independent validation of minfer's computation. All dequantization uses gguf.quants.dequantize() — the same Python implementation validated against llama.cpp's C reference in gguf-py/tests/test_quants.py (quantize + dequant must be bit-exact).

ScriptVerifiesUsage
verify_embed.pyToken embedding dequant (auto-detect type)uv run python -m scripts.verify_embed --model <gguf> --token-id <id>
verify_rmsnorm.pyRMSNorm output (bn)uv run python -m scripts.verify_rmsnorm --model <gguf> --layer 0 --token-id <id> --compare <dump>
dump_tensors.pyTensor layout (offset/n_bytes/shape/type)uv run python -m scripts.dump_tensors --model <gguf>

These read the same GGUF file as minfer, perform the same computation using the validated gguf.quants.dequantize(), and print key values for comparison with minfer dumps (under --features debug_dump).

Two additional verification paths are covered by Rust unit tests:

TestFileVerifies
test_q4k_dot_simplequants.rsQ4_K × Q8_0 scalar dot product
test_q8k_dot_simplequants.rsQ8_0 × Q8_0 scalar dot product

Output Files (minfer)

FileDump pointShapeBytes (Qwen2.5-0.5B, prompt="Hello"≈30 tokens)
minfer_dump_prompt.txt⓪ prompttext~200 B
minfer_dump_embed_out.f32① embedding30 × 896 = 26,880~105 KB
minfer_dump_layer0_bn.f32⑥ RMSNorm30 × 896 = 26,880~105 KB
minfer_dump_layer0_attn_out.f32② attention26,880~105 KB
minfer_dump_layer0_out.f32③ FFN26,880~105 KB
...
minfer_dump_layer23_attn_out.f32② attention26,880~105 KB
minfer_dump_layer23_out.f32③ FFN26,880~105 KB
minfer_dump_logits.f32④ logits30 × 151,936 = 4,558,080~17.4 MB
minfer_dump_q8_quant_verify.txt⑦ Q8_0 quantize1 line text~100 B

Total: 24 × 2 × 105 KB + 17.4 MB ≈ 22 MB for a 24-layer model.