File size: 5,706 Bytes
7eee2d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
"""Exports SAM 3 (facebook/sam3) to ONNX for in-browser inference with ONNX Runtime Web on WebGPU.

Three graphs (see `sam3_graphs.py`): `vision` (once per image), `text` and `detector` (once per
phrase), each at a fixed shape: a 1008 × 1008 image, a 32-token phrase, batch 1.

Files, in `<out>/onnx/`, named by graph and precision:

- `*_fp16.onnx`: fp16 weights and activations, inputs and outputs float32. Standard is the three
  of these.
- `vision_q8f16.onnx`, `text_q8f16.onnx`: as fp16, but every MatMul weight stored at 8 bits
  (`MatMulNBits`, symmetric, block size 128) and expanded on the GPU. Compact is these two and
  `detector_fp16.onnx`, which is small and left at fp16.

Weight-only quantisation keeps every node on WebGPU: the usual dynamic "int8" export runs
`MatMulInteger` and `DynamicQuantizeLinear`, which ONNX Runtime Web's WebGPU backend lacks.

Usage:
    python export_sam3.py --weights <facebook/sam3 checkout> --out <dir>

Requires torch, transformers (with `Sam3Model`), onnx, onnxscript, onnxconverter-common and
onnxruntime. Runs on the CPU. The fp32 graphs go to `<out>/fp32/`, with external data, and
`--skip-fp32` reuses them.
"""

import argparse
from pathlib import Path

import onnx
import torch
from onnxconverter_common import float16
from onnxruntime.quantization.matmul_nbits_quantizer import MatMulNBitsQuantizer
from transformers import Sam3Model

from sam3_graphs import IMAGE_SIZE, TEXT_TOKENS, Sam3Detector, Sam3Text, Sam3Vision, position_encoding_72

OPSET = 18
GRAPHS = ("vision", "text", "detector")
QUANTISED = ("vision", "text")


def export_fp32(model, out: Path) -> None:
    out.mkdir(parents=True, exist_ok=True)
    pixels = torch.zeros(1, 3, IMAGE_SIZE, IMAGE_SIZE)
    ids = torch.zeros(1, TEXT_TOKENS, dtype=torch.long)
    mask = torch.ones(1, TEXT_TOKENS, dtype=torch.long)
    vision = Sam3Vision(model).eval()
    with torch.no_grad():
        levels = vision(pixels)
    text = Sam3Text(model).eval()
    with torch.no_grad():
        features = text(ids, mask)
    detector = Sam3Detector(model, position_encoding_72(model)).eval()
    jobs = {
        "vision": (vision, (pixels,), ["pixel_values"], ["features_288", "features_144", "features_72"]),
        "text": (text, (ids, mask), ["input_ids", "attention_mask"], ["text_features"]),
        "detector": (
            detector,
            (*levels, features, mask),
            ["features_288", "features_144", "features_72", "text_features", "text_mask"],
            ["pred_logits", "pred_boxes", "presence_logits", "pred_masks", "semantic_seg"],
        ),
    }
    for name, (module, args, inputs, outputs) in jobs.items():
        print(f"exporting {name}")
        with torch.no_grad():
            torch.onnx.export(
                module,
                args,
                str(out / f"{name}.onnx"),
                input_names=inputs,
                output_names=outputs,
                opset_version=OPSET,
                dynamo=True,
                external_data=True,
                optimize=True,
            )


def fix_casts(model: onnx.ModelProto) -> int:
    """Points each Cast to float32 whose output the converter declared float16 at float16.

    `convert_float_to_float16` retypes a Cast's output but leaves its `to`, so a Cast from a
    non-float source (the text mask, cast from bool for pooling) still says float32 and the
    graph doesn't load. Returns how many it fixed.
    """
    declared = {vi.name: vi.type.tensor_type.elem_type for vi in model.graph.value_info}
    fixed = 0
    for node in model.graph.node:
        if node.op_type != "Cast" or declared.get(node.output[0]) != onnx.TensorProto.FLOAT16:
            continue
        for attribute in node.attribute:
            if attribute.name == "to" and attribute.i == onnx.TensorProto.FLOAT:
                attribute.i = onnx.TensorProto.FLOAT16
                fixed += 1
    return fixed


def to_fp16(src: Path, dst: Path) -> None:
    dst.mkdir(parents=True, exist_ok=True)
    for name in GRAPHS:
        model = onnx.load(str(src / f"{name}.onnx"))
        # Stale annotations from the export would fight the converter's own inference.
        del model.graph.value_info[:]
        half = float16.convert_float_to_float16(model, keep_io_types=True, disable_shape_infer=False)
        print(f"fp16 {name}: {fix_casts(half)} casts fixed")
        onnx.save(half, str(dst / f"{name}_fp16.onnx"))


def to_q8f16(onnx_dir: Path, bits: int, block_size: int) -> None:
    for name in QUANTISED:
        print(f"{bits}-bit {name}")
        quantiser = MatMulNBitsQuantizer(
            onnx.load(str(onnx_dir / f"{name}_fp16.onnx")),
            bits=bits,
            block_size=block_size,
            is_symmetric=True,
        )
        quantiser.process()
        onnx.save(quantiser.model.model, str(onnx_dir / f"{name}_q{bits}f16.onnx"))


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--weights", type=Path, required=True)
    parser.add_argument("--out", type=Path, required=True)
    parser.add_argument("--bits", type=int, default=8)
    parser.add_argument("--block-size", type=int, default=128)
    parser.add_argument("--skip-fp32", action="store_true", help="reuse an earlier fp32 export")
    args = parser.parse_args()

    if not args.skip_fp32:
        model = Sam3Model.from_pretrained(args.weights, attn_implementation="eager").eval()
        export_fp32(model, args.out / "fp32")
        del model
    to_fp16(args.out / "fp32", args.out / "onnx")
    to_q8f16(args.out / "onnx", args.bits, args.block_size)


if __name__ == "__main__":
    main()