Precision
September 1, 2026 · View on GitHub
Dtype policy
The input (x, or the original weight) and the LyCORIS module weights carry
their dtypes independently. Both may be fp16, bf16 or fp32, in any
combination — except fp16 together with bf16, which is a caller bug rather
than a case to resolve: the two have no common half, so either would have to
widen to fp32 to meet, and silently doing that hides a mistake.
For each call, lycoris/kernels/precision.py resolves two dtypes:
- compute — what the matmul operands are in. With any 16-bit operand
present, that 16-bit type (policy
mma16); otherwise fp32. - output — the promotion of every operand's dtype. An fp32 operand anywhere yields an fp32 result whatever the matmul ran in.
Accumulation is fp32 under every policy. A 16-bit matmul with fp32 accumulation is the arrangement the tensor cores implement natively, so this costs nothing and is what the eager path gets from cuBLAS too.
Gradients come back in each leaf's own dtype: an fp32 master weight beside an fp16 activation gets an fp32 gradient, with no cast in your training loop.
fp16 carries 11 mantissa bits against bf16's 8, so an fp16 run is the more accurate of the two 16-bit options here by roughly three bits — bf16 buys range, not precision.
Measured accuracy
Accuracy is reported in ULP of the working dtype against an fp64 oracle: 1.0 ULP is as exact as the dtype can represent, so the number is comparable across fp16, bf16 and fp32 rows. Reductions and GEMMs are scored against the reference's RMS (a near-zero output there is cancellation, not a small true value); pointwise kernels are scored elementwise.
Across the benchmark sweep the fused kernels land at or below the eager
error — e.g. lora merge at 9.6 ULP against eager's 24.6, oft at 5.0
against 4.0 — because the whole chain accumulates in fp32 registers instead
of round-tripping intermediates through 16-bit memory. TileLang's BOFT path
is the one place where the fused error is materially larger than eager's
(31.7 vs 2.3 ULP on the butterfly sweep); the Triton path is 10.8.
Where an intermediate is deliberately rounded — h = x @ downᵀ in the LoRA
bypass, for instance — it is rounded to the storage dtype before its second
dot, which is exactly the rounding the eager two-Linear chain applies. The
fused path reproduces the reference's rounding rather than quietly improving
on it, so a fused and an unfused run stay comparable.
Checking it yourself
python scripts/bench/kernels/quick.py # all paths, fp16/bf16/fp32, ULP + device time
python -m pytest test/kernels -q # correctness against the eager references
The bench harness computes ULP in the same row as the timing, so no figure reports a speed without the accuracy it was obtained at.
VRAM
The kernels are written so that the large intermediates never exist:
- ΔW and its gradient are never materialised on the bypass paths — the token tile is contracted straight through both factors.
- The Kronecker halves
w1andw2are generated per output tile in registers, not built and then read back. - BOFT's backward replays forward prefixes instead of caching the
mstage inputs (m · O · Ibytes).
What is cached is small and expensive to recompute: the DoRA row norms, one
fp32 value per row. Everything else — h, q, the Cayley transform, the
hadamard tiles — is recomputed in the backward, because recomputing them is
cheaper than the memory traffic of storing them.