Repository navigation
Gemma 4 generates correct text again, and FunctionGemma 270M function calling works - #355
Merged
Merged
Conversation
…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
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.
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:
word for word what llama.cpp produces, stopping naturally on a stop token.
<turn|>was never mangledIt is Gemma 4's real turn-close token, id 106 — Gemma 4 replaced Gemma 2/3's
<start_of_turn>/<end_of_turn>, andGemma4TokenizerParityTestalready 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_embdwas force-dequantized to dense FP32, taking the tied lm_head off the packed chain — llama keeps it packed and rewraps viaPackedRowDequantTensorData, and that pattern transplants directly.Why the output was garbage.
<eos>— so every generation was prefilled with an end-of-sequence token. The GGUF path never parsed token ids at all.[1, 106, 50]); the plain CLI loop had no stop check whatsoever and ran to the step limit.And two architecture parameters were wrong, each right for one family and applied to both:
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.gemmaNetwork()forced it for every checkpoint, but gemma3 has none — llama.cpp's graph isVcur → MUL_MAT → RESHAPE → VIEWwith 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-ireescaffolding implements an Octopus-v2<tool_N>fine-tune, not the released model. Added alongside it, untouched:FunctionGemmaOfficialChatTemplateandFunctionGemmaOfficialToolCallParserStrategy, ported from the Jinjachat_templateembedded in the release GGUF — including its<escape>-token string convention, which quotes would have broken.FunctionGemma.Style.OFFICIAL+callWithTools, and top-k/top-p inSamplingUtils.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
From a fused eager RMS norm (that phase was 64% of decode — the declared
FusedRmsNormOpsescape hatch is implemented by no backend, so the fast path never fired; now 0.8%) plus the engine-side kernel work in SKaiNET#1221.Gemma4WeightLoaderalso gains thedtypePolicy/keepBf16Native/keepF16Nativecapability llama's loader already had — a gemma3 checkpoint no longer has to be widened to FP32. Realising its decode benefit needs narrow-float routing inmatmulWeightTransposed, noted in the commit.Gates
Gemma4ChatGoldenTokenTestis 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.