Skip to content

English · Español

Lab 00 — Decodificar floats a mano y por código

Objetivo: interiorizar la disposición de bits de fp32 escribiendo un decodificador que produzca la misma salida que tu trabajo a lápiz.

Tiempo estimado: 60–90 minutos.


Lo que produces

Un directorio experiments/02-float-anatomy/ que contiene:

  • decode.py — un script de Python que implementa decode_fp32(x: float) -> dict y encode_fp32(s, e, m) -> float a partir de los campos de bits crudos.
  • worked.md — tus respuestas decodificadas a mano para los cuatro valores objetivo (ver TODOs).
  • bf16_round_trip.py — emulación de bf16 vía bit-cast.
  • fp16_round_trip.py — ida y vuelta de fp16 vía numpy.float16.
  • manifest.json — semilla, versiones, hardware, configuración.
  • README.md — interpretación.

TODOs

Bloque A — decodificar a mano

Para cada uno de estos cuatro valores, decodifica a mano en (sign, biased_exponent, mantissa_bits, value) y escribe el resultado en worked.md. No uses código todavía.

  1. El patrón de bits fp32 0x3F800000.
  2. El patrón de bits fp32 0x40490FDB (puede que lo reconozcas).
  3. La representación fp32 de 1/600 (la probabilidad uniforme del vocabulario de §A13). Para encontrarla, puedes usar el procedimiento inverso: escribe 1/600 en binario, normaliza, lee el signo / exponente / mantisa. Muestra tu trabajo.
  4. La representación fp32 de 92.0 (un logit de magnitud adversaria de un clasificador de tiempos — ver teoría 02).

🇪🇸 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 primer 1. es tu mantisa.

Bloque B — implementar decode_fp32 y encode_fp32

Dos funciones. Python puro (se permite el módulo struct; nada de NumPy).

decode_fp32(x: float) -> dict devuelve:

{
  "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 es la inversa.

Restricciones:

  • Usa struct.pack('<f', x) para obtener los bits fp32, struct.unpack('<I', ...) para leer como uint32.
  • Enmascara y desplaza para extraer campos. Nada de atajos con numpy.frombuffer — el punto es escribir la manipulación de bits explícitamente.
  • reconstructed_value debe coincidir con x por identidad de bits para cualquier entrada finita. Testea con: ±0, denormales (1e-40), 1.0, 1/600, 92.0, inf, -inf, nan.

Bloque C — verificar que el trabajo a mano coincide con el código

Para cada uno de los cuatro valores en el Bloque A, ejecuta tu decode_fp32 y compara con tu trabajo a mano. Deben coincidir exactamente. Cualquier discrepancia → arregla el trabajo a mano; si ambos concuerdan pero no estás seguro, escribe la discrepancia y razona sobre ella en worked.md.

Bloque D — emulación de bf16

bf16_round_trip.py: implementa

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))

Estrategia: fp32-a-bf16 es "tomar los 16 bits altos del patrón de bits fp32" (con redondeo apropiado — round-half-to-even, pero para este laboratorio el truncamiento simple está bien y puedes anotar el sesgo). bf16-a-fp32 es "desplazar a la izquierda los 16 bits a un hueco de 32 bits, poner a cero los 16 inferiores".

Haz ida y vuelta con estos valores:

  • 1.0
  • 1/600
  • 92.0
  • 0.1
  • La probabilidad 1e-20

Imprime: (original_fp32, bf16_bits, recovered_fp32, relative_error).

Bloque E — ida y vuelta de fp16

fp16_round_trip.py: usa np.float16 para la conversión (es IEEE-754 fp16, correcto en bits). Haz ida y vuelta con los mismos cinco valores. Imprime la misma tabla.

Observa: ¿92.0 sobrevive a bf16 (rango hasta ~3.4e38) pero desborda a +inf en fp16 (rango hasta ~6.5e4)? En realidad 92.0 está dentro del rango de fp16; el umbral de overflow de fp16 es para exp(92.0), no para 92.0 mismo. Anota esta distinción en tu README.md.

Bloque F — la demostración asesina

Muestra, en README.md, con la salida de tus scripts:

0.1 + 0.2 == 0.3            ?  →  False
hex(struct.pack('<d', 0.1+0.2))  →  ...
hex(struct.pack('<d', 0.3))      →  ...

Los patrones de bits deben diferir en el bit más bajo. Esta es tu evidencia — no solo una afirmación — de que la aritmética fp es aproximada.

Restricciones

  • Trabajo a mano primero, código después. El punto de este laboratorio no es escribir un decodificador; es pensar como un decodificador. Si te saltas el trabajo a mano, te estás haciendo trampas a ti mismo.
  • Nada de NumPy para decode_fp32/encode_fp32. Solo struct. Te fuerza a ver los campos de bits explícitamente.
  • Verifica cada salida. Un decode que imprime 1.0 es sospechoso — verifica los campos de bits.

Condiciones de parada

Has terminado cuando:

  1. worked.md contiene decodificaciones a mano para los cuatro objetivos del Bloque A.
  2. decode.py pasa un auto-test: decode_fp32(x)['reconstructed_value'] == x para x in [±0, 1.0, 1/600, 92.0, 0.1, 1e-40, np.inf, np.nan] (NaN es is_nan == True, ya que nan != nan).
  3. bf16_round_trip.py produce la tabla.
  4. fp16_round_trip.py produce la tabla.
  5. README.md contiene la evidencia de 0.1 + 0.2 y una interpretación en un párrafo de qué perdieron bf16 vs fp16 en cada valor.
  6. manifest.json está en su sitio.

Escollos

  • Endianness. Usa '<f' (little-endian) consistentemente. Mezclar con '>f' corromperá silenciosamente tu salida de cadena de bits.
  • Manejo de denormales en tu decodificador. Cuando biased_exponent == 0, el 1. líder se vuelve 0. y el exponente es 1 - bias, no 0 - bias. Testea con 1e-40.
  • NaN. Existen muchos patrones de bits NaN (cualquiera con e == 0xFF y m != 0). is_nan no debe comparar contra un patrón específico; comprueba la condición de campo.
  • Redondeo de bf16. El truncamiento simple sesga hacia cero. El redondeo bf16 "correcto" es round-half-to-even, pero para este laboratorio el truncamiento es aceptable; solo anota el sesgo en README.md.

Cuándo consultar solutions/

Tras commitear los cinco archivos. La solución vive en solutions/00-bit-anatomy-ref.md — escrita al abrir la fase.

Pista de último recurso

Si 1/600 te está peleando durante dos horas, aquí tienes la respuesta para comprobar:

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)

Usa esto solo para verificar, no para copiar. El punto es el procedimiento, no la respuesta.


Siguiente laboratorio: lab/01-softmax-stability.md.