File size: 8,767 Bytes
9496f98 | 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 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 | #!/usr/bin/env python3
"""Generate optimized ECSeg ONNX variants from an FP32 baseline.
Every variant PRESERVES the public graph I/O contract (input `images` float32 [1,3,640,640];
outputs labels int64 [1,300], boxes/scores/masks float32) because the app's curated-import
verification checks tensor dtype+shape against a pinned contract. A variant that changed an
output dtype would fail that gate, so we never let optimization touch the boundary tensors.
Subcommands (each writes <out_dir>/<stem>.<variant>.onnx and runs onnx.checker):
graphopt onnxslim constant-folding + lossless simplification (runtime-agnostic; no
ORT-CPU-specific fusions baked in, so it stays portable to ort-web WASM)
fp16 onnxconverter-common float16 weights, keep_io_types=True (Cast at boundaries),
with a conservative op_block_list for ops whose fp16 kernels are risky on WASM
int8-dynamic weight-only dynamic INT8 (per-channel) on MatMul/Gemm
int8-static static QDQ INT8 with real calibration images (Conv+MatMul), optional exclude list
Usage:
python optimize.py graphopt IN.onnx OUT_DIR
python optimize.py fp16 IN.onnx OUT_DIR
python optimize.py int8-dynamic IN.onnx OUT_DIR
python optimize.py int8-static IN.onnx OUT_DIR --calib-dir DIR [--calib-limit N] [--exclude-mask-head]
"""
from __future__ import annotations
import argparse
import os
import sys
import onnx
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import ecseg_common as ec # noqa: E402
def _check(path: str) -> None:
model = onnx.load(path, load_external_data=False)
onnx.checker.check_model(model)
print(f" onnx.checker OK: {path} ({os.path.getsize(path)/2**20:.2f} MiB)")
def _stem(in_path: str) -> str:
base = os.path.basename(in_path)
return base[:-5] if base.endswith(".onnx") else base
# --------------------------------------------------------------------------- graphopt --------
def graphopt(in_path: str, out_dir: str) -> str:
import onnxslim
out = os.path.join(out_dir, f"{_stem(in_path)}.graphopt.onnx")
# onnxslim performs constant folding, dead-node elimination and safe fusions while keeping the
# graph runtime-agnostic (unlike ORT's offline optimizer, which can bake in CPU-specific layout
# fusions that don't transfer to ort-web WASM).
model = onnxslim.slim(in_path)
onnx.save(model, out)
_check(out)
return out
# ------------------------------------------------------------------------------ fp16 ---------
# Ops we keep in fp32 even inside an fp16 graph: boundary-sensitive / sampling / reduction ops
# whose fp16 kernels are either absent or numerically fragile on the ort-web WASM CPU EP.
FP16_OP_BLOCK_LIST = [
"GridSample", # deformable mask sampling — precision-critical, fp16 kernel support uncertain
"Resize",
"LayerNormalization",
"Softmax",
"ReduceSum",
"ReduceMean",
"ReduceMax",
"Erf",
"TopK",
"GatherElements",
"Einsum",
]
def fp16(in_path: str, out_dir: str, hi_range: bool = False) -> str:
from onnxconverter_common import float16
suffix = "fp16-hi" if hi_range else "fp16"
out = os.path.join(out_dir, f"{_stem(in_path)}.{suffix}.onnx")
model = onnx.load(in_path)
kwargs = dict(
keep_io_types=True, # inputs/outputs stay float32 -> contract preserved
disable_shape_infer=False,
op_block_list=FP16_OP_BLOCK_LIST,
)
if hi_range:
# Use fp16's true representable range instead of the library's conservative ±1e4 clamp, so large
# mask logits are not clipped. min_positive_val = smallest fp16 normal (2**-14).
kwargs["max_finite_val"] = 65504.0
kwargs["min_positive_val"] = 5.9604645e-08
converted = float16.convert_float_to_float16(model, **kwargs)
onnx.save(converted, out)
_check(out)
return out
# ------------------------------------------------------------------------ int8-dynamic -------
def int8_dynamic(in_path: str, out_dir: str) -> str:
from onnxruntime.quantization import QuantType, quantize_dynamic
from onnxruntime.quantization.shape_inference import quant_pre_process
stem = _stem(in_path)
pre = os.path.join(out_dir, f"{stem}.preproc.onnx")
quant_pre_process(in_path, pre, skip_symbolic_shape=False)
out = os.path.join(out_dir, f"{stem}.int8-dynamic.onnx")
quantize_dynamic(
pre,
out,
weight_type=QuantType.QInt8,
per_channel=True,
# Conv dynamic quantization is not well supported; restrict to the matmul/gemm family.
op_types_to_quantize=["MatMul", "Gemm"],
)
os.remove(pre)
_check(out)
return out
# ------------------------------------------------------------------------- int8-static -------
class _CalibReader:
"""onnxruntime CalibrationDataReader over app-faithful preprocessed images."""
def __init__(self, files, input_name="images"):
self.files = files
self.input_name = input_name
self._it = iter(files)
def get_next(self):
path = next(self._it, None)
if path is None:
return None
return {self.input_name: ec.preprocess_file(path)}
def rewind(self):
self._it = iter(self.files)
# Mask-head node-name substrings to optionally keep in fp32 (the segmentation branch is the most
# quantization-sensitive part). Discovered empirically from the graph; matched case-insensitively.
MASK_HEAD_HINTS = ["mask", "seg", "pixel_decoder", "GridSample"]
def int8_static(
in_path: str,
out_dir: str,
calib_dir,
calib_limit=None,
exclude_mask_head=False,
) -> str:
from onnxruntime.quantization import (
CalibrationMethod,
QuantFormat,
QuantType,
quantize_static,
)
from onnxruntime.quantization.shape_inference import quant_pre_process
stem = _stem(in_path)
suffix = "int8-static-selective" if exclude_mask_head else "int8-static"
pre = os.path.join(out_dir, f"{stem}.preproc.onnx")
quant_pre_process(in_path, pre, skip_symbolic_shape=False)
files = ec.list_images(calib_dir if isinstance(calib_dir, list) else [calib_dir], limit=calib_limit)
if not files:
raise SystemExit(f"No calibration images found under {calib_dir}")
print(f" calibration images: {len(files)}")
nodes_to_exclude = []
if exclude_mask_head:
model = onnx.load(pre, load_external_data=False)
for node in model.graph.node:
name = (node.name or "").lower()
if any(h.lower() in name for h in MASK_HEAD_HINTS):
nodes_to_exclude.append(node.name)
print(f" excluding {len(nodes_to_exclude)} mask-head nodes from quantization")
out = os.path.join(out_dir, f"{stem}.{suffix}.onnx")
quantize_static(
pre,
out,
_CalibReader(files),
quant_format=QuantFormat.QDQ,
per_channel=True,
weight_type=QuantType.QInt8,
activation_type=QuantType.QUInt8,
calibrate_method=CalibrationMethod.MinMax,
nodes_to_exclude=nodes_to_exclude,
extra_options={"AddQDQPairToWeight": False},
)
os.remove(pre)
_check(out)
return out
# -------------------------------------------------------------------------------- cli --------
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
for name in ("graphopt", "fp16", "int8-dynamic"):
p = sub.add_parser(name)
p.add_argument("in_path")
p.add_argument("out_dir")
if name == "fp16":
p.add_argument("--hi-range", action="store_true",
help="use fp16's full ±65504 range instead of the default ±1e4 clamp")
ps = sub.add_parser("int8-static")
ps.add_argument("in_path")
ps.add_argument("out_dir")
ps.add_argument("--calib-dir", action="append", required=True)
ps.add_argument("--calib-limit", type=int, default=None)
ps.add_argument("--exclude-mask-head", action="store_true")
args = ap.parse_args()
os.makedirs(args.out_dir, exist_ok=True)
if args.cmd == "graphopt":
graphopt(args.in_path, args.out_dir)
elif args.cmd == "fp16":
fp16(args.in_path, args.out_dir, hi_range=getattr(args, "hi_range", False))
elif args.cmd == "int8-dynamic":
int8_dynamic(args.in_path, args.out_dir)
elif args.cmd == "int8-static":
int8_static(
args.in_path, args.out_dir, args.calib_dir, args.calib_limit, args.exclude_mask_head
)
return 0
if __name__ == "__main__":
sys.exit(main())
|