Target audience: engineers who need to implement
_calibratewhen adding a new model, want to understand whether a precision regression is a calibration issue or a weight issue, or are debuggingalpha = scale * weight_scalestyle bugs.TL;DR:
- FP8 E4M3 only has a dynamic range of ±448, so per-tensor scaling is mandatory to avoid overflow.
- Weight scales are precomputed once during
_load_weights(viaquant_fp8) and stored alongside the weight tensors.- Activation scales are computed during
set_promptby running one forward pass and measuring the amax at each GEMM input, then cached to disk.alpha = act_scale * weight_scaleis the CUTLASS FP8 GEMM descale multiplier and must be multiplied in f32 — not f64.- Calibration cache location:
~/.flash_rt/calibration/{ckpt_hash}_Se{N}.json.
The FP8 E4M3 format has 1 sign bit + 4 exponent bits + 3 mantissa bits. Its largest representable magnitude is ±448 and its smallest normal positive value is roughly 2^-6 — about five decimal digits of dynamic range.
FP16/BF16 weights and activations are typically distributed over roughly [-5, 5]. Casting them directly to FP8 causes two failure modes:
- Large values (> 448) saturate and lose precision.
- Small values (< 2^-9) truncate to zero and distort the sparsity pattern.
Every FP8 tensor therefore needs an accompanying per-tensor scale (float32):
fp8_value = clip(fp32_value / scale, -448, 448)
fp32_restored = fp8_value * scale
FlashRT's policy:
- Weights — static per-tensor scale, computed once at load time and never updated.
- Activations — static per-GEMM-input scale, computed once during calibration and held constant at runtime.
The activation scale can only be obtained by running a full forward pass and recording the amax at each GEMM input. That is exactly what _calibrate does.
How this compares to vLLM and similar frameworks: vLLM defaults to eager BF16 with no FP8 quantization. When AWQ or GPTQ is enabled, the quantization is weight-only (activations stay in FP16). FlashRT runs W8A8, which requires calibrating both sides.
Computed when loading the checkpoint, inside _load_weights via the Quant() transform in WEIGHT_SPEC:
# flash_rt/core/thor_frontend_utils.py
def quant_fp8(w):
w = w.contiguous()
a = w.float().abs().max().item()
s = max(a / 448.0, 1e-12)
return (w.float() / s).clamp(-448, 448).to(_FP8), sEvery quantized weight tensor has its own scale float. The scale_into="_enc_w_scales" directive in the spec gathers these scales into a python list in spec order; the frontend then wraps it as a device tensor:
self._enc_w_dev = torch.tensor(self._enc_w_scales, dtype=torch.float32, device='cuda')Storage: bound to the weight tensor itself, persisted across restarts with the checkpoint, never updated dynamically.
Computed during set_prompt — on the first call, or whenever recalibrate=True is passed.
The distribution at each GEMM input depends on:
- The statistics of every preceding layer's output (which are a function of the model weights).
- The input sequence length
Se(prompt tokens + SigLIP patches). - The prompt content itself — different text produces different distributions, but the difference is small enough to ignore. Verified empirically that calibrating with zero inputs still yields cos > 0.998 on real tasks.
_calibrate therefore uses a fixed zero input plus a representative prompt of length Se, then runs encoder_forward_calibrate followed by decoder_forward_calibrate:
# insert an amax reduction at each GEMM input
fvk.rms_norm_fp16(x, ones_buf, norm_scratch, Se, D, 1e-6, stream)
_measure_scale_gpu(fvk, norm_scratch, Se * D, d_scale, fp8_scratch, stream)
# d_scale now equals max(|norm_scratch|) / 448 with clamp
# written to calib_buf[l*4 + 0]; the next GEMM reads it directly
fvk.rms_norm_fp8_noweight_fp16(x, x_fp8, Se, D, d_scale, stream)Reference implementations: shared_primitives.py::encoder_forward_calibrate (lines 491-610) and decoder_forward_calibrate (from line 610 on).
Each Pi0.5 / Pi0 encoder layer has 4 FP8 GEMMs (QKV / O / Gate+Up / Down), so enc_calib_scales.shape == (L * 4,) == (72,). The decoder is identical: (La * 4,) == (72,).
A CUTLASS FP8 GEMM by itself produces fp8_a × fp8_b = fp32 without any scaling. To recover the true value, the accumulator must be multiplied by the descale factor:
output = fp32_accumulator × act_scale × weight_scale
= fp32_accumulator × alpha
alpha is a per-GEMM f32 scalar. It must be computed on the host in f32 (not f64), because the kernel that Myelin emits performs the multiplication in f32 — a mismatch introduces last-bit differences:
# Historical bug: computing float(...) * float(...) (implicit f64) produced
# different bits than the production C code, regressing cos to 0.9878.
self._enc_alpha_host = [
float(np.float32(self._enc_calib_scales[i].item()) * np.float32(enc_ws[i]))
for i in range(Le * 4)
]Always use np.float32(a) * np.float32(b) — never plain a * b.
~/.flash_rt/calibration/{ckpt_hash}_Se{N}.json, where:
ckpt_hash= SHA256(first 64 KB + file_size), first 16 hex chars (seecalibrator.py::_checkpoint_hash).Se= encoder input sequence length (a function of num_views and prompt length).
Why both parts are needed:
- Different fine-tunes of the same base → different activation distributions → different scales.
- Same checkpoint at a different
Se→ different GEMM shapes → potentially different optimal scales (the impact is small but nonzero).
{
"version": 1,
"ckpt_hash": "a1b2c3d4e5f67890",
"Se": 776,
"num_enc_scales": 72,
"num_ae_scales": 72,
"enc_scales": [x0, x1, ...], // activation scales, len = L * 4
"enc_alpha": [x0, x1, ...], // = enc_scales * enc_w_scales, len = L * 4
"enc_w_scales": [x0, x1, ...], // weight scales (for alpha recomputation)
"ae_scales": [x0, x1, ...] // decoder activation scales
}Why this redundancy: enc_w_scales is also stored in the cache so that if the weight quantizer is ever upgraded, alpha can be recomputed from the cache alone without rerunning the forward pass. The current production path does not exercise this, but the option is there.
Automatic invalidation conditions in load_calibration:
versionmismatch → recalibrate.ckpt_hashmismatch (user swapped the checkpoint) → recalibrate.Semismatch (prompt length changed) → recalibrate.
Manual invalidation:
rm ~/.flash_rt/calibration/{ckpt_hash}_Se{N}.json
# or wipe everything:
rm -rf ~/.flash_rt/calibration/load_model(..., recalibrate=True) also forces a rerun.
Every step in this progression was caused by a calibration bug. Keep the list as a reference checklist: if your new model shows cos < 0.995, check these four regressions first.
Symptom: cos collapsed overnight from 0.999 to 0.933 even though neither the weights nor the activation calibration had changed.
Root cause: _override_weight_scale indexed scales by a running counter, but the item order inside WEIGHT_SPEC drifted. The counter got out of sync, so layer L ended up using the weight scale belonging to layer L+N.
Fix: replace the counter with a fingerprint lookup that matches scales to tensors by identity.
How to avoid in a new model: the scale_into order in WEIGHT_SPEC must match the order the legacy C++ loader expects. Walk the old loader and verify every Quant site's scale index.
Symptom: Pi0.5 flow matching takes 10 steps, each with a slightly different activation distribution. Calibrating each step independently produced 10 distinct scale sets, which gave Myelin inconsistent KV-cache shapes at compile time and crashed it.
Fix: aggregate the max across all steps inside _calibrate. One calibration forward already covers every step because the decoder is iterative.
How to avoid in a new model: if your model is flow-matching or autoregressive-decode, do not compute per-step scales inside _calibrate. Use the final max from a single forward.
See §2.3 — force f32 multiplication.
Symptom: some layers had normal cos, others regressed.
Root cause: the old _override_weight_scale fell back to the legacy path for 30 special layers; only the remainder went through the fingerprint lookup. Those 30 fallback layers computed their scales incorrectly.
Fix: precompute inside _override_weight_scale and route every layer through fingerprint lookup uniformly.
How to avoid in a new model: do not branch. Either every layer goes through the new scale path or every layer stays on legacy — never mix.
The scales built on the first infer call are derived from zero inputs plus a placeholder prompt. A real task's distribution may differ. Pi0.5 exposes an optional _recalibrate_with_real_data method for this case:
# Pi05TorchFrontendThor.infer()
if not self._real_data_calibrated and <first real infer>:
self._recalibrate_with_real_data()
self._real_data_calibrated = TrueWhat it does: take the first real input (real image + real prompt) and rerun encoder_forward_calibrate + decoder_forward_calibrate, overwriting the cached scales.
Measured impact: Pi0.5 LIBERO Spatial success rate went from 91.8% to 98.2% thanks to this mechanism. Any task with significant domain shift from zero inputs should turn it on.
Should your new model do this? If the target task's distribution is far from a zero tensor (for example mobile manipulation), strongly recommended. The simplest path is to copy Pi0.5's _recalibrate_with_real_data verbatim and rename the bufs/weights/dims.
- Tactic noise: spread is usually ≤ 0.002 and fluctuates up and down across runs — not monotonic.
- Scale bug: typically consistently low, hovering around the same value on every run.
Run 3-5 A/B trials to disambiguate.
The calibrate function contains many _gpu_sync(stream); as_o = _d2h_float(d_scale) calls. These are required: the next GEMM's alpha depends on the host-side value of as_o. Do not remove these syncs in favor of async variants.
This is also why _calibrate is roughly 10x slower than _capture_*_graph (3-4s vs 0.3s) — four D2H transfers per layer.
enc_alpha_host: pythonlist[float]— used as a scalar argument to each GEMM in forward (cutlass_fp8_sq(... alpha=ENC_ALPHA_HOST[idx] ...)).enc_calib_scales:torch.tensor(..., device='cuda')— passed toquantize_fp8_static_fp16as the d_scale pointer.
You need both. Do not pack alpha into a device tensor — the kernel expects a host scalar parameter.
CudaBufferFlat.finalize already contains a single jax.block_until_ready(flat), which is sufficient. Adding another block_until_ready inside the calibration loop breaks the async pipeline and doubles the first set_prompt time.
- Every
Quant()item in WEIGHT_SPEC has ascale_intoname ordered to match what the C++ pipeline expects. -
_load_weightsfinishes by wrapping the scale list as a device tensor (self._enc_w_dev = torch.tensor(self._enc_w_scales, ...)). - The
enc_bufs/enc_weights/enc_dimsdictionaries in_calibrateuse exactly the keys thatshared_primitives.encoder_forward_calibrateexpects — a missing key is a segfault. -
_enc_alpha_hostis computed withnp.float32 * np.float32. - Call
load_calibration(ckpt, Se)first; only run the calibration forward on miss. The first cold start may take 3-4s, but a second start must be under 0.5s. - For flow-matching or iterative-decode models: a single
decoder_forward_calibratecall must cover every step — do not calibrate per step. - In
set_prompt, setself._enc_calib_scales/self._ae_calib_scalesas device tensors before graph capture. - Optional: implement
_recalibrate_with_real_dataand trigger it on the firstinfercall.
| Content | File |
|---|---|
quant_fp8 weight quantization function |
flash_rt/core/thor_frontend_utils.py |
| Calibration-cache read/write | flash_rt/core/quant/calibrator.py |
encoder_forward_calibrate / decoder_forward_calibrate |
flash_rt/hardware/thor/shared_primitives.py |
Pi0.5 _calibrate example |
flash_rt/frontends/torch/pi05_thor.py (L499-649) |
_recalibrate_with_real_data |
flash_rt/frontends/torch/pi05_thor.py (L954) |
| GROOT split calibrate (Qwen3 + DiT separate) | flash_rt/frontends/torch/groot_thor.py, flash_rt/frontends/torch/groot_rtx.py |
Related documentation:
docs/adding_new_model.md— overall guide to adding a new model.docs/kernel_fusion.md— which kernels accept a d_scale argument and where the fusion boundaries are.docs/stable_api.md— public contract for thescale_intoparameter inside WEIGHT_SPEC.docs/precision_spec.md— declarative description of the quantization produced by calibration.
TL;DR — empirically validated on Pi0.5 LIBERO-FT vs PyTorch FP32 reference. Both platforms (RTX 5090 SM120 datacentre GPU, Jetson AGX Thor SM110 edge SoC) support the same
calibrate(obs_list, percentile=...)API and stratified LIBERO sampling:
Platform Strategy cos vs FP32 ref maxdiff vs ref calibrate (one-off) RTX 5090 single-frame 0.9996 0.031 0.21 s RTX 5090 stratified N = 8 0.9998 0.020 0.69 s RTX 5090 stratified N = 64 0.9998 0.020 2.21 s Thor SM110 single-frame 0.9989 0.046 0.35 s Thor SM110 stratified N = 8 0.9992 0.043 1.24 s Thor SM110 stratified N = 16 0.9994 0.036 2.20 s Thor SM110 stratified N = 64 0.9997 0.025 8.22 s Thor SM110 stratified N = 256 0.9997 0.023 32.10 s
- Fine-tuned-for-domain models on RTX: use stratified N = 8 — halves maxdiff, costs ~0.7 s one-off.
N > 8plateaus.- Fine-tuned-for-domain models on Thor: use stratified N = 64 — cos improvement keeps scaling past N = 8 on SM110 (see §10.5 "Thor scaling is not the same as RTX"). Costs ~8 s one-off.
- Base / not-fine-tuned models: keep single-frame at an operating-point frame — multi-frame's wider scale can reduce per-frame precision (see §10.8 "model type matters").
- Match the openpi-jax-mlir toolchain on RTX for parity: N = 8, stratified by episode × frame position,
percentile = 99.9.- Graph-replay P50 is unchanged on both platforms (RTX: Pi0.5 ~21 ms, Thor Pi0.5 ~45 ms 2v / ~55 ms 3v). Multi-sample calibration only changes the scale values — the captured CUDA Graph structure and replay cost are identical.
Full data, methodology, and the precision-vs-coverage trade-off below.
The sections above describe single-frame calibration, which is what the framework does by default. For real hardware deployment — where the runtime distribution of activations spans lighting, occlusion, and pose variation that a single frame does not cover — FlashRT also supports multi-frame dataset calibration with percentile clipping.
A single frame produces an almost-correct amax. The failure modes are:
- Runtime frames are more extreme than the calibration frame → activations saturate at ±448 (FP8 E4M3 max), accuracy cliff.
- The calibration frame is an outlier → scales are inflated and normal runtime frames lose precision.
Adding more i.i.d. samples does not help against (1) — the only fix is to cover the deployment distribution. Against (2), a percentile reduction (not the true max) clips away outlier frames.
rt = load_model("pi05", "torch", "rtx_sm120")
rt.set_prompt(prompt)
# Default (back-compatible): single frame, percentile ignored
rt.calibrate(observations=[obs])
# Dataset mode
rt.calibrate(
observations=my_frames, # list or Iterable[dict]
percentile=99.9, # default; 100.0 == traditional max
max_samples=256,
verbose=True, # print per-point dispersion summary
)rt.calibrate_with_real_data([obs]) is retained as a thin alias for
backward compatibility.
| Scenario | N | percentile |
|---|---|---|
| Demo / single environment | 1 | n/a |
| Single-task real hardware | 16–64 | 99.9 |
| Multi-task / mixed scene | 128–256 | 99.9 |
| Outdoor / all-weather | 512–1024 | 99.9 |
| Training-set-derived samples with known outliers | 256+ | 99.0 or 95.0 |
Earlier versions of this document compared multi-frame outputs only to the single-frame output ("self"), which measures calibration drift but not absolute quality. The proper measurement is cosine vs. the PyTorch FP32 reference model running on the same inputs.
Pi0.5 on RTX 5090, deterministic image pair (same input the official
rtx_cosine_vs_official.py uses), all N ≥ 8 use stratified sampling
(episode × frame-position) per the openpi-jax-mlir toolchain:
| Strategy | cos vs FP32 ref | cos vs self | maxdiff vs ref |
|---|---|---|---|
| self (N=1, target frame) | 0.999639 | 1.000000 | 0.0312 |
| other_N1 (N=1, different frame) | 0.999575 | 0.999437 | 0.0360 |
| stratified N=8 | 0.999772 | 0.999865 | 0.0204 |
| stratified N=16 | 0.999583 | 0.999827 | 0.0344 |
| stratified N=32 | 0.999760 | 0.999836 | 0.0204 |
| stratified N=64 ⭐ best | 0.999831 | 0.999738 | 0.0204 |
| stratified N=128 | 0.999731 | 0.999851 | 0.0379 |
| stratified N=256 | 0.999785 | 0.999544 | 0.0221 |
Three things this table shows on RTX:
- Single-frame is measurably worse. Both single-frame rows
(
self0.999639,other_N10.999575) trail every stratified configuration with N ≥ 8 by ~0.00015–0.00025 in absolute cosine, and ~50 % higher maxdiff. Single-frame's amax can under-estimate the distribution envelope seen at runtime. - Stratified N = 8 is the sweet spot (cos 0.9998, maxdiff 0.0204) — matching the openpi-jax-mlir toolchain's default and its comment "8 is sufficient".
- Going past N = 8 plateaus. N = 64 is the numerical best (0.9998 vs 0.9998) but the delta over N = 8 is within run-to-run noise. Stratification quality matters more than raw count.
The N = 16 row is an outlier because the stratified-sample picker's exact frame positions happened to miss the high-amax regions that N = 8 captured. This is a sampling artifact, not a trend.
Pi0.5 3-view on Thor SM110 against the Pi0.5 PyTorch FP32 reference
(pytorch_reference.npz shipped inside the the PyTorch deploy container
container). Measured by
tests/bench_thor_calibration_vs_ref.py,
stratified samples drawn from LIBERO-10 (379 episodes / 101 k frames)
via flash_rt.datasets.libero:
| Strategy | cos vs FP32 ref | maxdiff vs ref | calibrate time |
|---|---|---|---|
| N=1 (ref frame) | 0.998938 | 0.0459 | 0.35 s |
| stratified N=8 | 0.999165 | 0.0433 | 1.24 s |
| stratified N=16 | 0.999436 | 0.0355 | 2.20 s |
| stratified N=64 ⭐ | 0.999685 | 0.0250 | 8.22 s |
| stratified N=256 | 0.999742 | 0.0232 | 32.10 s |
Replay latency P50 is a flat 55.49 ms ± 0.02 ms across all rows — the multi-sample path only changes scale values, not the captured CUDA Graph structure. Graph replay is identical in cost to the single-frame path.
How Thor differs from RTX on the same test:
- Thor's N=1 baseline sits ~0.0007 below RTX's. SM110's FP8 E4M3 kernels pick different CUTLASS tactics than SM120 — bit layouts and rounding edges differ slightly — so the single-frame starting point is a notch lower. Absolute quality is still well above the 0.998 deployment threshold.
- Thor keeps scaling past N = 8, unlike RTX. Each doubling of N continues to deliver meaningful cos gain on Thor (N=8 → 16 = +0.00027; 16 → 64 = +0.00025; 64 → 256 = +0.00006). On RTX, past N = 8 is within run-to-run noise. This is because Thor's lower starting point leaves more room for the wider percentile-reduced envelope to help.
- Thor per-sample cost ~128 ms asymptotic (≈ 4 × RTX's ~33 ms). Thor SM110 has fewer SMs and different memory bandwidth; each calibrate forward pass runs ~4× slower.
The previous measurement (before FP32 reference was wired up) reported "cos > 0.9999 across all N" — that was measuring the distance between two FP8 outputs (different calibrations of the same FlashRT pipeline) which is a calibration-drift signal. It cannot identify which strategy is closer to the true model, only how much strategies differ from each other. The dual-cosine table above is the calibration-choice signal that matters for deployment quality.
Pi0 without LIBERO fine-tuning has a different trade-off because its activations are broader. On Pi0 same-task dataset calibration measured against self-calibrated output (not FP32 ref):
| Model | Task 8: cos(N=256, self) | Task 9: cos(N=256, self) |
|---|---|---|
| Pi0 (base) | 0.9961 | 0.9325 |
| Pi0.5 (LIBERO-FT) | 0.9999+ | 0.9999+ |
Pi0's wide activation distribution means stratified calibration broadens the FP8 scale enough to reduce per-frame precision; Pi0.5's tightly-distributed activations absorb the broader scale with no measurable loss. This is why Pi0.5 is the canonical deployment target for measuring calibration quality on LIBERO — and why the recommendations below are keyed on "fine-tuned for domain" rather than on model name.
Multi-frame calibration is not unconditionally "better" on non- fine-tuned models. It trades per-frame precision for distribution coverage:
- Single-frame scale =
amax(this_frame) / 448. Narrower scale means each FP8 value represents a tighter range, so typical activations are quantized with higher precision. If a runtime frame steps outside this range, its activations saturate at ±448 and precision collapses. - Multi-frame scale =
percentile(per-frame amax) / 448. Broader scale avoids saturation on any of the sampled frames, but every value — including typical ones — is represented with coarser steps.
Empirical demonstration on Pi0 RTX (LIBERO, percentile=99.9) measured by cosine vs. self-calibration (the optimal FP8 output for each target):
| Target task | self | same-task N=16 | same-task N=64 | same-task N=256 |
|---|---|---|---|---|
| Task 8 (5047 frames, tight distribution) | 1.000 | 0.997 | 0.988 | 0.996 |
| Task 9 (3373 frames, wider variance) | 1.000 | 0.965 | 0.931 | 0.932 |
Task 8's activations are tightly distributed, so multi-frame's wider scale barely reduces precision. Task 9 has more episode-to-episode variance in activation magnitudes, and the target frame's typical activations do not fill the wider scale — multi-frame drops cosine noticeably.
Practical guidance:
| Deployment scenario | Model is fine-tuned for domain? | Recommended |
|---|---|---|
| Fixed environment (demo, single lab setup) | yes | Single-frame or N=8 stratified — both fine |
| Fixed environment | no (base model) | Single-frame at an operating-point frame |
| Production deployment | yes | N=8–32 stratified by episode / time-of-day, percentile=99.9 |
| Production deployment | no | Keep N=1 at operating point; consider re-fine-tuning first |
| Runtime distribution drifts (lighting / outdoor / scene switches) | yes | N=64–256 sampling across the drift range |
| Runtime contains sensor outliers | either | N ≥ 256 with percentile ≤ 99.0 |
When assembling a multi-sample calibration set, diversity of observations beats raw count. The openpi-jax-mlir production toolchain's stratification pattern:
- Select
min(num_episodes, max(N//2, 3))distinct episodes uniformly - Within each selected episode, take
frame_step = len(ep)//Nequally-spaced frames - Cap total to
N
This captures different task phases (reach / grasp / transport / release) and different object positions within the same task, which produces tighter activation-magnitude coverage than uniform-random sampling over frames.
A naive np.random.choice(all_frames, N) can over-sample mid-episode
steady-state frames and under-sample the open/close-phase activations
where amax is higher.
The rule of thumb: multi-frame pays for itself when runtime frames sometimes exceed the amax of any single representative calibration frame. If all runtime frames are expected to stay within the amax envelope of one well-chosen calibration frame, prefer that single frame.
percentile=99.9 is mathematically only meaningful when N is large
enough that "drop the top 0.1 %" can skip a whole sample. With
N ≤ 1/(1-p) ≈ 10 for p=0.9, or N ≤ 1000 for p=0.999, the
percentile interpolates between the last two ranked values and the
rejection effect is cosmetic. Rules of thumb:
| N | Effective clipping threshold for one outlier-per-set |
|---|---|
| 16 | percentile ≤ 93 (skip 1 of 16) |
| 64 | percentile ≤ 98.5 |
| 256 | percentile ≤ 99.6 |
| 1024 | percentile ≤ 99.9 |
Measured on a synthetic outlier-injection experiment with Pi0 RTX,
N=16, single adversarial frame: percentile=99.9 gave essentially the
same cosine as percentile=100 (0.985 vs 0.989 — within noise). The
clipping defaults are tuned for large-N deployments; if you are running
N ≤ 64 and expect outliers, drop the percentile accordingly (95 for
N=16, 98 for N=64).
| Model | N=1 | N=16 | N=64 | N=256 |
|---|---|---|---|---|
| Pi0 RTX (2v) | 0.27 s | 0.77 s | 2.39 s | 8.84 s |
| Pi0.5 RTX (2v) | 0.21 s | 0.69 s | 2.21 s | 8.27 s |
Per-frame cost asymptotes to ~33 ms (~1.6× a single infer) as N grows — the uncalibrated FP8 GEMM path is slightly slower than the captured-graph replay path.
| Model | N=1 | N=8 | N=16 | N=64 | N=256 |
|---|---|---|---|---|---|
| Pi0.5 Thor (3v) | 0.35 s | 1.24 s | 2.20 s | 8.22 s | 32.10 s |
Per-frame cost asymptotes to ~128 ms (~2.3× a single 55 ms replay).
Measured with the same LIBERO dataset and percentile=99.9 as the RTX
row, on Pi0.5 LIBERO-FT safetensors in the PyTorch deploy container.
See tests/bench_thor_calibration_vs_ref.py.
Calibration runs once per deployment session on both platforms. A Thor
fleet running N = 64 pays an 8-second start-up cost to gain 0.0008
absolute cosine and halve maxdiff vs single-frame — a trade worth
taking for production deployments, but skippable for a 50-shot demo on
a fixed scene where N = 1 is already above the accuracy-sufficient
threshold.
GROOT N1.7 Thor uses aux samples rather than LIBERO obs dicts for
multi-sample calibration. After set_prompt(aux=...) selects the
deployment prompt, GrootN17TorchFrontendThor.calibrate(aux_list, percentile=99.9) percentile-reduces FP8 backbone activation scales
across pre-captured aux samples. The focused Thor regression is
tests/test_thor_groot_n17_calibrate.py
with --ns 1,8.
Thor Pi05TorchFrontendThor.calibrate(obs_list, percentile=99.9)
supports N ≥ 2 today (same shape as the RTX API — see
flash_rt/frontends/torch/pi05_thor.py
_calibrate_multi_frame). The torch Pi0 and Pi0-FAST Thor frontends,
plus the non-FP4 JAX Thor frontends, follow the same obs-list contract.
GROOT N1.7 Thor supports N ≥ 2 through its aux-list API described
above. On RTX the same multi-sample API is also available for
groot_rtx (see
flash_rt/frontends/torch/groot_rtx.py
_calibrate_multi_frame), which percentile-reduces both the Qwen3 and
the DiT activation scales after running per-sample shadow forwards
through both stages. Motus RTX also supports
MotusTorchFrontendRtx.calibrate(obs_list, percentile=99.9) for
Stage3 bundles; it percentile-reduces Motus FP8 GEMM scales, AWQ-FP8
action/und scales, G7.24 action/und QKV scales, and VAE FP8 resample
scales before CUDA Graph capture. For the older GROOT N1.6 Thor path
(groot_thor), run tests/test_thor_groot_calibrate.py --ns 1,8
with N1.6 assets before using it as deployment evidence.
The multi-sample path writes the same per-tensor amax buffer, produces
the same calibrate_fp8() scale computation, triggers the same
autotune_gemms() and record_infer_graph() steps. The captured
CUDA Graph is identical in structure to the single-frame path — only
the values of the scales differ. Infer-time latency is therefore
unchanged (validated in tests: replay p50 difference ≤ ±0.05 ms).
After calibration, rt.precision_spec exposes a ModelPrecisionSpec
with one PrecisionSpec per quantized tensor, recording the scale, the
method ("single_frame" / "percentile"), the N and percentile used.
See precision_spec.md.