V100 notes · 03

A 33B BF16 video model on FP16-only silicon

MiniMax-H3 is a 33-billion-parameter omni-modal video-and-audio diffusion transformer, released as a BF16 checkpoint. Volta has no BF16 tensor cores, no FP8, no FlashAttention. Getting it to run took six independent fixes, none of them about arithmetic speed, and no quantisation at all.

August 2026 · MiniMax-H3, 33.12B, released BF16 · 2 × Tesla V100-PCIE-32GB, sm_70 · 704×416 and 960×544, picture and sound

0 / 63,240
corrupted blocks
61.7 → 37.5
GiB of weights
31.6 → 19.2
s/step
none
quantisation used
Ten seconds, 243 frames, 50 steps, picture and sound out of the same denoise loop, both cards computing every step of it. 22.73 s/step, peaking at 26.5 of the 31.73 GiB a card has, no quantisation anywhere and no upscaler. Sound on — the audio is the model's own, not a track laid underneath.
bamboo grove, wire-work, ink-wash grade
neon canyon, rain, chase camera
hard backlight down an avenue
a sprint down a burning street
All five clips: 704×416, 243 frames, 50 steps, seed 0, tensor-parallel across the two cards, native output with no upscaler. That canvas is not a taste decision. Fitting memory against sequence length gives 0.369 MiB per row over a fixed 21,453 MiB of weights, table and CUDA context; the line was then tested out of sample at 832×480, where it predicted 31,977 MiB and measured 31,140 — conservative in the safe direction. On the same line, 960×544 at 243 frames wants 35,166 MiB against the 32,768 that exist. Ten seconds is what fits.

The obvious plan is one line long: cast to fp16, split the model across the two cards. It fails for six independent reasons. Two are fp16 dynamic range, one is memory, two are kernel availability, one is a misconfigured offload, and one of the six we inflicted on ourselves while fixing another. None of them is about arithmetic speed, which is the thing Volta is usually assumed to be bad at.

The machine, measured rather than quoted

GEMM, fp16 (8192³) 91.8 TFLOPS
GEMM, bf16, same shape 9.9 TFLOPS  (9.3× slower, no tensor cores)
INT8 via DP4A ~62 TOPS  (below the fp16 rate)
SDPA backends EFFICIENT ✅ · MATH ✅ · FLASH ❌ · CUDNN ❌
usable memory 31.73 GiB per card, 63.46 total
PyTorch 2.9.1+cu128 has native sm_70 SASS; Volta drops out at 2.11

Two consequences shape everything below. "Just run it as released" is not an answer: running the 50-block stack in the checkpoint's own dtype throws away an order of magnitude of arithmetic. So the constraint for the whole project is keep the stack in fp16, and the work becomes entirely about making fp16's dynamic range survive a model that was never trained inside it.

And quantisation is not a speed tool here. Volta's integer path is slower than its fp16 tensor cores, so on this hardware INT8 costs quality and buys no throughput. It is a memory tool, and memory turned out not to be the binding constraint after one particular observation.

Do not move this environment to CUDA 13. The 12.8 PyTorch builds ship real sm_70 kernels; every CUDA 13.x build starts at sm_75. That 91.8 TFLOPS is compiled SASS, not JIT-ed PTX. For the same reason torch.compile stays off the critical path: Triton documents a compute capability 8.0 floor, while PyTorch's gate lets it try anyway.

Six blockers

blocker kind fix
1 text embedder emits inf on entry range bf16 text branch, back to fp16 at the refiner output
2 61.7 GiB of weights against 63.46 GiB of card memory 13B params precomputed into a table
3 a 77.71 GiB single allocation in attention kernel SDPA priority list, EFFICIENT then MATH
4 VAE decode is 30× the denoise config stream the offload, then stop offloading
5 "No available kernel" (self-inflicted by 1 + 3) kernel never pin a backend exclusively
6 fp16 range fails in four separate places range fp32 accumulator, two exact rescalings, one flag

The residual stream was never representable

With the accumulator widened to fp32, the true values become visible for the first time: 97,307 at block 0, already 1.49× over the fp16 ceiling, 325,926 by block 10, and a trajectory peak of 7,480,017, which is 114× the fp16 maximum. Everything measured before that was fp16 clipping on the way out.

The load-bearing reading is the other one: the tensors entering the matmuls are nowhere near the ceiling (single digits, against 65,504). What is large is the accumulator. Widening only the accumulator therefore costs nothing on the tensor cores, and the GEMMs stay in fp16 at full rate.

Why the overflow fixes are free

Inside a transformer block, every linear layer is bias-free. That is verified against the checkpoint index, not assumed, and it is the single structural fact that does the most work in this project. A bias-free map is exactly homogeneous, so f(x/32)·32 == f(x), and 32 is a power of two, so dividing an fp16 tensor by it only decrements exponents. Not one mantissa bit is lost. The compensating multiply lands in the fp32 accumulate that already exists.

The SwiGLU projection cannot use the same trick, because the activation is not homogeneous. But the overflow is in the elementwise product, not in the GEMM, so widening only the gating product to fp32 fixes it while both GEMMs stay on the tensor cores. Measured: the projection output peaks at 0.008× the ceiling, with 130× to spare, while the gated product reaches 110,154 and crosses the ceiling at step 4. The fp32 gating is not insurance; without it, every step from the fourth onward overflows.

At 19,300 rows a fixed scale is the wrong shape of fix: a single constant must be large enough for the worst row, which pushes every other row toward subnormals for nothing, and it still fails the moment one row is more extreme than the constant anticipated. Scaling per row by a power of two stays exact (bias-free implies row-wise homogeneous) and, because the compensation undoes it exactly, it reports true magnitudes: the largest was 2.51e6, above what a fixed 1/32 can represent. That row genuinely had run out of range.

One of the four was not an overflow at all

One site produced a single inf while its finite maximum sat at 6,172, more than ten times below the ceiling, with no neighbour anywhere close. The cause was allow_fp16_reduced_precision_reduction, which defaults to true and lets cuBLAS accumulate the split-K partial sums of an fp16 GEMM in fp16. A 14,336-deep contraction can therefore blow past 65,504 in a partial sum long before the final dot product, worth a few thousand, is ever formed. Turning it off produced the first complete 20-step trajectory with picture and sound.

This explanation is held to a lower grade than the rest: consistent, not established. Two flags changed rather than one, the runs diverge from the first block so it was a global reroll of every fp16 GEMM, and what it removed was a single marginal element. Reroll and mechanism predict the observation equally well.

39.3% of the model is a lookup table

The adaptive-normalisation projection is 13.006B parameters, 24.2 GiB, 39.3% of the model, and its input is only the timestep embedding. It does not depend on tokens at all. For a 20-step run the entire per-block projection is about 0.5 GFLOP. Thirty-nine percent of the memory is doing a rounding error's worth of the compute.

as released, fp16: 61.7 GiB as released, fp16 61.70 GiB 1.76 GiB left for activations — spills to host RAM AdaLN precomputed to a table: 37.5 GiB AdaLN precomputed to a table 37.50 GiB ~19 GiB per card, nothing offloaded 63.46 GiB of card
39.3% of the parameters consume only the timestep, so they precompute into a 0.34 GiB table. That is the difference between spilling to host RAM and running with headroom on both cards.
Data
configuration parameters fp16 weights
as released 33.12 B 61.70 GiB
AdaLN precomputed 20.11 B 37.50 GiB
available 63.46 GiB

This also explains the vendor's own compact release, which is about 21B parameters: the same 13B removed. Ours comes out in fp16, the only precision with tensor cores on this hardware, rather than INT8, which buys nothing here.

Two details that cost time. The two modalities walk different noise schedules, zipped per step, so an N-step run visits 1 + 2(N−1) distinct timesteps, not N; the first table was sized for one schedule. And dropping 13B parameters moved the automatic device split, which put the output heads on the wrong card. They are pinned explicitly now, with an assertion, because anything that changes the parameter count can move that boundary again.

The picture was wrong, and it was not our arithmetic

For most of the project the video carried saturated colour blocks that survived every numerics fix. Three measurements settled it, in order.

The corruption is quantised to the transformer's own token grid. Chroma edge strength on the 32-pixel grid is 2.40 horizontally and 3.36 vertically, against 1.19 and 1.29 for boundaries that are on 16 but not 32. And 32 pixels is exactly one video token. VAE tiling is ruled out twice over: vertically the frame is smaller than one tile, so no vertical seam can exist, yet the vertical edges are the strongest in the frame.

The VAE is cleared independently: one latent decoded three ways gives a maximum fp16 deviation of 0.069 against a signal range of 3.83, with zero non-finite outputs across 437 leaf modules.

And the entire recipe is cleared by a control that should have existed on day one. Running bf16 with the stock block forward and zero patches, same seed and prompt and canvas, produced 6.4% fewer corrupted blocks: the same fox in the same pose behind the same cyan blocks in the same places.

The control, side by side: left is our fp16 recipe, right is stock bf16 with every patch removed — same seed, same prompt, same canvas. Six range fixes, an fp32 accumulator, two rescalings and a reduced-precision flag are exonerated at once, because removing all of them changes nothing about the defect. The bf16 run even carries 6.4% fewer bad blocks.

What was left was the canvas. The small canvas is legal (the dimensions divide correctly) but not supported: it gives 60 video tokens per latent frame, and the artefacts are quantised to exactly that grid, roving and sporadic, surviving a complete change of arithmetic. That is what a model asked for a canvas it was never trained on looks like.

320 × 192 — legal, not supported
Six frames from the 320x192 clip, each carrying saturated colour blocks
960 × 544 — a canvas the model was built for
Six frames from the 960x544 clip, all clean
Same prompt, same seed, same recipe, same two cards. 639 corrupted blocks and 26 clean frames on the left; zero corrupted blocks and 124 clean frames on the right. Peak chroma excursion falls from 126 — a fully saturated block — to 36. This is what promoted the lookup table from a tidiness item to the critical path: it was the only way to reach a canvas where the question could be asked at all.

What this does not establish

There is no bf16 reference at the working canvas, and there cannot be one on this hardware. bf16 has no memory-efficient attention kernel on sm_70, so attention falls back to the materialising backend, which at 19,300 rows needs a 75 GiB allocation. This is the concrete counterexample to "anything an H100 can run, a V100 can run more slowly": not slower, unavailable.

So the honest statement is not "fp16 is accurate here". It is: fp16 moves the latent substantially (29.4% of range against the stock bf16 path), and at the only canvas where the comparison can be run, the output is already degraded enough that the change does not show. Whether the recipe is acceptable at a canvas that works is unmeasured, and unmeasurable on this machine.

Phase two: making it fast

Before optimising anything, every region of every block was timed with CUDA events on the production forward.

attention (SDPA): 18.197 s/step attention (SDPA) 18.20 s 59.5% FFN first GEMM: 3.573 s/step FFN first GEMM 3.57 s 11.7% QKV GEMM: 2.65 s/step QKV GEMM 2.65 s 8.7% FFN second GEMM: 1.851 s/step FFN second GEMM 1.85 s 6.1% output projection: 0.87 s/step output projection 0.87 s 2.8% QK-norm + RoPE: 0.833 s/step QK-norm + RoPE 0.83 s 2.7% elementwise, modulation, gathers: 2.404 s/step elementwise, modulation, gathers 2.40 s 7.9% everything outside the 50 blocks: 0.18 s/step everything outside the 50 blocks 0.18 s 0.6%
Where a 30.56 s step goes, measured with CUDA events on the production forward. Attention is 60% of the step and runs at a third of the GEMM rate — any large win has to come from there.
Data
region s/step share
attention (SDPA) 18.197 59.5%
FFN first GEMM 3.573 11.7%
QKV GEMM 2.650 8.7%
FFN second GEMM 1.851 6.1%
output projection 0.870 2.8%
QK-norm + RoPE 0.833 2.7%
elementwise, modulation, gathers 2.404 7.9%
everything outside the 50 blocks 0.180 0.6%

Attention is 60% of the step and runs at 30 TFLOPS against the 91.8 the same silicon does on a dense matmul. The PCIe handoff between cards costs 0.18 s, so the pipeline split was never communication-bound: it was bound by only one card computing at a time. (A third finding: the diagnostic forward used for every previous measurement costs 1.03× by itself, so every number before this phase was 3% pessimistic.)

Tensor parallelism, 1.65×

Four facts make sharding unusually clean on this model:

  • Rotary embeddings broadcast over the head axis, so splitting heads 28/28 needs no communication inside attention at all.
  • Every linear is bias-free, so row-parallel partial sums add without a correction term and the fp16 recipe survives intact.
  • The attention mask is empty on this path, so there is nothing to shard or replicate.
  • The sequence is packed and contiguous: no cross-attention, no ragged batching.

Two all-reduces per block, 100 per step, measured at 27.75 ms each — 2.78 s/step of communication that the sharded compute more than pays for.

320 × 192 320 × 192 — bf16, stock, offloaded: 19.8 s/step bf16, stock, offloaded 19.8 s 320 × 192 — fp16 recipe, offloaded: 7.58 s/step fp16 recipe, offloaded 7.58 s 320 × 192 — fully resident: 2.02 s/step fully resident 2.02 s 960 × 544 960 × 544 — pipeline split: 31.64 s/step pipeline split 31.64 s 960 × 544 — tensor parallel: 19.22 s/step tensor parallel 19.22 s Seconds per denoise step. The 3.75× on the left is a memory result, not a compute one; the 1.65× on the right is sharding.
Two separate wins. Dropping 13B parameters into a lookup table ended the CPU spill (3.75×); sharding every block across both cards ended the idle card (1.65×).
Data
canvas configuration s/step
320×192 bf16, stock, offloaded 19.8
320×192 fp16 recipe, offloaded 7.58
320×192 fully resident 2.02
960×544 pipeline split 31.64
960×544 tensor parallel 19.22

On every quality instrument tensor parallelism is indistinguishable from the pipeline split: the shot cut lands on the same frame, marginally sharper, 1.1% less motion, audio spectral cosine 0.997.

The implementation trap: accelerate's dispatch replaces the block's forward as an instance attribute, so patching the class afterwards silently does nothing. Three arms of one experiment all ran the same forward and were nearly reported as a result. Every arm now asserts which forward it actually ran.

The vendor's own reproducible prompt at seed 0, 50 steps, tensor parallel. Its text contains a literal timing cue — "at 00:04.500, the camera cuts to a close-up" — and the temporal instrument finds the largest frame-to-frame change of the whole clip frame-exact at that timestamp, at 40× the median frame delta. That turns "does it look right" into a measurable event, and it is how every optimisation in this phase was graded.

Sparse attention is dead here, and the measurement is the point

97.8% of rows are video. The proposal was to let each video query attend to its own frame plus all non-video keys: 940 of 19,300 keys, benchmarked in isolation at 10.8× faster, one SDPA call, no kernel to write. The pass/fail rule was committed before the run. Over 1,400 sampled (step, block, head) combinations:

top-940 mass       (can ANY 940-key scheme work?)   median 0.890
spatial+nonvideo   (does THIS scheme work?)         median 0.103
head classes: {'DENSE': 56}    0 of 56 heads clear a 0.7 bar

The sparsity is real; the locality assumption is false. Depth does not rescue it, the schedule does not rescue it, and the best single head reaches 0.374. Finding the right 940 keys means ranking all 19,300, which is the full attention score computation that sparsity exists to avoid. That is the entire difference between static and dynamic sparsity, and a projected 3× on this route is retracted.

Step caching: the speed is arithmetic, the quality is a different sample

A skipped call costs 0.15 s against 19.48 s computed, so the speedup is exactly 1/(1−skip) and four arms confirm it to within 1%. The interesting result is that the published indicators are worse than no indicator at all:

arm indicator skipped speedup cut sharpness audio cosine
uncached 0% 1.00× 46.6× 1.000
fixed interval none 47% 1.87× 46.6× 0.992
threshold 0.0216 first-block 41% 1.68× 19.5× 0.964
threshold 0.0541 first-block 67% 3.01× 34.2× 0.977

The 41% arm skips fewer steps, runs slower, and loses on the cut, the smear, all three audio numbers and both pixel distances. The skip patterns explain it: the indicator skips two calls in a row very early, while the sample's global structure is still being decided, then computes every one of the last ten, when nothing is left to decide. "Never skip the opening steps" is not a heuristic, it is the whole result, and a uniform pattern satisfies it by construction with nothing to calibrate.

Both published indicators are essentially uncorrelated with what they predict (Pearson −0.160 and −0.015). Fitting the standard degree-4 polynomial gives an in-sample R² of 0.791 and a leave-one-out R² of −104.6, which is worse than predicting the mean. And the quantity actually reused never changes by less than 11% anywhere in the schedule, so there is no quiet region to exploit: every skip is a real substitution, not an approximation of one.

The sting: the loop was 22% of the run

After all of that, the setup stages of a 50-step job were timed for the first time.

denoise (the optimised loop)setup
cold start 72.5 min text encoder offload: 37.8 min AdaLN table: 14.2 min denoise: 16.0 min load + shard: 2.5 min VAE decode: 2.0 min both constants cached 19.7 min setup, from cache: 1.7 min denoise: 16.0 min VAE decode: 2.0 min One 50-step clip, wall clock The loop this project spent a phase optimising is the blue segment: 22% of the cold run.
Reading the checkpoint takes 11 seconds; wrapping it for offloading took 37.8 minutes, to enable 11 seconds of work. Caching the two constants took cold start from 25 minutes to under two, bit-identical output.
Data
stage cold, min cached, min
text encoder read + offload + encode 37.8 0
AdaLN table 14.2 0.2–0.9
denoise 16.0 16.0
transformer load + shard 2.5 1.5
VAE decode 2.0 2.0

Reading the checkpoint takes eleven seconds. Wrapping it takes thirty-eight minutes, to enable eleven seconds of work, on a model never touched again. The 1.65× on the loop is about 12% end to end.

And 37.8 minutes is not a constant: three timings of the same line gave 2267 s, 775 s and 298 s, a 7.6× spread. The mechanism is visible in the two numbers next to each other. Loading returns in eleven seconds because the checkpoint is memory-mapped and nothing has been read yet; the reading happens inside the offload wrapper when it touches every leaf module. That line is not hook overhead, it is 63 GB coming off a shared filesystem at 28, 83 and 211 MB/s on three different days.

Both large items are constants — the table depends only on the schedule, the prompt state only on the prompt — so both go to disk. Cold start went from ~25 minutes to under two, and the cached prompt state that replaces 775 s of setup is 5.5 MB. Four independent productions of the same clip share one SHA-256, so the caching changed nothing at all, and the checksum discriminates: a differently-parallelised run hashes differently.

One step further: the text encoder does not need a GPU. The offload machinery exists to walk a 64 GB model through a 32 GB card; on a CPU node with 187 GB of RAM it is not made faster, it is absent. Running it there in bf16 beats fp32 by a wide margin while computing 6.8× slower, because fp32 has to read and upconvert twice the bytes and the whole route is read-bound.

Two failure modes explain almost every retraction

This project retracted a long list of claims. Recording them is not penance: two failure modes account for nearly all of them, and both are cheap to defend against once named.

A sample reported as coverage. Five times, including once during an audit that was correcting earlier instances, and once after the failure mode had been written up as a lesson twice. "4.7× margin across all 50 blocks" came from a table that sampled every fifth block and never printed block 49 — and block 49 is where the next failure was. "Never exceeds 2,474" came from a run that went non-finite at block 13, so blocks 14 to 49 had no numbers at all; the true value is 13,792.

An acceptance bar set as though the system does not amplify. Three times, in a system whose defining property is amplification. The general form: a bit-exactness criterion on the output of a chaotic iteration is not a correctness criterion. It tests determinism, which is a different property, and it fails for correct code. Any bar downstream of a 50-block stack has to be set against a perturbation already accepted, not against zero.

And one instrumentation defect worth fearing more than the others: folding a scale factor into the weights at setup time. Under CPU spill, the framework restores offloaded weights from its own copy at every forward, so the fold never takes on them while the compensating multiply in the forward applies everywhere. It would have amplified part of the stack 32× while raising nothing, and produced plausible video from a wrong model. It was caught by reading, not by a test. The same trap has a reading form: a weight is only valid inside its own module's forward; read it afterwards and you get plausible garbage.

A structural finding that fell out of the sweeps

Reading the modulation weights across all 50 blocks, three modalities and six slices, the complete list of exactly zero slices in the entire model is one line: four slices in the final block's text path. Those are precisely the parameters with no gradient path to the loss — the output heads discard text rows after the stack, so anything that only moves the text residual after attention in the last block can never reach the loss, while the two slices that shape the text keys and values other modalities attend to stay live. Four predicted dead, four measured dead, zero false positives across 900 slices.

Two numerical consequences: a zero gate does not neutralise an overflow, because in fp16 zero times infinity is NaN; and exactly-zero rows break any adaptive per-row scheme, which is why the per-row scaling carries a clamp.

Previous: a frontier-class 27B on two cards · Next: the same hardware, two orders of magnitude cheaper per clip

Ask about these notes

A small agent with three tools — search, read, outline — over the 44 sections of these notes. It looks things up before answering and links the section it read. It will tell you when the notes do not cover something rather than guess.

Grounded only in these pages, and it cites the section it read. A question costs your browser about a third of a second of arithmetic — that is the spam gate, and it stores nothing.