arodland commited on
Commit
5b19c77
·
verified ·
1 Parent(s): 661d27d

export: measured per-layer int8 tuning against off-distribution probes

Browse files
Files changed (1) hide show
  1. 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 load_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,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 are loose because int8 really is coarse here. Measured
76
- # 2026-07-27 over 10 COCO val images: latent RMS 1.88e-01, costing
77
- # 0.28 dB of picture. `quantize_dynamic` turns every Conv into
78
- # `ConvInteger`, which supports only a **per-tensor** weight scale --
79
- # `per_channel=True` is silently a no-op on this graph, verified. Static
80
- # (calibrated) quantisation is the lever if int8 accuracy ever matters;
81
- # it is not implemented here.
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
- def convert_int8(src: Path, dst: Path) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = load_model(args.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
- stamp_metadata(dst, {
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
  },