mlboydaisuke commited on
Commit
a8e7c27
·
verified ·
1 Parent(s): 1c44fa6

Mirror the conversion script

Browse files
Files changed (1) hide show
  1. convert_sam2_decoder.py +264 -0
convert_sam2_decoder.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SAM 2.1 (hiera-tiny) mask decoder -> LiteRT GPU-clean .tflite (Bucket 1: model-side re-authoring only)
3
+
4
+ Phase-2 companion to convert_sam2.py (the image encoder). This converts the prompt-conditioned
5
+ mask DECODER. The tiny prompt-encoder (point -> sparse tokens, sin/cos) is done HOST-SIDE in Kotlin
6
+ (see emit at the end) so the GPU graph stays sin/cos-free; the decoder takes `sparse` as an input.
7
+
8
+ Walls re-authored (all model-side; no converter patch):
9
+ 1. Sam2Attention (7x: 2 blocks x 3 + 1 final) : 4D fused attn -> 3D batched SDPA [heads, N, d]
10
+ 2. ConvTranspose2d (upscale_conv1/2) : -> ZeroStuffConvT (exact zero-stuff + Conv2d), TRANSPOSE_CONV-free
11
+ 3. mask head (hyper_in @ upscaled) : kept <=4D (no [1,1,4,256,256] 5D tensor); collapse batch==1
12
+ 4. LayerNorm (9x) : SafeLayerNorm (scale-before-square), fp16-overflow-safe, exact
13
+ 5. image_positional_embeddings + no-mask dense : baked CONSTANT buffers (host doesn't supply them)
14
+ 6. multimask_output=True path : static slice [1:], no dynamic-stability argmax/gather/where
15
+
16
+ Decoder I/O (single point prompt):
17
+ inputs : image_embeddings [1,256,64,64], sparse [1,2,256], feat_s1 [1,64,128,128], feat_s0 [1,32,256,256]
18
+ outputs: pred_masks [1,3,256,256] (logits, 3 multimask), iou_scores [1,3]
19
+
20
+ Run:
21
+ python convert_sam2_decoder.py # eager parity vs transformers reference (correctness gate)
22
+ python convert_sam2_decoder.py --convert # + litert_torch convert + op-gate + fp16
23
+ """
24
+ import sys, types, argparse, math
25
+ import torch
26
+ import torch.nn as nn
27
+ import torch.nn.functional as F
28
+
29
+ # macOS scipy stub (same as convert_sam2.py)
30
+ _svdp = types.ModuleType("scipy.sparse.linalg._svdp"); _svdp._svdp = lambda *a, **k: None
31
+ sys.modules["scipy.sparse.linalg._svdp"] = _svdp
32
+ _opt = types.ModuleType("scipy.optimize"); _opt.linear_sum_assignment = lambda *a, **k: (None, None)
33
+ sys.modules["scipy.optimize"] = _opt
34
+
35
+ from transformers import Sam2Model
36
+
37
+ MODEL_ID = "facebook/sam2.1-hiera-tiny"
38
+ SCRATCH = "/private/tmp/claude-501/-Users-majimadaisuke-Downloads-meeting/4ab9d785-6580-4aef-9d43-30f02ad9879b/scratchpad/sam2"
39
+
40
+
41
+ # ----- LayerNorm. SafeLayerNorm (scale-before-square) protects the encoder's huge deep-stage
42
+ # activations from fp16 variance overflow, but the decoder's activations are normal-scale, where
43
+ # the down-scaling instead HURTS GPU fp16 (device A/B: SafeLN decoder masks the background).
44
+ # PLAIN_LN=1 uses stock LayerNorm for the decoder. -----
45
+ import os
46
+ _PLAIN_LN = os.environ.get("PLAIN_LN") == "1"
47
+
48
+
49
+ def safe_ln(x, weight, bias, eps, sc=0.03125):
50
+ if _PLAIN_LN:
51
+ xc = x - x.mean(-1, keepdim=True)
52
+ var = (xc * xc).mean(-1, keepdim=True)
53
+ return xc * torch.rsqrt(var + eps) * weight + bias
54
+ xc = x - x.mean(-1, keepdim=True)
55
+ xs = xc * sc
56
+ var = (xs * xs).mean(-1, keepdim=True) / (sc * sc)
57
+ return xc * torch.rsqrt(var + eps) * weight + bias
58
+
59
+
60
+ # ----- ZeroStuffConvT: ConvTranspose2d(k=s,stride=s) == zero-stuff(nearest x top-left mask) + Conv2d(flipped w) -----
61
+ class ZeroStuffConvT(nn.Module):
62
+ def __init__(self, ct, H, W):
63
+ super().__init__()
64
+ self.s = ct.stride[0]; self.k = ct.kernel_size[0]
65
+ self.register_buffer("w", ct.weight.flip(2, 3).transpose(0, 1).contiguous())
66
+ self.register_buffer("b", ct.bias.detach().clone() if ct.bias is not None else torch.zeros(ct.out_channels))
67
+ s = self.s
68
+ mk = torch.zeros(H * s, W * s)
69
+ mk[::s, ::s] = 1.0
70
+ self.register_buffer("mask", mk[None, None])
71
+
72
+ def forward(self, x):
73
+ H, W = x.shape[-2], x.shape[-1]
74
+ s, k = self.s, self.k
75
+ xn = F.interpolate(x, size=(H * s, W * s), mode="nearest")
76
+ y = F.conv2d(xn * self.mask, self.w, bias=self.b, padding=k - 1)
77
+ return y[:, :, :H * s, :W * s]
78
+
79
+
80
+ class CleanMaskDecoder(nn.Module):
81
+ """Static single-point SAM2 mask decoder, GPU-clean. batch==1, point_batch==1 collapsed away."""
82
+ def __init__(self, model: Sam2Model):
83
+ super().__init__()
84
+ dec = model.mask_decoder
85
+ self.dec = dec
86
+ self.layers = dec.transformer.layers
87
+ self.final_attn = dec.transformer.final_attn_token_to_image
88
+ self.ln_final = dec.transformer.layer_norm_final_attn
89
+ self.mlps = dec.output_hypernetworks_mlps
90
+ self.iou_head = dec.iou_prediction_head
91
+ self.act = dec.activation
92
+ self.upscale_ln = dec.upscale_layer_norm # Sam2LayerNorm channels_first (32ch)
93
+
94
+ # ConvTranspose2d -> ZeroStuffConvT (input sizes are static: 64x64 -> 128 -> 256)
95
+ self.upscale_conv1 = ZeroStuffConvT(dec.upscale_conv1, 64, 64) # 256->64, 64x64 -> 128x128
96
+ self.upscale_conv2 = ZeroStuffConvT(dec.upscale_conv2, 128, 128) # 64->32, 128x128 -> 256x256
97
+
98
+ # baked constants
99
+ with torch.no_grad():
100
+ image_pos = model.get_image_wide_positional_embeddings() # [1,256,64,64]
101
+ self.register_buffer("image_pos_flat", image_pos.flatten(2).transpose(1, 2)[0].contiguous()) # [4096,256]
102
+ dense = model.prompt_encoder.no_mask_embed.weight.reshape(1, -1, 1, 1).expand(1, 256, 64, 64).contiguous()
103
+ self.register_buffer("dense", dense) # [1,256,64,64]
104
+ out_tokens = torch.cat([dec.obj_score_token.weight, dec.iou_token.weight, dec.mask_tokens.weight], 0)
105
+ self.register_buffer("output_tokens", out_tokens.contiguous()) # [6,256]
106
+
107
+ def _ln(self, ln_module, x):
108
+ return safe_ln(x, ln_module.weight, ln_module.bias, ln_module.eps)
109
+
110
+ def _attn(self, mod, query, key, value):
111
+ """3D batched SDPA. query [Nq,C], key/value [Nk,C] -> [Nq,C]."""
112
+ Nq, Nk = query.shape[0], key.shape[0]
113
+ H, hd = mod.num_attention_heads, mod.head_dim
114
+ q = mod.q_proj(query).reshape(Nq, H, hd).transpose(0, 1) # [H,Nq,hd]
115
+ k = mod.k_proj(key).reshape(Nk, H, hd).transpose(0, 1) # [H,Nk,hd]
116
+ v = mod.v_proj(value).reshape(Nk, H, hd).transpose(0, 1) # [H,Nk,hd]
117
+ o = F.scaled_dot_product_attention(q, k, v, scale=mod.scaling) # [H,Nq,hd]
118
+ o = o.transpose(0, 1).reshape(Nq, H * hd) # [Nq, internal]
119
+ return mod.o_proj(o) # [Nq, C]
120
+
121
+ def _block(self, layer, queries, keys, qpe, kpe, skip):
122
+ if skip:
123
+ queries = self._attn(layer.self_attn, queries, queries, queries)
124
+ else:
125
+ qq = queries + qpe
126
+ queries = queries + self._attn(layer.self_attn, qq, qq, queries)
127
+ queries = self._ln(layer.layer_norm1, queries)
128
+ qq = queries + qpe; kk = keys + kpe
129
+ queries = queries + self._attn(layer.cross_attn_token_to_image, qq, kk, keys)
130
+ queries = self._ln(layer.layer_norm2, queries)
131
+ queries = queries + layer.mlp(queries)
132
+ queries = self._ln(layer.layer_norm3, queries)
133
+ qq = queries + qpe; kk = keys + kpe
134
+ keys = keys + self._attn(layer.cross_attn_image_to_token, kk, qq, queries)
135
+ keys = self._ln(layer.layer_norm4, keys)
136
+ return queries, keys
137
+
138
+ def forward(self, image_embeddings, sparse, feat_s1, feat_s0):
139
+ keys = (image_embeddings + self.dense).flatten(2).transpose(1, 2)[0] # [4096,256]
140
+ kpe = self.image_pos_flat # [4096,256]
141
+ queries = torch.cat([self.output_tokens, sparse[0]], 0) # [8,256]
142
+ qpe = queries # query_point_embedding (constant across layers)
143
+
144
+ q, k = queries, keys
145
+ q, k = self._block(self.layers[0], q, k, qpe, kpe, skip=True)
146
+ q, k = self._block(self.layers[1], q, k, qpe, kpe, skip=False)
147
+ fq, fk = q + qpe, k + kpe
148
+ q = q + self._attn(self.final_attn, fq, fk, k)
149
+ q = self._ln(self.ln_final, q)
150
+
151
+ iou_tok = q[1:2] # [1,256]
152
+ mask_toks = q[2:6] # [4,256]
153
+
154
+ img = k.transpose(0, 1).reshape(1, 256, 64, 64) # [1,256,64,64]
155
+ u = self.upscale_conv1(img) + feat_s1 # [1,64,128,128]
156
+ # upscale_layer_norm: channels_first SafeLN over the 64 channels
157
+ u = u.permute(0, 2, 3, 1)
158
+ u = safe_ln(u, self.upscale_ln.weight, self.upscale_ln.bias, self.upscale_ln.eps)
159
+ u = u.permute(0, 3, 1, 2)
160
+ u = self.act(u)
161
+ u = self.act(self.upscale_conv2(u) + feat_s0) # [1,32,256,256]
162
+
163
+ hyper = torch.cat([self.mlps[j](mask_toks[j:j + 1]) for j in range(4)], 0) # [4,32]
164
+ uf = u.reshape(32, 256 * 256) # [32,65536]
165
+ masks = (hyper @ uf).reshape(4, 256, 256)[1:].unsqueeze(0) # [1,3,256,256]
166
+ iou = self.iou_head(iou_tok)[:, 1:] # [1,3]
167
+ return masks, iou
168
+
169
+
170
+ def main():
171
+ ap = argparse.ArgumentParser()
172
+ ap.add_argument("--convert", action="store_true")
173
+ args = ap.parse_args()
174
+
175
+ m = Sam2Model.from_pretrained(MODEL_ID).eval()
176
+ ref = torch.load(f"{SCRATCH}/ref_decoder.pt")
177
+ image_embeddings = ref["image_embeddings"] # list of 3
178
+ img_emb = image_embeddings[-1] # [1,256,64,64]
179
+ feat_s0, feat_s1 = image_embeddings[0], image_embeddings[1]
180
+ sparse = ref["sparse"][0] # [1,1,2,256] -> [1,2,256]
181
+ ref_masks, ref_iou = ref["masks"], ref["iou"] # [1,1,3,256,256], [1,1,3]
182
+
183
+ net = CleanMaskDecoder(m).eval()
184
+ with torch.no_grad():
185
+ masks, iou = net(img_emb, sparse, feat_s1, feat_s0)
186
+
187
+ rm = ref_masks.reshape(3, -1)
188
+ gm = masks.reshape(3, -1)
189
+ cos = F.cosine_similarity(gm.flatten(), rm.flatten(), dim=0).item()
190
+ mae = (gm - rm).abs().mean().item()
191
+ # mask agreement (binary IoU at threshold 0)
192
+ inter = ((gm > 0) & (rm > 0)).float().sum().item()
193
+ union = ((gm > 0) | (rm > 0)).float().sum().item()
194
+ iou_mask = inter / max(union, 1.0)
195
+ print(f"[eager] masks cos={cos:.6f} mae={mae:.3e} | binary-IoU(thr0)={iou_mask:.5f}")
196
+ print(f"[eager] iou ref={ref_iou.flatten().tolist()}")
197
+ print(f"[eager] iou got={iou.flatten().tolist()}")
198
+ assert cos > 0.9999, f"re-authoring changed the math! cos={cos}"
199
+ print(" -> re-authoring is numerically exact ✓")
200
+
201
+ if args.convert:
202
+ import os, collections, numpy as np, litert_torch
203
+ from ai_edge_litert.interpreter import Interpreter
204
+ BANNED = {"GATHER_ND", "GATHER", "TOPK_V2", "FLEX_ERF", "ERF", "BROADCAST_TO", "TRANSPOSE_CONV"}
205
+ FP32 = f"{SCRATCH}/sam2_tiny_dec_fp32.tflite"
206
+ FP16 = f"{SCRATCH}/sam2_tiny_mask_decoder_fp16.tflite"
207
+ ex = (img_emb, sparse, feat_s1, feat_s0)
208
+
209
+ with torch.no_grad():
210
+ ref_out = [t.detach().numpy().astype("float64").reshape(-1) for t in net(*ex)]
211
+
212
+ print("converting (litert_torch) ...")
213
+ litert_torch.convert(net, ex).export(FP32)
214
+
215
+ def gate(path, tag):
216
+ it = Interpreter(model_path=path); it.allocate_tensors()
217
+ hist = collections.Counter(d["op_name"] for d in it._get_ops_details())
218
+ over4d = sum(1 for d in it.get_tensor_details() if len(d.get("shape", [])) > 4)
219
+ bad = {k: v for k, v in hist.items() if k in BANNED}
220
+ print(f"[{tag}] ops: {dict(sorted(hist.items(), key=lambda kv: -kv[1]))}")
221
+ print(f"[{tag}] banned: {bad or 'NONE'} | >4D tensors: {over4d}")
222
+ return it, bad, over4d
223
+
224
+ def parity(it, tag):
225
+ ins = it.get_input_details()
226
+ order = [img_emb, sparse, feat_s1, feat_s0]
227
+ # match each model input slot to our tensors by shape
228
+ for d in ins:
229
+ want = next(t for t in order if tuple(t.shape) == tuple(d["shape"]))
230
+ it.set_tensor(d["index"], want.numpy().astype(d["dtype"]))
231
+ it.invoke()
232
+ outs = [it.get_tensor(o["index"]).astype("float64").reshape(-1) for o in it.get_output_details()]
233
+ for ro in ref_out:
234
+ cand = [o for o in outs if o.size == ro.size]
235
+ if cand:
236
+ c = max(np.corrcoef(ro, o)[0, 1] for o in cand)
237
+ print(f"[{tag}] parity corr={c:.6f} (len {ro.size})")
238
+
239
+ it32, bad, over4d = gate(FP32, "FP32")
240
+ parity(it32, "FP32")
241
+
242
+ print("quantizing fp16 (FLOAT_CASTING) ...")
243
+ from ai_edge_quantizer import quantizer, recipe_manager
244
+ from ai_edge_quantizer.recipe import AlgorithmName, qtyping
245
+ rmgr = recipe_manager.RecipeManager()
246
+ rmgr.add_quantization_config(
247
+ regex=".*", operation_name=qtyping.TFLOperationName.ALL_SUPPORTED,
248
+ op_config=qtyping.OpQuantizationConfig(
249
+ weight_tensor_config=qtyping.TensorQuantizationConfig(num_bits=16, dtype=qtyping.TensorDataType.FLOAT),
250
+ compute_precision=qtyping.ComputePrecision.FLOAT),
251
+ algorithm_key=AlgorithmName.FLOAT_CASTING)
252
+ if os.path.exists(FP16):
253
+ os.remove(FP16)
254
+ qt = quantizer.Quantizer(float_model=FP32)
255
+ qt.load_quantization_recipe(rmgr.get_quantization_recipe())
256
+ qt.quantize().export_model(FP16)
257
+ print(f"SIZE fp32 {os.path.getsize(FP32)/1e6:.1f} MB -> fp16 {os.path.getsize(FP16)/1e6:.1f} MB")
258
+ it16, bad16, over4d16 = gate(FP16, "FP16")
259
+ parity(it16, "FP16")
260
+ print(f"\n{'OK -> GPU-clean' if not bad16 and over4d16 == 0 else 'BLOCKERS REMAIN'}: {FP16}")
261
+
262
+
263
+ if __name__ == "__main__":
264
+ main()