The cargo-class push deliberately excluded the neural/semantic work to avoid reverting origin; origin never independently changed neural, so this brings it forward cleanly and wires it up. - neural/mod.rs declares the full decoder family; rans.rs gains the categorical decoder it needs (glc/mlicpp/elic/hyperprior/int_synth/ cmic/dcvc/snac + the JPEG-AI entropy/codestream/σ/value/filter port). - runtime.rs uses the shared deterministic ort_session loader. - Restores the semantic/generative path (semantic.rs, min_sim/sim_metric) behind the `semantic` feature, with the bins/tests that construct ModelRequirement updated accordingly. - Re-adds byte-exact-conformance and the tools/ export/encode scripts. Green together: wai-rs all features (75 lib + integration tests) + clippy, byte-exact-conformance (8), wai-web wasm, wai-spz-gen. Co-Authored-By: Claude Opus 4.8 <[email protected]>
120 lines
5.1 KiB
Python
120 lines
5.1 KiB
Python
"""Train an integer synthesis for `wai.neural.int_synth` and export the
|
|
`int_synth.json` the Rust decoder runs — the WORKING path: float training +
|
|
post-training quantization with PER-LAYER ACTIVATION-SCALE CALIBRATION.
|
|
|
|
Why not pure QAT-through-the-shift: the integer requantize `>> shift` has zero
|
|
gradient (floor), so straight QAT can't learn; even with a straight-through
|
|
floor, a fixed shift collapses small activations to 0. The robust recipe is to
|
|
train in float, then quantize each layer with a shift derived from the MEASURED
|
|
activation range so the integer pipeline tracks the float one:
|
|
|
|
s_w = max|W| / 127 (per-layer weight scale)
|
|
s_i = max|activation_i| / 127 (per-layer activation scale, calibrated)
|
|
shift = round(log2( s_i / (s_w · s_{i-1}) )) (≥ 0)
|
|
W_int = round(W / s_w) b_int = round(b / (s_w · s_{i-1}))
|
|
latent_int = round(latent / s_0)
|
|
|
|
The integer forward (`int_synth.rs`) is then bit-exact across machines BY
|
|
CONSTRUCTION, and the Rust decode matches this tool's integer reference to
|
|
max|Δ|=0 (`int_synth_trained_model_matches_integer_forward`). Quality scales
|
|
with model size / data / per-channel scales; this reference run hits ~16 dB on a
|
|
tiny synthetic set in seconds.
|
|
|
|
Run (needs torch; bring images as a [N,3,H,W] float tensor in [0,1], H=W=2^k·S):
|
|
python tools/wai_int_synth_train.py --data imgs.pt --out wai-web/demo/models/int_synth/int_synth.json
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import torch
|
|
import torch.nn as nn
|
|
import torch.nn.functional as F
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--data", help="torch .pt: float images [N,3,H,W] in [0,1]. Omit for a synthetic demo set.")
|
|
ap.add_argument("--chans", type=int, nargs="+", default=[24, 24, 16, 8, 3])
|
|
ap.add_argument("--latent-side", type=int, default=4)
|
|
ap.add_argument("--epochs", type=int, default=600)
|
|
ap.add_argument("--lr", type=float, default=2e-3)
|
|
ap.add_argument("--out", required=True)
|
|
args = ap.parse_args()
|
|
torch.manual_seed(0)
|
|
C, LS = args.chans[0], args.latent_side
|
|
|
|
if args.data:
|
|
data = torch.load(args.data, map_location="cpu").float()
|
|
else:
|
|
SZ = LS * 16
|
|
imgs = []
|
|
for k in range(24):
|
|
xx, yy = np.meshgrid(np.linspace(0, 1, SZ), np.linspace(0, 1, SZ))
|
|
imgs.append(np.stack([np.sin((xx + .1 * k) * 8) * .5 + .5,
|
|
np.cos((yy + .07 * k) * 6) * .5 + .5, (xx * yy + .05 * k) % 1.0], 0))
|
|
data = torch.from_numpy(np.stack(imgs).astype(np.float32))
|
|
|
|
dn = lambda a, b: nn.Conv2d(a, b, 5, 2, 2)
|
|
up = lambda a, b: nn.ConvTranspose2d(a, b, 5, 2, 2, output_padding=1)
|
|
n_down = int(round(math.log2(data.shape[-1] // LS)))
|
|
enc_layers = []
|
|
ch = 3
|
|
for _ in range(n_down):
|
|
enc_layers += [dn(ch, C), nn.ReLU()]; ch = C
|
|
enc = nn.Sequential(*enc_layers[:-1])
|
|
dec = nn.ModuleList([up(args.chans[i], args.chans[i + 1]) for i in range(len(args.chans) - 1)])
|
|
|
|
def dfwd(y):
|
|
x = y
|
|
for i, c in enumerate(dec):
|
|
x = c(x)
|
|
if i < len(dec) - 1:
|
|
x = torch.relu(x)
|
|
return x
|
|
|
|
opt = torch.optim.Adam(list(enc.parameters()) + [p for c in dec for p in c.parameters()], lr=args.lr)
|
|
for ep in range(args.epochs):
|
|
opt.zero_grad()
|
|
recon = dfwd(enc(data) * 64.0)
|
|
loss = F.mse_loss(recon.clamp(0, 1) * 255, data * 255)
|
|
loss.backward(); opt.step()
|
|
if ep % 100 == 0 or ep == args.epochs - 1:
|
|
print(f" epoch {ep:3d} px-mse {loss.item():.1f}")
|
|
|
|
# calibrate activation scales, then quantize.
|
|
with torch.no_grad():
|
|
x = enc(data) * 64.0
|
|
amax = []
|
|
for i, c in enumerate(dec):
|
|
x = c(x)
|
|
if i < len(dec) - 1:
|
|
x = torch.relu(x)
|
|
amax.append(float(x.abs().max()))
|
|
s0 = float((enc(data) * 64.0).abs().max()) / 127.0
|
|
scales = [s0] + [a / 127.0 for a in amax[:-1]] + [1.0 / 255.0] # final: int = pixel
|
|
layers, sin = [], s0
|
|
for i, c in enumerate(dec):
|
|
sw = float(c.weight.detach().abs().max()) / 127.0
|
|
shift = max(int(round(math.log2(scales[i + 1] / (sw * sin)))), 0)
|
|
layers.append({
|
|
"in_ch": c.in_channels, "out_ch": c.out_channels, "k": 5, "stride": 2, "pad": 2, "out_pad": 1,
|
|
"weight": torch.clamp(torch.round(c.weight.detach() / sw), -128, 127).to(torch.int64).reshape(-1).tolist(),
|
|
"bias": torch.round(c.bias.detach() / (sw * sin)).to(torch.int64).reshape(-1).tolist(),
|
|
"shift": shift, "relu": i < len(dec) - 1,
|
|
})
|
|
sin = scales[i + 1]
|
|
out = Path(args.out); out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_text(json.dumps({"layers": layers}, separators=(",", ":")))
|
|
print(f"wrote calibrated integer synthesis → {out} shifts={[l['shift'] for l in layers]}; "
|
|
f"decode is bit-exact on any machine. Latent scale s0={s0:.4g} (encoder is not exported; "
|
|
f"quantize your latents as round(latent/s0) ∈ int8).")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|