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()
|