psiddh commited on
Commit
1c8d800
·
verified ·
1 Parent(s): 1b0ed34

Re-export with public ExecuTorch 1.5.1 (recipe in export/)

Browse files
NOTICE CHANGED
@@ -1,4 +1,4 @@
1
- This repository contains a modified version of facebook/sam2-hiera-tiny.
2
 
3
  Modifications by Meta Platforms, Inc.: conversion to the ExecuTorch program format (.pte) for the
4
  ExecuTorch WebGPU backend.
 
1
+ This repository contains a modified version of facebook/sam2.1-hiera-tiny.
2
 
3
  Modifications by Meta Platforms, Inc.: conversion to the ExecuTorch program format (.pte) for the
4
  ExecuTorch WebGPU backend.
README.md CHANGED
@@ -1,7 +1,7 @@
1
  ---
2
  license: apache-2.0
3
  base_model:
4
- - facebook/sam2-hiera-tiny
5
  library_name: executorch
6
  pipeline_tag: mask-generation
7
  inference: false
@@ -11,27 +11,27 @@ tags:
11
  - on-device
12
  - browser
13
  ---
14
- # SAM 2 Hiera Tiny for ExecuTorch WebGPU
15
 
16
  This repository contains an [ExecuTorch](https://github.com/pytorch/executorch) conversion of
17
- [facebook/sam2-hiera-tiny](https://huggingface.co/facebook/sam2-hiera-tiny) for the ExecuTorch **WebGPU** backend, so the model runs entirely in a web browser on
18
  the visitor's GPU. It powers the corresponding demo in the ExecuTorch WebGPU Playground (launching soon on [executorch.ai](https://executorch.ai)).
19
 
20
- - **Base model:** [facebook/sam2-hiera-tiny](https://huggingface.co/facebook/sam2-hiera-tiny)
21
  - **Format:** ExecuTorch `.pte` programs delegated to the WebGPU backend
22
- - **Quantization:** fp32 (as exported)
23
- - **Total size:** 149.3 MB
24
 
25
  ## About the original model
26
 
27
- The tiny Hiera variant of SAM 2, a foundation model for promptable visual segmentation in images and videos.
28
 
29
  - **Developed by:** Meta FAIR
30
  - **Paper:** [SAM 2: Segment Anything in Images and Videos](https://arxiv.org/abs/2408.00714)
31
- - 38.9M parameters
32
  - Segments any object from point or box prompts
33
 
34
- For training data, evaluation results and full details, see the original model card: [facebook/sam2-hiera-tiny](https://huggingface.co/facebook/sam2-hiera-tiny).
35
 
36
  ## Intended use and limitations
37
 
@@ -41,14 +41,18 @@ This conversion covers single-image segmentation only (no video memory). Masks c
41
 
42
  ## What changed in this conversion
43
 
44
- - Image encoder and mask decoder exported as separate programs
45
- - Fixed 512x512 input (upstream: 1024x1024)
46
- - Prompt embeddings are precomputed and shipped as `prompt_*.bin` files
 
 
47
 
48
  ## Inputs and outputs
49
 
50
- - `sam2_encoder.pte`: one 512x512 RGB image, ImageNet-normalized (mean 0.485/0.456/0.406, std 0.229/0.224/0.225), to image embeddings
51
- - `sam2_decoder.pte`: image embeddings plus prompt tokens built from `prompt_*.bin`, to mask logits
 
 
52
 
53
  ## Files
54
 
@@ -62,16 +66,17 @@ This conversion covers single-image segmentation only (no video memory). Masks c
62
  | `prompt_not_a_point.bin` | 1.0 KB | `19771c067b922d9d…` |
63
  | `prompt_point_embed_bg.bin` | 1.0 KB | `91c432d10fcef8e3…` |
64
  | `prompt_point_embed_fg.bin` | 1.0 KB | `2e037dab5bee002d…` |
65
- | `sam2_decoder.pte` | 16.2 MB | `4d6f790ccf3c7ae8…` |
66
- | `sam2_encoder.pte` | 131.2 MB | `46626cd828cca2f2…` |
67
 
68
  Full checksums are in `manifest.json`. The playground verifies them after download.
69
 
70
  ## Running it
71
 
72
  These programs were exported with the ExecuTorch WebGPU backend
73
- ([backends/webgpu](https://github.com/pytorch/executorch/tree/main/backends/webgpu)) and validated with the
74
- WebAssembly runtime build used by the ExecuTorch WebGPU Playground (launching soon on [executorch.ai](https://executorch.ai)). That runtime build is not published yet; until it is,
 
75
  the playground is the supported way to run them. It downloads these files once, caches them in the browser,
76
  and runs everything locally.
77
 
@@ -79,8 +84,11 @@ Requirements: a browser with WebGPU enabled.
79
 
80
  ## Provenance
81
 
82
- - Converted from the upstream checkpoint by the ExecuTorch team with the WebGPU export path.
83
- - A reproducible export recipe will be published alongside the open-source ExecuTorch WebGPU runtime.
 
 
 
84
 
85
  ## Citation
86
 
 
1
  ---
2
  license: apache-2.0
3
  base_model:
4
+ - facebook/sam2.1-hiera-tiny
5
  library_name: executorch
6
  pipeline_tag: mask-generation
7
  inference: false
 
11
  - on-device
12
  - browser
13
  ---
14
+ # SAM 2.1 Hiera Tiny for ExecuTorch WebGPU
15
 
16
  This repository contains an [ExecuTorch](https://github.com/pytorch/executorch) conversion of
17
+ [facebook/sam2.1-hiera-tiny](https://huggingface.co/facebook/sam2.1-hiera-tiny) for the ExecuTorch **WebGPU** backend, so the model runs entirely in a web browser on
18
  the visitor's GPU. It powers the corresponding demo in the ExecuTorch WebGPU Playground (launching soon on [executorch.ai](https://executorch.ai)).
19
 
20
+ - **Base model:** [facebook/sam2.1-hiera-tiny](https://huggingface.co/facebook/sam2.1-hiera-tiny)
21
  - **Format:** ExecuTorch `.pte` programs delegated to the WebGPU backend
22
+ - **Quantization:** fp32 (no quantization)
23
+ - **Total size:** 128.1 MB
24
 
25
  ## About the original model
26
 
27
+ The tiny Hiera variant of SAM 2.1, the improved checkpoint release of SAM 2, a foundation model for promptable visual segmentation in images and videos.
28
 
29
  - **Developed by:** Meta FAIR
30
  - **Paper:** [SAM 2: Segment Anything in Images and Videos](https://arxiv.org/abs/2408.00714)
31
+ - 39.0M parameters
32
  - Segments any object from point or box prompts
33
 
34
+ For training data, evaluation results and full details, see the original model card: [facebook/sam2.1-hiera-tiny](https://huggingface.co/facebook/sam2.1-hiera-tiny).
35
 
36
  ## Intended use and limitations
37
 
 
41
 
42
  ## What changed in this conversion
43
 
44
+ - Image encoder and mask decoder exported as two separate programs; the prompt encoder runs on the host from `prompt_*.bin` tables generated from the upstream weights
45
+ - Fixed 512x512 input (upstream: 1024x1024), so masks are 128x128 (upstream: 256x256)
46
+ - The encoder adds `no_memory_embedding` to the image embedding, as upstream does for single images
47
+ - Three prompt tokens: a click is [point, padding, padding] and a box is [top-left, bottom-right, padding]. The decoder ignores a padding token that follows another padding token, so every prompt type matches upstream
48
+ - No mask-prompt input. All weights fp32. Attention uses ExecuTorch's fused `et_vk.sdpa` op (within 5e-6 of PyTorch attention), and Hiera's windowed max-pooling runs as one batched pool (exact)
49
 
50
  ## Inputs and outputs
51
 
52
+ - `sam2_encoder.pte`: `forward(pixels float32 [1,3,512,512])`, an RGB image resized to 512x512 (aspect ratio not kept), scaled to [0, 1] and ImageNet-normalized (mean 0.485/0.456/0.406, std 0.229/0.224/0.225) -> `image_embeddings [1,256,32,32]`, `hr0 [1,256,128,128]`, `hr1 [1,256,64,64]` (high-resolution features)
53
+ - `sam2_decoder.pte`: `forward(image_embeddings, image_pe [1,256,32,32], sparse [1,1,3,256], dense [1,256,32,32], hr0, hr1)` -> `masks [1,3,128,128]` (logits of 3 candidate masks), `iou_scores [1,1,3]` (predicted IoU, after sigmoid), `object_score_logits [1,1,1]`
54
+ - `image_pe` is `prompt_img_pos.bin` and `dense` is `prompt_dense.bin` (no mask prompt). Each sparse token is `[sin(2πcG), cos(2πcG)] + e`, where `G` [2,128] is `prompt_gaussian.bin`, `c` is the point mapped to the 512 grid (+0.5 pixel) and normalized to [-1, 1], and `e` is `prompt_point_embed_fg.bin` / `prompt_point_embed_bg.bin` / `prompt_box_tl.bin` / `prompt_box_br.bin`; a padding token is `prompt_not_a_point.bin` alone; fill unused slots with padding tokens
55
+ - All tensors are float32, contiguous NCHW; `.bin` files are raw little-endian float32
56
 
57
  ## Files
58
 
 
66
  | `prompt_not_a_point.bin` | 1.0 KB | `19771c067b922d9d…` |
67
  | `prompt_point_embed_bg.bin` | 1.0 KB | `91c432d10fcef8e3…` |
68
  | `prompt_point_embed_fg.bin` | 1.0 KB | `2e037dab5bee002d…` |
69
+ | `sam2_decoder.pte` | 16.2 MB | `57b91034acc71be2…` |
70
+ | `sam2_encoder.pte` | 109.9 MB | `30f9fb469b779558…` |
71
 
72
  Full checksums are in `manifest.json`. The playground verifies them after download.
73
 
74
  ## Running it
75
 
76
  These programs were exported with the ExecuTorch WebGPU backend
77
+ ([backends/webgpu](https://github.com/pytorch/executorch/tree/main/backends/webgpu)) and checked against the upstream transformers model at 512x512 (click and box prompts give masks with IoU 1.000),
78
+ and run in a browser on WebGPU with the WebAssembly runtime build used by the ExecuTorch WebGPU Playground (launching soon on [executorch.ai](https://executorch.ai)), where the masks match the CPU run with IoU 1.000.
79
+ They have not yet been tested on a hardware GPU. That runtime build is not published yet; until it is,
80
  the playground is the supported way to run them. It downloads these files once, caches them in the browser,
81
  and runs everything locally.
82
 
 
84
 
85
  ## Provenance
86
 
87
+ - **Upstream:** `facebook/sam2.1-hiera-tiny` at revision `de431c4043854a71d8101e17995dfe596bf101a5` (official)
88
+ - **Checked against the official checkpoint:** `model.safetensors` (sha256 `48c14467e5cf9e51870511feb72c89688e82dd74523142c0538b663e193ac2a7`) has 469/469 tensors bit-identical to the authors' original checkpoint `sam2.1_hiera_tiny.pt` (sha256 `7402e0d864fa82708a20fbd15bc84245c2f26dff0eb43a4b5b93452deb34be69`) in the same repo at the same revision; `python export/_sam2.py verify-official` repeats the check
89
+ - **Exported with:** executorch 1.5.1+cpu, torch 2.14.1+cpu, torchao 0.18.0+cpu, transformers 5.19.0 (executorch git `3b6068392324`)
90
+ - **Reproduce:** `python recipes/sam2-hiera-tiny.py --out out/sam2.1-hiera-tiny` (recipe in `export/`)
91
+ - Re-running the recipe produces byte-identical files.
92
 
93
  ## Citation
94
 
export/_common.py ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared helpers for the ExecuTorch WebGPU export recipes in this directory.
2
+
3
+ Each recipe builds an eager model from a pinned upstream revision and calls
4
+ `export_vision` (or its own LLM path), which writes:
5
+
6
+ <out>/<name>_webgpu.pte program lowered with WebGPUPartitioner (delegate id "VulkanBackend")
7
+ <out>/<name>_portable.pte same exported graph without a delegate, for CPU validation only
8
+ <out>/recipe.json upstream id + revision, versions, shapes, dtype, command line
9
+ <out>/validation.json eager vs exported-program vs portable .pte (and goldens, if given)
10
+ """
11
+
12
+ import hashlib
13
+ import io
14
+ import json
15
+ import platform
16
+ import shlex
17
+ import sys
18
+ import urllib.request
19
+ import zipfile
20
+ from pathlib import Path
21
+
22
+ import numpy as np
23
+ import torch
24
+
25
+ PROXY_HINT = (
26
+ "set https_proxy if the machine needs a proxy for huggingface.co / pypi.org"
27
+ )
28
+
29
+
30
+ def versions():
31
+ import executorch
32
+ import torchao
33
+ from executorch.version import git_version
34
+
35
+ out = {
36
+ "python": platform.python_version(),
37
+ "torch": torch.__version__,
38
+ "torchao": torchao.__version__,
39
+ "executorch": getattr(executorch, "__version__", None)
40
+ or __import__("executorch.version").version.__version__,
41
+ "executorch_git": git_version,
42
+ }
43
+ try:
44
+ import transformers
45
+
46
+ out["transformers"] = transformers.__version__
47
+ except ImportError:
48
+ pass
49
+ try:
50
+ import huggingface_hub
51
+
52
+ out["huggingface_hub"] = huggingface_hub.__version__
53
+ except ImportError:
54
+ pass
55
+ return out
56
+
57
+
58
+ def sha256_file(path):
59
+ h = hashlib.sha256()
60
+ with open(path, "rb") as fh:
61
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
62
+ h.update(chunk)
63
+ return h.hexdigest()
64
+
65
+
66
+ def hf_file(repo_id, filename, revision, expected_sha256=None, cache_dir=None):
67
+ from huggingface_hub import hf_hub_download
68
+
69
+ path = hf_hub_download(
70
+ repo_id=repo_id, filename=filename, revision=revision, cache_dir=cache_dir
71
+ )
72
+ digest = sha256_file(path)
73
+ if expected_sha256 and digest != expected_sha256:
74
+ sys.exit(
75
+ f"{repo_id}@{revision}:{filename} sha256 {digest} != {expected_sha256}"
76
+ )
77
+ return Path(path), digest
78
+
79
+
80
+ def pypi_wheel_member(url, expected_sha256, member):
81
+ """Read one file out of a PyPI wheel, verifying the wheel's sha256."""
82
+ with urllib.request.urlopen(url, timeout=120) as resp:
83
+ blob = resp.read()
84
+ digest = hashlib.sha256(blob).hexdigest()
85
+ if digest != expected_sha256:
86
+ sys.exit(f"{url}: sha256 {digest} != {expected_sha256} ({PROXY_HINT})")
87
+ with zipfile.ZipFile(io.BytesIO(blob)) as zf:
88
+ return zf.read(member).decode()
89
+
90
+
91
+ def scrub(text, out_dir):
92
+ """Replace machine-specific paths so recipe.json can ship with the model."""
93
+ text = text.replace(str(Path(out_dir).resolve()), "<out>")
94
+ text = text.replace(str(Path(__file__).resolve().parent), "recipes")
95
+ return text.replace(str(Path.home()), "~")
96
+
97
+
98
+ def command_line(out_dir):
99
+ argv = [f"recipes/{Path(sys.argv[0]).name}", *sys.argv[1:]]
100
+ args = []
101
+ for a in argv:
102
+ if a.startswith("/") or a.startswith("."):
103
+ a = scrub(str(Path(a).resolve()), out_dir)
104
+ args.append(a)
105
+ return scrub(" ".join(["python", *(shlex.quote(a) for a in args)]), out_dir)
106
+
107
+
108
+ def lower_webgpu(ep):
109
+ from executorch.backends.webgpu.partitioner import WebGPUPartitioner
110
+ from executorch.exir import to_edge_transform_and_lower
111
+
112
+ return to_edge_transform_and_lower(
113
+ ep, partitioner=[WebGPUPartitioner()]
114
+ ).to_executorch()
115
+
116
+
117
+ def lower_portable(ep):
118
+ from executorch.exir import to_edge_transform_and_lower
119
+
120
+ return to_edge_transform_and_lower(ep).to_executorch()
121
+
122
+
123
+ def delegate_summary(pte_bytes):
124
+ """Non-delegated kernels and per-delegate op histograms of a serialized program."""
125
+ import collections
126
+
127
+ from executorch.backends.vulkan.serialization.vulkan_graph_serialize import (
128
+ extract_vk_flatbuffer,
129
+ flatbuffer_to_vk_graph,
130
+ )
131
+ from executorch.exir._serialize._program import deserialize_pte_binary
132
+
133
+ pte = deserialize_pte_binary(pte_bytes)
134
+ prog = pte.program if hasattr(pte, "program") else pte
135
+ seg_base = int.from_bytes(pte_bytes[24:32], "little")
136
+ plan = prog.execution_plan[0]
137
+ ops = [f"{o.name}.{o.overload}" for o in plan.operators]
138
+ kernels = collections.Counter()
139
+ for chain in plan.chains:
140
+ for ins in chain.instructions:
141
+ if type(ins.instr_args).__name__ == "KernelCall":
142
+ kernels[ops[ins.instr_args.op_index]] += 1
143
+ delegates = []
144
+ for d in plan.delegates:
145
+ proc = d.processed
146
+ if proc.location.name == "INLINE":
147
+ blob = prog.backend_delegate_data[proc.index].data
148
+ else:
149
+ seg = prog.segments[proc.index]
150
+ blob = pte_bytes[seg_base + seg.offset : seg_base + seg.offset + seg.size]
151
+ g = flatbuffer_to_vk_graph(extract_vk_flatbuffer(blob))
152
+ delegates.append(
153
+ {
154
+ "id": d.id,
155
+ "ops": dict(collections.Counter(c.name for c in g.chain)),
156
+ "inputs": len(g.input_ids),
157
+ "outputs": len(g.output_ids),
158
+ }
159
+ )
160
+ return {"non_delegated_kernels": dict(kernels), "delegates": delegates}
161
+
162
+
163
+ def compare(a, b):
164
+ a = np.asarray(a, dtype=np.float64).ravel()
165
+ b = np.asarray(b, dtype=np.float64).ravel()
166
+ n = min(a.size, b.size)
167
+ a, b = a[:n], b[:n]
168
+ cos = float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-30))
169
+ return {
170
+ "max_abs": float(np.max(np.abs(a - b))),
171
+ "mean_abs": float(np.mean(np.abs(a - b))),
172
+ "cosine": cos,
173
+ }
174
+
175
+
176
+ def run_pte(pte_bytes, inputs):
177
+ from executorch.extension.pybindings.portable_lib import (
178
+ _load_for_executorch_from_buffer,
179
+ )
180
+
181
+ m = _load_for_executorch_from_buffer(pte_bytes)
182
+ return [
183
+ o.detach().numpy() if isinstance(o, torch.Tensor) else o
184
+ for o in m.forward(inputs)
185
+ ]
186
+
187
+
188
+ def try_run_webgpu_pte_on_cpu(pte_bytes, inputs):
189
+ try:
190
+ return run_pte(pte_bytes, inputs), None
191
+ except Exception as e: # noqa: BLE001 - the error text is the result
192
+ return None, f"{type(e).__name__}: {str(e).splitlines()[0][:300]}"
193
+
194
+
195
+ def export_vision(
196
+ *,
197
+ name,
198
+ model,
199
+ example_input,
200
+ out_dir,
201
+ upstream,
202
+ extra_recipe,
203
+ golden=None,
204
+ output_index=None,
205
+ ):
206
+ """Export, lower, write both .pte files, recipe.json, and validation.json.
207
+
208
+ `golden` is an optional (input ndarray, output ndarray) pair; when its input
209
+ shape matches `example_input`, all paths are also compared against it.
210
+ `output_index` picks one tensor when the eager model returns a tuple.
211
+ """
212
+ out_dir = Path(out_dir)
213
+ out_dir.mkdir(parents=True, exist_ok=True)
214
+ torch.manual_seed(0)
215
+ model = model.eval()
216
+
217
+ class Wrapped(torch.nn.Module):
218
+ def __init__(self, m):
219
+ super().__init__()
220
+ self.m = m
221
+
222
+ def forward(self, x):
223
+ y = self.m(x)
224
+ return y[output_index] if output_index is not None else y
225
+
226
+ wrapped = Wrapped(model).eval()
227
+ with torch.no_grad():
228
+ ep = torch.export.export(wrapped, (example_input,), strict=False)
229
+ webgpu = lower_webgpu(ep)
230
+ portable = lower_portable(ep)
231
+
232
+ webgpu_path = out_dir / f"{name}_webgpu.pte"
233
+ portable_path = out_dir / f"{name}_portable.pte"
234
+ webgpu_path.write_bytes(webgpu.buffer)
235
+ portable_path.write_bytes(portable.buffer)
236
+
237
+ summary = delegate_summary(webgpu.buffer)
238
+ out_shape = list(ep.module()(example_input).shape)
239
+ recipe = {
240
+ "model": name,
241
+ "upstream": upstream,
242
+ "versions": versions(),
243
+ "export": {
244
+ "api": "torch.export.export(strict=False) -> to_edge_transform_and_lower(partitioner=[WebGPUPartitioner()]) -> to_executorch()",
245
+ "partitioner": "executorch.backends.webgpu.partitioner.WebGPUPartitioner() (default compile options; wraps VulkanPartitioner)",
246
+ "inputs": [
247
+ {
248
+ "shape": list(example_input.shape),
249
+ "dtype": "float32",
250
+ "layout": "NCHW",
251
+ }
252
+ ],
253
+ "outputs": [{"shape": out_shape, "dtype": "float32"}],
254
+ "dtype": "float32",
255
+ "quantization": "none (fp32)",
256
+ **extra_recipe,
257
+ },
258
+ "files": {
259
+ webgpu_path.name: {
260
+ "bytes": webgpu_path.stat().st_size,
261
+ "sha256": sha256_file(webgpu_path),
262
+ },
263
+ portable_path.name: {
264
+ "bytes": portable_path.stat().st_size,
265
+ "sha256": sha256_file(portable_path),
266
+ "note": "CPU-only validation artifact (portable kernels, no delegate); not for release",
267
+ },
268
+ },
269
+ "graph": summary,
270
+ "command": command_line(out_dir),
271
+ }
272
+ (out_dir / "recipe.json").write_text(json.dumps(recipe, indent=2) + "\n")
273
+
274
+ # Validation on CPU.
275
+ gen = torch.Generator().manual_seed(1234)
276
+ rand_input = torch.rand(example_input.shape, generator=gen)
277
+ cases = {"random": rand_input}
278
+ if golden is not None and tuple(golden[0].shape) == tuple(example_input.shape):
279
+ cases["golden"] = torch.from_numpy(golden[0].copy())
280
+ report = {
281
+ "cpu_runtime": "executorch pybindings (portable_lib) from the pip wheel",
282
+ "cases": {},
283
+ }
284
+ webgpu_cpu_error = None
285
+ for label, x in cases.items():
286
+ with torch.no_grad():
287
+ eager = wrapped(x).numpy()
288
+ exported = ep.module()(x).numpy()
289
+ port = run_pte(portable.buffer, (x,))[0]
290
+ res = {
291
+ "exported_vs_eager": compare(exported, eager),
292
+ "portable_pte_vs_eager": compare(port, eager),
293
+ }
294
+ wg, err = try_run_webgpu_pte_on_cpu(webgpu.buffer, (x,))
295
+ if wg is not None:
296
+ res["webgpu_pte_vs_eager"] = compare(wg[0], eager)
297
+ else:
298
+ webgpu_cpu_error = err
299
+ if label == "golden":
300
+ ref = golden[1]
301
+ res["internal_golden_vs_eager"] = compare(eager, ref)
302
+ res["internal_golden_vs_portable_pte"] = compare(port, ref)
303
+ res["golden_note"] = (
304
+ "reference output from the internal goldens.manifest.json"
305
+ )
306
+ report["cases"][label] = res
307
+ report["webgpu_pte_on_cpu"] = (
308
+ "ran"
309
+ if webgpu_cpu_error is None
310
+ else f"not runnable on CPU pybindings: {webgpu_cpu_error}"
311
+ )
312
+ (out_dir / "validation.json").write_text(json.dumps(report, indent=2) + "\n")
313
+ print(
314
+ json.dumps(
315
+ {"recipe": recipe["files"], "graph": summary, "validation": report},
316
+ indent=2,
317
+ )
318
+ )
319
+ return ep, webgpu, portable
320
+
321
+
322
+ def load_golden(manifest_path, key):
323
+ man = json.loads(Path(manifest_path).read_text())[key]["tensors"]
324
+ base = Path(manifest_path).parent
325
+
326
+ def ld(t):
327
+ return np.fromfile(base / t["file"], dtype=np.float32).reshape(t["shape"])
328
+
329
+ inp = man.get("input")
330
+ return ld(inp), ld(man["output"])
export/_sam2.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Inspection helpers for the SAM 2 recipe (sam2-hiera-tiny.py). CPU only, read-only on inputs.
3
+
4
+ _sam2.py ops A.pte [B.pte] every delegated op call with its argument types/shapes; with B,
5
+ the op-signature set difference (ops/arity/ranks in A not in B)
6
+ _sam2.py consts A.pte B.pte how many delegate constants of A are byte-identical in B
7
+ _sam2.py io A.pte method names, program inputs/outputs, non-delegated kernels
8
+ """
9
+
10
+ import collections
11
+ import hashlib
12
+ import json
13
+ import sys
14
+
15
+
16
+ def _program(data):
17
+ from executorch.exir._serialize._program import deserialize_pte_binary
18
+
19
+ pte = deserialize_pte_binary(data)
20
+ return pte, (pte.program if hasattr(pte, "program") else pte)
21
+
22
+
23
+ def _blobs(data):
24
+ pte, prog = _program(data)
25
+ seg_base = int.from_bytes(data[24:32], "little")
26
+ out = []
27
+ for plan in prog.execution_plan:
28
+ for d in plan.delegates:
29
+ proc = d.processed
30
+ if proc.location.name == "INLINE":
31
+ out.append(
32
+ (
33
+ plan.name,
34
+ d.id,
35
+ bytes(prog.backend_delegate_data[proc.index].data),
36
+ )
37
+ )
38
+ else:
39
+ seg = prog.segments[proc.index]
40
+ out.append(
41
+ (
42
+ plan.name,
43
+ d.id,
44
+ data[seg_base + seg.offset : seg_base + seg.offset + seg.size],
45
+ )
46
+ )
47
+ nd = getattr(pte, "named_data", None)
48
+ resolve = (
49
+ (
50
+ lambda k: bytes(nd.buffers[nd.pte_data[k].buffer_index])
51
+ if k in nd.pte_data
52
+ else None
53
+ )
54
+ if nd
55
+ else (lambda k: None)
56
+ )
57
+ return out, resolve
58
+
59
+
60
+ def vk_graph(blob):
61
+ from executorch.backends.vulkan.serialization.vulkan_graph_serialize import (
62
+ extract_vk_flatbuffer,
63
+ flatbuffer_to_vk_graph,
64
+ )
65
+
66
+ return flatbuffer_to_vk_graph(extract_vk_flatbuffer(blob))
67
+
68
+
69
+ def describe(g, vid):
70
+ v = g.values[vid].value
71
+ n = type(v).__name__
72
+ if n == "VkTensor":
73
+ kind = "const" if v.constant_id >= 0 else "tensor"
74
+ return f"{kind}{list(v.dims)}:{v.datatype.name if hasattr(v.datatype, 'name') else v.datatype}"
75
+ if n == "Int":
76
+ return f"Int={v.int_val}"
77
+ if n == "Double":
78
+ return f"Double={v.double_val:g}"
79
+ if n == "Bool":
80
+ return f"Bool={v.bool_val}"
81
+ if n in ("IntList", "DoubleList", "BoolList"):
82
+ return f"{n}={list(v.items)}"
83
+ if n == "ValueList":
84
+ return "ValueList[" + ", ".join(describe(g, i) for i in v.items) + "]"
85
+ if n == "String":
86
+ return f"String={v.string_val!r}"
87
+ return n
88
+
89
+
90
+ def ops(path):
91
+ data = open(path, "rb").read()
92
+ blobs, _ = _blobs(data)
93
+ calls = []
94
+ for plan, did, blob in blobs:
95
+ g = vk_graph(blob)
96
+ for c in g.chain:
97
+ calls.append(
98
+ {"plan": plan, "op": c.name, "args": [describe(g, a) for a in c.args]}
99
+ )
100
+ return calls
101
+
102
+
103
+ def signature(call):
104
+ """Op name + arity + per-argument kind/rank (shape-agnostic) for set comparisons."""
105
+ parts = []
106
+ for a in call["args"]:
107
+ if a.startswith(("tensor[", "const[")):
108
+ dims = json.loads(a[a.index("[") : a.index("]") + 1])
109
+ parts.append(f"{a.split('[')[0]}{len(dims)}d:{a.rsplit(':', 1)[1]}")
110
+ else:
111
+ parts.append(a.split("=")[0])
112
+ return f"{call['op']}({', '.join(parts)})"
113
+
114
+
115
+ def consts(path):
116
+ from executorch.backends.vulkan.serialization.vulkan_graph_serialize import (
117
+ VulkanDelegateHeader,
118
+ )
119
+
120
+ data = open(path, "rb").read()
121
+ blobs, resolve = _blobs(data)
122
+ out = []
123
+ for plan, did, blob in blobs:
124
+ h = VulkanDelegateHeader.from_bytes(
125
+ blob[: VulkanDelegateHeader.EXPECTED_LENGTH]
126
+ )
127
+ g = vk_graph(blob)
128
+ raw = blob[h.bytes_offset : h.bytes_offset + h.bytes_size]
129
+ for v in g.values:
130
+ t = v.value
131
+ if type(t).__name__ != "VkTensor" or t.constant_id < 0:
132
+ continue
133
+ c = g.constants[t.constant_id]
134
+ b = (
135
+ resolve(c.named_key)
136
+ if c.named_key
137
+ else raw[c.offset : c.offset + c.length]
138
+ )
139
+ out.append(
140
+ {
141
+ "dims": list(t.dims),
142
+ "sha": hashlib.sha256(b).hexdigest(),
143
+ "bytes": len(b),
144
+ }
145
+ )
146
+ return out
147
+
148
+
149
+ def io(path):
150
+ data = open(path, "rb").read()
151
+ _, prog = _program(data)
152
+ res = {"file": path, "bytes": len(data), "methods": {}}
153
+ for plan in prog.execution_plan:
154
+
155
+ def d(i):
156
+ v = plan.values[i].val
157
+ return (
158
+ f"{list(v.sizes)}:{v.scalar_type.name}"
159
+ if hasattr(v, "sizes")
160
+ else type(v).__name__
161
+ )
162
+
163
+ names = [f"{o.name}.{o.overload}" for o in plan.operators]
164
+ kernels = collections.Counter(
165
+ names[ins.instr_args.op_index]
166
+ for ch in plan.chains
167
+ for ins in ch.instructions
168
+ if type(ins.instr_args).__name__ == "KernelCall"
169
+ )
170
+ res["methods"][plan.name] = {
171
+ "inputs": [d(i) for i in plan.inputs],
172
+ "outputs": [d(i) for i in plan.outputs],
173
+ "delegates": [x.id for x in plan.delegates],
174
+ "non_delegated_kernels": dict(kernels),
175
+ }
176
+ return res
177
+
178
+
179
+ OFFICIAL = {
180
+ "sam2.1": ("facebook/sam2.1-hiera-tiny", "de431c4043854a71d8101e17995dfe596bf101a5", "sam2.1_hiera_tiny.pt",
181
+ "7402e0d864fa82708a20fbd15bc84245c2f26dff0eb43a4b5b93452deb34be69"),
182
+ }
183
+
184
+
185
+ def verify_official(weights="sam2.1"):
186
+ import importlib.util
187
+ import pathlib
188
+ import tempfile
189
+
190
+ import torch
191
+ from huggingface_hub import hf_hub_download
192
+ from safetensors.torch import load_file
193
+
194
+ here = pathlib.Path(__file__).resolve().parent
195
+ spec = importlib.util.spec_from_file_location("sam2_recipe", here / "sam2-hiera-tiny.py")
196
+ recipe = importlib.util.module_from_spec(spec)
197
+ spec.loader.exec_module(recipe)
198
+ snap, digest = recipe.snapshot(weights)
199
+ hf = load_file(str(snap / "model.safetensors"))
200
+ repo, rev, name, want = OFFICIAL[weights]
201
+ with tempfile.TemporaryDirectory() as tmp:
202
+ p = hf_hub_download(repo, name, revision=rev, local_dir=tmp)
203
+ got = hashlib.sha256(open(p, "rb").read()).hexdigest()
204
+ if got != want:
205
+ sys.exit(f"{name}: sha256 {got} != {want}")
206
+ orig = torch.load(p, map_location="cpu", weights_only=True)["model"]
207
+ h = lambda t: hashlib.sha256(t.detach().contiguous().numpy().tobytes()).hexdigest() # noqa: E731
208
+ pool = {h(v): k for k, v in orig.items()}
209
+ matched, unmatched = 0, []
210
+ for k, v in hf.items():
211
+ if h(v) in pool:
212
+ matched += 1
213
+ else:
214
+ unmatched.append(k)
215
+ notes = {}
216
+ if "prompt_encoder.point_embed.weight" in unmatched:
217
+ pe = hf["prompt_encoder.point_embed.weight"]
218
+ if all(torch.equal(pe[i], orig[f"sam_prompt_encoder.point_embeddings.{i}.weight"][0]) for i in range(pe.shape[0])):
219
+ unmatched.remove("prompt_encoder.point_embed.weight")
220
+ matched += 1
221
+ notes["prompt_encoder.point_embed.weight"] = "rows == sam_prompt_encoder.point_embeddings.{0..3}.weight"
222
+ return {"hf": f"{repo}@{rev}/model.safetensors", "hf_sha256": digest, "official": f"{repo}@{rev}/{name}",
223
+ "official_sha256": want, "hf_tensors": len(hf), "official_tensors": len(orig),
224
+ "hf_tensors_bit_identical_to_official": matched, "unmatched": unmatched, "notes": notes}
225
+
226
+
227
+ def main():
228
+ cmd, args = sys.argv[1], sys.argv[2:]
229
+ if cmd == "io":
230
+ print(json.dumps(io(args[0]), indent=1))
231
+ elif cmd == "ops":
232
+ a = ops(args[0])
233
+ if len(args) == 1:
234
+ for c in a:
235
+ print(c["op"], ", ".join(c["args"]))
236
+ return
237
+ b = ops(args[1])
238
+ sa, sb = (
239
+ collections.Counter(map(signature, a)),
240
+ collections.Counter(map(signature, b)),
241
+ )
242
+ print(
243
+ json.dumps(
244
+ {
245
+ "ops_count": [sum(sa.values()), sum(sb.values())],
246
+ "op_types_only_in_a": sorted(
247
+ {c["op"] for c in a} - {c["op"] for c in b}
248
+ ),
249
+ "op_types_only_in_b": sorted(
250
+ {c["op"] for c in b} - {c["op"] for c in a}
251
+ ),
252
+ "signatures_only_in_a": {
253
+ k: v for k, v in sorted(sa.items()) if k not in sb
254
+ },
255
+ "signatures_only_in_b": {
256
+ k: v for k, v in sorted(sb.items()) if k not in sa
257
+ },
258
+ },
259
+ indent=1,
260
+ )
261
+ )
262
+ elif cmd == "verify-official":
263
+ print(json.dumps(verify_official(*(args or ["sam2.1"])), indent=1))
264
+ elif cmd == "consts":
265
+ a, b = consts(args[0]), consts(args[1])
266
+ hb, ha = (
267
+ collections.Counter(c["sha"] for c in b),
268
+ collections.Counter(c["sha"] for c in a),
269
+ )
270
+ print(
271
+ json.dumps(
272
+ {
273
+ "a_constants": len(a),
274
+ "b_constants": len(b),
275
+ "a_found_in_b": sum(c["sha"] in hb for c in a),
276
+ "b_found_in_a": sum(c["sha"] in ha for c in b),
277
+ "a_missing": [c["dims"] for c in a if c["sha"] not in hb],
278
+ "b_missing": [c["dims"] for c in b if c["sha"] not in ha],
279
+ },
280
+ indent=1,
281
+ )
282
+ )
283
+ else:
284
+ sys.exit(__doc__)
285
+
286
+
287
+ if __name__ == "__main__":
288
+ main()
export/recipe.json ADDED
@@ -0,0 +1,589 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "sam2.1-hiera-tiny (512 encoder/decoder split)",
3
+ "upstream": {
4
+ "model": "facebook/sam2.1-hiera-tiny",
5
+ "revision": "de431c4043854a71d8101e17995dfe596bf101a5",
6
+ "official": true,
7
+ "license": "Apache-2.0",
8
+ "files": {
9
+ "model.safetensors": {
10
+ "sha256": "48c14467e5cf9e51870511feb72c89688e82dd74523142c0538b663e193ac2a7"
11
+ }
12
+ },
13
+ "note": "transformers Sam2Model port published by the SAM 2 authors on the Hub"
14
+ },
15
+ "versions": {
16
+ "python": "3.12.15+meta",
17
+ "torch": "2.14.1+cpu",
18
+ "torchao": "0.18.0+cpu",
19
+ "executorch": "1.5.1+cpu",
20
+ "executorch_git": "3b60683923245cf472b7323426920e15623ba361",
21
+ "transformers": "5.19.0",
22
+ "huggingface_hub": "2.2.0"
23
+ },
24
+ "export": {
25
+ "api": "torch.export.export(strict=False) -> to_edge_transform_and_lower(partitioner=[WebGPUPartitioner()]) -> to_executorch(), one program per module",
26
+ "partitioner": "executorch.backends.webgpu.partitioner.WebGPUPartitioner() (default compile options; wraps VulkanPartitioner)",
27
+ "model_code": "transformers Sam2Model; attention through torch.ops.et_vk.sdpa (transformers attention backend, recipes/sam2-hiera-tiny.py:et_vk_attention); config image_size overridden 1024 -> 512",
28
+ "programs": {
29
+ "sam2_encoder.pte": {
30
+ "method": "forward",
31
+ "inputs": [
32
+ {
33
+ "name": "pixels",
34
+ "shape": [
35
+ 1,
36
+ 3,
37
+ 512,
38
+ 512
39
+ ],
40
+ "dtype": "float32"
41
+ }
42
+ ],
43
+ "outputs": [
44
+ {
45
+ "name": "image_embeddings",
46
+ "shape": [
47
+ 1,
48
+ 256,
49
+ 32,
50
+ 32
51
+ ],
52
+ "dtype": "float32",
53
+ "note": "no_memory_embedding added"
54
+ },
55
+ {
56
+ "name": "hr0",
57
+ "shape": [
58
+ 1,
59
+ 256,
60
+ 128,
61
+ 128
62
+ ],
63
+ "dtype": "float32",
64
+ "note": "before mask_decoder.conv_s0"
65
+ },
66
+ {
67
+ "name": "hr1",
68
+ "shape": [
69
+ 1,
70
+ 256,
71
+ 64,
72
+ 64
73
+ ],
74
+ "dtype": "float32",
75
+ "note": "before mask_decoder.conv_s1"
76
+ }
77
+ ]
78
+ },
79
+ "sam2_decoder.pte": {
80
+ "method": "forward",
81
+ "prompt_tokens": 3,
82
+ "inputs": [
83
+ {
84
+ "name": "image_embeddings",
85
+ "shape": [
86
+ 1,
87
+ 256,
88
+ 32,
89
+ 32
90
+ ],
91
+ "dtype": "float32"
92
+ },
93
+ {
94
+ "name": "image_pe",
95
+ "shape": [
96
+ 1,
97
+ 256,
98
+ 32,
99
+ 32
100
+ ],
101
+ "dtype": "float32"
102
+ },
103
+ {
104
+ "name": "sparse",
105
+ "shape": [
106
+ 1,
107
+ 1,
108
+ 3,
109
+ 256
110
+ ],
111
+ "dtype": "float32"
112
+ },
113
+ {
114
+ "name": "dense",
115
+ "shape": [
116
+ 1,
117
+ 256,
118
+ 32,
119
+ 32
120
+ ],
121
+ "dtype": "float32"
122
+ },
123
+ {
124
+ "name": "hr0",
125
+ "shape": [
126
+ 1,
127
+ 256,
128
+ 128,
129
+ 128
130
+ ],
131
+ "dtype": "float32"
132
+ },
133
+ {
134
+ "name": "hr1",
135
+ "shape": [
136
+ 1,
137
+ 256,
138
+ 64,
139
+ 64
140
+ ],
141
+ "dtype": "float32"
142
+ }
143
+ ],
144
+ "outputs": [
145
+ {
146
+ "name": "masks",
147
+ "shape": [
148
+ 1,
149
+ 3,
150
+ 128,
151
+ 128
152
+ ],
153
+ "dtype": "float32",
154
+ "note": "logits, mask tokens 1..3"
155
+ },
156
+ {
157
+ "name": "iou_scores",
158
+ "shape": [
159
+ 1,
160
+ 1,
161
+ 3
162
+ ],
163
+ "dtype": "float32"
164
+ },
165
+ {
166
+ "name": "object_score_logits",
167
+ "shape": [
168
+ 1,
169
+ 1,
170
+ 1
171
+ ],
172
+ "dtype": "float32"
173
+ }
174
+ ]
175
+ }
176
+ },
177
+ "prompt_tables": {
178
+ "prompt_gaussian.bin": {
179
+ "shape": [
180
+ 2,
181
+ 128
182
+ ],
183
+ "dtype": "float32 little-endian"
184
+ },
185
+ "prompt_point_embed_bg.bin": {
186
+ "shape": [
187
+ 256
188
+ ],
189
+ "dtype": "float32 little-endian"
190
+ },
191
+ "prompt_point_embed_fg.bin": {
192
+ "shape": [
193
+ 256
194
+ ],
195
+ "dtype": "float32 little-endian"
196
+ },
197
+ "prompt_box_tl.bin": {
198
+ "shape": [
199
+ 256
200
+ ],
201
+ "dtype": "float32 little-endian"
202
+ },
203
+ "prompt_box_br.bin": {
204
+ "shape": [
205
+ 256
206
+ ],
207
+ "dtype": "float32 little-endian"
208
+ },
209
+ "prompt_not_a_point.bin": {
210
+ "shape": [
211
+ 256
212
+ ],
213
+ "dtype": "float32 little-endian"
214
+ },
215
+ "prompt_img_pos.bin": {
216
+ "shape": [
217
+ 1,
218
+ 256,
219
+ 32,
220
+ 32
221
+ ],
222
+ "dtype": "float32 little-endian"
223
+ },
224
+ "prompt_dense.bin": {
225
+ "shape": [
226
+ 1,
227
+ 256,
228
+ 32,
229
+ 32
230
+ ],
231
+ "dtype": "float32 little-endian"
232
+ }
233
+ },
234
+ "prompt_table_sources": {
235
+ "prompt_gaussian.bin": "shared_image_embedding.positional_embedding",
236
+ "prompt_point_embed_bg.bin": "prompt_encoder.point_embed.weight[0]",
237
+ "prompt_point_embed_fg.bin": "prompt_encoder.point_embed.weight[1]",
238
+ "prompt_box_tl.bin": "prompt_encoder.point_embed.weight[2]",
239
+ "prompt_box_br.bin": "prompt_encoder.point_embed.weight[3]",
240
+ "prompt_not_a_point.bin": "prompt_encoder.not_a_point_embed.weight[0]",
241
+ "prompt_img_pos.bin": "Sam2Model.get_image_wide_positional_embeddings() with image_size=512",
242
+ "prompt_dense.bin": "prompt_encoder.no_mask_embed.weight broadcast to [1,256,32,32]"
243
+ },
244
+ "graph_rewrites": [
245
+ "Hiera q-pooling of windowed tensors [nW,h,w,C] done as one batch-1 max_pool2d over the windows stacked along H (exact: h is a multiple of the stride); keeps all pools in the delegate",
246
+ "attention via torch.ops.et_vk.sdpa (same math as torch SDPA; checked in validation.json)",
247
+ "decoder: a not-a-point token directly after another one gets an additive -1e9 key bias in every attention (computed in-graph from the sparse input), so padding up to the fixed token count does not change the result",
248
+ "Hiera position embedding (bicubic pos_embed resize + tiled window embedding) for the fixed input size computed in eager and stored as a constant"
249
+ ],
250
+ "differences_from_upstream_pipeline": [
251
+ "input 512x512 instead of 1024x1024 (masks 128x128 instead of 256x256)",
252
+ "fixed 3 prompt tokens per decode: points, box corners, one not-a-point pad, then padding pads that the decoder masks out (exactly upstream's prompt for any prompt that fits; larger prompts are composed by the site)",
253
+ "no mask prompt input (dense = no_mask_embed)"
254
+ ],
255
+ "preprocessing_outside_graph": "RGB, resize to 512x512 without keeping aspect, /255, ImageNet mean/std",
256
+ "dtype": "float32",
257
+ "quantization": "none (fp32)"
258
+ },
259
+ "files": {
260
+ "prompt_box_br.bin": {
261
+ "bytes": 1024,
262
+ "sha256": "a0df40a29af4a9b65d390ae46913d4170762a306fc8061ea87f1d8f4a4a1b0d5"
263
+ },
264
+ "prompt_box_tl.bin": {
265
+ "bytes": 1024,
266
+ "sha256": "96c208c44e95f7044bc480951be257c415cfa9c91db907e4575ae3c92d09e6b7"
267
+ },
268
+ "prompt_dense.bin": {
269
+ "bytes": 1048576,
270
+ "sha256": "52cece57b549dc81439c1156a07a25ff02db8cb81329e8eefb68c90457a884f3"
271
+ },
272
+ "prompt_gaussian.bin": {
273
+ "bytes": 1024,
274
+ "sha256": "c37a6a9361886286fc3e868dd279dfa4703919e3fee65c07a07e5a75e980d84f"
275
+ },
276
+ "prompt_img_pos.bin": {
277
+ "bytes": 1048576,
278
+ "sha256": "89dc4182ee4ac6a366dbfd0e9b825e4d7b6e88ded91b2ab06604eb5139e1cc2c"
279
+ },
280
+ "prompt_not_a_point.bin": {
281
+ "bytes": 1024,
282
+ "sha256": "19771c067b922d9d652e6f3dd1f6e2976c68abb8e6fd9db571255ae37687ea50"
283
+ },
284
+ "prompt_point_embed_bg.bin": {
285
+ "bytes": 1024,
286
+ "sha256": "91c432d10fcef8e32ce561eb4820beb6a772a61a3a937d7c2af8597061759cfa"
287
+ },
288
+ "prompt_point_embed_fg.bin": {
289
+ "bytes": 1024,
290
+ "sha256": "2e037dab5bee002d94bdb12d3141ab952cfe2315f0c9feb53a7c78a7e656ed33"
291
+ },
292
+ "sam2_decoder.pte": {
293
+ "bytes": 16949252,
294
+ "sha256": "57b91034acc71be2ce1835ea546a174295daee1046e40db0743e0238ef11e278"
295
+ },
296
+ "sam2_decoder_portable.pte": {
297
+ "bytes": 16968388,
298
+ "sha256": "cac19779cd445105fdfed5599bf4f6ed041fbc4d006c931be8bc2c3cdf3c4655",
299
+ "note": "CPU-only validation artifact (portable kernels, no delegate; torch SDPA instead of et_vk.sdpa); not for release"
300
+ },
301
+ "sam2_encoder.pte": {
302
+ "bytes": 115248512,
303
+ "sha256": "30f9fb469b779558b6b5c7c3eb995c50e1213ffbe082e31430c929ba2b3ffe20"
304
+ },
305
+ "sam2_encoder_portable.pte": {
306
+ "bytes": 115320192,
307
+ "sha256": "b18fc39b847470555f8b69401a2acc67a39f635124ce5ac9c2d6e25917fe44f7",
308
+ "note": "CPU-only validation artifact (portable kernels, no delegate; torch SDPA instead of et_vk.sdpa); not for release"
309
+ }
310
+ },
311
+ "graph": {
312
+ "sam2_encoder": {
313
+ "non_delegated_kernels": {},
314
+ "delegates": [
315
+ {
316
+ "id": "VulkanBackend",
317
+ "ops": {
318
+ "et_vk.prepack.default": 2,
319
+ "aten.convolution.default": 5,
320
+ "aten.clone.default": 32,
321
+ "aten.permute_copy.default": 83,
322
+ "aten.add.Tensor": 27,
323
+ "aten.native_layer_norm.default": 24,
324
+ "aten.constant_pad_nd.default": 9,
325
+ "aten.view_copy.default": 114,
326
+ "aten.linear.default": 51,
327
+ "aten.slice_copy.Tensor": 46,
328
+ "aten.squeeze_copy.dims": 36,
329
+ "et_vk.sdpa.default": 12,
330
+ "aten.gelu.default": 12,
331
+ "aten.max_pool2d_with_indices.default": 6,
332
+ "aten.upsample_nearest2d.vec": 1
333
+ },
334
+ "inputs": 1,
335
+ "outputs": 3
336
+ }
337
+ ],
338
+ "io": {
339
+ "methods": [
340
+ "forward"
341
+ ],
342
+ "inputs": [
343
+ {
344
+ "shape": [
345
+ 1,
346
+ 3,
347
+ 512,
348
+ 512
349
+ ],
350
+ "dtype": "FLOAT",
351
+ "dim_order": [
352
+ 0,
353
+ 1,
354
+ 2,
355
+ 3
356
+ ]
357
+ }
358
+ ],
359
+ "outputs": [
360
+ {
361
+ "shape": [
362
+ 1,
363
+ 256,
364
+ 32,
365
+ 32
366
+ ],
367
+ "dtype": "FLOAT",
368
+ "dim_order": [
369
+ 0,
370
+ 1,
371
+ 2,
372
+ 3
373
+ ]
374
+ },
375
+ {
376
+ "shape": [
377
+ 1,
378
+ 256,
379
+ 128,
380
+ 128
381
+ ],
382
+ "dtype": "FLOAT",
383
+ "dim_order": [
384
+ 0,
385
+ 1,
386
+ 2,
387
+ 3
388
+ ]
389
+ },
390
+ {
391
+ "shape": [
392
+ 1,
393
+ 256,
394
+ 64,
395
+ 64
396
+ ],
397
+ "dtype": "FLOAT",
398
+ "dim_order": [
399
+ 0,
400
+ 1,
401
+ 2,
402
+ 3
403
+ ]
404
+ }
405
+ ]
406
+ },
407
+ "io_contract": "matches the previous export (shapes, fp32, contiguous dim order, single forward method)"
408
+ },
409
+ "sam2_decoder": {
410
+ "non_delegated_kernels": {},
411
+ "delegates": [
412
+ {
413
+ "id": "VulkanBackend",
414
+ "ops": {
415
+ "et_vk.prepack.default": 13,
416
+ "aten.sub.Tensor": 2,
417
+ "aten.abs.default": 1,
418
+ "aten.sum.dim_IntList": 1,
419
+ "aten.mul.Tensor": 3,
420
+ "aten.relu.default": 15,
421
+ "aten.slice_copy.Tensor": 5,
422
+ "aten.full.default": 1,
423
+ "aten.clone.default": 23,
424
+ "aten.cat.default": 4,
425
+ "aten.unsqueeze_copy.default": 16,
426
+ "aten.convolution.default": 4,
427
+ "aten.repeat.default": 1,
428
+ "aten.add.Tensor": 25,
429
+ "aten.view_copy.default": 57,
430
+ "aten.permute_copy.default": 36,
431
+ "aten.squeeze_copy.dims": 34,
432
+ "aten.linear.default": 47,
433
+ "aten.expand_copy.default": 7,
434
+ "et_vk.sdpa.default": 7,
435
+ "aten.native_layer_norm.default": 10,
436
+ "aten.bmm.default": 4,
437
+ "aten.select_copy.int": 7,
438
+ "aten.gelu.default": 2,
439
+ "aten.sigmoid.default": 1
440
+ },
441
+ "inputs": 6,
442
+ "outputs": 3
443
+ }
444
+ ],
445
+ "io": {
446
+ "methods": [
447
+ "forward"
448
+ ],
449
+ "inputs": [
450
+ {
451
+ "shape": [
452
+ 1,
453
+ 256,
454
+ 32,
455
+ 32
456
+ ],
457
+ "dtype": "FLOAT",
458
+ "dim_order": [
459
+ 0,
460
+ 1,
461
+ 2,
462
+ 3
463
+ ]
464
+ },
465
+ {
466
+ "shape": [
467
+ 1,
468
+ 256,
469
+ 32,
470
+ 32
471
+ ],
472
+ "dtype": "FLOAT",
473
+ "dim_order": [
474
+ 0,
475
+ 1,
476
+ 2,
477
+ 3
478
+ ]
479
+ },
480
+ {
481
+ "shape": [
482
+ 1,
483
+ 1,
484
+ 3,
485
+ 256
486
+ ],
487
+ "dtype": "FLOAT",
488
+ "dim_order": [
489
+ 0,
490
+ 1,
491
+ 2,
492
+ 3
493
+ ]
494
+ },
495
+ {
496
+ "shape": [
497
+ 1,
498
+ 256,
499
+ 32,
500
+ 32
501
+ ],
502
+ "dtype": "FLOAT",
503
+ "dim_order": [
504
+ 0,
505
+ 1,
506
+ 2,
507
+ 3
508
+ ]
509
+ },
510
+ {
511
+ "shape": [
512
+ 1,
513
+ 256,
514
+ 128,
515
+ 128
516
+ ],
517
+ "dtype": "FLOAT",
518
+ "dim_order": [
519
+ 0,
520
+ 1,
521
+ 2,
522
+ 3
523
+ ]
524
+ },
525
+ {
526
+ "shape": [
527
+ 1,
528
+ 256,
529
+ 64,
530
+ 64
531
+ ],
532
+ "dtype": "FLOAT",
533
+ "dim_order": [
534
+ 0,
535
+ 1,
536
+ 2,
537
+ 3
538
+ ]
539
+ }
540
+ ],
541
+ "outputs": [
542
+ {
543
+ "shape": [
544
+ 1,
545
+ 3,
546
+ 128,
547
+ 128
548
+ ],
549
+ "dtype": "FLOAT",
550
+ "dim_order": [
551
+ 0,
552
+ 1,
553
+ 2,
554
+ 3
555
+ ]
556
+ },
557
+ {
558
+ "shape": [
559
+ 1,
560
+ 1,
561
+ 3
562
+ ],
563
+ "dtype": "FLOAT",
564
+ "dim_order": [
565
+ 0,
566
+ 1,
567
+ 2
568
+ ]
569
+ },
570
+ {
571
+ "shape": [
572
+ 1,
573
+ 1,
574
+ 1
575
+ ],
576
+ "dtype": "FLOAT",
577
+ "dim_order": [
578
+ 0,
579
+ 1,
580
+ 2
581
+ ]
582
+ }
583
+ ]
584
+ },
585
+ "io_contract": "matches the previous export (shapes, fp32, contiguous dim order, single forward method)"
586
+ }
587
+ },
588
+ "command": "python recipes/sam2-hiera-tiny.py --out out/sam2.1-hiera-tiny"
589
+ }
export/requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment the re-exports in REEXPORT.md were produced with (Python 3.12, Linux x86_64, CPU only).
2
+ # python -m venv venv
3
+ # venv/bin/pip install -r recipes/requirements.txt --extra-index-url https://download.pytorch.org/whl/cpu
4
+ # executorch 1.5.1 = pytorch/executorch git 3b60683923245cf472b7323426920e15623ba361; its wheel ships
5
+ # backends/webgpu (WebGPUPartitioner) and examples/models/llama (export_llm).
6
+ executorch==1.5.1
7
+ torch==2.14.1+cpu
8
+ torchvision==0.29.1+cpu
9
+ torchao==0.18.0+cpu
10
+ transformers==5.19.0
11
+ huggingface_hub==2.2.0
12
+ safetensors==0.8.0
13
+ tokenizers==0.23.3
14
+ numpy==2.5.4
15
+ pillow==12.3.0
export/sam2-hiera-tiny.py ADDED
@@ -0,0 +1,939 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """SAM 2.1 Hiera Tiny -> two ExecuTorch WebGPU programs + prompt tables, 512x512 input.
3
+
4
+ Same split and I/O contract as the playground's previous export (sam2_encoder.pte, sam2_decoder.pte,
5
+ prompt_*.bin); the prompt encoder runs in JS (src/runtime/sam.js) from the tables.
6
+
7
+ sam2_encoder.pte forward(pixels[1,3,512,512] f32: RGB, square resize, /255, ImageNet mean/std)
8
+ -> (image_embeddings[1,256,32,32], hr0[1,256,128,128], hr1[1,256,64,64])
9
+ hr0/hr1 are the FPN levels *before* conv_s0/conv_s1 (the decoder applies them).
10
+ sam2_decoder.pte forward(image_embeddings[1,256,32,32], image_pe[1,256,32,32], sparse[1,1,3,256],
11
+ dense[1,256,32,32], hr0[1,256,128,128], hr1[1,256,64,64])
12
+ -> (masks[1,3,128,128] logits, iou_scores[1,1,3], object_score_logits[1,1,1])
13
+ multimask output (mask tokens 1..3), three prompt tokens (--prompt-tokens): points,
14
+ box corners, one not-a-point pad, then pads; pads after the first are masked, so
15
+ clicks, click+exclude and boxes decode exactly as upstream.
16
+ prompt_gaussian.bin [2,128] f32 random-Fourier matrix (shared_image_embedding)
17
+ prompt_point_embed_bg.bin [256] f32 point_embed[0] (negative click)
18
+ prompt_point_embed_fg.bin [256] f32 point_embed[1] (positive click)
19
+ prompt_box_tl.bin / _br.bin [256] f32 point_embed[2] / [3] (box corners)
20
+ prompt_not_a_point.bin [256] f32 not_a_point_embed (padding token)
21
+ prompt_img_pos.bin [1,256,32,32] f32 get_image_wide_positional_embeddings() at 512
22
+ prompt_dense.bin [1,256,32,32] f32 no_mask_embed broadcast (no mask prompt)
23
+
24
+ Upstream: the HF transformers port (Apache-2.0) of facebook/sam2.1-hiera-tiny, which is what the
25
+ previous export was built from (its decoder weights and all prompt tables are bit-identical to it).
26
+ `--weights sam2` uses facebook/sam2-hiera-tiny (SAM 2.0) instead.
27
+
28
+ Graph changes, all output-preserving: attention goes through the public fused op
29
+ torch.ops.et_vk.sdpa (registered as a transformers attention backend); the Hiera windowed absolute
30
+ position embedding (bicubic resize of pos_embed + tiled window embedding) for the fixed input size is
31
+ computed once in eager and stored as a constant; Hiera's q-pooling of windowed tensors runs as one
32
+ batch-1 max_pool2d over the windows stacked along H (exact; keeps every pool in the delegate). Unlike the previous export, the encoder adds
33
+ `no_memory_embedding` to the 32x32 embedding, as upstream does for single images
34
+ (transformers Sam2Model.forward; `directly_add_no_mem_embed: true` in the official yaml).
35
+ `--no-memory-embedding omit` reproduces the previous behaviour.
36
+ """
37
+
38
+ import argparse
39
+ import json
40
+ import sys
41
+ import types
42
+ from pathlib import Path
43
+
44
+ import numpy as np
45
+ import torch
46
+ import torch.nn.functional as F
47
+
48
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
49
+ import _common # noqa: E402
50
+
51
+ WEIGHTS = {
52
+ "sam2.1": {
53
+ "repo": "facebook/sam2.1-hiera-tiny",
54
+ "revision": "de431c4043854a71d8101e17995dfe596bf101a5",
55
+ "files": {
56
+ "model.safetensors": "48c14467e5cf9e51870511feb72c89688e82dd74523142c0538b663e193ac2a7",
57
+ },
58
+ },
59
+ "sam2": {
60
+ "repo": "facebook/sam2-hiera-tiny",
61
+ "revision": "7c218beaf0bb87874785f32b582f640134fc1c09",
62
+ "files": {
63
+ "model.safetensors": "1449035ba6ba2ed524c646da989e5cb3a9b93b97f1c1b2d474e598c3b748d99f",
64
+ },
65
+ },
66
+ }
67
+ CONFIG_FILES = ("config.json", "preprocessor_config.json", "processor_config.json")
68
+
69
+ # Validation images. Prompts are in original-image pixels, without padding; site_tokens() turns
70
+ # them into the decoder's tokens the way planPrompt() in sam.js does (points, box corners, one
71
+ # not-a-point pad, then pads up to the decoder's token count).
72
+ IMAGE = {
73
+ "name": "sam-car.png",
74
+ "repo": "huggingface/documentation-images",
75
+ "repo_type": "dataset",
76
+ "revision": "79244b5169b24b4e1444393d9b87349c9182cfa4",
77
+ "file": "transformers/model_doc/sam-car.png",
78
+ "sha256": "c6ae6440e3862dd9768cd9f7098b783ac980629dd7bc1fe69c10b39fca5a84cd",
79
+ "prompts": {
80
+ "door_click": [("pos", 1680, 840)],
81
+ "wheel_click": [("pos", 832, 1088)],
82
+ "door_plus_exclude": [("pos", 1680, 840), ("neg", 1000, 200)],
83
+ "car_box": [("tl", 120, 400), ("br", 2528, 1248)],
84
+ "door_and_wheel_clicks": [("pos", 1680, 840), ("pos", 832, 1088)],
85
+ },
86
+ }
87
+ # Optional: extra sample images for validation (`--assets-dir`).
88
+ # Not public inputs; pinned by sha256 so the numbers are reproducible.
89
+ ASSET_IMAGES = {
90
+ "real_pets.jpg": {
91
+ "sha256": "85e4f2f27368a203c30eac68a71e4dde40edac6417b4a8c9df80af5d30b191b2",
92
+ "prompts": {
93
+ "pug_face_click": [("pos", 505, 150)],
94
+ "blanket_click": [("pos", 500, 500)],
95
+ "blanket_minus_face": [("pos", 500, 500), ("neg", 505, 150)],
96
+ "pug_box": [("tl", 165, 65), ("br", 850, 720)],
97
+ },
98
+ },
99
+ "yolo_people.jpg": {
100
+ "sha256": "16d73869e3267a7d4ed00de8e860833bd1657c1b252e94c0c348277adc7b6edb",
101
+ "prompts": {
102
+ "left_man_head_click": [("pos", 570, 260)],
103
+ "right_man_face_click": [("pos", 980, 180)],
104
+ "left_jacket_minus_right": [("pos", 300, 520), ("neg", 880, 520)],
105
+ "right_man_box": [("tl", 750, 40), ("br", 1150, 720)],
106
+ },
107
+ },
108
+ }
109
+ SIZE = 512
110
+ MEAN = (0.485, 0.456, 0.406)
111
+ STD = (0.229, 0.224, 0.225)
112
+ INPUT_NAMES = ["image_embeddings", "image_pe", "sparse", "dense", "hr0", "hr1"]
113
+
114
+
115
+ # Additive key bias [1, 1, 1, tokens] set by Decoder.forward while the mask decoder runs: it hides
116
+ # extra padding tokens (see Decoder.pad_bias). None everywhere else (encoder, references).
117
+ _TOKEN_KEY_BIAS = None
118
+
119
+
120
+ def _key_bias(query, key, attention_mask):
121
+ bias = _TOKEN_KEY_BIAS
122
+ if bias is None or key.shape[-2] != bias.shape[-1]:
123
+ return attention_mask
124
+ full = bias.expand(query.shape[0], query.shape[1], query.shape[2], key.shape[-2])
125
+ return full if attention_mask is None else attention_mask + full
126
+
127
+
128
+ def et_vk_attention(
129
+ module, query, key, value, attention_mask, scaling=None, dropout=0.0, **kwargs
130
+ ):
131
+ """transformers attention backend emitting the fused et_vk.sdpa op (public ExecuTorch Vulkan custom op,
132
+ registered by the WebGPU runtime). q/k/v are [B, H, S, D]; returns [B, S, H, D] like the built-in backends."""
133
+ import executorch.backends.vulkan.custom_ops_lib # noqa: F401 (defines torch.ops.et_vk.*)
134
+
135
+ mask = _key_bias(query, key, attention_mask)
136
+ out = torch.ops.et_vk.sdpa(query, key, value, mask, scaling)
137
+ return out.transpose(1, 2).contiguous(), None
138
+
139
+
140
+ def torch_attention(
141
+ module, query, key, value, attention_mask, scaling=None, dropout=0.0, **kwargs
142
+ ):
143
+ """Same as et_vk_attention with torch SDPA (for the CPU-only portable decoder)."""
144
+ mask = _key_bias(query, key, attention_mask)
145
+ out = F.scaled_dot_product_attention(query, key, value, attn_mask=mask, scale=scaling)
146
+ return out.transpose(1, 2).contiguous(), None
147
+
148
+
149
+ def register_attention():
150
+ from transformers import AttentionInterface
151
+
152
+ AttentionInterface.register("et_vk_sdpa", et_vk_attention)
153
+ AttentionInterface.register("torch_sdpa_padmask", torch_attention)
154
+
155
+
156
+ def site_tokens(prompt, n_tokens):
157
+ """planPrompt() in sam.js for a prompt that fits one decode: points (positive first), box corners,
158
+ one not-a-point pad (SAM 2's own padding), then pads up to the decoder's token count."""
159
+ pos = [t for t in prompt if t[0] == "pos"]
160
+ neg = [t for t in prompt if t[0] == "neg"]
161
+ corners = [t for t in prompt if t[0] in ("tl", "br")]
162
+ native = pos + neg + corners + [("pad",)]
163
+ if len(native) > n_tokens:
164
+ sys.exit(f"prompt {prompt} needs {len(native)} tokens; the decoder takes {n_tokens}")
165
+ return native + [("pad",)] * (n_tokens - len(native))
166
+
167
+
168
+ def snapshot(weights):
169
+ """Pinned upstream snapshot: weights (sha256-checked) + configs at the same revision."""
170
+ w = WEIGHTS[weights]
171
+ path, digest = _common.hf_file(
172
+ w["repo"], "model.safetensors", w["revision"], w["files"]["model.safetensors"]
173
+ )
174
+ for f in CONFIG_FILES:
175
+ _common.hf_file(w["repo"], f, w["revision"])
176
+ return path.parent, digest
177
+
178
+
179
+ def load_model(snap, size, attn="sdpa"):
180
+ from transformers import Sam2Config, Sam2Model
181
+
182
+ cfg = Sam2Config.from_pretrained(snap)
183
+ if size != cfg.image_size:
184
+ f = size // cfg.prompt_encoder_config.patch_size
185
+ cfg.image_size = size
186
+ cfg.prompt_encoder_config.image_size = size
187
+ cfg.vision_config.backbone_config.image_size = [size, size]
188
+ cfg.vision_config.backbone_feature_sizes = [
189
+ [4 * f, 4 * f],
190
+ [2 * f, 2 * f],
191
+ [f, f],
192
+ ]
193
+ model, info = Sam2Model.from_pretrained(
194
+ snap, config=cfg, attn_implementation=attn, output_loading_info=True
195
+ )
196
+ if info["missing_keys"] or info["mismatched_keys"] or info["unexpected_keys"]:
197
+ sys.exit(f"weights did not load cleanly: {info}")
198
+ return model.eval()
199
+
200
+
201
+ def pool_windows_as_one_image():
202
+ """Hiera's q-pooling (2x2 max pool, stride 2) on windowed tensors [nW, h, w, C] is rewritten to pool
203
+ the windows stacked along H as a single [1, C, nW*h, w] image. h is a multiple of the stride, so no
204
+ pooling cell straddles two windows and the result is identical; batch-1 pooling is what the Vulkan
205
+ partitioner can represent (a [256, 384, 4, 4] pool is left on CPU otherwise)."""
206
+ from transformers.models.sam2 import modeling_sam2
207
+
208
+ original = getattr(modeling_sam2, "_upstream_do_pool", modeling_sam2.do_pool)
209
+ modeling_sam2._upstream_do_pool = original
210
+
211
+ def do_pool(x, query_stride=None):
212
+ if query_stride is None or x.shape[0] == 1:
213
+ return original(x, query_stride)
214
+ b, h, w, c = x.shape
215
+ sh, sw = (query_stride, query_stride) if isinstance(query_stride, int) else tuple(query_stride)
216
+ if h % sh or w % sw:
217
+ return original(x, query_stride)
218
+ y = x.reshape(1, b * h, w, c).permute(0, 3, 1, 2)
219
+ y = F.max_pool2d(y, kernel_size=query_stride, stride=query_stride, ceil_mode=False)
220
+ return y.permute(0, 2, 3, 1).reshape(b, h // sh, w // sw, c)
221
+
222
+ modeling_sam2.do_pool = do_pool
223
+
224
+
225
+ def upstream_pool():
226
+ """Undo pool_windows_as_one_image() (upstream code path, for references)."""
227
+ from transformers.models.sam2 import modeling_sam2
228
+
229
+ modeling_sam2.do_pool = getattr(modeling_sam2, "_upstream_do_pool", modeling_sam2.do_pool)
230
+
231
+
232
+ def freeze_hiera_pos_embed(model, size):
233
+ bb = model.vision_encoder.backbone
234
+ with torch.no_grad():
235
+ frozen = bb._get_pos_embed((size // 4, size // 4)).detach().contiguous().clone()
236
+ bb.register_buffer("frozen_pos_embed", frozen, persistent=False)
237
+ bb._get_pos_embed = types.MethodType(lambda self, _hw: self.frozen_pos_embed, bb)
238
+ return model
239
+
240
+
241
+ class Encoder(torch.nn.Module):
242
+ def __init__(self, model, add_no_memory=True):
243
+ super().__init__()
244
+ self.vision = model.vision_encoder
245
+ self.add_no_memory = add_no_memory
246
+ self.register_buffer(
247
+ "no_mem",
248
+ model.no_memory_embedding.detach().reshape(1, -1, 1, 1).clone(),
249
+ persistent=False,
250
+ )
251
+
252
+ def forward(self, pixels):
253
+ hr0, hr1, emb = self.vision(pixels).fpn_hidden_states
254
+ if self.add_no_memory:
255
+ emb = emb + self.no_mem
256
+ return emb, hr0, hr1
257
+
258
+
259
+ class Decoder(torch.nn.Module):
260
+ """Mask decoder on precomputed prompt embeddings.
261
+
262
+ A pad (not-a-point) token that directly follows another pad is hidden from every attention as a
263
+ key (additive -1e9, which underflows to an exact 0 weight). Nothing reads such a token's own
264
+ output, so a fixed-size decode of [pos, pad, pad] equals upstream's [pos, pad], and [tl, br, pad]
265
+ equals upstream's box prompt. The first pad is kept: it is SAM 2's own padding token."""
266
+
267
+ def __init__(self, model):
268
+ super().__init__()
269
+ self.md = model.mask_decoder
270
+ self.n_out = self.md.obj_score_token.weight.shape[0] + 1 + self.md.num_mask_tokens
271
+ self.register_buffer(
272
+ "not_a_point",
273
+ model.prompt_encoder.not_a_point_embed.weight[0].detach().clone(),
274
+ persistent=False,
275
+ )
276
+
277
+ def pad_bias(self, sparse):
278
+ d = (sparse - self.not_a_point).abs().sum(-1) # [1, 1, N]; 0 only for an exact pad token
279
+ is_pad = torch.relu(1.0 - d * 1e6) # 1.0 for a pad, 0.0 otherwise
280
+ repeated = is_pad[..., 1:] * is_pad[..., :-1]
281
+ keep = torch.zeros(1, 1, self.n_out + 1, dtype=sparse.dtype)
282
+ return (torch.cat([keep, repeated], dim=-1) * -1e9).unsqueeze(2) # [1, 1, 1, n_out + N]
283
+
284
+ def forward(self, emb, image_pe, sparse, dense, hr0, hr1):
285
+ global _TOKEN_KEY_BIAS
286
+ _TOKEN_KEY_BIAS = self.pad_bias(sparse) if sparse.shape[2] > 1 else None
287
+ try:
288
+ return self._decode(emb, image_pe, sparse, dense, hr0, hr1)
289
+ finally:
290
+ _TOKEN_KEY_BIAS = None
291
+
292
+ def _decode(self, emb, image_pe, sparse, dense, hr0, hr1):
293
+ masks, iou, _, obj = self.md(
294
+ image_embeddings=emb,
295
+ image_positional_embeddings=image_pe,
296
+ sparse_prompt_embeddings=sparse,
297
+ dense_prompt_embeddings=dense,
298
+ multimask_output=True,
299
+ high_resolution_features=[self.md.conv_s0(hr0), self.md.conv_s1(hr1)],
300
+ )
301
+ # [B, point_batch=1, 3, H, W] -> [B, 3, H, W], as the previous export.
302
+ return masks[:, 0], iou, obj
303
+
304
+
305
+ def prompt_tables(model):
306
+ pe = model.prompt_encoder
307
+ with torch.no_grad():
308
+ dense = (
309
+ pe.no_mask_embed.weight.reshape(1, -1, 1, 1)
310
+ .expand(1, -1, *pe.image_embedding_size)
311
+ .contiguous()
312
+ )
313
+ tabs = {
314
+ "gaussian": model.shared_image_embedding.positional_embedding,
315
+ "point_embed_bg": pe.point_embed.weight[0],
316
+ "point_embed_fg": pe.point_embed.weight[1],
317
+ "box_tl": pe.point_embed.weight[2],
318
+ "box_br": pe.point_embed.weight[3],
319
+ "not_a_point": pe.not_a_point_embed.weight[0],
320
+ "img_pos": model.get_image_wide_positional_embeddings(),
321
+ "dense": dense,
322
+ }
323
+ return {
324
+ k: v.detach().to(torch.float32).contiguous().numpy() for k, v in tabs.items()
325
+ }
326
+
327
+
328
+ def encode_tokens(tokens, width, height, tabs, size=SIZE):
329
+ """Python port of the playground's browser-side prompt encoding (float64 math, f32 out)."""
330
+ g = tabs["gaussian"].reshape(-1).astype(np.float64)
331
+ hidden = tabs["not_a_point"].size
332
+ D = hidden // 2
333
+ kind = {
334
+ "neg": tabs["point_embed_bg"],
335
+ "pos": tabs["point_embed_fg"],
336
+ "tl": tabs["box_tl"],
337
+ "br": tabs["box_br"],
338
+ }
339
+ out = np.zeros((len(tokens), hidden), np.float64)
340
+ for r, t in enumerate(tokens):
341
+ if t[0] == "pad":
342
+ out[r] = tabs["not_a_point"]
343
+ continue
344
+ x, y = round(t[1] * 10) / 10, round(t[2] * 10) / 10
345
+ cx = (2 * (x * (size / width) + 0.5)) / size - 1
346
+ cy = (2 * (y * (size / height) + 0.5)) / size - 1
347
+ a = 2 * np.pi * (cx * g[:D] + cy * g[D:])
348
+ out[r, :D] = np.sin(a) + kind[t[0]][:D]
349
+ out[r, D:] = np.cos(a) + kind[t[0]][D:]
350
+ return torch.from_numpy(out.astype(np.float32)).reshape(1, 1, len(tokens), hidden)
351
+
352
+
353
+ def load_image(path, size):
354
+ from PIL import Image
355
+
356
+ img = Image.open(path).convert("RGB")
357
+ a = np.asarray(img.resize((size, size), Image.BILINEAR), dtype=np.float32) / 255.0
358
+ a = (a - np.array(MEAN, np.float32)) / np.array(STD, np.float32)
359
+ return torch.from_numpy(a.transpose(2, 0, 1).copy()).unsqueeze(0), img
360
+
361
+
362
+ def iou(a, b):
363
+ a, b = np.asarray(a) > 0, np.asarray(b) > 0
364
+ u = np.logical_or(a, b).sum()
365
+ return round(float(np.logical_and(a, b).sum() / u), 4) if u else 1.0
366
+
367
+
368
+ def upsample(masks, w, h):
369
+ """Bilinear, align_corners=False: the same mapping as upsampleMask() in sam.js."""
370
+ t = torch.from_numpy(np.ascontiguousarray(masks, np.float32)).reshape(
371
+ 1, -1, *np.asarray(masks).shape[-2:]
372
+ )
373
+ return F.interpolate(t, size=(h, w), mode="bilinear", align_corners=False)[
374
+ 0
375
+ ].numpy()
376
+
377
+
378
+ def upstream_inputs(tokens):
379
+ """The same prompt in transformers' native form (Sam2PromptEncoder pads points with a not-a-point
380
+ token and always adds one after box corners, like the original SAM 2 predictor)."""
381
+ pts = [t for t in tokens if t[0] in ("pos", "neg")]
382
+ corners = [t for t in tokens if t[0] in ("tl", "br")]
383
+ kw = {}
384
+ if corners:
385
+ (_, x0, y0), (_, x1, y1) = corners
386
+ kw["input_boxes"] = [[[x0, y0, x1, y1]]]
387
+ if pts:
388
+ kw["input_points"] = [[[[t[1], t[2]] for t in pts]]]
389
+ kw["input_labels"] = [[[1 if t[0] == "pos" else 0 for t in pts]]]
390
+ return kw
391
+
392
+
393
+ def upstream_reference(model, processor, img, tokens):
394
+ """Upstream transformers pipeline end to end (Sam2Processor -> Sam2Model -> post_process_masks):
395
+ mask logits at the original image size, and the IoU head's scores."""
396
+ inputs = processor(images=img, return_tensors="pt", **upstream_inputs(tokens))
397
+ with torch.no_grad():
398
+ o = model(
399
+ pixel_values=inputs["pixel_values"],
400
+ input_points=inputs.get("input_points"),
401
+ input_labels=inputs.get("input_labels"),
402
+ input_boxes=inputs.get("input_boxes"),
403
+ multimask_output=True,
404
+ )
405
+ m = processor.post_process_masks(
406
+ o.pred_masks, inputs["original_sizes"], binarize=False
407
+ )[0]
408
+ return m[0].numpy(), o.iou_scores[0, 0].numpy()
409
+
410
+
411
+ def upstream_512_same_pixels(model, pixels, tokens, w, h):
412
+ """Upstream Sam2Model.forward (512 config) on exactly the pixels the export sees."""
413
+ s = model.prompt_encoder.input_image_size
414
+ kw = upstream_inputs(tokens)
415
+ sx, sy = s / w, s / h
416
+ args = {}
417
+ if "input_boxes" in kw:
418
+ x0, y0, x1, y1 = kw["input_boxes"][0][0]
419
+ args["input_boxes"] = torch.tensor(
420
+ [[[x0 * sx, y0 * sy, x1 * sx, y1 * sy]]], dtype=torch.float32
421
+ )
422
+ if "input_points" in kw:
423
+ args["input_points"] = torch.tensor(
424
+ [[[[x * sx, y * sy] for x, y in kw["input_points"][0][0]]]],
425
+ dtype=torch.float32,
426
+ )
427
+ args["input_labels"] = torch.tensor(kw["input_labels"], dtype=torch.int64)
428
+ with torch.no_grad():
429
+ o = model(pixel_values=pixels, multimask_output=True, **args)
430
+ return o.pred_masks[0, 0].numpy(), o.iou_scores[0, 0].numpy()
431
+
432
+
433
+ CONTRACT = {
434
+ "sam2_encoder": (
435
+ [[1, 3, SIZE, SIZE]],
436
+ [[1, 256, SIZE // 16, SIZE // 16], [1, 256, SIZE // 4, SIZE // 4], [1, 256, SIZE // 8, SIZE // 8]],
437
+ ),
438
+ "sam2_decoder": (
439
+ [[1, 256, SIZE // 16, SIZE // 16], [1, 256, SIZE // 16, SIZE // 16], [1, 1, "N", 256],
440
+ [1, 256, SIZE // 16, SIZE // 16], [1, 256, SIZE // 4, SIZE // 4], [1, 256, SIZE // 8, SIZE // 8]],
441
+ [[1, 3, SIZE // 4, SIZE // 4], [1, 1, 3], [1, 1, 1]],
442
+ ),
443
+ }
444
+
445
+
446
+ def program_io(pte_bytes):
447
+ """Method names and forward() I/O (shape, dtype, dim order) as serialized in the program."""
448
+ from executorch.exir._serialize._program import deserialize_pte_binary
449
+
450
+ pte = deserialize_pte_binary(pte_bytes)
451
+ prog = pte.program if hasattr(pte, "program") else pte
452
+
453
+ def desc(plan, i):
454
+ v = plan.values[i].val
455
+ return {"shape": list(v.sizes), "dtype": v.scalar_type.name, "dim_order": list(v.dim_order)}
456
+
457
+ plan = {p.name: p for p in prog.execution_plan}
458
+ f = plan["forward"]
459
+ return {
460
+ "methods": sorted(plan),
461
+ "inputs": [desc(f, i) for i in f.inputs],
462
+ "outputs": [desc(f, i) for i in f.outputs],
463
+ }
464
+
465
+
466
+ def check_contract(name, io, size, n_tokens):
467
+ """The playground's programs: one `forward`, fp32, contiguous NCHW, these shapes."""
468
+ if size != SIZE:
469
+ return "skipped (non-default size)"
470
+ want_in, want_out = CONTRACT[name]
471
+ want_in = [[n_tokens if d == "N" else d for d in s] for s in want_in]
472
+ ok = (
473
+ io["methods"] == ["forward"]
474
+ and [t["shape"] for t in io["inputs"]] == want_in
475
+ and [t["shape"] for t in io["outputs"]] == want_out
476
+ and all(t["dtype"] == "FLOAT" and t["dim_order"] == list(range(len(t["shape"]))) for t in io["inputs"] + io["outputs"])
477
+ )
478
+ if not ok:
479
+ sys.exit(f"{name}: I/O does not match the previous contract: {json.dumps(io)}")
480
+ return "matches the previous export (shapes, fp32, contiguous dim order, single forward method)"
481
+
482
+
483
+ def write_if_changed(path, data):
484
+ """Leave an identical file alone; otherwise replace it with a new inode, so hard links made to an
485
+ earlier output elsewhere keep their content."""
486
+ data = bytes(data)
487
+ if path.exists():
488
+ if path.read_bytes() == data:
489
+ return
490
+ path.unlink()
491
+ path.write_bytes(data)
492
+
493
+
494
+ def export_programs(enc, dec, enc_std, dec_std, pixels, dec_inputs):
495
+ with torch.no_grad():
496
+ ep_enc = torch.export.export(enc, (pixels,), strict=False)
497
+ ep_dec = torch.export.export(dec, dec_inputs, strict=False)
498
+ ep_enc_std = torch.export.export(enc_std, (pixels,), strict=False)
499
+ ep_dec_std = torch.export.export(dec_std, dec_inputs, strict=False)
500
+ return {
501
+ "sam2_encoder": (
502
+ ep_enc,
503
+ _common.lower_webgpu(ep_enc),
504
+ _common.lower_portable(ep_enc_std),
505
+ ),
506
+ "sam2_decoder": (
507
+ ep_dec,
508
+ _common.lower_webgpu(ep_dec),
509
+ _common.lower_portable(ep_dec_std),
510
+ ),
511
+ }
512
+
513
+
514
+ def validation_images(args):
515
+ from huggingface_hub import hf_hub_download
516
+
517
+ out = []
518
+ p = Path(
519
+ hf_hub_download(
520
+ IMAGE["repo"],
521
+ IMAGE["file"],
522
+ repo_type=IMAGE["repo_type"],
523
+ revision=IMAGE["revision"],
524
+ )
525
+ )
526
+ digest = _common.sha256_file(p)
527
+ if digest != IMAGE["sha256"]:
528
+ sys.exit(f"{IMAGE['file']}: sha256 {digest} != {IMAGE['sha256']}")
529
+ out.append(
530
+ (
531
+ IMAGE["name"],
532
+ p,
533
+ digest,
534
+ IMAGE["prompts"],
535
+ f"hf://datasets/{IMAGE['repo']}@{IMAGE['revision']}/{IMAGE['file']}",
536
+ )
537
+ )
538
+ if args.assets_dir:
539
+ for name, spec in ASSET_IMAGES.items():
540
+ ap = Path(args.assets_dir) / name
541
+ d = _common.sha256_file(ap)
542
+ if spec["sha256"] and d != spec["sha256"]:
543
+ sys.exit(f"{ap}: sha256 {d} != {spec['sha256']}")
544
+ out.append(
545
+ (
546
+ name,
547
+ ap,
548
+ d,
549
+ spec["prompts"],
550
+ f"sample image {name}",
551
+ )
552
+ )
553
+ return out
554
+
555
+
556
+ def validate(args, snap, model, tabs, progs, enc, dec, enc_std):
557
+ from transformers import Sam2Processor
558
+
559
+ val = {
560
+ "cpu_runtime": "executorch pybindings (portable_lib) from the executorch 1.5.1 wheel"
561
+ }
562
+ ep_enc, wg_enc, port_enc = progs["sam2_encoder"]
563
+ ep_dec, wg_dec, port_dec = progs["sam2_decoder"]
564
+ img_pe = torch.from_numpy(tabs["img_pos"])
565
+ dense = torch.from_numpy(tabs["dense"])
566
+
567
+ ref512 = load_model(snap, args.size)
568
+ ref1024 = load_model(snap, 1024)
569
+ processor = Sam2Processor.from_pretrained(snap)
570
+ other = Encoder(model, add_no_memory=not enc.add_no_memory).eval()
571
+ # Everything below runs the upstream do_pool; the export used the rewrite (checked to be exact).
572
+ upstream_pool()
573
+
574
+ for p_name, ep, wg in (("encoder", ep_enc, wg_enc), ("decoder", ep_dec, wg_dec)):
575
+ _, err = _common.try_run_webgpu_pte_on_cpu(wg.buffer, ep.example_inputs[0])
576
+ val[f"webgpu_{p_name}_pte_on_cpu"] = (
577
+ f"not runnable on CPU pybindings: {err}" if err else "ran"
578
+ )
579
+
580
+ val["images"] = {}
581
+ refs = Path(args.refs_dir) if args.refs_dir else None
582
+ refs_meta = {"prompt_tokens": args.prompt_tokens, "prompts": {}}
583
+ for name, path, digest, prompts, src in validation_images(args):
584
+ pixels, img = load_image(path, args.size)
585
+ W, H = img.size
586
+ with torch.no_grad():
587
+ ref = enc(pixels)
588
+ pool_windows_as_one_image()
589
+ rewritten = enc(pixels)
590
+ upstream_pool()
591
+ exported = ep_enc.module()(pixels)
592
+ std = enc_std(pixels)
593
+ emb_other = other(pixels)[0]
594
+ port = _common.run_pte(port_enc.buffer, (pixels,))
595
+ rec = {
596
+ "source": src,
597
+ "sha256": digest,
598
+ "size": [W, H],
599
+ "encoder": {},
600
+ "prompts": {},
601
+ }
602
+ for i, n in enumerate(["image_embeddings", "hr0", "hr1"]):
603
+ rec["encoder"][n] = {
604
+ "exported_vs_eager": _common.compare(
605
+ exported[i].numpy(), ref[i].numpy()
606
+ ),
607
+ "pool_rewrite_vs_upstream_code_max_abs": float((rewritten[i] - ref[i]).abs().max()),
608
+ "eager_torch_sdpa_vs_eager_et_vk_sdpa": _common.compare(
609
+ std[i].numpy(), ref[i].numpy()
610
+ ),
611
+ "portable_pte_vs_eager": _common.compare(port[i], ref[i].numpy()),
612
+ }
613
+ emb, hr0, hr1 = (t.contiguous() for t in ref)
614
+ if refs is not None and name == IMAGE["name"]:
615
+ refs.mkdir(parents=True, exist_ok=True)
616
+ refs.joinpath("pixels.f32").write_bytes(pixels.numpy().tobytes())
617
+ for i, t in enumerate((emb, hr0, hr1)):
618
+ refs.joinpath(f"enc_out{i}.f32").write_bytes(t.numpy().tobytes())
619
+ refs_meta.update(image=src, image_sha256=digest, size=[W, H])
620
+ for pname, toks in prompts.items():
621
+ dtoks = site_tokens(toks, args.prompt_tokens)
622
+ sparse = encode_tokens(dtoks, W, H, tabs, args.size)
623
+ ins = (emb, img_pe, sparse, dense, hr0, hr1)
624
+ with torch.no_grad():
625
+ eager = [t.numpy() for t in dec(*ins)]
626
+ exp = [t.numpy() for t in ep_dec.module()(*ins)]
627
+ alt_out = dec(emb_other, img_pe, sparse, dense, hr0, hr1)
628
+ alt, alt_iou = alt_out[0].numpy(), alt_out[1].numpy()
629
+ # End to end through both portable programs (encoder output -> decoder input).
630
+ pd = _common.run_pte(
631
+ port_dec.buffer,
632
+ (
633
+ torch.from_numpy(np.ascontiguousarray(port[0])),
634
+ img_pe,
635
+ sparse,
636
+ dense,
637
+ torch.from_numpy(np.ascontiguousarray(port[1])),
638
+ torch.from_numpy(np.ascontiguousarray(port[2])),
639
+ ),
640
+ )
641
+ m_pte = pd[0][0]
642
+ u512_m, u512_iou = upstream_512_same_pixels(ref512, pixels, toks, W, H)
643
+ u_m, u_iou = upstream_reference(ref1024, processor, img, toks)
644
+ up = upsample(m_pte, W, H)
645
+ up_alt = upsample(alt[0], W, H)
646
+ best = int(np.argmax(u_iou))
647
+ own = int(np.argmax(np.asarray(pd[1]).reshape(-1)))
648
+ alt_own = int(np.argmax(alt_iou.reshape(-1)))
649
+ if refs is not None and name == IMAGE["name"]:
650
+ for i, t in enumerate(eager):
651
+ refs.joinpath(f"dec_{pname}_out{i}.f32").write_bytes(np.ascontiguousarray(t, np.float32).tobytes())
652
+ refs.joinpath(f"sparse_{pname}.f32").write_bytes(sparse.numpy().tobytes())
653
+ refs.joinpath(f"up512_{pname}_masks.f32").write_bytes(np.ascontiguousarray(u512_m, np.float32).tobytes())
654
+ refs_meta["prompts"][pname] = {"prompt": [list(t) for t in toks], "tokens": [list(t) for t in dtoks]}
655
+ rec["prompts"][pname] = {
656
+ "prompt": [list(t) for t in toks],
657
+ "decoder_tokens": [t[0] for t in dtoks],
658
+ "eager_vs_upstream_512_masks": _common.compare(eager[0][0], u512_m),
659
+ "eager_vs_upstream_512_iou_scores": _common.compare(eager[1], u512_iou),
660
+ "exported_vs_eager_masks": _common.compare(exp[0], eager[0]),
661
+ "portable_e2e_vs_eager_masks": _common.compare(pd[0], eager[0]),
662
+ "portable_e2e_vs_eager_iou_scores": _common.compare(pd[1], eager[1]),
663
+ "portable_e2e_vs_eager_object_score": _common.compare(pd[2], eager[2]),
664
+ "mask_iou_portable_vs_eager": [
665
+ iou(m_pte[i], eager[0][0][i]) for i in range(3)
666
+ ],
667
+ "mask_iou_portable_vs_upstream_512_same_pixels": [
668
+ iou(m_pte[i], u512_m[i]) for i in range(3)
669
+ ],
670
+ "mask_iou_portable_vs_upstream_1024_pipeline_at_image_res": [
671
+ iou(up[i], u_m[i]) for i in range(3)
672
+ ],
673
+ "portable_iou_scores": np.asarray(pd[1]).reshape(-1).round(4).tolist(),
674
+ "upstream_512_iou_scores": u512_iou.round(4).tolist(),
675
+ "upstream_1024_iou_scores": u_iou.round(4).tolist(),
676
+ "upstream_1024_best_mask": best,
677
+ "best_mask_iou_portable_vs_upstream_1024": iou(up[best], u_m[best]),
678
+ "portable_object_score_logit": round(
679
+ float(np.asarray(pd[2]).reshape(-1)[0]), 4
680
+ ),
681
+ "mask_area_fraction_portable": [
682
+ round(float((m_pte[i] > 0).mean()), 4) for i in range(3)
683
+ ],
684
+ "upstream_1024_best_mask_vs_closest_export_mask": max(iou(up[i], u_m[best]) for i in range(3)),
685
+ "export_best_by_own_iou_head_vs_upstream_1024_best": iou(up[own], u_m[best]),
686
+ "variant_%s_no_memory_embedding" % ("without" if enc.add_no_memory else "with"): {
687
+ "mask_iou_vs_this_export": [iou(alt[0][i], eager[0][0][i]) for i in range(3)],
688
+ "mask_iou_vs_upstream_1024_pipeline_at_image_res": [iou(up_alt[i], u_m[i]) for i in range(3)],
689
+ "upstream_1024_best_mask_vs_closest_mask": max(iou(up_alt[i], u_m[best]) for i in range(3)),
690
+ "best_by_own_iou_head_vs_upstream_1024_best": iou(up_alt[alt_own], u_m[best]),
691
+ },
692
+ }
693
+ val["images"][name] = rec
694
+ print(json.dumps({name: rec}, indent=1), flush=True)
695
+ if refs is not None:
696
+ refs.joinpath("meta.json").write_text(json.dumps(refs_meta, indent=1) + "\n")
697
+
698
+ if args.internal_dir:
699
+ d = Path(args.internal_dir)
700
+ val["prompt_tables_vs_internal"] = {
701
+ f"prompt_{k}.bin": "bit-identical"
702
+ if (d / f"prompt_{k}.bin").read_bytes() == v.astype(np.float32).tobytes()
703
+ else "max abs %.3g"
704
+ % float(
705
+ np.abs(
706
+ np.fromfile(d / f"prompt_{k}.bin", dtype=np.float32) - v.reshape(-1)
707
+ ).max()
708
+ )
709
+ for k, v in tabs.items()
710
+ }
711
+ return val
712
+
713
+
714
+ def main():
715
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
716
+ ap.add_argument("--out", required=True)
717
+ ap.add_argument("--weights", choices=sorted(WEIGHTS), default="sam2.1")
718
+ ap.add_argument(
719
+ "--size", type=int, default=SIZE, help="square input size (multiple of 32)"
720
+ )
721
+ ap.add_argument(
722
+ "--no-memory-embedding",
723
+ choices=["add", "omit"],
724
+ default="add",
725
+ help="add (upstream single-image behaviour, default) or omit (as the previous export)",
726
+ )
727
+ ap.add_argument(
728
+ "--assets-dir",
729
+ help="optional: directory of sample images for extra validation",
730
+ )
731
+ ap.add_argument(
732
+ "--internal-dir",
733
+ help="optional: compare prompt tables with an existing prompt_*.bin set",
734
+ )
735
+ ap.add_argument(
736
+ "--prompt-tokens",
737
+ type=int,
738
+ default=3,
739
+ help="sparse prompt tokens per decode (trailing extra pads are masked, so any prompt that fits matches upstream)",
740
+ )
741
+ ap.add_argument(
742
+ "--refs-dir",
743
+ help="optional: write CPU reference tensors for the browser harness (sam-car.png prompts)",
744
+ )
745
+ ap.add_argument(
746
+ "--skip-validation",
747
+ action="store_true",
748
+ help="export only (for the byte-identical re-run check)",
749
+ )
750
+ args = ap.parse_args()
751
+ out = Path(args.out)
752
+ out.mkdir(parents=True, exist_ok=True)
753
+ torch.manual_seed(0)
754
+
755
+ snap, digest = snapshot(args.weights)
756
+ register_attention()
757
+ pool_windows_as_one_image()
758
+ add_nm = args.no_memory_embedding == "add"
759
+ model = freeze_hiera_pos_embed(
760
+ load_model(snap, args.size, attn="et_vk_sdpa"), args.size
761
+ )
762
+ enc, dec = Encoder(model, add_nm).eval(), Decoder(model).eval()
763
+ tabs = prompt_tables(model)
764
+ # Same weights with torch SDPA: only for the CPU portable .pte (et_vk.sdpa has no portable kernel).
765
+ model_std = freeze_hiera_pos_embed(
766
+ load_model(snap, args.size, attn="sdpa"), args.size
767
+ )
768
+ model_std_dec = load_model(snap, args.size, attn="torch_sdpa_padmask")
769
+ enc_std, dec_std = Encoder(model_std, add_nm).eval(), Decoder(model_std_dec).eval()
770
+
771
+ # Example inputs for export: a fixed-seed tensor (the graph does not depend on values).
772
+ gen = torch.Generator().manual_seed(0)
773
+ pixels = torch.randn(1, 3, args.size, args.size, generator=gen)
774
+ with torch.no_grad():
775
+ # Contiguous example inputs: torch.export records each input's dim order.
776
+ emb, hr0, hr1 = (t.contiguous() for t in enc(pixels))
777
+ sparse = encode_tokens(
778
+ site_tokens([("pos", args.size / 2, args.size / 2)], args.prompt_tokens),
779
+ args.size,
780
+ args.size,
781
+ tabs,
782
+ args.size,
783
+ )
784
+ dec_inputs = (
785
+ emb,
786
+ torch.from_numpy(tabs["img_pos"]),
787
+ sparse,
788
+ torch.from_numpy(tabs["dense"]),
789
+ hr0,
790
+ hr1,
791
+ )
792
+ progs = export_programs(enc, dec, enc_std, dec_std, pixels, dec_inputs)
793
+
794
+ files, graphs = {}, {}
795
+ for name, (_, wg, port) in progs.items():
796
+ write_if_changed(out / f"{name}.pte", wg.buffer)
797
+ write_if_changed(out / f"{name}_portable.pte", port.buffer)
798
+ graphs[name] = _common.delegate_summary(wg.buffer)
799
+ graphs[name]["io"] = program_io(wg.buffer)
800
+ graphs[name]["io_contract"] = check_contract(name, graphs[name]["io"], args.size, args.prompt_tokens)
801
+ if graphs[name]["non_delegated_kernels"] or len(graphs[name]["delegates"]) != 1:
802
+ sys.exit(f"{name}: expected one delegate and no CPU kernels: {json.dumps(graphs[name])[:2000]}")
803
+ for k, v in tabs.items():
804
+ write_if_changed(out / f"prompt_{k}.bin", v.astype(np.float32).tobytes())
805
+ for p in sorted(out.iterdir()):
806
+ if p.suffix in (".pte", ".bin"):
807
+ files[p.name] = {
808
+ "bytes": p.stat().st_size,
809
+ "sha256": _common.sha256_file(p),
810
+ }
811
+ if p.name.endswith("_portable.pte"):
812
+ files[p.name]["note"] = (
813
+ "CPU-only validation artifact (portable kernels, no delegate; torch SDPA instead of et_vk.sdpa); not for release"
814
+ )
815
+
816
+ w = WEIGHTS[args.weights]
817
+ q = args.size // 4
818
+ recipe = {
819
+ "model": f"{w['repo'].split('/')[1]} ({args.size} encoder/decoder split)",
820
+ "upstream": {
821
+ "model": w["repo"],
822
+ "revision": w["revision"],
823
+ "official": True,
824
+ "license": "Apache-2.0",
825
+ "files": {"model.safetensors": {"sha256": digest}},
826
+ "note": "transformers Sam2Model port published by the SAM 2 authors on the Hub",
827
+ },
828
+ "versions": _common.versions(),
829
+ "export": {
830
+ "api": "torch.export.export(strict=False) -> to_edge_transform_and_lower(partitioner=[WebGPUPartitioner()]) -> to_executorch(), one program per module",
831
+ "partitioner": "executorch.backends.webgpu.partitioner.WebGPUPartitioner() (default compile options; wraps VulkanPartitioner)",
832
+ "model_code": "transformers Sam2Model; attention through torch.ops.et_vk.sdpa (transformers attention backend, recipes/sam2-hiera-tiny.py:et_vk_attention); config image_size overridden 1024 -> %d"
833
+ % args.size,
834
+ "programs": {
835
+ "sam2_encoder.pte": {
836
+ "method": "forward",
837
+ "inputs": [
838
+ {
839
+ "name": "pixels",
840
+ "shape": [1, 3, args.size, args.size],
841
+ "dtype": "float32",
842
+ }
843
+ ],
844
+ "outputs": [
845
+ {
846
+ "name": "image_embeddings",
847
+ "shape": list(emb.shape),
848
+ "dtype": "float32",
849
+ "note": "no_memory_embedding %s"
850
+ % ("added" if add_nm else "not added"),
851
+ },
852
+ {
853
+ "name": "hr0",
854
+ "shape": list(hr0.shape),
855
+ "dtype": "float32",
856
+ "note": "before mask_decoder.conv_s0",
857
+ },
858
+ {
859
+ "name": "hr1",
860
+ "shape": list(hr1.shape),
861
+ "dtype": "float32",
862
+ "note": "before mask_decoder.conv_s1",
863
+ },
864
+ ],
865
+ },
866
+ "sam2_decoder.pte": {
867
+ "method": "forward",
868
+ "prompt_tokens": args.prompt_tokens,
869
+ "inputs": [
870
+ {"name": n, "shape": list(t.shape), "dtype": "float32"}
871
+ for n, t in zip(INPUT_NAMES, dec_inputs)
872
+ ],
873
+ "outputs": [
874
+ {
875
+ "name": "masks",
876
+ "shape": [1, 3, q, q],
877
+ "dtype": "float32",
878
+ "note": "logits, mask tokens 1..3",
879
+ },
880
+ {"name": "iou_scores", "shape": [1, 1, 3], "dtype": "float32"},
881
+ {
882
+ "name": "object_score_logits",
883
+ "shape": [1, 1, 1],
884
+ "dtype": "float32",
885
+ },
886
+ ],
887
+ },
888
+ },
889
+ "prompt_tables": {
890
+ f"prompt_{k}.bin": {
891
+ "shape": list(v.shape),
892
+ "dtype": "float32 little-endian",
893
+ }
894
+ for k, v in tabs.items()
895
+ },
896
+ "prompt_table_sources": {
897
+ "prompt_gaussian.bin": "shared_image_embedding.positional_embedding",
898
+ "prompt_point_embed_bg.bin": "prompt_encoder.point_embed.weight[0]",
899
+ "prompt_point_embed_fg.bin": "prompt_encoder.point_embed.weight[1]",
900
+ "prompt_box_tl.bin": "prompt_encoder.point_embed.weight[2]",
901
+ "prompt_box_br.bin": "prompt_encoder.point_embed.weight[3]",
902
+ "prompt_not_a_point.bin": "prompt_encoder.not_a_point_embed.weight[0]",
903
+ "prompt_img_pos.bin": "Sam2Model.get_image_wide_positional_embeddings() with image_size=%d"
904
+ % args.size,
905
+ "prompt_dense.bin": "prompt_encoder.no_mask_embed.weight broadcast to [1,256,%d,%d]"
906
+ % (args.size // 16, args.size // 16),
907
+ },
908
+ "graph_rewrites": [
909
+ "Hiera q-pooling of windowed tensors [nW,h,w,C] done as one batch-1 max_pool2d over the windows stacked along H (exact: h is a multiple of the stride); keeps all pools in the delegate",
910
+ "attention via torch.ops.et_vk.sdpa (same math as torch SDPA; checked in validation.json)",
911
+ "decoder: a not-a-point token directly after another one gets an additive -1e9 key bias in every attention (computed in-graph from the sparse input), so padding up to the fixed token count does not change the result",
912
+ "Hiera position embedding (bicubic pos_embed resize + tiled window embedding) for the fixed input size computed in eager and stored as a constant",
913
+ ],
914
+ "differences_from_upstream_pipeline": [
915
+ "input %dx%d instead of 1024x1024 (masks %dx%d instead of 256x256)"
916
+ % (args.size, args.size, q, q),
917
+ "fixed %d prompt tokens per decode: points, box corners, one not-a-point pad, then padding pads that the decoder masks out (exactly upstream's prompt for any prompt that fits; larger prompts are composed by the site)" % args.prompt_tokens,
918
+ "no mask prompt input (dense = no_mask_embed)",
919
+ ],
920
+ "preprocessing_outside_graph": "RGB, resize to %dx%d without keeping aspect, /255, ImageNet mean/std"
921
+ % (args.size, args.size),
922
+ "dtype": "float32",
923
+ "quantization": "none (fp32)",
924
+ },
925
+ "files": files,
926
+ "graph": graphs,
927
+ "command": _common.command_line(out),
928
+ }
929
+ (out / "recipe.json").write_text(json.dumps(recipe, indent=2) + "\n")
930
+ print(json.dumps({"files": files, "graph": graphs}, indent=1), flush=True)
931
+ if args.skip_validation:
932
+ return
933
+ val = validate(args, snap, model, tabs, progs, enc, dec, enc_std)
934
+ (out / "validation.json").write_text(json.dumps(val, indent=2) + "\n")
935
+ print(json.dumps(val, indent=1))
936
+
937
+
938
+ if __name__ == "__main__":
939
+ main()
manifest.json CHANGED
@@ -44,13 +44,13 @@
44
  },
45
  {
46
  "path": "sam2_decoder.pte",
47
- "bytes": 16935552,
48
- "sha256": "4d6f790ccf3c7ae8f54fff162202703d3f9e78099b25c3e80b70a2c4eb3e1ac8"
49
  },
50
  {
51
  "path": "sam2_encoder.pte",
52
- "bytes": 137529216,
53
- "sha256": "46626cd828cca2f22fb2623ab7b9cafeec8f724355ac8d7265aa1d54245fe5dd"
54
  }
55
  ]
56
  }
 
44
  },
45
  {
46
  "path": "sam2_decoder.pte",
47
+ "bytes": 16949252,
48
+ "sha256": "57b91034acc71be2ce1835ea546a174295daee1046e40db0743e0238ef11e278"
49
  },
50
  {
51
  "path": "sam2_encoder.pte",
52
+ "bytes": 115248512,
53
+ "sha256": "30f9fb469b779558b6b5c7c3eb995c50e1213ffbe082e31430c929ba2b3ffe20"
54
  }
55
  ]
56
  }
sam2_decoder.pte CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:4d6f790ccf3c7ae8f54fff162202703d3f9e78099b25c3e80b70a2c4eb3e1ac8
3
- size 16935552
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:57b91034acc71be2ce1835ea546a174295daee1046e40db0743e0238ef11e278
3
+ size 16949252
sam2_encoder.pte CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:46626cd828cca2f22fb2623ab7b9cafeec8f724355ac8d7265aa1d54245fe5dd
3
- size 137529216
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:30f9fb469b779558b6b5c7c3eb995c50e1213ffbe082e31430c929ba2b3ffe20
3
+ size 115248512