export: measured per-layer int8 tuning against off-distribution probes
Browse files- scripts/export_onnx.py +158 -14
scripts/export_onnx.py
CHANGED
|
@@ -46,7 +46,7 @@ import torch
|
|
| 46 |
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 47 |
|
| 48 |
from sstvae import checkpoint # noqa: E402
|
| 49 |
-
from sstvae.codec import
|
| 50 |
from sstvae.config import LATENT_CHANNELS, LATENT_H, LATENT_W # noqa: E402
|
| 51 |
from sstvae.images import IMG_H, IMG_W # noqa: E402
|
| 52 |
|
|
@@ -72,13 +72,13 @@ CHANNEL_NOISE_RMS = 0.367
|
|
| 72 |
# natural scale: int8 scores ~24 dB on it while costing ~0.15 dB of
|
| 73 |
# actual picture quality, so gating on it would reject a good artifact.
|
| 74 |
#
|
| 75 |
-
# The int8 gates
|
| 76 |
-
#
|
| 77 |
-
# 0.
|
| 78 |
-
# `
|
| 79 |
-
# `per_channel=True` is silently a
|
| 80 |
-
#
|
| 81 |
-
#
|
| 82 |
TOLERANCES = {
|
| 83 |
"fp32": {"latent_rms": 1e-4, "quality_db": 0.02},
|
| 84 |
"fp16": {"latent_rms": 5e-3, "quality_db": 0.05},
|
|
@@ -159,10 +159,101 @@ def convert_fp16(src: Path, dst: Path) -> None:
|
|
| 159 |
onnx.save(float16.convert_float_to_float16(model, keep_io_types=True), str(dst))
|
| 160 |
|
| 161 |
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
from onnxruntime.quantization import QuantType, quantize_dynamic
|
| 164 |
|
| 165 |
-
quantize_dynamic(str(src), str(dst), weight_type=QuantType.QInt8
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
|
| 168 |
def stamp_metadata(path: Path, props: dict) -> None:
|
|
@@ -278,6 +369,9 @@ def main() -> int:
|
|
| 278 |
"low-frequency probes are used if omitted")
|
| 279 |
ap.add_argument("--n-probe", type=int, default=4,
|
| 280 |
help="number of verification images (default 4)")
|
|
|
|
|
|
|
|
|
|
| 281 |
ap.add_argument("--push", action="store_true",
|
| 282 |
help="upload the artifacts to the Hub after verifying")
|
| 283 |
ap.add_argument("--repo", default=checkpoint.DEFAULT_REPO,
|
|
@@ -294,7 +388,7 @@ def main() -> int:
|
|
| 294 |
ckpt_sha = sha256(ckpt_path)
|
| 295 |
print(f"checkpoint {ckpt_path.name} sha256:{ckpt_sha[:16]}...")
|
| 296 |
|
| 297 |
-
model =
|
| 298 |
model.eval()
|
| 299 |
|
| 300 |
if args.images:
|
|
@@ -328,6 +422,52 @@ def main() -> int:
|
|
| 328 |
export_fp32(model.decoder, (z0, torch.ones_like(z0)), base["decoder"],
|
| 329 |
["latents", "weights"], ["image"])
|
| 330 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
for precision in precisions:
|
| 332 |
paths = {}
|
| 333 |
for part in ("encoder", "decoder"):
|
|
@@ -337,15 +477,18 @@ def main() -> int:
|
|
| 337 |
elif precision == "fp16":
|
| 338 |
convert_fp16(base[part], dst)
|
| 339 |
else:
|
| 340 |
-
convert_int8(base[part], dst)
|
| 341 |
-
|
| 342 |
"sstvae.source_checkpoint": ckpt_path.name,
|
| 343 |
"sstvae.source_sha256": ckpt_sha,
|
| 344 |
"sstvae.part": part,
|
| 345 |
"sstvae.precision": precision,
|
| 346 |
"sstvae.opset": OPSET,
|
| 347 |
"sstvae.torch_version": torch.__version__,
|
| 348 |
-
}
|
|
|
|
|
|
|
|
|
|
| 349 |
paths[part] = dst
|
| 350 |
|
| 351 |
r = verify(model, images, paths, precision)
|
|
@@ -367,6 +510,7 @@ def main() -> int:
|
|
| 367 |
"opset": OPSET,
|
| 368 |
"torch_version": torch.__version__,
|
| 369 |
"channel_noise_rms": CHANNEL_NOISE_RMS,
|
|
|
|
| 370 |
"artifacts": {
|
| 371 |
p.name: {"sha256": sha256(p), "bytes": p.stat().st_size} for p in written
|
| 372 |
},
|
|
|
|
| 46 |
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 47 |
|
| 48 |
from sstvae import checkpoint # noqa: E402
|
| 49 |
+
from sstvae.codec import load_torch_model # noqa: E402
|
| 50 |
from sstvae.config import LATENT_CHANNELS, LATENT_H, LATENT_W # noqa: E402
|
| 51 |
from sstvae.images import IMG_H, IMG_W # noqa: E402
|
| 52 |
|
|
|
|
| 72 |
# natural scale: int8 scores ~24 dB on it while costing ~0.15 dB of
|
| 73 |
# actual picture quality, so gating on it would reject a good artifact.
|
| 74 |
#
|
| 75 |
+
# The int8 gates stay loose enough to survive an untuned export failing
|
| 76 |
+
# gracefully, but the tuned one is far inside them: latent RMS 7.31e-02,
|
| 77 |
+
# costing 0.002 dB on photographs and 0.112 dB off-distribution.
|
| 78 |
+
# `quantize_dynamic` turns every Conv into `ConvInteger`, which supports
|
| 79 |
+
# only a **per-tensor** weight scale -- `per_channel=True` is silently a
|
| 80 |
+
# no-op on this graph, verified -- which is why the fix is per-layer
|
| 81 |
+
# exclusion rather than a quantiser setting.
|
| 82 |
TOLERANCES = {
|
| 83 |
"fp32": {"latent_rms": 1e-4, "quality_db": 0.02},
|
| 84 |
"fp16": {"latent_rms": 5e-3, "quality_db": 0.05},
|
|
|
|
| 159 |
onnx.save(float16.convert_float_to_float16(model, keep_io_types=True), str(dst))
|
| 160 |
|
| 161 |
|
| 162 |
+
# int8 tuning: greedily keep the most quantisation-sensitive conv layers
|
| 163 |
+
# at fp32, because `ConvInteger` carries one scale per weight *tensor*
|
| 164 |
+
# and a single outlier-heavy tensor therefore sets a coarse scale for
|
| 165 |
+
# everything in it.
|
| 166 |
+
#
|
| 167 |
+
# **Tune on off-distribution content too, or this measures nothing.**
|
| 168 |
+
# Quantisation sensitivity barely shows on the photographs the model was
|
| 169 |
+
# trained on and shows enormously on everything else. Measured on v1's
|
| 170 |
+
# decoder: fully quantised costs 0.10 dB on COCO but **1.54 dB** on
|
| 171 |
+
# smooth synthetic probes, and excluding one layer takes the latter to
|
| 172 |
+
# -0.14 dB. Tuned against COCO alone the search sees 0.10 dB, concludes
|
| 173 |
+
# there is nothing to fix, and ships the 1.54 dB. That is not an
|
| 174 |
+
# academic case: operators send test cards, charts and screenshots.
|
| 175 |
+
INT8_TUNE_SYNTHETIC_PROBES = 2
|
| 176 |
+
|
| 177 |
+
# Stop once the error is under target -- **absolute, not a
|
| 178 |
+
# relative-improvement rule**. At the tail a large *fractional* error
|
| 179 |
+
# reduction is worth 0.00 dB of picture, which is how an earlier
|
| 180 |
+
# relative-only rule talked itself into three decoder exclusions that
|
| 181 |
+
# together bought 0.005 dB. `INT8_MIN_GAIN` remains only as a guard
|
| 182 |
+
# against grinding away at a target that cannot be reached.
|
| 183 |
+
#
|
| 184 |
+
# The encoder is measured in latent RMS against the channel noise, since
|
| 185 |
+
# that is the quantity that goes on the air. The decoder is measured in
|
| 186 |
+
# dB of picture, since its error reaches nobody else.
|
| 187 |
+
INT8_TARGET_DB_UNDER_CHANNEL = 13.0
|
| 188 |
+
# A tenth of a dB of your own picture. Tighter than this (0.05 was tried)
|
| 189 |
+
# buys a second decoder exclusion worth 0.02 dB for +1.8 MB, which is the
|
| 190 |
+
# wrong trade for the precision that exists for constrained devices.
|
| 191 |
+
INT8_TARGET_DECODER_DB = 0.10
|
| 192 |
+
INT8_MIN_GAIN = 0.15
|
| 193 |
+
INT8_MAX_EXCLUDED = 3
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def convert_int8(src: Path, dst: Path, exclude: list[str] | None = None) -> None:
|
| 197 |
from onnxruntime.quantization import QuantType, quantize_dynamic
|
| 198 |
|
| 199 |
+
quantize_dynamic(str(src), str(dst), weight_type=QuantType.QInt8,
|
| 200 |
+
nodes_to_exclude=list(exclude or []))
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _conv_nodes(path: Path) -> list[str]:
|
| 204 |
+
import onnx
|
| 205 |
+
|
| 206 |
+
return [n.name for n in onnx.load(str(path)).graph.node
|
| 207 |
+
if n.op_type in ("Conv", "ConvTranspose")]
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def tune_int8_exclusions(src: Path, measure, target: float, *,
|
| 211 |
+
log=print) -> list[str]:
|
| 212 |
+
"""Find which conv layers to leave at fp32, by measuring.
|
| 213 |
+
|
| 214 |
+
Quantisation error is not spread evenly across layers -- it is
|
| 215 |
+
dominated by a few whose weight distribution has outliers, because
|
| 216 |
+
`quantize_dynamic` emits `ConvInteger`, which carries **one scale
|
| 217 |
+
per tensor**. A single bad tensor therefore sets a coarse scale for
|
| 218 |
+
all of its weights. (This is also why `per_channel=True` does
|
| 219 |
+
nothing here: ConvInteger has nowhere to put per-channel scales.)
|
| 220 |
+
|
| 221 |
+
Measured on v1: excluding one 0.59 MB layer took the encoder from
|
| 222 |
+
1.88e-01 to 7.31e-02 RMS -- 5.8 dB to 14.0 dB under the channel
|
| 223 |
+
noise -- for 8% more file. That is worth finding automatically
|
| 224 |
+
rather than hardcoding, because the sensitive layer is a property of
|
| 225 |
+
the trained weights and will move between revisions.
|
| 226 |
+
|
| 227 |
+
`measure(path) -> float` returns an error, lower being better, and
|
| 228 |
+
`target` is the value below which the error stops mattering.
|
| 229 |
+
"""
|
| 230 |
+
candidates = _conv_nodes(src)
|
| 231 |
+
excluded: list[str] = []
|
| 232 |
+
with tempfile.TemporaryDirectory() as td:
|
| 233 |
+
def err(excl: list[str]) -> float:
|
| 234 |
+
p = Path(td) / f"probe{len(excl)}.onnx"
|
| 235 |
+
convert_int8(src, p, excl)
|
| 236 |
+
return measure(p)
|
| 237 |
+
|
| 238 |
+
current = err([])
|
| 239 |
+
log(f" all quantised: {current:.4e} (target {target:.4e})")
|
| 240 |
+
while current > target and len(excluded) < INT8_MAX_EXCLUDED:
|
| 241 |
+
scored = [(err(excluded + [c]), c)
|
| 242 |
+
for c in candidates if c not in excluded]
|
| 243 |
+
if not scored:
|
| 244 |
+
break
|
| 245 |
+
best_err, best = min(scored)
|
| 246 |
+
gain = (current - best_err) / current if current else 0.0
|
| 247 |
+
if gain < INT8_MIN_GAIN:
|
| 248 |
+
log(f" stop: best remaining gain {gain:.1%} < "
|
| 249 |
+
f"{INT8_MIN_GAIN:.0%}")
|
| 250 |
+
break
|
| 251 |
+
excluded.append(best)
|
| 252 |
+
current = best_err
|
| 253 |
+
log(f" keep {best} at fp32 -> {current:.4e} ({gain:.0%} better)")
|
| 254 |
+
if current <= target:
|
| 255 |
+
log(f" at target ({current:.4e} <= {target:.4e})")
|
| 256 |
+
return excluded
|
| 257 |
|
| 258 |
|
| 259 |
def stamp_metadata(path: Path, props: dict) -> None:
|
|
|
|
| 369 |
"low-frequency probes are used if omitted")
|
| 370 |
ap.add_argument("--n-probe", type=int, default=4,
|
| 371 |
help="number of verification images (default 4)")
|
| 372 |
+
ap.add_argument("--no-int8-tuning", action="store_true",
|
| 373 |
+
help="skip the per-layer int8 sensitivity search (faster, "
|
| 374 |
+
"but a worse int8 encoder -- see docs/onnx.md)")
|
| 375 |
ap.add_argument("--push", action="store_true",
|
| 376 |
help="upload the artifacts to the Hub after verifying")
|
| 377 |
ap.add_argument("--repo", default=checkpoint.DEFAULT_REPO,
|
|
|
|
| 388 |
ckpt_sha = sha256(ckpt_path)
|
| 389 |
print(f"checkpoint {ckpt_path.name} sha256:{ckpt_sha[:16]}...")
|
| 390 |
|
| 391 |
+
model = load_torch_model(args.model)
|
| 392 |
model.eval()
|
| 393 |
|
| 394 |
if args.images:
|
|
|
|
| 422 |
export_fp32(model.decoder, (z0, torch.ones_like(z0)), base["decoder"],
|
| 423 |
["latents", "weights"], ["image"])
|
| 424 |
|
| 425 |
+
exclusions = {"encoder": [], "decoder": []}
|
| 426 |
+
if "int8" in precisions and not args.no_int8_tuning:
|
| 427 |
+
# A subset is plenty to *rank* layers -- the full set still
|
| 428 |
+
# verifies the result afterwards -- but it must include
|
| 429 |
+
# off-distribution probes. See INT8_TUNE_SYNTHETIC_PROBES.
|
| 430 |
+
tune_imgs = torch.cat([
|
| 431 |
+
images[:min(3, images.shape[0])],
|
| 432 |
+
probe_images(INT8_TUNE_SYNTHETIC_PROBES, seed=1234),
|
| 433 |
+
])
|
| 434 |
+
tune_n = tune_imgs.shape[0]
|
| 435 |
+
src = tune_imgs.numpy()
|
| 436 |
+
with torch.no_grad():
|
| 437 |
+
ref_z = torch.cat([model.encoder(tune_imgs[i:i + 1])
|
| 438 |
+
for i in range(tune_n)])
|
| 439 |
+
ref_pic = torch.cat([
|
| 440 |
+
model.decoder(ref_z[i:i + 1], torch.ones_like(ref_z[i:i + 1]))
|
| 441 |
+
for i in range(tune_n)]).numpy()
|
| 442 |
+
ref_z = ref_z.numpy()
|
| 443 |
+
torch_q = float(np.mean([psnr(ref_pic[i], src[i]) for i in range(tune_n)]))
|
| 444 |
+
|
| 445 |
+
def enc_err(path: Path) -> float:
|
| 446 |
+
got = np.concatenate([
|
| 447 |
+
run_onnx(path, {"primary": src[i:i + 1]})
|
| 448 |
+
for i in range(tune_n)])
|
| 449 |
+
return float(np.sqrt(np.mean((got - ref_z) ** 2)))
|
| 450 |
+
|
| 451 |
+
def dec_err(path: Path) -> float:
|
| 452 |
+
"""dB of picture lost, decoding the *torch* latents."""
|
| 453 |
+
got = np.concatenate([
|
| 454 |
+
run_onnx(path, {"primary": ref_z[i:i + 1],
|
| 455 |
+
"secondary": np.ones_like(ref_z[i:i + 1])})
|
| 456 |
+
for i in range(tune_n)])
|
| 457 |
+
q = float(np.mean([psnr(got[i], src[i]) for i in range(tune_n)]))
|
| 458 |
+
return max(torch_q - q, 0.0)
|
| 459 |
+
|
| 460 |
+
plan = {
|
| 461 |
+
"encoder": (enc_err, CHANNEL_NOISE_RMS
|
| 462 |
+
/ 10 ** (INT8_TARGET_DB_UNDER_CHANNEL / 20)),
|
| 463 |
+
"decoder": (dec_err, INT8_TARGET_DECODER_DB),
|
| 464 |
+
}
|
| 465 |
+
for part, (measure, target) in plan.items():
|
| 466 |
+
print(f" tuning int8 {part} "
|
| 467 |
+
f"({tune_n} probes, {INT8_TUNE_SYNTHETIC_PROBES} synthetic)...")
|
| 468 |
+
exclusions[part] = tune_int8_exclusions(
|
| 469 |
+
base[part], measure, target, log=print)
|
| 470 |
+
|
| 471 |
for precision in precisions:
|
| 472 |
paths = {}
|
| 473 |
for part in ("encoder", "decoder"):
|
|
|
|
| 477 |
elif precision == "fp16":
|
| 478 |
convert_fp16(base[part], dst)
|
| 479 |
else:
|
| 480 |
+
convert_int8(base[part], dst, exclusions[part])
|
| 481 |
+
props = {
|
| 482 |
"sstvae.source_checkpoint": ckpt_path.name,
|
| 483 |
"sstvae.source_sha256": ckpt_sha,
|
| 484 |
"sstvae.part": part,
|
| 485 |
"sstvae.precision": precision,
|
| 486 |
"sstvae.opset": OPSET,
|
| 487 |
"sstvae.torch_version": torch.__version__,
|
| 488 |
+
}
|
| 489 |
+
if precision == "int8" and exclusions[part]:
|
| 490 |
+
props["sstvae.int8_fp32_layers"] = ",".join(exclusions[part])
|
| 491 |
+
stamp_metadata(dst, props)
|
| 492 |
paths[part] = dst
|
| 493 |
|
| 494 |
r = verify(model, images, paths, precision)
|
|
|
|
| 510 |
"opset": OPSET,
|
| 511 |
"torch_version": torch.__version__,
|
| 512 |
"channel_noise_rms": CHANNEL_NOISE_RMS,
|
| 513 |
+
"int8_fp32_layers": exclusions,
|
| 514 |
"artifacts": {
|
| 515 |
p.name: {"sha256": sha256(p), "bytes": p.stat().st_size} for p in written
|
| 516 |
},
|