Learning 07 · the weights' number format
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.
Same idea, different budgets
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:
The revealing comparison is bf16 vs fp16 — both 16 bits, but bf16 spends them on the exponent and fp16 on the mantissa:
| format | bits | exponent | mantissa | range | precision |
|---|---|---|---|---|---|
fp32 | 32 | 8 | 23 | huge | high |
fp16 | 16 | 5 | 10 | small | higher of the two |
bf16 | 16 | 8 | 7 | = fp32 | lower |
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
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.
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
We have bf16_to_f32, but M1 never calls it.
Here's the arithmetic that forces the choice:
~1.4 GB
2 bytes / weight, mapped straight from the file.
Tensors stay &[u8] slices into the mmap — zero-copy.
~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.
BF16 dtype string comes from; “the bytes are the numbers.”Matrix turns shape + layout into gather/matmul
offsets.src/safetensors.rs — Dtype, bf16_to_f32,
and the raw-byte checksum path. · Full note:
docs/learnings/07-bf16.md.