Skip to content

implement f16 - #9

Closed
AJ0070 wants to merge 3 commits into
tinygrad:masterfrom
AJ0070:ptx-fp16
Closed

implement f16#9
AJ0070 wants to merge 3 commits into
tinygrad:masterfrom
AJ0070:ptx-fp16

Conversation

@AJ0070

@AJ0070 AJ0070 commented Aug 23, 2026

Copy link
Copy Markdown

Stacked on #8. I cannot set a fork branch as the base, so the diff also shows #8's two commits; the change here is the last one, implement f16. Without #8 .target sm_53 does not parse, so none of this is reachable.

cvt to and from f16 threw conversion not implemented, and add, sub, mul, abs, neg, min, max, fma, set, setp, mov and ex2 rejected f16 as an instruction type. So nothing targeting sm_53 or newer could use half, and for a CUDA 12 toolchain that is every target, since sm_35 is gone.

f32 represents every f16 exactly, so operandAsHalf widens an operand and the existing f32 paths are reused: cvt takes f16 as a source by falling through to the f32 case, set and setp likewise, and destinations narrow with floatToHalf. The reuse is exact rather than merely convenient: half significands are 11 bits, so the exact product of two halves needs 22 of f32's 24 and the exact sum fits too, which makes computing in f32 and rounding once correctly rounded rather than double rounded. ex2 is .approx in PTX, so the f32 approximation is in spec.

Reading an operand cannot key off the declared type. tinygrad's PTX renderer declares halves .f16; nvrtc declares them .b16 and puts the op in inline asm:

.reg .b16 %rs<5>;
{mul.f16 %rs2,%rs3,%rs1;}

The half is in the low 16 bits either way. Immediates keep the f32 handling, because the parser types an immediate from the instruction and the lexer has no 0H half literal, so a constant reaching an .f16 instruction was written as a float and lives in imm_single; reading it as bits picks up the wrong member of the union.

cvt needed one more thing. relaxedConvert stores the mnemonic's type in a.relaxedType and leaves a.type as the register declaration, so operandAsF32 has to honour either. Without that, half loads on the nvrtc path came back as denormals like 2.15e-41, which is 0x3C00 sitting in a float. set also accepts a .b16 destination, since that is the register nvrtc writes the 1.0h/0.0h into.

halfToFloat and floatToHalf were checked against numpy before being wired in: all 65536 half bit patterns widen to the exact f32 bit pattern, and 531082 narrowing cases including every exact tie midpoint round identically, ties to even.

mad still rejects f16, since it is not implemented for it. Opening a gate without an implementation turns a clean abort into a wrong answer.

Measured on tinygrad's emulated CI, test/backend, on both emulator consumers, DEV=MOCK+CUDA:PTX and DEV=MOCK+NV:

ocelot target ptx cell nv cell
master sm_35 1738 pass, 0 fail, 453 skip 1669 pass, 0 fail, 458 skip
this sm_35 1738 pass, 0 fail, 453 skip 1669 pass, 0 fail, 458 skip
this sm_53 1738 pass, 0 fail, 443 skip 1669 pass, 0 fail, 449 skip

Zero failures everywhere, and sm_35 is unchanged on both cells, so this is a no-op at the current target. The freed skips at sm_53 are the fp16 tests, which have never been able to run: tinygrad disables half below sm_53, so a Kepler-reporting mock meant the NV emulator never tested fp16 at all. The nv cell at sm_53 runs on nvrtc 12.9 with matching cudart headers.

I closed the earlier version of this PR because it only handled tinygrad's PTX and silently returned wrong values on the nvrtc path. That is fixed and both paths are now verified.

AJ0070 added 3 commits August 23, 2026 12:58
The lexer enumerated sm_10 through sm_35, so every newer .target was a
lexical error and ocelot aborted. That pinned callers to Kepler, which
CUDA 12 no longer supports at all.

Nothing reads the target string back: targetElement pushes it into
statement.targets and ir/Module.cpp hardcodes "sm_21" for internally
built modules. So one text-carrying token replaces the eight hardcoded
ones and never needs updating for a new architecture.
PTX types bfi's pos and len operands .u32 regardless of whether the
instruction is .b32 or .b64, but the parser types immediates from the
instruction type, so `bfi.b64 d, a, b, 32, 32` yielded b64 immediates
and was rejected. Operand 1 already exempts immediates; operands 3 and
4 did not. Operand 4 also tested b.type instead of c.type.

eval_Bfi already reads both as operandAsU32 for .b32 and .b64, so only
the validator disagreed. nvcc emits bfi.b64 from sm_50 on.
cvt to and from f16 threw "conversion not implemented", and add, mul,
max, setp, mov and ex2 rejected f16 as an instruction type, so nothing
targeting sm_53 or newer could use half at all.

f32 represents every f16 exactly, so operandAsF32 widens an f16 operand
and the existing f32 paths are reused unchanged: cvt gets f16 as a source
by falling through to the f32 case, and setp likewise. Destinations
narrow with floatToHalf. The exact result of an f16 add or mul also fits
in f32 (11 bit significands, so a product needs 22 of the 24 available),
so computing in f32 and rounding once to f16 is correctly rounded, not
double rounded. ex2 is .approx in PTX, so the f32 approximation is in spec.

halfToFloat and floatToHalf were checked against numpy: all 65536 half
bit patterns widen exactly, and 531082 narrowing cases including every
exact tie midpoint round identically, ties to even.

mad and min still reject f16 since they are not implemented for it.

Measured on tinygrad's emulated CI, test/backend with DEV=MOCK+CUDA:PTX:
sm_35 stays 1738 pass 0 fail 453 skip, identical to master. sm_53 goes
from 109 errors to 1738 pass 0 fail 443 skip, the 10 freed skips being
the fp16 tests that could never run before.
@AJ0070

AJ0070 commented Aug 23, 2026

Copy link
Copy Markdown
Author

Closing: this is incomplete and I found it after opening, so it does not meet the bar.

nvrtc declares half registers .b16 rather than .f16 and emits the ops inside inline asm:

.reg .b16 %rs<5>;
{mul.f16 %rs2,%rs3,%rs1;}

The widening here keys off the operand type, so those reads are wrong. Worse, before this change the validator cleanly rejected mul.f16 on .b16 registers; after it, they are accepted and return garbage. That is the exact abort-turned-into-wrong-answer failure I cited in the description as the reason for leaving mad and min closed, so it should not ship.

It is unreachable at sm_35, since half is disabled below sm_53, and I verified no change there. But unreachable is not correct.

Reopening once an operandAsHalf helper handles both register declarations and immediates, and both emulator consumers verify: tinygrad's own PTX renderer and nvrtc-generated PTX.

#8 is independent of this and unaffected.

@AJ0070 AJ0070 closed this Aug 23, 2026
@AJ0070 AJ0070 mentioned this pull request Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant