English · Español
Lab 00 — Decode floats by hand and by code¶
Goal: internalize the fp32 bit layout by writing a decoder that produces the same output as your pencil work.
Estimated time: 60–90 minutes.
What you produce¶
A directory experiments/02-float-anatomy/ containing:
decode.py— a Python script implementingdecode_fp32(x: float) -> dictandencode_fp32(s, e, m) -> floatfrom raw bit fields.worked.md— your hand-decoded answers for the four target values (see TODOs).bf16_round_trip.py— bf16 emulation via bit-cast.fp16_round_trip.py— fp16 round-trip vianumpy.float16.manifest.json— seed, versions, hardware, config.README.md— interpretation.
TODOs¶
Block A — decode by hand¶
For each of these four values, decode by hand into (sign, biased_exponent, mantissa_bits, value) and write the result in worked.md. Do not use code yet.
- The fp32 bit pattern
0x3F800000. - The fp32 bit pattern
0x40490FDB(you may recognize it). - The fp32 representation of
1/600(the §A13 vocabulary's uniform probability). To find it, you may use the inverse procedure: write1/600in binary, normalize, read off sign / exponent / mantissa. Show your work. - The fp32 representation of
92.0(an adversarial-magnitude logit from a tense classifier — see theory02).
🇪🇸 Truco:
1/600 ≈ 0.001\overline{6}. Normaliza al rango[1, 2):1/600 = 1.70\overline{6} \times 2^{-10}(verifícalo:1.7066... / 1024 ≈ 0.00166...). La parte después del primer1.es tu mantisa.
Block B — implement decode_fp32 and encode_fp32¶
Two functions. Pure Python (struct module is allowed; no NumPy).
decode_fp32(x: float) -> dict returns:
{
"raw_bits": "0x...",
"sign": 0 or 1,
"biased_exponent": int in [0, 255],
"unbiased_exponent": int (biased_exponent - 127),
"mantissa_bits": "0b... (23-bit string)",
"is_normal": bool,
"is_denormal": bool,
"is_zero": bool,
"is_inf": bool,
"is_nan": bool,
"reconstructed_value": float
}
encode_fp32(sign, biased_exponent, mantissa) -> float is the inverse.
Constraints:
- Use
struct.pack('<f', x)to get the fp32 bits,struct.unpack('<I', ...)to read as uint32. - Mask and shift to extract fields. No
numpy.frombuffershortcuts — the point is to write the bit manipulation explicitly. reconstructed_valueshould matchxto bit identity for any finite input. Test with:±0, denormals (1e-40),1.0,1/600,92.0,inf,-inf,nan.
Block C — verify hand work matches code¶
For each of the four values in Block A, run your decode_fp32 and compare to your hand work. They must match exactly. Any mismatch → fix the hand work; if both agree but you're not sure, write down the discrepancy and reason about it in worked.md.
Block D — bf16 emulation¶
bf16_round_trip.py: implement
def to_bf16(x: float) -> int:
# returns 16-bit integer (the bf16 bit pattern)
...
def from_bf16(bits: int) -> float:
# returns fp32 value
...
def round_trip_bf16(x: float) -> float:
return from_bf16(to_bf16(x))
Strategy: fp32-to-bf16 is "take the high 16 bits of the fp32 bit pattern" (with appropriate rounding — round-half-to-even, but for this lab simple truncation is fine and you can note the bias). bf16-to-fp32 is "left-shift the 16 bits into a 32-bit slot, zero the bottom 16".
Round-trip these values:
1.01/60092.00.1- The probability
1e-20
Print: (original_fp32, bf16_bits, recovered_fp32, relative_error).
Block E — fp16 round-trip¶
fp16_round_trip.py: use np.float16 for the conversion (it's IEEE-754 fp16, bit-correct). Round-trip the same five values. Print the same table.
Observe: 92.0 survives bf16 (range up to ~3.4e38) but overflows to +inf in fp16 (range up to ~6.5e4)? Actually 92.0 is within fp16 range; the overflow threshold for fp16 is for exp(92.0), not for 92.0 itself. Note this distinction in your README.md.
Block F — the killer demonstration¶
Show, in README.md, with output from your scripts:
The bit patterns must differ in the lowest bit. This is your evidence — not just an assertion — that fp arithmetic is approximate.
Constraints¶
- Hand work first, code second. The point of this lab is not to write a decoder; it's to think like a decoder. If you skip the hand work, you're cheating yourself.
- No NumPy for
decode_fp32/encode_fp32.structonly. Forces you to see the bit fields explicitly. - Verify every output. A decode that prints
1.0is suspicious — verify the bit fields.
Stop conditions¶
You're done when:
worked.mdcontains hand decodes for all four Block A targets.decode.pypasses a self-test:decode_fp32(x)['reconstructed_value'] == xforx in [±0, 1.0, 1/600, 92.0, 0.1, 1e-40, np.inf, np.nan](NaN isis_nan == True, sincenan != nan).bf16_round_trip.pyoutputs the table.fp16_round_trip.pyoutputs the table.README.mdcontains the0.1 + 0.2evidence and a one-paragraph interpretation of what bf16 vs fp16 lost on each value.manifest.jsonis in place.
Pitfalls¶
- Endianness. Use
'<f'(little-endian) consistently. Mixing with'>f'will silently corrupt your bit-string output. - Denormal handling in your decoder. When
biased_exponent == 0, the leading1.becomes0.and the exponent is1 - bias, not0 - bias. Test with1e-40. - NaN. Many NaN bit patterns exist (any with
e == 0xFFandm != 0).is_nanshouldn't compare to a specific pattern; check the field condition. - bf16 rounding. Simple truncation biases toward zero. The "correct" bf16 rounding is round-half-to-even, but for this lab truncation is acceptable; just note the bias in
README.md.
When to consult solutions/¶
After committing all five files. The solution lives in solutions/00-bit-anatomy-ref.md — written at phase open.
Hint of last resort¶
If 1/600 is fighting you for two hours, here's the answer to check against:
sign = 0
exponent = 117 (biased) → unbiased -10
mantissa = 0b10110100111010000001110 = 5927950 (with rounding noise)
reconstructed ≈ 0.0016666667... (off by ~3e-11 from the exact 1/600)
Use this only to verify, not to copy. The point is the procedure, not the answer.
Next lab: lab/01-softmax-stability.md.