Skip to content

IEEE 754 anatomy, ulp, round to nearest even, and bf16 and fp16 emulation

ModuleM09.1 · build · Python · Pass 2 · 2 to 3 h
You buildpython/tinyllm/num/fp.py: decompose_f32, compose_f32, ulp, f32_to_bf16_bits, bf16_bits_to_f32, round_to_bf16, round_to_fp16
Contractcourse/contracts/py/tinyllm/num/fp.pyi
Testscourse/tests/M09.1/test_fp.py (what they check: section 4)
Needsnothing to build first. Reading: M00.1 exponents and logarithms (powers of two, log⁡2\log_2)
Used byL0.6 reads and writes BF16 safetensors through f32_to_bf16_bits and bf16_bits_to_f32 · later L7.9 loads bf16 checkpoints through bf16_bits_to_f32, L11.1 rounds weights and activations with round_to_bf16, M09.4 builds fp8 and MX formats on the same bit arithmetic
MilestoneMS-P2 (the foundations gate)
Optional depthGoldberg, “What Every Computer Scientist Should Know About Floating-Point Arithmetic” (ACM Computing Surveys, 1991), sections 1 and 2; Muller et al., Handbook of Floating-Point Arithmetic (2nd ed.), ch. 2 and 3; IEEE Std 754-2019, section 3
  • A float32 is three integers: sign ss, biased exponent EE, and mantissa MM; a normal value is (−1)s(1+M/223) 2E−127(-1)^s (1 + M/2^{23})\, 2^{E - 127}, and E=0E = 0 and E=255E = 255 hold the zeros, subnormals, infinities, and NaNs (test_decompose_hand_example, test_decompose_every_class).
  • The gap between neighbouring floats, one ulp, is 2e−p+12^{e - p + 1}: it doubles at every power of two and stops shrinking in the subnormal range (test_ulp_matches_numpy_spacing).
  • bfloat16 is the top half of a float32, so rounding to it is integer arithmetic on the bits: add 0x7FFF\mathtt{0x7FFF} plus the lowest kept bit, then shift. Ties go to the even neighbour, and a carry walks into the exponent exactly as it should (test_bf16_matches_exact_rational_golden).
  • The trick is wrong for NaN, which can turn into infinity; every NaN must map to the quiet code 0x7FC0 (test_bf16_nan_stays_nan).
  • Codes are decoded with a bit view, never a value cast: astype turns the code 0x3F80 into 16256.016256.0 instead of 1.01.0 (test_bf16_all_codes_roundtrip).
Terminal window
ol start M09.1 # stubs python/tinyllm/num/fp.py into your repo
ol tests M09.1 # read the test catalog first: rung R0, you write no tests here
ol check M09.1 # exit code is the verdict
ol diff M09.1 # after passing: your code against the reference

Everything your tracer stores is float32: L0.0 writes the bigram’s weights as F32 in safetensors, and M03.1 multiplies them in C. Pass 2 starts to lean on what float32 actually is. The stable softmax of M09.2 exists because float32 overflows at e88.72e^{88.72}, and that number comes from the exponent field. Later passes go below 32 bits. The SmolLM2 checkpoint L7.9 loads is stored as BF16, two bytes per weight, and the obvious loader, np.frombuffer(raw, np.uint16).astype(np.float32), gives you a model whose weight 1.0 reads as 16256.0. Its first forward pass returns NaN and nothing crashes. Mixed-precision training (L11.1) rounds every weight to bf16, and the obvious rounding (keep the top 16 bits) is biased: it always rounds toward zero, and over thousands of steps the weights drift. This module takes the float32 format apart bit by bit and rebuilds the two 16-bit formats from it, so the loader and the rounding are right the first time.

SymbolMeaningType
sssign bit: 0 for positive, 1 for negativeint, 0 or 1
EEbiased exponent field: 8 bits in float32int, 0 to 255
MMmantissa (fraction) field: 23 bits in float32int, 0 to 223−12^{23} - 1
e=E−127e = E - 127the unbiased exponent of a normal float32int, -126 to 127
ppprecision: significand bits including the implicit leading 124 (f32), 11 (f16), 8 (bf16)
emin⁡e_{\min}smallest normal exponent-126 (f32, bf16), -14 (f16)
ulp(x)\mathrm{ulp}(x)the gap from ∣x∣\lvert x \rvert to the next larger magnitude in the formatfloat64
u=2−pu = 2^{-p}unit roundoff: the largest relative error of one roundingfloat
rn(x)\mathrm{rn}(x)xx rounded to the nearest representable value, ties to evena value of the format
bbthe 32 bits of a float32 read as an unsigned integer (a uint32 view)int

A float32 occupies 32 bits: bit 31 is the sign ss, bits 30 to 23 the biased exponent EE, bits 22 to 0 the mantissa MM. Reading the same 32 bits as an unsigned integer gives b=s⋅231+E⋅223+Mb = s \cdot 2^{31} + E \cdot 2^{23} + M, so the fields are b >> 31, (b >> 23) & 0xFF, and b & 0x7FFFFF. The value depends on EE:

EEclassvalue
1 to 254normal(−1)s(1+M223)2E−127(-1)^s \left(1 + \frac{M}{2^{23}}\right) 2^{E - 127}
0zero (M=0M = 0) or subnormal (M≠0M \ne 0)(−1)sM223 2−126(-1)^s \frac{M}{2^{23}}\, 2^{-126}
255infinity (M=0M = 0) or NaN (M≠0M \ne 0)±∞\pm\infty, or not a number

A normal number has p=24p = 24 significant bits: the 23 stored ones plus a leading 1 that is implied, not stored. The bias 127 lets the exponent field be an unsigned integer, which has a useful consequence: for non-negative floats, ordering the bit patterns as integers orders the values. The largest finite float32 is E=254E = 254, M=223−1M = 2^{23} - 1, which is (2−2−23) 2127≈3.40×1038=e88.72(2 - 2^{-23})\, 2^{127} \approx 3.40 \times 10^{38} = e^{88.72}. The smallest normal is 2−1262^{-126}. Below it, E=0E = 0 drops the implicit 1 and keeps the exponent at −126-126, so the subnormals fill the gap down to 2−1492^{-149} in equal steps. There are two zeros, +0+0 and −0-0, which compare equal. A NaN is any pattern with E=255E = 255 and M≠0M \ne 0; there are about 2242^{24} of them.

Between 2e2^e and 2e+12^{e+1} (one binade) a format with precision pp has 2p−12^{p-1} equally spaced values, so their spacing is

ulp(x)=2 e−p+1,e=max⁡(⌊log⁡2∣x∣⌋, emin⁡).\mathrm{ulp}(x) = 2^{\,e - p + 1}, \qquad e = \max(\lfloor \log_2 \lvert x \rvert \rfloor,\ e_{\min}).

The spacing doubles at every power of two and is constant across the subnormal range, which is why ee is clamped at emin⁡e_{\min}. At x=1x = 1: 2−232^{-23} in float32, 2−102^{-10} in float16, 2−72^{-7} in bfloat16. At x=0x = 0 the gap to the next value is the smallest subnormal, 2emin⁡−p+12^{e_{\min} - p + 1}. Infinity and NaN have no next value, so their ulp is NaN.

Rounding to nearest moves a value by at most half an ulp, and within a binade the ulp is at most 2−(p−1)∣x∣2^{-(p-1)} \lvert x \rvert, so one rounding has a relative error of at most u=2−pu = 2^{-p}: rn(x)=x(1+δ)\mathrm{rn}(x) = x(1 + \delta) with ∣δ∣≤u\lvert \delta \rvert \le u, for any xx in the normal range. That one fact is the model of every error bound in this track (M09.2, M09.3).

Computing ⌊log⁡2∣x∣⌋\lfloor \log_2 \lvert x \rvert \rfloor needs care. np.log2(x) returns a rounded float, and for xx a hair below 2k2^k the true value k−εk - \varepsilon rounds up to exactly kk, giving the next binade’s ulp. The exponent is already stored exactly: np.frexp(x) returns (m,k)(m, k) with x=m 2kx = m\, 2^k and m∈[0.5,1)m \in [0.5, 1), so ⌊log⁡2∣x∣⌋=k−1\lfloor \log_2 \lvert x \rvert \rfloor = k - 1 with no rounding at all.

2.3 Round to nearest even, on the bits, for bfloat16

Section titled “2.3 Round to nearest even, on the bits, for bfloat16”

bfloat16 has the float32 layout cut in half: 1 sign bit, the same 8 exponent bits with the same bias, and 7 mantissa bits (p=8p = 8). Every bf16 code is the top 16 bits of a float32, and decoding is a shift: bf16_bits_to_f32(c) is the float32 whose bits are c << 16. Because the range is float32’s, nothing a float32 model produces overflows on the way down; only precision is lost.

Encoding has to round. IEEE 754’s default is round to nearest, ties to even: take the nearest representable value, and when the value is exactly halfway between two, take the one whose last kept bit is 0. Ties to even is unbiased: half of all ties round up and half round down, where “ties away from zero” pushes every tie the same way.

Split the float32 bits into the half we keep and the half we drop: h=b≫16h = b \gg 16 and ℓ=b&0xFFFF\ell = b \mathbin{\&} \mathtt{0xFFFF}. The halfway point of the dropped half is ℓ=0x8000\ell = \mathtt{0x8000}. The rule:

dropped half ℓ\ellresult
below 0x8000hh (round down)
above 0x8000h+1h + 1 (round up)
exactly 0x8000hh if hh is even, h+1h + 1 if odd

All three rows are one integer expression:

bf16(b)=(b+0x7FFF+(h&1))≫16.\mathrm{bf16}(b) = (b + \mathtt{0x7FFF} + (h \mathbin{\&} 1)) \gg 16.

When ℓ<0x8000\ell < \mathtt{0x8000}, adding at most 0x8000\mathtt{0x8000} keeps the low half below 0x10000\mathtt{0x10000}, so no carry reaches hh. When ℓ>0x8000\ell > \mathtt{0x8000}, adding 0x7FFF\mathtt{0x7FFF} already carries. When ℓ=0x8000\ell = \mathtt{0x8000}, the sum is 0xFFFF\mathtt{0xFFFF} plus the parity bit, so it carries exactly when hh is odd.

The carry needs no special case. If the 7 kept mantissa bits are all ones, adding 1 to hh clears them and increments the exponent field: the next code is the first value of the next binade, which is the correct rounded result. At the top, the largest finite float32 0x7F7FFFFF rounds to 0x7F80, which is +∞+\infty: exactly IEEE’s rule, since (2−2−23) 2127(2 - 2^{-23})\, 2^{127} is nearer to 21282^{128} than to the largest bf16 value. Infinity itself has ℓ=0\ell = 0 and stays infinity. Work in uint64 (or check for it) so that the addition cannot wrap at 0xFFFFFFFF.

NaN breaks it. A NaN may have its only set mantissa bits in the dropped half: 0x7F800001 has h=0x7F80h = \mathtt{0x7F80} and ℓ=1\ell = 1, and so does every NaN whose payload is small. Truncated, it becomes 0x7F80, infinity. Rounded, 0x7F800001 + 0x7FFF = 0x7F808000, still infinity. A NaN loss that turns into infinity no longer trips np.isnan. So NaNs are mapped first, to the single quiet NaN code 0x7FC0, matching the C conversions in tinyllm/numerics.h.

float16 (IEEE binary16) has 1 sign bit, 5 exponent bits with bias 15, and 10 mantissa bits (p=11p = 11, emin⁡=−14e_{\min} = -14). Its range is tiny: the largest finite value is (2−2−10) 215=65504(2 - 2^{-10})\, 2^{15} = 65504, and the smallest subnormal is 2−242^{-24}. So a float32 can overflow it, land in its subnormal range, or underflow it entirely, and the exponent field has to be re-biased. The top-half trick does not apply.

Write a finite ∣x∣=σ⋅2E′−23\lvert x \rvert = \sigma \cdot 2^{E' - 23} with an integer significand σ<224\sigma < 2^{24} (σ=223+M\sigma = 2^{23} + M and E′=E−127E' = E - 127 for a normal float32, σ=M\sigma = M and E′=−126E' = -126 for a subnormal). binary16 spaces its values 2Eh−102^{E_h - 10} apart with Eh=max⁡(E′,−14)E_h = \max(E', -14). Dividing ∣x∣\lvert x \rvert by that spacing gives q=σ/2tq = \sigma / 2^{t} with t=13+(Eh−E′)≥13t = 13 + (E_h - E') \ge 13: shift right by tt and round the remainder to nearest even, exactly as for bf16 but with a variable cut. Then

code=((Eh+14)≪10)+q.\text{code} = ((E_h + 14) \ll 10) + q.

For a normal result (210≤q<2112^{10} \le q < 2^{11}) this equals the field form ((Eh+15)≪10)∣(q−210)((E_h + 15) \ll 10) \mid (q - 2^{10}); for a subnormal (Eh=−14E_h = -14, q<210q < 2^{10}) it is just qq; and a qq that rounded up to 2112^{11} carries into the exponent on its own, as in bf16. Any code at or above 0x7C00 is infinity (overflow), and NaN gets 0x7E00. numpy’s float32.astype(float16) implements the same IEEE rounding, which gives the tests an independent oracle.

np.asarray(x, np.float32).view(np.uint32) reinterprets the same bytes as integers and changes no bit; .astype(np.uint32) converts the value (1.0 becomes 1). Every function here moves between the two worlds with views. Decoding bf16 is the case people get wrong: the codes arrive as uint16, and the value of code cc is the float32 with bits c≪16c \ll 16, not the number cc.

Anatomy of −6.25-6.25. 6.25=110.012=1.10012×226.25 = 110.01_2 = 1.1001_2 \times 2^2. So s=1s = 1, E=2+127=129=1000 00012E = 2 + 127 = 129 = 1000\,0001_2, and the mantissa is the fraction after the leading 1, .10012.1001_2, padded to 23 bits: 100 1000 0000 0000 0000 00002=0x480000100\,1000\,0000\,0000\,0000\,0000_2 = \mathtt{0x480000}. All 32 bits: 1 10000001 10010000000000000000000 = 0xC0C80000. decompose_f32(-6.25) == (1, 129, 0x480000). Check: (1+0x480000/223)⋅22=(1+0.5625)⋅4=6.25(1 + \mathtt{0x480000}/2^{23}) \cdot 2^{2} = (1 + 0.5625) \cdot 4 = 6.25.

ulp. 6.256.25 lies in the binade [4,8)[4, 8), e=2e = 2, so ulp(6.25)=22−23=2−21\mathrm{ulp}(6.25) = 2^{2 - 23} = 2^{-21} in float32, 22−10=2−82^{2 - 10} = 2^{-8} in float16, and 22−7=2−52^{2 - 7} = 2^{-5} in bfloat16. At 1.0 the three are 2−232^{-23}, 2−102^{-10}, 2−72^{-7} (test_ulp_hand_values).

0.10.1 to bfloat16. 0.10.1 has no finite binary expansion. float32(0.1) has bits 0x3DCCCCCD and the value 0.1000000014901161193847656250.100000001490116119384765625. The kept half is h=0x3DCCh = \mathtt{0x3DCC}, the dropped half ℓ=0xCCCD\ell = \mathtt{0xCCCD}:

stepvalue
ℓ\ell vs 0x80000xCCCD > 0x8000: round up
h&1h \mathbin{\&} 10 (0x3DCC is even)
b+0x7FFF+0b + \mathtt{0x7FFF} + 00x3DCCCCCD + 0x7FFF = 0x3DCD4CCC
≫16\gg 160x3DCD

Decode 0x3DCD = 0 01111011 1001101: E=123E = 123, e=−4e = -4, mantissa 10011012=771001101_2 = 77, value (1+77/128) 2−4=205/2048=0.10009765625(1 + 77/128)\, 2^{-4} = 205/2048 = 0.10009765625. The error, about 9.8×10−59.8 \times 10^{-5}, is below half the bf16 ulp at 0.10.1, 2−12≈2.4×10−42^{-12} \approx 2.4 \times 10^{-4}.

Ties. 1+2−81 + 2^{-8} has bits 0x3F808000: h=0x3F80h = \mathtt{0x3F80} (that is 1.0), ℓ=0x8000\ell = \mathtt{0x8000}, exactly halfway to 1+2−71 + 2^{-7}. hh is even, so the sum 0x3F808000 + 0x7FFF = 0x3F80FFFF does not carry: the result is 0x3F80, 1.01.0. Next, 1+3⋅2−81 + 3 \cdot 2^{-8} has bits 0x3F818000: halfway between 0x3F81 (odd) and 0x3F82 (even). Now the parity bit is 1, 0x3F818000 + 0x7FFF + 1 = 0x3F820000, and the result is 0x3F82 =1+2−6= 1 + 2^{-6}. One tie went down and one went up, both to the even code (test_bf16_hand_examples).

0.10.1 to float16. e=−4≥−14e = -4 \ge -14, so the result is normal and the cut is at t=13t = 13. The float32 mantissa 0x4CCCCD is 100 1100 1100 1100 1100 11012100\,1100\,1100\,1100\,1100\,1101_2; the top 10 bits are 10 0110 01102=0x26610\,0110\,0110_2 = \mathtt{0x266} and the 13 dropped bits are 0 1100 1100 11012=0x0CCD0\,1100\,1100\,1101_2 = \mathtt{0x0CCD}, below the halfway point 0x1000, so it rounds down. The code is ((−4+15)≪10)∣0x266=0x2C00∣0x266=0x2E66((-4 + 15) \ll 10) \mid \mathtt{0x266} = \mathtt{0x2C00} \mid \mathtt{0x266} = \mathtt{0x2E66}, the value (1+614/1024) 2−4=0.0999755859375(1 + 614/1024)\, 2^{-4} = 0.0999755859375. float16 lands closer to 0.10.1 than bf16 did: 3 more mantissa bits.

Edges of float16. 6551965519 is below the midpoint 6552065520 between 6550465504 and the would-be next value 6553665536, so it rounds to 6550465504; 6552065520 is the tie, and ties to even picks 6553665536, which does not exist, so the result is +∞+\infty. At the bottom, 2−252^{-25} is half the smallest subnormal 2−242^{-24}: a tie between 00 and 2−242^{-24}, and the even choice is 00 (test_fp16_edges).

def decompose_f32(x: float) -> tuple[int, int, int]: ... # (sign, biased exponent, mantissa)
def compose_f32(sign: int, exponent: int, mantissa: int) -> float: ...
def ulp(x: ArrayLike, dtype: Literal["f32", "f16", "bf16"]) -> NDArray: ... # float64
def f32_to_bf16_bits(x: ArrayLike) -> NDArray: ... # uint16, RNE, NaN -> 0x7FC0
def bf16_bits_to_f32(u16: ArrayLike) -> NDArray: ... # float32, exact
def round_to_bf16(x: ArrayLike) -> NDArray: ... # float32 in, float32 out
def round_to_fp16(x: ArrayLike) -> NDArray: ... # float32, same bits as numpy's cast

Array functions accept anything numpy converts, convert it to float32 first, and keep the input’s shape (a scalar gives a 0-d array). L7.9 reads a BF16 tensor as bf16_bits_to_f32(np.frombuffer(raw, "<u2").reshape(shape)); L11.1 keeps float32 master weights and computes with round_to_bf16(w).

TestKINDChecksWhy it matters downstream
test_decompose_hand_exampleunit, smokesection 3’s −6.25→(1,129,0x480000)-6.25 \to (1, 129, \mathtt{0x480000}), plus 1.0 and −2.5-2.5you and the test agree on the fields
test_decompose_every_classboundary±0\pm 0, the smallest subnormal and normal, the largest finite, ∞\infty, NaNa checkpoint can hold any of them
test_compose_roundtrippropertycompose inverts decompose on 3000 floats; out-of-range fields raisethe three fields describe the number completely
test_ulp_hand_valuesunit, smokeulp(1)\mathrm{ulp}(1) in each format, ulp(0)\mathrm{ulp}(0), NaN for ±∞\pm\infty and NaN, unknown dtypethe relative precision of each format
test_ulp_matches_numpy_spacingdifferentialfloat32 and float16 ulps equal np.spacing, subnormals includedthe spacing M09.3 builds tolerances from
test_ulp_just_below_a_power_of_twoboundarynextafter(1024, 0) and nextafter(2**100, 0) stay in the lower binadenp.log2 rounds up there
test_bf16_hand_examplesunit, smokesection 3: 0.1 -> 0x3DCD, the two tiesthe rounding rule itself
test_bf16_matches_exact_rational_goldengolden10 156 float32 patterns against rounding done with exact fractionsevery binade, tie, and overflow
test_bf16_nan_stays_nanboundaryfour NaNs, including 0x7F800001, give 0x7FC0a NaN loss stays visible in L11.1
test_bf16_all_codes_roundtrippropertyall 65536 codes decode exactly and encode backL7.9 loads every weight exactly
test_rounding_is_idempotent_and_monotonepropertyrounding twice changes nothing; sorted inputs stay sortedclipping and comparisons after rounding
test_fp16_matches_numpy_bitwisedifferential, goldenthe golden inputs and 10510^5 random patterns equal numpy’s cast bit for bitan independent IEEE implementation agrees
test_fp16_edgesboundary6550465504, 65519→6550465519 \to 65504, 65520→∞65520 \to \infty, subnormals, 2−25→02^{-25} \to 0why fp16 training needs loss scaling and bf16 does not
test_shapes_and_scalarsboundarymatrices keep their shape, a scalar gives a 0-d array, the sign survivesweight matrices go through unchanged

The golden file course/fixtures/M09.1/round_f32.npz was written by course/oracle/M09.1/lowp_golden.py, which rounds each float32 value as an exact fractions.Fraction and checks its float16 column against numpy.

PitfallSymptomCaught by
keeping the top 16 bits (truncation)every value rounds toward zero; 0.1 gives 0x3DCCtest_bf16_hand_examples (mutant s01)
adding 0x8000 (ties away from zero)1 + 2**-8 rounds to 0x3F81, a biased tietest_bf16_hand_examples (mutant s02)
no special case for NaN0x7F800001 becomes 0x7F80, infinitytest_bf16_nan_stays_nan (mutant s03)
⌊log⁡2x⌋\lfloor \log_2 x \rfloor through np.log2the ulp just below a power of two is twice too largetest_ulp_just_below_a_power_of_two (mutant s04)
no clamp at emin⁡e_{\min}subnormal ulps keep shrinking past the real spacingtest_ulp_matches_numpy_spacing (mutant s05)
flushing float16 subnormals to zerovalues below 2−142^{-14} vanishtest_fp16_edges (mutant s06)
no clamp at 0x7C00values past 65504 wrap into NaN codestest_fp16_edges (mutant s07)
float16 ties rounded uphalf of all ties off by one ulptest_fp16_matches_numpy_bitwise (mutant s08)
astype instead of a bit viewcode 0x3F80 reads as 16256.016256.0test_bf16_all_codes_roundtrip (mutant s09)
exponent not masked with 0xFFthe sign bit leaks into the exponent of negative numberstest_decompose_hand_example (mutant s10)
DirectionModuleHow it uses this
BackM00.1powers of two and log⁡2\log_2 (reading)
ForwardM09.2where float32 overflows and underflows, and the unit roundoff behind its summation bounds (reading)
ForwardL0.6safetensors for every dtype: BF16 tensors are encoded and decoded with this module’s bit converters
ForwardM09.4fp8 E4M3 and E5M2 and MX block formats: the same bit-level rounding with fewer bits, in Python and C
ForwardL7.9loads the SmolLM2 BF16 safetensors through bf16_bits_to_f32
ForwardL11.1mixed precision: float32 master weights, round_to_bf16 for the compute copy

If you skip this module, ol check L0.6 stops with needs M09.1: build it, or pass --ref-deps.

Your pieceProduction equivalentWhat it addsWhere to look
f32_to_bf16_bitsPyTorch c10::BFloat16the same add-0x7FFF-plus-parity rounding, in C++ and in CUDA for every kernelc10/util/BFloat16.h, round_to_nearest_even
round_to_fp16the F16C instructions (vcvtps2ph), CUDA __float2half_rnthe conversion in one hardware instruction, with the rounding mode as an operandIntel SDM, VCVTPS2PH; CUDA Math API, half precision intrinsics
round_to_bf16 in trainingmixed-precision training with loss scalingfloat32 master weights, low-precision compute, dynamic loss scaling for fp16’s narrow rangeMicikevicius et al., “Mixed Precision Training” (ICLR 2018)
bf16 and fp16OCP FP8 and MX formats8-bit and block-scaled formats built on the same encoding rulesOCP Microscaling Formats (MX) v1.0 spec; M09.4