Skip to content

Commit 56f4ddb

Browse files
committed
feat(model/gemma4): LoadedVision assemble — the vision loader output (vision loader 3/n)
The structured output of the gemma4 vision loader (#8): AssembleVision gathers the SigLIP tower + projector weights off a checkpoint's tensors into a LoadedVision (byte views by role — patch embed, per-layer norms / QK-normed attention / gated MLP, the multimodal projector), the vision parallel of model.LoadedModel, with the config inferred from the shapes + a fail-loud presence check. Returns (nil,nil) for text-only / projector-only packs. Ported faithfully from metal's buildGemma4VisionModel, reusing the canonicalise + infer front + model.WeightAny. Native splits the loader (byte views) from the device upload (the forward), unlike metal which couples them — so this stays pure Go; the bufView upload + the SigLIP forward are #9, backend-side. pkg/model green. Co-Authored-By: Virgil <virgil@lethean.io>
1 parent 065f26e commit 56f4ddb

2 files changed

Lines changed: 211 additions & 0 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// SPDX-Licence-Identifier: EUPL-1.2
2+
3+
package gemma4
4+
5+
import (
6+
core "dappco.re/go"
7+
"dappco.re/go/mlx/pkg/model"
8+
"dappco.re/go/mlx/pkg/safetensors"
9+
)
10+
11+
// vision_assemble.go is the gemma4 vision tower's loader output: it gathers the SigLIP tower's weights
12+
// off a checkpoint's tensors into a LoadedVision — byte views by role, the vision parallel of
13+
// model.LoadedModel. A backend uploads these views to its device at encode time (native splits the
14+
// loader (byte views) from the forward (device upload), unlike metal which couples the upload into the
15+
// build), so this stays pure Go with no driver type. Lifted from buildGemma4VisionModel in
16+
// pkg/metal/model/gemma4/vision_load.go, reusing the canonicalise + infer front + model.WeightAny.
17+
18+
// LoadedVisionLinear is one vision linear's weight + optional bias byte views (nil bias = none).
19+
type LoadedVisionLinear struct {
20+
Weight, Bias []byte
21+
}
22+
23+
// LoadedVisionLayer is one SigLIP encoder layer's weights: pre/post norms, QK-normed attention, gated MLP.
24+
type LoadedVisionLayer struct {
25+
InputNorm, PostAttnNorm, PreFFNorm, PostFFNorm []byte
26+
Q, K, V, O LoadedVisionLinear
27+
QNorm, KNorm []byte
28+
Gate, Up, Down LoadedVisionLinear
29+
}
30+
31+
// LoadedVisionProjector is the vision-to-text projector's weights (a single projection, or fc1+fc2).
32+
type LoadedVisionProjector struct {
33+
Projection, Linear1, Linear2 LoadedVisionLinear
34+
}
35+
36+
// LoadedVision is the whole SigLIP tower + projector as byte views — the loader output a backend uploads.
37+
type LoadedVision struct {
38+
PatchEmbedding []byte
39+
PositionEmbeddings []byte
40+
PostLayernorm []byte
41+
StdBias, StdScale []byte
42+
Layers []LoadedVisionLayer
43+
Projector LoadedVisionProjector
44+
Cfg *Gemma4VisionConfig
45+
}
46+
47+
func visionWeight(weights map[string]safetensors.Tensor, names ...string) []byte {
48+
if t, ok := model.WeightAny(weights, names...); ok {
49+
return t.Data
50+
}
51+
return nil
52+
}
53+
54+
// visionLinear gathers a vision linear's weight + bias from the first present prefix (.weight or
55+
// .linear.weight, with the matching .bias / .linear.bias).
56+
func visionLinear(weights map[string]safetensors.Tensor, prefixes ...string) LoadedVisionLinear {
57+
for _, p := range prefixes {
58+
if w := visionWeight(weights, p+".weight", p+".linear.weight"); w != nil {
59+
return LoadedVisionLinear{Weight: w, Bias: visionWeight(weights, p+".bias", p+".linear.bias")}
60+
}
61+
}
62+
return LoadedVisionLinear{}
63+
}
64+
65+
// AssembleVision gathers the gemma4 vision tower (when the pack carries one) into a LoadedVision, with the
66+
// config inferred from the weight shapes. Returns (nil, nil) when the pack is text-only / projector-only.
67+
func AssembleVision(weights map[string]safetensors.Tensor, textCfg *Gemma4TextConfig) (*LoadedVision, error) {
68+
if !gemma4VisionShouldBuildEncoderTower(textCfg) || !HasVisionTowerWeights(weights) {
69+
return nil, nil
70+
}
71+
visionCfg := textCfg.VisionConfig
72+
if visionCfg == nil {
73+
visionCfg = &Gemma4VisionConfig{}
74+
}
75+
visionCfg = inferGemma4VisionConfig(weights, normalizeGemma4VisionConfig(visionCfg))
76+
77+
patch := visionWeight(weights,
78+
"patch_embedder.input_proj.weight", "patch_embedder.input_proj.linear.weight",
79+
"embeddings.patch_embedding.weight", "patch_embedding.weight")
80+
if patch == nil {
81+
return nil, core.E("gemma4.AssembleVision", "missing patch embedding weight", nil)
82+
}
83+
84+
v := &LoadedVision{
85+
PatchEmbedding: patch,
86+
PositionEmbeddings: visionWeight(weights, "patch_embedder.position_embedding_table", "embeddings.position_embedding.weight"),
87+
PostLayernorm: visionWeight(weights, "post_layernorm.weight", "post_layer_norm.weight", "encoder.post_layernorm.weight", "vision_model.post_layernorm.weight"),
88+
StdBias: visionWeight(weights, "std_bias"),
89+
StdScale: visionWeight(weights, "std_scale"),
90+
Layers: make([]LoadedVisionLayer, int(visionCfg.NumHiddenLayers)),
91+
Cfg: visionCfg,
92+
}
93+
for i := range v.Layers {
94+
p := core.Sprintf("encoder.layers.%d", i)
95+
L := &v.Layers[i]
96+
L.InputNorm = visionWeight(weights, p+".input_layernorm.weight", p+".layer_norm1.weight")
97+
L.PostAttnNorm = visionWeight(weights, p+".post_attention_layernorm.weight", p+".post_attention_layernorm.linear.weight")
98+
L.PreFFNorm = visionWeight(weights, p+".pre_feedforward_layernorm.weight", p+".layer_norm2.weight")
99+
L.PostFFNorm = visionWeight(weights, p+".post_feedforward_layernorm.weight", p+".post_feedforward_layernorm.linear.weight")
100+
L.Q = visionLinear(weights, p+".self_attn.q_proj", p+".attention.q_proj")
101+
L.K = visionLinear(weights, p+".self_attn.k_proj", p+".attention.k_proj")
102+
L.V = visionLinear(weights, p+".self_attn.v_proj", p+".attention.v_proj")
103+
L.O = visionLinear(weights, p+".self_attn.o_proj", p+".attention.out_proj", p+".attention.o_proj")
104+
L.QNorm = visionWeight(weights, p+".self_attn.q_norm.weight")
105+
L.KNorm = visionWeight(weights, p+".self_attn.k_norm.weight")
106+
L.Gate = visionLinear(weights, p+".mlp.gate_proj", p+".mlp.fc1")
107+
L.Up = visionLinear(weights, p+".mlp.up_proj")
108+
L.Down = visionLinear(weights, p+".mlp.down_proj", p+".mlp.fc2")
109+
if err := validateLoadedVisionLayer(L, i); err != nil {
110+
return nil, err
111+
}
112+
}
113+
v.Projector = LoadedVisionProjector{
114+
Projection: visionLinear(weights, "embed_vision.embedding_projection", "multi_modal_projector.embedding_projection", "multi_modal_projector.proj", "multi_modal_projector"),
115+
Linear1: visionLinear(weights, "multi_modal_projector.linear_1", "multi_modal_projector.fc1"),
116+
Linear2: visionLinear(weights, "multi_modal_projector.linear_2", "multi_modal_projector.fc2"),
117+
}
118+
return v, nil
119+
}
120+
121+
// validateLoadedVisionLayer fails loud on a missing required weight in an encoder layer — a malformed
122+
// vision pack, surfaced at load rather than as a nil view deep in the encode.
123+
func validateLoadedVisionLayer(L *LoadedVisionLayer, idx int) error {
124+
for _, c := range []struct {
125+
b []byte
126+
name string
127+
}{
128+
{L.InputNorm, "input norm"}, {L.PostAttnNorm, "post-attn norm"}, {L.PreFFNorm, "pre-ff norm"}, {L.PostFFNorm, "post-ff norm"},
129+
{L.Q.Weight, "q proj"}, {L.K.Weight, "k proj"}, {L.V.Weight, "v proj"}, {L.O.Weight, "o proj"},
130+
{L.QNorm, "q norm"}, {L.KNorm, "k norm"},
131+
{L.Gate.Weight, "gate proj"}, {L.Up.Weight, "up proj"}, {L.Down.Weight, "down proj"},
132+
} {
133+
if len(c.b) == 0 {
134+
return core.E("gemma4.AssembleVision", core.Sprintf("encoder layer %d missing %s", idx, c.name), nil)
135+
}
136+
}
137+
return nil
138+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// SPDX-Licence-Identifier: EUPL-1.2
2+
3+
package gemma4
4+
5+
import (
6+
"testing"
7+
8+
core "dappco.re/go"
9+
"dappco.re/go/mlx/pkg/safetensors"
10+
)
11+
12+
// TestAssembleVision builds a synthetic 2-layer SigLIP tower + projector and pins that AssembleVision
13+
// gathers every role, infers the layer count, and validates presence.
14+
func TestAssembleVision(t *testing.T) {
15+
mk := func(rows, cols int) safetensors.Tensor {
16+
return safetensors.Tensor{Dtype: "BF16", Shape: []int{rows, cols}, Data: make([]byte, rows*cols*2)}
17+
}
18+
vec := func(n int) safetensors.Tensor {
19+
return safetensors.Tensor{Dtype: "BF16", Shape: []int{n}, Data: make([]byte, n*2)}
20+
}
21+
const H, layers = 64, 2
22+
w := map[string]safetensors.Tensor{"patch_embedding.weight": mk(H, 588)} // hidden 64, patchDim 588 → patch 14
23+
for i := 0; i < layers; i++ {
24+
p := core.Sprintf("encoder.layers.%d", i)
25+
for _, n := range []string{".input_layernorm", ".post_attention_layernorm", ".pre_feedforward_layernorm", ".post_feedforward_layernorm", ".self_attn.q_norm", ".self_attn.k_norm"} {
26+
w[p+n+".weight"] = vec(H)
27+
}
28+
for _, n := range []string{".self_attn.q_proj", ".self_attn.k_proj", ".self_attn.v_proj", ".self_attn.o_proj"} {
29+
w[p+n+".weight"] = mk(H, H)
30+
}
31+
w[p+".mlp.gate_proj.weight"] = mk(H*4, H)
32+
w[p+".mlp.up_proj.weight"] = mk(H*4, H)
33+
w[p+".mlp.down_proj.weight"] = mk(H, H*4)
34+
}
35+
w["multi_modal_projector.proj.weight"] = mk(H, H)
36+
37+
tc := &Gemma4TextConfig{}
38+
tc.ModelType = "gemma4"
39+
tc.VisionConfig = &Gemma4VisionConfig{}
40+
tc.VisionConfig.NumAttentionHeads = 8
41+
42+
v, err := AssembleVision(w, tc)
43+
if err != nil {
44+
t.Fatalf("AssembleVision: %v", err)
45+
}
46+
if v == nil {
47+
t.Fatal("expected a vision tower")
48+
}
49+
if len(v.Layers) != layers {
50+
t.Fatalf("layers = %d, want %d", len(v.Layers), layers)
51+
}
52+
if v.PatchEmbedding == nil {
53+
t.Fatal("patch embedding missing")
54+
}
55+
if v.Layers[0].Q.Weight == nil || v.Layers[0].QNorm == nil || v.Layers[0].Gate.Weight == nil {
56+
t.Fatal("layer 0 q/qnorm/gate missing")
57+
}
58+
if v.Projector.Projection.Weight == nil {
59+
t.Fatal("projector missing")
60+
}
61+
}
62+
63+
// TestAssembleVisionTextOnly pins that a pack with no vision tower yields (nil, nil).
64+
func TestAssembleVisionTextOnly(t *testing.T) {
65+
tc := &Gemma4TextConfig{}
66+
tc.ModelType = "gemma4"
67+
v, err := AssembleVision(map[string]safetensors.Tensor{
68+
"model.layers.0.self_attn.q_proj.weight": {Shape: []int{4, 4}},
69+
}, tc)
70+
if err != nil || v != nil {
71+
t.Fatalf("text-only pack should yield (nil,nil), got (%v, %v)", v, err)
72+
}
73+
}

0 commit comments

Comments
 (0)