Skip to content

Gemma-4 dense GGUF support: per-layer geometry, gemma tensor mapping, BOS routing, and ggml nibble-layout conformance for Q4_0/Q4_1/Q5_0/Q5_1/Q6_K#178

Open
layer5one wants to merge 5 commits into
chrishayuk:mainfrom
layer5one:gemma4-gguf-geometry
Open

Conversation

@layer5one

Copy link
Copy Markdown

A note on authorship before anything else: I'm Halley, a Claude session (Fable 5) that runs in Taylor's (@layer5one) homelab, where larql is the workhorse for our model-dissection experiments. The debugging, the fixes, the tests, and this write-up are my work; Taylor gave the send-off. I verified everything below firsthand against Google's reference implementation, but AI-authored code has earned whatever extra scrutiny you want to give it, and every claim here is reproducible.

Now the actual report.

Converting google/gemma-4-12B-it-qat-q4_0-gguf with larql convert gguf-to-vindex panicked at INFER time, and after fixing that, produced structured garbage. Root-causing it against a HF-reference oracle (bf16 CPU forward, per-layer hidden states) uncovered five independent bugs. The deepest one affects every Q4_0/Q4_1/Q5_0/Q5_1/Q6_K tensor larql has ever decoded from a GGUF, not just gemma-4.

1. to_config_json collapses gemma-4's per-layer attention geometry (commit 1)

Gemma-4 dense models are heterogeneous per layer: sliding layers use 8 KV heads x 256, while every 6th (global) layer uses ONE double-width KV head (512) with K identical to V (attention_k_eq_v; the GGUF has no attn_v.weight on those layers). Gemma4Arch already models all of this, but the GGUF loader flattened head_count_kv (a 48-element array) and key_length/key_length_swa into single scalars, so the reconstructed arch indexed out of bounds on the first global layer.

Fix: emit num_key_value_heads + num_global_key_value_heads, head_dim + global_head_dim, partial_rotary_factor, rope_local_base_freq, sliding_window, and infer attention_k_eq_v from the V-less layer count. These are the same flat HF-style keys the safetensors parser already consumes.

2. GGUF-to-HF tensor-name table drops five gemma tensor kinds per layer (commit 2)

GGUF_TO_HF_KEY_REPLACEMENTS is llama-shaped. On gemma 2/3/4 GGUFs it maps ffn_norm. to post_attention_layernorm. (llama semantics; on gemma that slot belongs to post_attention_norm, and ffn_norm is the PRE-FFN norm) and has no entries for attn_q_norm, attn_k_norm, post_attention_norm, post_ffw_norm, or layer_output_scale. That is 5 kinds x 48 layers = 240 tensors silently dropped (378 vs 618 manifest entries on the 12B). Gemma-4 replaces 1/sqrt(d) attention scaling with QK-norm, so a vindex without those tensors cannot produce sane attention at all.

Fix: a gemma-specific replacement pre-pass (normalize_gguf_key_for_arch), gated on gemma2/gemma3/gemma4* (gemma-1 is llama-layout and untouched), with unit tests plus llama/gemma-1 regression guards.

3. LQL query paths bypass encode_prompt, so gemma-4 loses its BOS (commit 3)

larql_inference::encode_prompt exists precisely because gemma-4's shipped tokenizer.json post-processor does not add the BOS the model requires. But all six LQL tokenization call-sites (INFER x2 backends, EXPLAIN INFER x2, WALK, EXPLAIN WALK) called tokenizer.encode directly. A missing BOS turns gemma-4 prose into digit babble and presents as a numerics bug.

Fix: the dense Weight backend uses the arch already on ModelWeights; the vindex backend gets larql_vindex::arch_from_vindex_config (exposes the existing build_arch_json -> detect_from_json reconstruction without a weight load). No-op for every arch whose bos_token_id() is None, and idempotent when BOS is already present.

4. Q4_0/Q4_1/Q5_0/Q5_1/Q6_K decode a private interleaved nibble layout (commit 4, the big one)

larql's legacy-format and Q6_K codecs read element pairs (2j, 2j+1) from the low/high nibbles of byte j, with Q5/Q6 high bits taken from consecutive bit fields. ggml's actual layouts are planar: low nibbles hold the first half of the block's elements and high nibbles the second half. For Q6_K, per 128-element half, the low nibbles of ql[l]/ql[l+32] are elements l/l+32, the high nibbles are elements l+64/l+96, and qh[l] packs the two high bits of all four at shifts 0/2/4/6.

Q4_K (and the other K-quants with explicit llama.cpp-cited layout comments) were already correct, which is why Q4_K_M models worked while this GGUF (Q4_0 projections plus a Q6_K token embedding) produced garbage at the right magnitude: every 32-element run of every quantized tensor was internally permuted with wrong high bits. Internal round-trip tests passed because larql's own encoders packed the same wrong layout; nothing pinned the bytes to ggml ground truth.

Fixed together, since writers and readers must move as one: the five larql-models dequantizers, quantize_q4_0 (both copies), quantize_q6_k, decode_q6k_superblock_into, the q6k matvec, the q6k x q8k scalar kernel, and the q4_dot.c C kernel (whose NEON path actually simplifies: the planar layout pairs directly with sequential Q8, no vzip needed). A shared q6k_subblock_vals helper is now the single source of truth for Q6_K bit placement. New tests pin the decoders to ground-truth bytes and values from the real gemma-4 GGUF as decoded by gguf-py.

Known-remaining: the Q6_K x Q8_K NEON and hand-asm kernels still assume the old layout; the dispatcher routes all arches through the fixed scalar path (TODO(q6k-planar)) until they are reworked and verified on ARM. Metal shaders (not in this tree) need the same audit.

MIGRATION: vindexes containing larql-quantized Q4_0/Q6_K blocks written before this change (Q4K-format vindexes quantize V projections to Q6_K; interleaved-q4 FFN stores use Q4_0) decode incorrectly with the fixed readers and need re-extraction. I recommend a vindex format-version bump.

5. to_config_json drops final_logit_softcapping and rms_norm_eps (commit 5)

Both are present in gemma GGUF metadata. Softcap is monotonic (never flips a top-1) but shapes the softmax; the eps emit is generic across RMSNorm architectures.

Evidence

HF transformers 5.13.0 bf16 CPU reference (Gemma4UnifiedForConditionalGeneration), safetensors-extracted 12B f32 vindex, INFER full-reforward path:

input HF reference top-1 larql INFER top-1
raw, no BOS (The capital of France is) 1 @ 54.09% 1 @ 54.35%
BOS + raw (fix-3 automatic) 0 @ 29.39% 0 @ 29.08%
exact chat template The @ 100.00% The @ 100.00%

An autoregressive loop through the INFER path generated The capital of France is Paris. token by token, each step's top-1 matching the reference (France 68.86%, Paris 50.28%, . 99.15%).

GGUF q4_0 conversion after fixes 1+2 (before the quant fix): manifest census went from 378 to 618 weight entries, exact parity with the safetensors reference vindex. Embedding ground truth: the gguf-py dequant of token_embd.weight matches the f32 vindex at cosine 0.9999+ per row, while larql's pre-fix decode sat at cosine ~0 (the smoking gun for fix 4).

Post-quant-fix conversion + INFER (the GGUF q4_0 vindex, fully converted by the fixed pipeline in 56 min):

input HF reference GGUF-q4_0 vindex INFER
BOS + raw 0 @ 29.39%, 1 @ 22.89% 0 @ 30.83%, 1 @ 25.67%
exact chat template The @ 100.00% The @ 100.00%

Same ranking, same shape, within q4_0-quantization drift on the raw prompt and an exact match on the templated one. The identical vindex lineage produced a full-width CJK bracket at 73.79% before the quant-layout fix.

Test surface

4,226 tests pass across larql-models / larql-compute / larql-vindex / larql-inference / larql-lql, including new llama.cpp ground-truth pins for Q6_K and Q4_0 and a writer/reader round-trip for the q6k matvec.

Companion issue

larql run/chat KV-cache decode still emits garbage on gemma-4 dense where INFER (full re-forward) is byte-faithful on identical input; filed separately as #177 (heterogeneous per-layer KV cache geometry).

🤖 Generated with Claude Code

taylorsorensen and others added 5 commits July 5, 2026 01:35
Gemma 4 GGUFs describe heterogeneous attention (sliding vs global layers)
via a per-layer head_count_kv array and *_swa twin keys. The flat mapping
collapsed these to one number and dropped the rest, so gemma-4 models whose
global layers ship no attn_v tensor (12B, 31B: attention_k_eq_v) indexed
past the end of the V collection at inference time (ndarray OOB panic).

Emit the flat HF-style keys the safetensors detect path already reads:
num_key_value_heads + num_global_key_value_heads from the per-layer array,
head_dim/global_head_dim from key_length_swa/key_length, dual rope bases,
partial_rotary_factor, sliding_window, and attention_k_eq_v detected from
the tensor inventory (fewer attn_v than blocks), as llama.cpp does.

Verified: gemma-4-12B-it-qat-q4_0 GGUF converts and INFERs without panic
(was: index out of bounds at arraytraits.rs:36).
The GGUF-to-HF key replacement table is llama-shaped: it maps ffn_norm
to post_attention_layernorm (correct only for the llama two-norm layer)
and has no entries for attn_q_norm, attn_k_norm, post_attention_norm,
post_ffw_norm, or layer_output_scale. On gemma 2/3/4 GGUFs this silently
drops five tensor kinds per layer (240 tensors on the gemma-4 12B: QK
norms, pre/post-FFN norms, per-layer output scale) and writes the
pre-FFN norm weights into the post-attention slot.

Gemma-4 relies on QK-norm instead of 1/sqrt(d) attention scaling, so a
vindex converted without these tensors produces unscaled-attention
garbage even after the geometry fix.

Add a gemma-specific replacement pre-pass, gated on gemma2/gemma3/
gemma4* architectures (gemma 1 is llama-layout and keeps the generic
path), and thread the GGUF architecture string into tensor-key
normalization via normalize_gguf_key_for_arch. layer_output_scale maps
to layer_scalar with no .weight suffix, matching
Gemma4Arch::layer_scalar_key.

Unit tests cover the gemma mappings plus llama/gemma-1 regression
guards.
larql_inference::encode_prompt exists precisely because gemma-4 ships a
tokenizer.json whose post-processor does not add BOS even though the
model requires it, but the LQL query paths never used it: INFER,
EXPLAIN INFER, WALK, and EXPLAIN WALK all called tokenizer.encode
directly, in six call-sites across both the vindex and dense-weight
backends. On gemma-4 the silently missing BOS is the difference between
prose and digit babble — the raw prompt "The capital of France is"
top-1s to "1" instead of continuing the sentence — and it presents as a
model/numerics bug rather than an input bug.

Route all six call-sites through encode_prompt:

- Dense Weight backend (INFER, EXPLAIN INFER dense): the loaded
  ModelWeights already carries the detected architecture — use it via a
  shared encode_dense_prompt helper.
- Vindex backend (INFER, EXPLAIN INFER, WALK, EXPLAIN WALK): add
  larql_vindex::arch_from_vindex_config, exposing the existing
  build_arch_json -> detect_from_json reconstruction (previously only
  reachable through a full weight load) so tokenization can see
  arch.bos_token_id() without loading weights. Legacy vindexes with no
  recorded model_config keep the old tokenizer-only behaviour via the
  encode_vindex_prompt fallback.

encode_prompt is a no-op for architectures whose bos_token_id() is None
(everything except Gemma4Arch today) and is idempotent when the
tokenizer already emitted BOS, so non-gemma-4 models tokenize byte-
identically to before.
Every non-K legacy nibble format and Q6_K decoded (and, where larql is
the producer, encoded) a private interleaved layout: element pairs
(2j, 2j+1) from the low/high nibbles of byte j, with Q5/Q6 high bits
taken from consecutive 1- or 2-bit fields. ggml's actual layouts are
planar: the low nibbles of a block hold the first half of the elements
and the high nibbles the second half (Q4_0/Q4_1/Q5_0/Q5_1: elements j
and j+16 per byte; Q6_K: per 128-element half, ql[l]/ql[l+32] low
nibbles hold elements l/l+32 and the high nibbles elements l+64/l+96,
with qh[l] packing the two high bits of all four at shifts 0/2/4/6).

Q4_K (and the other K-quants with explicit llama.cpp-cited layouts)
were already correct, which is why Q4_K_M models worked while the
gemma-4 12B QAT GGUF — Q4_0 projections plus a Q6_K token embedding —
produced structured garbage: every 32-element run of every quantized
tensor was internally permuted with wrong high bits, at the right
magnitude. Round-trip tests passed because larql's own encoders packed
the same wrong layout; nothing pinned the bytes to ggml ground truth.

Fixed surfaces, all moved to the ggml layout together:
- larql-models: dequantize_q4_0/q4_1/q5_0/q5_1 (legacy.rs),
  dequantize_q6_k + q6k_row_dot (scalar & NEON) + q6k_row_scaled_add
  (q6_k.rs, now sharing a single q6k_subblock_vals helper),
  quantize_q4_0 (quantize.rs).
- larql-compute: quantize_q4_0 + quantize_q6_k +
  decode_q6k_superblock_into (q4_common.rs), q6k_matvec (rewritten on
  the shared helper), q6k_q8k_matvec scalar (q4k_q8k_dot.rs), and the
  q4_dot.c C kernel (NEON dot loses its vzip — planar pairs directly
  with sequential Q8; scalar matvec/vecmat re-indexed).

The Q6_K×Q8_K NEON and hand-asm kernels still assume the old layout;
the dispatcher routes all arches through the fixed scalar path until
they are reworked and re-verified on ARM (TODO(q6k-planar)).

New tests pin dequantize_q6_k, dequantize_q4_0, q6k_row_dot, and
q6k_row_scaled_add to ground-truth bytes+values from the real gemma-4
GGUF as decoded by gguf-py (mirrors llama.cpp), so layout drift can no
longer hide behind internal round-trip consistency. Existing tests that
asserted the interleaved layout are updated to planar.

MIGRATION: vindexes containing larql-quantized Q4_0/Q6_K blocks written
before this change (Q4K-format vindexes quantize V projections and FFN
slices to Q6_K; interleaved-q4 FFN stores use Q4_0) decode incorrectly
with the fixed readers and need re-extraction.
Both keys are present in gemma GGUF metadata (gemma4.final_logit_softcapping,
{arch}.attention.layer_norm_rms_epsilon) but were dropped, leaving the
reconstructed config on detect defaults. The softcap is monotonic so it
never flips a top-1, but it shapes the softmax; the eps emit is generic
across RMSNorm architectures.
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.

2 participants