Add opt-in FP8 DiT TensorRT engine for SA3-medium - #47
Conversation
TensorRT serializes plugin attributes as 1-element numpy arrays and calls
the annotated type on them when instantiating the plugin. With the
`num_heads: int` annotation this hits `int(np.array([H]))`, which NumPy
(>=1.25 deprecation, error in 2.x) rejects:
tensorrt_bindings/plugin/_lib.py:279, in create_plugin
attrs[f.name] = attr_type_annot(f.data)
TypeError: only 0-dimensional arrays can be converted to Python scalars
so any build or load of an engine using samel::diff_attn_swa (the SAME-L
decoder) crashes. Annotate the attribute as npt.NDArray[np.int64] and
coerce to a Python int inside the impl. Verified end-to-end on TRT
10.16.1.11 + NumPy 2.4.4: the attr arrives as ndarray, the kernel output
is unchanged.
Producer recipe (build_dit_fp8.py) builds a ModelOpt FP8 GEMM-trunk DiT on top of the FP16-mixed graph: FP8 PTQ on MatMul/Gemm, initializer repair plus activation-scale recalibration, re-applied FP32 islands (RMSNorm/Softmax/RoPE plus the conditioning front-end), and per-channel weight scales. make_calib.py captures calibration inputs from the model's own pingpong generate(), pulling prompts from interface/reprompt.py. ~1.8x faster steps than FP16-mixed at B=1, amortizing further under batched dispatch. Consumer wiring adds sa3-m-fp8 as an opt-in target (excluded from all/all-both and 'build all missing'; built only by explicit name, gated on the published ONNX) and a --precision fp8 selection that pairs the FP8 DiT with the FP16-mixed decoder, guarded so non-medium DiTs cannot request it.
The compounded euler final-latent cosine was previously quoted as a single-prompt range (~0.96 to 0.976). Measured over all 47 reprompt calibration prompts (warmed engines, deterministic euler, L=646) the distribution is mean 0.953, median 0.957, p5 0.915, worst 0.873; the worst single-step latent cosine over all 376 samples is 0.9982. The rollout is chaotic at the early sigmas (an eps=1e-3 input perturbation alone compounds to ~0.967), so the docs now report the distribution with that caveat and point to the production-sampler listening gate (decoded audio tracks the FP16-mixed generation at ~0.90 RMS-curve correlation) as the real acceptance test. Also note that TRT tactic selection is nondeterministic per build: step time varied 10.6-11.0 ms across rebuilds of the same ONNX, so a noticeably slow engine should be rebuilt rather than blamed on the recipe.
9ff9ace to
0399212
Compare
|
Claude: Hi @ryanontheinside — we've been optimizing the medium-DiT fp8 tier and your calibration work here was the key lever. Isolating it, your real-conditioning calibration ( We shipped a variant in #86 that grafts your calibrated scales onto the already-merged bakedmin structure (baked fp32 RoPE + bf16 fused MHA), so it keeps the faster bf16-fused attention — your fp16-attention + runtime-RoPE-island engine gets the last ~0.03 of step-0 fidelity for +2 ms/fwd, which we've noted as a speed/quality knob. Your |
…@ryanontheinside, #47) fp8 medium DiT: calibrated tier — same bf16-fused speed, better worst-step fidelity (calibration by @ryanontheinside, #47)
Captures the FP8 calibration .npz for the medium DiT by driving the model's own pingpong generate() and recording the six DiT engine inputs at every sampling step. Native by construction: loads via the repo's own load_diffusion_cond, prompts from interface/reprompt.py, the real pingpong sigma schedule (no re-implementation of the inference path). From Stability-AI#47 (feat/dit-fp8); included here to support the calibrated fp8 tier.
Grafts @ryanontheinside's calibrated fp8 scales (Stability-AI#47) onto the shipped bakedmin fp8 structure (baked fp32 RoPE constant + bf16 fused MHA + weakly-typed), so the `dit_fp8` tier keeps its bf16-fused speed while gaining much better worst-step fidelity. The build recipe is unchanged — calibration lives entirely in the ONNX scale values. - transplant_scales.py: match every quantized Linear by weight-initializer name and swap only the fp8 scale VALUES (per-tensor activation amax + per-channel weight scales) from Stability-AI#47's calibrated ONNX onto our RoPE-baked graph; the 5.8 GB fp32 weights are untouched (TRT re-quantizes at build). - build_from_onnx.py: update the sa3-m-fp8 recipe notes — dit_fp8 is now the calibrated engine. - build/README.md + tensorRT/README.md: document the calibrated tier, the bake -> transplant producer flow, and that a from-scratch recalibration uses Stability-AI#47's full pipeline (make_calib.py -> build_dit_fp8.py). Measured H200 (L=4096, same run): speed unchanged (31.2 vs uncalibrated 30.8 ms/fwd, within noise); worst-step velocity-cos vs fp32 on adversarial seeds 0.52/0.57/0.64 -> 0.92/0.94/0.92, with steps 1-7 tracking the fully-calibrated Stability-AI#47 reference within ~0.001. Calibration approach + make_calib.py tooling by @ryanontheinside (Stability-AI#47). Co-authored-by: RyanOnTheInside <7623207+ryanontheinside@users.noreply.github.com>
fp8 E4M3 grafted onto the linear GEMMs of the known-good fp16mixed graph (attention fp16-fused, fp32 RoPE/RMSNorm islands left intact) — deliberately NOT the medium's baked-RoPE recipe, since the small DiTs use standard attention and never had bf16's long-angle RoPE problem. A clean weight-halving tier: engine 479 vs 936 MB, velocity-cos ~0.99 vs eager, clip% at/below fp16mixed; only marginally faster (~1.10-1.17x) because a small DiT's ~5 ms forward at batch 1 is overhead-bound, so fp8's GEMM-math savings barely show. Default stays fp16mixed; fp8 is selectable for the smaller engine/weight footprint. - canon (sa3_trt_core.py): register fp8 for sm-music/sm-sfx, drop the medium-only fp8 guard, update precision docs/CLI help. - build_from_onnx.py: sa3-sm-music-fp8 / sa3-sm-sfx-fp8 STRONGLY_TYPED targets. - build/make_dit_fp8_smalldit.py: the producer (calibrate + fp8-QDQ graft; fp16 scales, floored, so the fp16 trunk stays consistent). NB producing these via Stability-AI#47's ModelOpt path breaks the fp32 islands (collapses to velcos ~0.69). - docs: build/README.md + runtime README precision sections. Engines + ONNX uploaded to HF (tensorRT/sm_90/sa3-sm-{music,sfx}/dit_fp8.trt, onnx/sa3-sm-{music,sfx}/dit_fp8.onnx). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Depends upon #49, please merge that first.
FP8 DiT TensorRT engine (opt-in, ~1.8x per step, ~2.5x batched throughput)
Adds an optional FP8 GEMM-trunk TensorRT engine for the SA3-medium DiT, on top
of the existing FP16-mixed recipe. The DiT step is the inner loop of the
pingpong sampler, so this is the highest-leverage place to cut latency. The
engine keeps FP32 inputs/outputs, so it is a drop-in swap for the FP16-mixed
engine at inference (
sa3_trt --precision fp8), pairing an FP8 DiT with theexisting FP16-mixed decoder.
Why FP8 here (and why batching is the larger win)
The FP16-mixed DiT engine is compute-saturated: a single batched forward barely
amortizes (<=1.09x at B=4), so a ring-buffer pipeline at depth > 1 hits a flat
throughput ceiling. FP8 cuts per-row GEMM compute ~1.8x, which frees the SM
throughput the FP16 engine saturated, so batching amortizes.
The recipe (why it is more than
mtq.quantize)build_dit_fp8.pytakes the publisheddit_fp16mixed.onnxplus a calibration.npzand producesdit_fp8.onnx:the island surgery, and ModelOpt/ORT reject that).
disable_mha_qdq(attention BMMs stayon the FP16/FP32 path),
maxcalibration.and recalibrate activation scales on a Q/DQ-bypassed copy with real
conditioning.
Softmax / RoPE) plus the conditioning front-end, which must stay FP32 or the
t>=0.984 timestep features flush.
free at runtime). Activations stay per-tensor (TRT requirement for FP8
activation quant);
maxcalibration, because the activation outliers aresignal and percentile clipping regresses parity.
Calibration data is captured from the model's own
generate()bymake_calib.py, which records the six DiT engine inputs across the pingpongschedule. Prompts come from this repo's
interface/reprompt.pyMusic examples,the deployment-matched reprompt format. The
.npzis a reproducible producerartifact (gitignored, never committed).
What is in this PR
Producer (model maintainers):
build/make_calib.py(new): calibration capture from the checkpoint.build/build_dit_fp8.py(new): the FP8 build recipe above.Consumer:
build/build_from_onnx.py,build/build.py: addsa3-m-fp8as an opt-intarget. It is excluded from
all/all-both/ "build all missing" and isbuilt only by explicit name, gated on the published ONNX existing.
scripts/sa3_trt.py,scripts/sa3_trt_core.py:--precision fp8selection(FP8 DiT + FP16-mixed decoder).
build/README.md: producer and consumer documentation..gitignore: ignore*.calib.npz.Testing and validation
All results below were produced on a single RTX 5090 (sm_120), TensorRT
10.16, on SA3-medium at L=646 (the latent length of a ~54 s generation). Step
times are hardware-dependent; the speedup ratios are the portable claim.
1. Clean-room reproduction of the producer chain
The full producer path was rebuilt from a clean checkout, using only the inputs
a model maintainer has: the SA3-medium checkpoint and the published
dit_fp16mixed.onnx(both pulled from HuggingFace), nothing carried over fromdevelopment.
make_calib.pycaptured a fresh calibration set, thenbuild_dit_fp8.pyproduced the engine end to end.Calibration capture: 376 samples (47 reprompt Music prompts x 8 sigmas) at
L=646, schedule
[1.0, 0.9944, 0.9845, 0.9579, 0.8909, 0.7455, 0.5125, 0.2739],t5_hiddenrange[-52.33, 36.10],xrange[-5.87, 5.38],local_add_condall zero (text-to-music), matching the expected referenceprofile.
Build stages, all clean:
maxcalibration over the376 samples.
(
to_timestep_embed.2.bias6060 -> 0.12,layers.22.to_local_embed3286 -> 1.30), 417 Q/DQ pairs bypassed, 834 activation scales recalibrated,
0 mask-path pairs.
front-end.
verified.
The resulting engine deserializes and exposes the expected six FP32 inputs
(
x,t,t5_hidden,t5_mask,seconds_total,local_add_cond) and thevelocityFP32 output, with dynamic latent length.2. Numerical parity vs the FP16-mixed engine
Parity was measured by feeding the captured DiT inputs through both the FP8 and
FP16-mixed engines at batch 1 and comparing outputs.
Single-step latent agreement (
x + dt * v, the quantity that actually advancesthe sampler), over all 376 samples, by sigma:
Worst single-step latent cosine 0.99824, mean 0.99971.
Compounded agreement was measured with an 8-step deterministic euler rollout
per prompt: each engine is chained from the same sigma=1.0 latent with its own
velocities and the final latents are compared, once per calibration prompt
(both engines warmed by the 376 single-step evaluations beforehand). Over all
47 prompts the final-latent cosine distribution is mean 0.953, median 0.957,
p5 0.915, worst 0.873, best 0.990.
Two caveats on reading the compounded number: the rollout is chaotic at the
early sigmas (a 1e-3 relative input perturbation alone compounds to ~0.967
final-latent cosine, and single FP8 steps at sigma 0.994/0.984 dominate the
divergence), and the FP16-mixed engine itself scores only ~0.998 compounded vs
PT eager. So compounded cosine is a guide rather than a gate; the acceptance
test is decoded audio under the production pingpong sampler, judged by ear,
where this engine's generation tracks the FP16-mixed generation at ~0.90
RMS-curve correlation (same conditioning and seeds).
Two negative results worth recording. BF16 was tried and rejected: it
compounds error over the 8 steps (final-latent cos ~0.81) and is audibly
degraded. Dequantizing the conditioning front-end GEMMs (
to_cond_embed,project_in,project_out) was also tried and rejected: it improves everyeuler metric (worst single-step latent 0.9982 -> 0.9993, compounded mean
0.953 -> 0.967) yet reproducibly diverges under the production pingpong
sampler: decoded-audio RMS correlation vs the FP16-mixed generation drops
from ~0.90 to ~0.33, audibly a different song, across three independent
engine builds. Euler metrics under-weight the high-sigma steps (dt ~ 0)
exactly where pingpong's denoised prediction
x - t*vamplifies velocityerror most, so recipe changes here must be gated by the listening test, not
by euler cosines. Under the stochastic pingpong sampler the FP8 engine yields
a different but comparable sample.
3. Per-step latency (B=1, L=646)
Median of 200 timed steps on a real calibration sample:
The ranges are run-to-run and build-to-build variance. TensorRT tactic
selection is nondeterministic per build (we observed a ~10% slow outlier on
one build of a closely related graph). If a freshly built engine benches
noticeably slower than expected, rebuild it; the ONNX is deterministic, only
the engine compilation varies.
4. Batched throughput, depths 1..8
For each engine the input batch dimension was made dynamic, a STRONGLY_TYPED
B=1..8 engine was compiled, each batched row was validated against the serial
B=1 engine, then the median batched step time was benched against serial
dispatch.
gens/sis the steady-state generation rate of an 8-step pingpongring buffer running at that batch (depth).
FP16-mixed:
(serial B=1 reference engine: 20.9 ms)
FP8:
(serial B=1 reference engine: 10.6 ms)
Reading these together:
batch, batching buys <=1.09x, and the ring-buffer ceiling is ~6.5 gens/s.
1.41x at B=4..5, lifting the ceiling to ~16.5 gens/s.
at B=4, 2.65x at B=8), and the end-to-end pipeline throughput a depth > 1
ring buffer actually hits rises from ~6.5 to ~16.5 gens/s, about 2.5x. The
headline 1.8x is the B=1 step; under the batching the pipeline uses, FP8
compounds it.
5. Batching correctness
Each batched row was validated against the serial B=1 engine on identical
inputs. The FP16-mixed batched engine matched its serial engine at 0.99994
(worst row). The FP8 batched engine matched at a uniform 0.992 to 0.995 across
all rows: this is FP8 kernel-tactic variance between two separate engine builds
(the per-tensor activation scales are the same), not a batch-size
specialization bug, which the uniformity across rows confirms. Timing is
unaffected.
Status: ONNX not yet in the official model repo
dit_fp8.onnx+dit_fp8.onnx.dataare not instabilityai/stable-audio-3-optimizedyet, sobuild_from_onnx.py sa3-m-fp8and
sa3_trt --precision fp8will 404 until they are uploaded there underexactly those filenames. This is why
sa3-m-fp8is opt-in and kept out of thedefault
allbuild paths.The built artifacts from the clean-room run above are staged at
ryanontheinside/stable-audio-3-optimized-fp8in the official repo's layout (
onnx/sa3-m/dit_fp8.onnx+.data, plus aprebuilt
tensorRT/sm_120engine), so they can be verified directly or copiedinto the official repo. Equally, a maintainer run of the two producer commands
below reproduces them from nothing but the checkpoint and the published
dit_fp16mixed.onnx; the staging copy exists for convenience, the recipe isthe source of truth.
Usage
Producer:
Requires
nvidia-modelopt+onnxruntime-gpuon top of the consumer deps.Consumer (once the ONNX is published):