test(gemma4): record the HF reference for the 12B text tower - #832
Open
FeathBow wants to merge 1 commit into
Open
test(gemma4): record the HF reference for the 12B text tower#832FeathBow wants to merge 1 commit into
FeathBow wants to merge 1 commit into
Conversation
The layer and forward comparisons need something to compare against, so this commits the reference and the generator that produced it. Nothing reads the fixture yet; a gate arrives with the first consumer. Three cases, each named by what it answers. One BOS token, where softmax runs over a single key so the query, its RoPE and the mask cannot affect the result -- what stays on the path is the value and output projections, the norms, the MLP and the final softcap. Nine tokens, the compact multi-token case: causal masking and non-zero positions are live from two tokens on, and nine spans several of them while keeping the probes small. And 1024 tokens, exactly the sliding window. The first two carry activation probes; at window width those would dwarf the rest of the file and nothing reads them. The window edge was measured, not derived. Layer 0 is sliding and has no global layer ahead of it, so whether token 0 still reaches the last position of its output reads the boundary directly: at 1023 and at 1024 that position moves when token 0 changes, at 1025 it is bitwise unchanged. The window admits sliding_window keys inclusive of the current position, so sliding_window tokens is the widest prefill that evicts nothing. Probe layers come out of layer_types rather than being hardcoded: both ends of both layer types, so type dispatch is exercised at each end. Cuts are layer boundaries, so the input of layer i and the output of layer i-1 are one tensor. Layers 46 and 47 are adjacent, which is why there are eight cuts and not nine. The final-norm cut is what keeps the tail diagnosable, splitting a logprob mismatch between the norm, the tied head and the softcap. Activations come from forward hooks even though output_hidden_states works here. That argument returns 49 tensors, but its last entry is the final norm applied to the last layer's output rather than that output itself, so it cannot supply both global_last_out and final_norm_out; hooks also collect eight cuts instead of all 49. Probes are stored in bf16, the dtype the model computes in; widening would store a converted value rather than the reference one. Sampled ids skip all 24 special and added tokens, which include the image and audio ids that text-only serving has to reject. Two facts the reference pins are worth stating outside the doc. The embedding scale is 62.0, not sqrt(3840) = 61.9677: the buffer is cast to the weight dtype before the multiply, so bf16 rounding is part of the reference, and the 5.2e-4 relative gap is far too large to pass off as accumulation noise. And each decoder layer ends by scaling its output by layer_scalar, after both residual adds, so that tensor applies to the layer output rather than to either branch. Metadata is a single sorted-JSON key. safetensors serializes its metadata map in randomized order, so a multi-key block made two runs differ byte for byte while carrying identical tensors and identical metadata as dicts -- a fixture that is reproducible looking like one that is not. With one key, regeneration is checked with sha256 and nothing else. Signed-off-by: Feathbow <feathbow@gmail.com>
FeathBow
marked this pull request as ready for review
August 2, 2026 18:35
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.
Description
Closes #831
The loader can put the 12B text tower on a device, but nothing can say yet whether a forward pass through it would be right. Every comparison ahead — one local layer, the first and last global layer, the full single-token forward, the sub-window prefills — needs the same thing first: a reference produced by the published implementation, checked in, and regenerable from a recorded command. This commits that reference and the generator behind it. The engine for this model line does not exist yet, so nothing reads the fixture today; a gate lands with its first consumer, on the same grounds as #784.
tools/accuracy/dump_gemma4_hf_golden.pyproducestest_data/gemma4-12b-hf-golden.safetensorsfrom a local checkpoint. Three cases, each named by what it answers: one BOS token, where softmax runs over a single key so the query, its RoPE and the mask cannot affect the result, leaving the value and output projections, the norms, the MLP and the final softcap on the path; nine tokens, the compact multi-token case, causal masking and non-zero positions being live from two tokens on; and 1024 tokens, exactly the sliding window. The first two carry activation probes — at window width those would dwarf the rest of the file and nothing reads them.The window edge was measured, not derived. Layer 0 is sliding and has no global layer ahead of it, so whether token 0 still reaches the last position of its output reads the boundary directly: at 1023 and at 1024 that position moves when token 0 changes, at 1025 it is bitwise unchanged. The window admits
sliding_windowkeys inclusive of the current position, sosliding_windowtokens is the widest prefill that evicts nothing andsliding_window + 1is the first that evicts.Probe layers come out of
layer_typesrather than being hardcoded, both ends of both layer types, so layer-type dispatch is exercised at each end. Cuts are layer boundaries: the input of layer i and the output of layer i-1 are one activation, so adjacent probe layers share a tensor rather than storing it twice — layers 46 and 47 are adjacent, which is why there are eight cuts and not nine. A cut after the final norm keeps the tail diagnosable, splitting a logprob mismatch between the norm, the tied head and the softcap.Activations come from forward hooks even though
output_hidden_states=Trueworks here. That argument returns 49 tensors, but its last entry is the final norm applied to the last layer's output rather than that output itself, so it cannot supply bothglobal_last_outandfinal_norm_out; hooks also collect eight cuts instead of all 49. They are stored in bf16, the dtype the model computes in — widening to fp32 would store a converted value rather than the reference one, and double the file for no added precision. Sampled ids skip all 24 special and added tokens, which include the image and audio ids that text-only serving has to reject.Two facts the reference pins are worth stating outside the doc. The embedding scale is 62.0, not
sqrt(3840)= 61.9677: the buffer is cast to the weight dtype before the multiply, so bf16 rounding is part of the reference, and the resulting 5.2e-4 relative gap is far too large to pass off as accumulation noise. And each decoder layer ends by scaling its output bylayer_scalar, after both residual adds, so that tensor applies to the layer output rather than to either branch.Metadata is a single sorted-JSON key. safetensors serializes its metadata map in randomized order, so a multi-key block made two runs differ byte for byte while carrying identical tensors and identical metadata as dicts — a fixture that is reproducible looking like one that is not. With one key, regeneration is checked with
sha256sumand nothing else.Test Env
google/gemma-4-12B-itat revision707f0a3b8a3c7ad586ed01e27eafbad8a27dd0f7. The fixture records sha256 ofconfig.json,generation_config.jsonand the safetensors header — the header pins the tensor layout without reading 22 GiB of payload, and the revision pins the payload.5.10.0.dev0, a development build that was never released, so the pin is the release verified to load this architecture rather than a guess at what that build became.Verification
Regeneration is byte-identical: two runs of the script agree, a third after reformatting the script agrees, and the committed file matches all three at sha256
c30a338d499512e6f0505bd12b184ebb5af9d7536f0b7fc9ea2bdfdb18b1a46d.The dumper refuses to write rather than emit a wrong reference. It aborts if a case is not bitwise reproducible within the process, if a probe layer is not the layer type it was selected as, or if any logit escapes the declared softcap. All three hold on the committed run: the 12B resolves to sliding 0/46 and global 5/47, and logits peak at 29.5 against a cap of 30.
ruff checkandruff format --checkclean on the new script. No Rust changes, so no cargo surface moves.