Skip to content

Commit 065f26e

Browse files
committed
feat(model/gemma4): neutral vision config inference from weight shapes (vision loader 2/n)
Completes the neutral front of the gemma4 vision loader (#8): gemma4VisionShouldBuildEncoderTower (the unified text/vision variants declare no encoder) + inferGemma4VisionConfig (hidden_size + patch_size from the patch-embedding shape, head_dim from hidden/heads, the layer count by walking encoder.layers.N) — the vision-side parallel of the text infer.go, reusing model.WeightAny. Read from the tensors, never guessed. The device build of the SigLIP tower from these weights (native structs + weight upload) is next, in pkg/native. pkg/model green. Co-Authored-By: Virgil <virgil@lethean.io>
1 parent 33e76d3 commit 065f26e

2 files changed

Lines changed: 135 additions & 0 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// SPDX-Licence-Identifier: EUPL-1.2
2+
3+
package gemma4
4+
5+
import (
6+
"math"
7+
8+
core "dappco.re/go"
9+
"dappco.re/go/mlx/pkg/model"
10+
"dappco.re/go/mlx/pkg/safetensors"
11+
)
12+
13+
// vision_infer.go completes the neutral vision-loader front: decide whether a pack carries a SigLIP
14+
// encoder tower (vs the unified text-only / projector-only variants) and infer the vision config's
15+
// per-model dims from the weight SHAPES (the don't-guess rule) — the vision-side parallel of infer.go.
16+
// Lifted from pkg/metal/model/gemma4/vision_load.go; the device build of the tower from these weights is
17+
// backend-side (pkg/native), the same split as the text path.
18+
19+
// gemma4VisionShouldBuildEncoderTower reports whether a pack carries a full SigLIP encoder tower. The
20+
// unified text / unified-vision variants declare no encoder.
21+
func gemma4VisionShouldBuildEncoderTower(cfg *Gemma4TextConfig) bool {
22+
if cfg == nil {
23+
return true
24+
}
25+
if cfg.ModelType == "gemma4_unified" || cfg.ModelType == "gemma4_unified_text" {
26+
return false
27+
}
28+
if cfg.VisionConfig != nil && cfg.VisionConfig.ModelType == "gemma4_unified_vision" {
29+
return false
30+
}
31+
return true
32+
}
33+
34+
// inferGemma4VisionConfig fills the vision config's per-model dims from the weight shapes: hidden_size +
35+
// patch_size from the patch-embedding weight, head_dim from hidden/heads, kv-heads default, and the layer
36+
// count by walking encoder.layers.N until a q_proj is absent. Read from the tensors, never guessed.
37+
func inferGemma4VisionConfig(weights map[string]safetensors.Tensor, cfg *Gemma4VisionConfig) *Gemma4VisionConfig {
38+
if cfg == nil {
39+
cfg = &Gemma4VisionConfig{}
40+
}
41+
if w, ok := model.WeightAny(weights,
42+
"patch_embedder.input_proj.weight",
43+
"patch_embedder.input_proj.linear.weight",
44+
"embeddings.patch_embedding.weight",
45+
"patch_embedding.weight",
46+
); ok {
47+
shape := w.Shape
48+
if len(shape) > 0 && shape[0] > 0 {
49+
cfg.HiddenSize = int32(shape[0])
50+
}
51+
patchDim := 0
52+
switch len(shape) {
53+
case 2:
54+
patchDim = shape[1]
55+
case 4:
56+
patchDim = shape[1] * shape[2] * shape[3]
57+
}
58+
channels := int(cfg.NumChannels)
59+
if channels <= 0 {
60+
channels = 3
61+
}
62+
if patchDim > 0 && patchDim%channels == 0 {
63+
patch := int(math.Round(math.Sqrt(float64(patchDim / channels))))
64+
if patch > 0 && channels*patch*patch == patchDim {
65+
cfg.PatchSize = int32(patch)
66+
}
67+
}
68+
}
69+
if cfg.HiddenSize > 0 && cfg.NumAttentionHeads > 0 {
70+
cfg.HeadDim = cfg.HiddenSize / cfg.NumAttentionHeads
71+
}
72+
if cfg.NumKeyValueHeads == 0 {
73+
cfg.NumKeyValueHeads = cfg.NumAttentionHeads
74+
}
75+
for i := 0; ; i++ {
76+
prefix := core.Sprintf("encoder.layers.%d", i)
77+
if _, ok := model.WeightAny(weights,
78+
prefix+".self_attn.q_proj.weight",
79+
prefix+".self_attn.q_proj.linear.weight",
80+
prefix+".attention.q_proj.weight",
81+
prefix+".attention.q_proj.linear.weight",
82+
); !ok {
83+
if i > 0 {
84+
cfg.NumHiddenLayers = int32(i)
85+
}
86+
break
87+
}
88+
}
89+
return normalizeGemma4VisionConfig(cfg)
90+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// SPDX-Licence-Identifier: EUPL-1.2
2+
3+
package gemma4
4+
5+
import (
6+
"testing"
7+
8+
"dappco.re/go/mlx/pkg/safetensors"
9+
)
10+
11+
// TestInferGemma4VisionConfig pins the shape-derived dims: hidden_size + patch_size from the
12+
// patch-embedding weight (588 = 3·14·14 → patch 14), and the encoder-layer count by walking the q_projs.
13+
func TestInferGemma4VisionConfig(t *testing.T) {
14+
weights := map[string]safetensors.Tensor{
15+
"patch_embedding.weight": {Shape: []int{1152, 588}},
16+
"encoder.layers.0.self_attn.q_proj.weight": {Shape: []int{1152, 1152}},
17+
"encoder.layers.1.self_attn.q_proj.weight": {Shape: []int{1152, 1152}},
18+
}
19+
cfg := &Gemma4VisionConfig{}
20+
cfg.NumAttentionHeads = 16 // promoted from the embedded neutral core
21+
got := inferGemma4VisionConfig(weights, cfg)
22+
if got.HiddenSize != 1152 {
23+
t.Fatalf("HiddenSize = %d, want 1152", got.HiddenSize)
24+
}
25+
if got.PatchSize != 14 {
26+
t.Fatalf("PatchSize = %d, want 14 (round(sqrt(588/3)))", got.PatchSize)
27+
}
28+
if got.NumHiddenLayers != 2 {
29+
t.Fatalf("NumHiddenLayers = %d, want 2", got.NumHiddenLayers)
30+
}
31+
}
32+
33+
// TestGemma4VisionShouldBuildEncoderTower pins the tower/no-tower decision by model_type.
34+
func TestGemma4VisionShouldBuildEncoderTower(t *testing.T) {
35+
unified := &Gemma4TextConfig{}
36+
unified.ModelType = "gemma4_unified"
37+
if gemma4VisionShouldBuildEncoderTower(unified) {
38+
t.Fatal("gemma4_unified declares no encoder tower")
39+
}
40+
dense := &Gemma4TextConfig{}
41+
dense.ModelType = "gemma4"
42+
if !gemma4VisionShouldBuildEncoderTower(dense) {
43+
t.Fatal("gemma4 should build an encoder tower")
44+
}
45+
}

0 commit comments

Comments
 (0)