Learning 07 · the weights' number format

bf16: fp32 with a haircut

Context: M1, the dtype in model.safetensors · Status: living

Qwen3-0.6B's weights are all BF16. Before we load a single tensor it's worth knowing exactly what those two bytes are — because M1's signature decision, “lazy bf16,” only makes sense once you can see the format.

This is not quantization. That's a conditional M7 decision, and it's a different, lossy story (bytes that need decoding). bf16 is a plain IEEE-style float — just a narrow one, whose bytes already are the numbers.
📖 Inference Engineering (Kiely) · §5.1 (p.120) 🔧 ds4 · f16/f32 tensors in ds4.c; metal/* compute in f32 🧭 Google Brain bfloat16 · Raschka on mixed precision

Same idea, different budgets

Three floats, one bit-budget to split

A binary float spends its bits on three fields: sign · exponent · mantissa (the fraction). The exponent sets the range (how big / how small); the mantissa sets the precision (how many significant bits). Drawn to scale — one tick per bit — the three formats we care about split that budget very differently:

fp32 S exponent · 8 mantissa · 23 fp16 S exp · 5 mantissa · 10 bf16 S exponent · 8 mant · 7 ← fp32's top 16 bits = bf16
sign exponent → range mantissa → precision
bf16 and fp32 share the same sign and the same 8-bit exponent; bf16 just stops after 7 mantissa bits — exactly fp32's top 16 bits.

The revealing comparison is bf16 vs fp16 — both 16 bits, but bf16 spends them on the exponent and fp16 on the mantissa:

formatbitsexponentmantissarangeprecision
fp3232823hugehigh
fp1616510smallhigher of the two
bf161687= fp32lower

bf16 keeps fp32's exponent and sacrifices mantissa, so it covers the same dynamic range as fp32 (≈ 10±38) — just represented more coarsely (7 fraction bits ≈ 2–3 decimal digits). fp16, with only 5 exponent bits, tops out near 65504 and underflows early — which is why fp16 training/inference famously needs loss-scaling gymnastics. bf16 trades precision for range, and for neural nets range is what keeps you numerically safe. That's why modern weights ship as bf16.

The punchline

Widening bf16 → f32 is a 16-bit shift

Because bf16 is fp32's top 16 bits, going the other way just puts those bits back in the high half and zero-fills the low half:

pub fn bf16_to_f32(bytes: [u8; 2]) -> f32 {
    f32::from_bits((u16::from_le_bytes(bytes) as u32) << 16)
}

No lookup table, no branch, no special-casing — and it's exact for every bf16 value: normals, subnormals, and inf/NaN all round-trip, because we never touch the sign or exponent, only re-seat the mantissa in a wider field. Rounding only enters the other direction (fp32 → bf16), which we never do here.

Contrast with quantization. A Q4_K weight is not a float you can shift into place — it's a 4-bit index into a block that shares a scale, and decoding it is real arithmetic. bf16 → f32 is free; dequant is not. Keep the two mentally separate.

The M1 decision

Why M1 keeps the bytes raw

We have bf16_to_f32, but M1 never calls it. Here's the arithmetic that forces the choice:

Raw bf16 (what we do)

~1.4 GB

2 bytes / weight, mapped straight from the file. Tensors stay &[u8] slices into the mmap — zero-copy.

Eager f32 (what we avoid)

~2.8 GB

4 bytes / weight. Converting up front doubles resident memory and copies every weight — undoing exactly what mmap bought us.

So in M1: tensors stay raw bf16 bytes; our checksums run over those raw bytes (proving we read the right region of the file, not that we can do float math); and bf16_to_f32 is written and unit-tested but first called at M2.

M2 actually computes, so it deliberately makes a different tradeoff: Weights::load widens each tensor once into an owned, contiguous f32 Matrix/vector. That working copy costs roughly twice the weight bytes (while the mmap may remain resident), but every numerical helper sees one plain type and can match the fp32 oracle without mixed-precision noise. M1 preserves the zero-copy file view; M2 pays for the clearest compute representation. Memory/bandwidth optimization comes later, after correctness.

Mental model. bf16 = fp32 with a haircut: same range, coarser steps, half the bytes. To widen, shift it back into fp32's high half — free and exact. To narrow (which we don't), you'd round — and rounding is where number formats get interesting. Quantization (M5) is a different animal: the bytes need decoding, not shifting.