Skip to content

Gemma 4 generates correct text again, and FunctionGemma 270M function calling works - #355

Merged
michalharakal merged 8 commits into
developfrom
feat/0.51-migration
Aug 31, 2026
Merged

michalharakal merged 8 commits into
developfrom
feat/0.51-migration

Conversation

@michalharakal

Copy link
Copy Markdown
Contributor

Makes Gemma 4 work, and makes official FunctionGemma 270M function calling work. Fixes the failure recorded in ISSUE-gemma4-broken-generation.md (degenerate <turn|> output at ~0.04 tok/s while Llama 3.2 ran fine through the identical runtime). Refs #325, #330, #342, #346.

The CLI now answers the prompt from that report:

$ kgemma gemma-4-E2B-it-Q4_K_M.gguf "Translate 'hello world' to German" 48 0.0 --chat
The translation of "hello world" to German is **Hallo Welt**.

word for word what llama.cpp produces, stopping naturally on a stop token.

<turn|> was never mangled

It is Gemma 4's real turn-close token, id 106 — Gemma 4 replaced Gemma 2/3's <start_of_turn>/<end_of_turn>, and Gemma4TokenizerParityTest already pinned that against HF ids. The model was genuinely predicting it, for six reasons that stacked.

Why it was slow. The kgemma CLI never installed the kernel packs (kllama got this in #354; kgemma was skipped), so every matmul ran on the decoding reference kernel. token_embd was force-dequantized to dense FP32, taking the tied lm_head off the packed chain — llama keeps it packed and rewraps via PackedRowDequantTensorData, and that pattern transplants directly.

Why the output was garbage.

  • BOS defaulted to 1 — which is Gemma's <eos> — so every generation was prefilled with an end-of-sequence token. The GGUF path never parsed token ids at all.
  • The stop set held one id where Gemma 4 ships three ([1, 106, 50]); the plain CLI loop had no stop check whatsoever and ran to the step limit.
  • No chat template in plain CLI mode on an instruction-tuned checkpoint.
  • RMS-norm epsilon was hardcoded rather than read from metadata.

And two architecture parameters were wrong, each right for one family and applied to both:

  • Gemma 4's attention scale is 1.0, not 1/sqrt(head_dim) — its QK-norm already controls the dot-product magnitude, so the extra division flattened the softmax ~16x. At the second token we put 41% of attention weight on the current token where llama.cpp puts ~1%. It had been 1.0 and was changed to the generic default on the theory that 1.0 "over-sharpened"; that conclusion was drawn while the V-norm bug below was corrupting the same attention block, so the evidence pointed the wrong way.
  • V-norm is gemma4-only. gemmaNetwork() forced it for every checkpoint, but gemma3 has none — llama.cpp's graph is Vcur → MUL_MAT → RESHAPE → VIEW with nothing between, and the comment beside the flag already said so. Q and K stayed bit-exact while V was rescaled, so the error was invisible at position 0 and compounded: FunctionGemma matched llama.cpp at prefill n=3 and diverged from n=8.

Both are now conditional on the checkpoint's declared architecture rather than assumed constant across the family.

FunctionGemma 270M — official function calling

The existing gemma-iree scaffolding implements an Octopus-v2 <tool_N> fine-tune, not the released model. Added alongside it, untouched: FunctionGemmaOfficialChatTemplate and FunctionGemmaOfficialToolCallParserStrategy, ported from the Jinja chat_template embedded in the release GGUF — including its <escape>-token string convention, which quotes would have broken. FunctionGemma.Style.OFFICIAL + callWithTools, and top-k/top-p in SamplingUtils.

It emits and parses call:get_weather{location:<escape>Paris<escape>}, and matches llama.cpp's greedy argmax at all ten sampled prefill lengths (3, 8, 16, 24, 32, 48, 64, 72, 80, 85).

Speed

before after
E2B decode 443 ms/token 189 ms/token
E2B prefill 432 ms/token 186 ms/token
FunctionGemma load 2254 ms 1672 ms

From a fused eager RMS norm (that phase was 64% of decode — the declared FusedRmsNormOps escape hatch is implemented by no backend, so the fast path never fired; now 0.8%) plus the engine-side kernel work in SKaiNET#1221.

Gemma4WeightLoader also gains the dtypePolicy / keepBf16Native / keepF16Native capability llama's loader already had — a gemma3 checkpoint no longer has to be widened to FP32. Realising its decode benefit needs narrow-float routing in matmulWeightTransposed, noted in the commit.

Gates

Gemma4ChatGoldenTokenTest is the new one worth knowing about: it pins tokenization against llama.cpp byte-for-byte, then asserts the greedy first token at a position where the reference is ~99% confident — a far better probe than the old "Hi" prompt, where llama.cpp's own distribution is nearly flat and the argmax flips on ordinary quantization noise.

Green: that gate, Gemma4ChatModelGgufSmokeTest, Gemma4TokenizerParityTest, FunctionGemmaOfficialGgufTest (tokenizer parity + template pin + e2e tool call), FunctionGemmaOfficialFormatTest, KernelBootstrapOrderingTest, plus llm-core / llm-inference-gemma / gemma-iree suites.

Model-gated harnesses are kept, since the numbers above should be reproducible rather than folklore: prefill-vs-decode profiles, per-module self-timing via forward hooks (this is what found that 73% of decode sat in two dense FP32 projections), and llama.cpp layer-parity probes driven by llama-eval-callback. Their doc comments record the row-indexing gotcha that briefly made a correct implementation look broken.

Requires SKaiNET#1221 for the engine-side fixes; built against it via -PuseLocalSkainet=true.

michalharakal and others added 8 commits August 31, 2026 00:28
…ed token_embd

Fixes the Gemma 4 E2B Q4_K_M failure reported in ISSUE-gemma4-broken-generation.md
(degenerate <turn|> output at ~0.04 tok/s while Llama 3.2 worked through the same
runtime). <turn|> is Gemma 4's real turn-close token (id 106), so this was never a
tokenizer bug — it was five defects stacking:

- KgemmaKernels.ensureInstalled(): one shared kernel-pack installer, called from the
  kgemma CLI, Gemma4ChatModel and FunctionGemma. kllama got this in #354; kgemma was
  skipped, so every matmul fell to KernelDispatch's decoding reference kernel.
- Gemma4WeightLoader: parse bos/eos/pad ids and layer_norm_rms_epsilon from GGUF
  metadata (new Gemma4ModelMetadata.rmsNormEps, threaded through gemmaNetwork()).
  BOS previously defaulted to 1 — which is Gemma's <eos> — so every generation was
  prefilled with an end-of-sequence token.
- generateUntilStop(eosTokenIds: Set<Int>) overload plus Gemma4StopTokens.resolve()
  and GGUFTokenizer.tokenId(): Gemma 4 ships three stop ids, and the CLI's plain loop
  had no stop check at all.
- token_embd keeps the default packed form and is rewrapped via
  PackedRowDequantTensorData (llama's embeddingReady pattern) instead of being forced
  to dense FP32, so the tied lm_head stays on the packed matmul chain.
- kgemma CLI: --chat renders a real chat turn, and tok/s is reported over tokens
  actually generated.

FunctionGemma: adds the OFFICIAL google/functiongemma-270m-it format alongside the
existing Octopus-v2 fine-tune support — template and tool-call parser ported from the
Jinja chat_template embedded in the release GGUF, including its <escape>-token string
convention. Adds top-k/top-p to SamplingUtils.

Tests: tokenizer parity against llama.cpp b10621 for both checkpoints, a golden-token
gate at a high-confidence reference position, and modelless format tests. The golden
gate currently FAILS: with byte-identical prompt ids we predict <turn|> where llama.cpp
predicts 'The' at 99%. That divergence is pre-existing and independent of weight form
(packed and dense agree within quantization noise) and reproduces on FunctionGemma's
gemma3 path too — tracked in GEMMA4-IMPLEMENTATION-TRACKER.md.

Refs #325 #330 #342 #346

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKeDSK4JF295y53Uvez954
… packs

KernelPacks.install() defaults its provider to KernelRegistry.bestAvailable(),
which is null while the provider registry is empty. The registry is only filled
lazily, by DefaultCpuOpsJvm.ensureKernelProviders(), the first time an ops
instance touches a kernel — so a bootstrap that runs at process start (all of
them do) contributes nothing but the reference kernel from that call.

Measured: 8 of 17 kernels landed. The 7 ffm-rowmajor-* kernels install
unconditionally, which is why the GGUF decode path still got fast; silently
missing were the dense-FP32 view kernels (contiguous + strided) and the seven
input-block-major packed kernels, all of which fell back to the decoding
reference kernel instead.

Calling KernelServiceLoader.installAll() first when the registry is empty lands
all 17. KernelBootstrapOrderingTest pins both the gap and the fix without
needing a checkpoint.

Note: kllama-cli, KLlamaJava and skainet-cli have the same ordering and need the
same one-line change; and the real fix belongs in the engine, where
KernelDispatch should self-heal the way KernelRegistry already does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKeDSK4JF295y53Uvez954
…heals

The engine populates KernelDispatch on first use (ensureInstalled() plus the
ViewKernelPack ServiceLoader SPI), so the four hand-rolled bootstraps are dead
weight — and each carried the ordering bug where KernelPacks.install() ran
against an empty provider registry and landed 8 of 17 kernels.

Removed: KgemmaKernels and its call sites (Gemma4ChatModel, kgemma CLI,
FunctionGemma, the parity diagnostic, the golden-token test), kllama-cli's
install block, KLlamaJava.ensureKernelPacksInstalled() and its once-flag, and
skainet-cli's install block, plus the imports each left behind.

The backend dependencies stay. Nothing references them by symbol any more —
they are ServiceLoader dependencies now — but dropping native-cpu from the
runtime classpath would make the FFM row-major pack undiscoverable and silently
return every matmul to the decoding reference kernel. Documented at the
declaration, and KernelBootstrapOrderingTest (rewritten as a self-heal guard)
fails if either that classpath entry or the engine's self-heal goes away.

Verified on gemma-4-E2B-it-Q4_K_M: with no bootstrap code anywhere downstream,
load plus 10 forward passes takes 9.4s and produces the same output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKeDSK4JF295y53Uvez954
….cpp

gemmaNetwork() forced vNormNoScale = true for every checkpoint it builds. That is
correct for Gemma 4 — HF Gemma4TextAttention declares
v_norm = Gemma4RMSNorm(head_dim, with_scale=False) and applies it after v_proj —
but gemma3 has no such norm, as the code comment beside the flag already said and
as llama.cpp's graph confirms: Vcur -> MUL_MAT -> RESHAPE -> VIEW, nothing in
between (verified with llama-eval-callback). The builder serves both families, so
every gemma3 checkpoint silently ran with a V rescale the model was never
trained with.

Symptom shape, which is why this hid for so long: Q and K stay bit-exact
(q_norm, k_norm and both RoPE outputs match llama.cpp to 4 decimals), only V is
perturbed, so the attention output is barely wrong at position 0 and compounds as
more values enter the weighted sum. FunctionGemma 270M matched llama.cpp's greedy
argmax at prefill n=3 and diverged from n=8 onward.

vNorm is now a gemmaNetwork() parameter defaulting to
metadata.architecture.startsWith(gemma4).

Verified against llama.cpp b10621 on functiongemma-270m-it-BF16, greedy, with
byte-identical prompt ids — argmax now matches at ALL ten sampled prefill
lengths, where before only n=3 and two coincidences did:

  n=  3  107 '\n'                     n= 48  31162 'optional'
  n=  8 5192 ' language'              n= 64     52 '<escape>'
  n= 16 6436 ' tools'                 n= 72 236777 'I'
  n= 24 5939 'format'                 n= 80    106 '<end_of_turn>'
  n= 32 4563 ' location'              n= 85     48 '<start_function_call>'

FunctionGemmaOfficialGgufTest now passes end to end: the model emits
call:get_weather{location:<escape>Paris<escape>} and it parses.

Gemma 4 E2B is unaffected by this change (it keeps v_norm) and its own
control-token divergence remains open — tracked separately.

Refs #325

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKeDSK4JF295y53Uvez954
Diagnostic harnesses built while hunting the control-token divergence, kept
because they are the reference method for any future family parity work:

- FunctionGemmaLayerParityProbe: prefill-length bisect of greedy argmax against
  llama.cpp, plus the raw embedding row (this is what proved weight loading and
  the embedding gather were exact, killing the 'row 106 is abnormally attractive'
  theory).
- FunctionGemmaRopeSweepProbe: sweeps the architecture values a gemma3 GGUF does
  not declare (RoPE bases, partial-rotary factor, ropeType, sliding/full layer
  pattern). All were exonerated, and changing the sliding base BROKE a
  previously-correct result, which is how we knew the defaults were right.
- FunctionGemmaBlockTraceProbe / Gemma4BlockTraceProbe: capture every module
  output via ExecutionContext hooks so our per-block trace can be lined up
  against 'llama-eval-callback'. This is what isolated the fault to attention
  with Q/K bit-exact and V perturbed.

Method, for whoever picks this up: run
  llama-eval-callback -m <gguf> -p <prompt> -n 1
and line its named tensors up with the probe output. Mind the row indexing —
llama.cpp prints 3-D tensors token-major with one line per head, so for a
{head_dim, n_head, n_tokens} tensor the row for token t head h is line
t*n_head + h, while 2-D {dim, n_tokens} tensors have one line per token.
Getting that wrong makes a correct implementation look broken.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKeDSK4JF295y53Uvez954
…B now generates correct text

Gemma 4 RMS-normalizes Q and K per head before the dot product, which already
controls its magnitude. Dividing by sqrt(head_dim) on top flattens the softmax by
a further ~16x, so the model spread attention across positions instead of
committing to one. Measured on E2B against llama.cpp at the second token: our
softmax put 41% of its weight on the current token where the reference put ~1%.

gemmaNetwork() passed attentionScale = null (the 1/sqrt(head_dim) default) for
every checkpoint. It had previously been hardcoded 1.0 and was changed to null on
the theory that 1.0 'over-sharpened softmax for >1 token' — but that was concluded
while the V-norm bug (previous commit) was still corrupting the same attention
block, so the evidence pointed the wrong way. Now architecture-conditional, like
vNorm: 1.0 for gemma4, null for gemma3 (llama.cpp's gemma3 graph scales Q
explicitly by 0.0625 at head_dim 256, and FunctionGemma matches with the default).

How it was found: llama-eval-callback showed every attention INPUT already
matching — q_norm, k_norm, v_norm and both RoPE outputs agree with llama.cpp
within Q4_K noise — while the attention OUTPUT did not. Solving the output as a
convex combination of the two cached V rows gave the weight split above, and a
scale sweep confirmed it: 1.0 predicts 'The' at 20.4 vs a 14.2 runner-up
(reference: ~99%), while 0.0625 and 0.0722 both predict '<turn|>'.

Gemma 4 E2B Q4_K_M end to end, the prompt from the original issue:

  $ kgemma gemma-4-E2B-it-Q4_K_M.gguf "Translate 'hello world' to German" 48 0.0 --chat
  The translation of "hello world" to German is **Hallo Welt**.
  generated 14 tokens; tok/s: 1.19

word for word what llama.cpp produces, stopping naturally on a stop token.
The issue reported <turn|> x 16 at 0.04 tok/s.

Gates: Gemma4ChatGoldenTokenTest (tokenizer parity + golden first token),
Gemma4ChatModelGgufSmokeTest, Gemma4TokenizerParityTest all pass, and
FunctionGemma still matches llama.cpp at all ten sampled prefill lengths.

Fixes the failure reported in ISSUE-gemma4-broken-generation.md. Refs #325

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKeDSK4JF295y53Uvez954
…d the speed work

RMSNormalization ran eight tensor ops per call (x*x, mean, +eps, sqrt, unsqueeze,
divide, reshape, multiply), each allocating and walking the data again. Affordable
once; a Gemma 4 decode step runs it ~250 times (seven norms x 35 blocks), and it
was 64% of decode — more than the matmuls it wraps. The declared FusedRmsNormOps
escape hatch is implemented by no backend, so the fast path never fired.

Adds a single-pass eager path over raw float arrays: one output allocation, two
passes per row, and it handles Gemma's unitOffset gain that the backend interface
deliberately excludes. Defers to the decomposed path for non-FP32 dtypes, an
unreadable placeholder weight, or while the context is recording a graph, where
every step must go through ctx.ops to stay traceable.

  rmsnorm phase: 1399 ms -> 8 ms over 24 decode tokens (64% of decode -> 0.8%)

Harnesses kept, because the numbers in these commit messages should be
reproducible: Gemma4SpeedProfile (prefill vs decode split, PhaseProfile and
KernelProfile breakdowns), Gemma4ModuleTimingProfile (self-time per module via
forward hooks — this is what found that 73% of decode sat in two dense FP32
projections), FunctionGemmaSpeedProfile (end-to-end tool-call latency), and PLE
step timings in PerLayerInputBlockHook.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKeDSK4JF295y53Uvez954
…loader already had

Gemma4WeightLoader always widened narrow-float sources to FP32, so a checkpoint
stored at 2 bytes per element was read at 4 — doubling the bytes a decode step
touches, which is what decode is bound by. llama's DecoderGgufWeightLoader has had
keepF16Native/keepBf16Native since the engine loader gained them; the gemma loader
simply never passed the flags. Exactly the kind of drift #346 exists to stop.

Adds the same dtypePolicy parameter, resolved through the same
DTypePolicyValidation.keepsNative, so a policy means one thing whichever family
loads the checkpoint. Default stays DTypePolicy.Any (widen), so nothing changes
for existing callers.

FunctionGemma.fromGguf now asks for Prefer(BF16) — its checkpoint ships BF16 — and
drops the blanket GEMMA_DEQUANTIZE_ALL. Confirmed by probe: blk.0.attn_q.weight,
blk.0.ffn_gate.weight and token_embd.weight all arrive as Bf16DenseTensorData
instead of dense FP32.

  load: 2254 ms -> 1672 ms (26% — less dequantization at load)

Decode is unchanged so far, and the reason is worth writing down: linearProject
goes through matmulWeightTransposed, whose dense branch materializes Wt and
widens narrow floats to FP32 once (cached per weight). The bf16 kernel — the
fastest of the set at 14.33 GFLOP/s — is only reachable from chooseQuantizedMatmul2D
via matmul(a, b), which that path never takes. Routing narrow-float weights to it
under transposed semantics is the follow-up; this commit is the half that belongs
to the loader.

Gates green: Gemma 4 golden token and smoke, FunctionGemma tokenizer/template/e2e
(still parses call:get_weather{location:Paris}), tokenizer parity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YKeDSK4JF295y53Uvez954
@michalharakal
michalharakal merged commit c9c7d1f into develop Aug 31, 2026
0 of 2 checks passed
@michalharakal
michalharakal deleted the feat/0.51-migration branch August 31, 2026 10:17
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.

1 participant