SenseiTeoría desde cero0/21

2 · Números y estadística para producciónLección 6 de 7

Cómo guarda números una computadora

How a computer stores numbers

Bits, enteros y números con punto decimal, por dentro: por qué un píxel llega hasta 255, por qué 0.1 + 0.2 no da 0.3 y cómo un modelo cabe en la mitad o en la cuarta parte de la memoria sin olvidar lo que aprendió.

⏱ 57 min lo esencial · 80 min todo13 conceptos nuevos3 calculadoras11 ejerciciosModelo
Secciones14
  1. 01Bits y enteros: contar con ceros y unos
  2. 02Punto flotante: notación científica en base 2
  3. 03FP32, FP16 y BF16: el mismo truco con distintos bits
  4. 04Redondeo: por qué 0.1 + 0.2 no da 0.3 extra
  5. 05Precisión mixta: entrenar en 16 bits sin perder lo importante extra
  6. 06Cuantización: enteros con una escala
  7. 07Memoria del modelo
  8. 08Cuantizar a fondo: punto cero, INT4, FP8 y el examen extra
  9. 09Errores comunes
  10. 10En la vida real extra
  11. 11Ejercicios
  12. 12Lo que sigue
  13. 13Palabras que vas a oír
  14. 14Chequeo
La pregunta

¿Cómo guarda una computadora un número, qué se pierde al guardarlo con menos bits y por qué eso decide cuánta memoria y cuánta GPU pide un modelo?

01

Bits y enteros: contar con ceros y unos

Esencial

Bits and integers: counting with zeros and ones

En la lección de imágenes viste que cada color de un píxel es un número del 0 al 255. En la de unidades, que un byte son 8 bits. Aquí vamos a abrir ese byte: cómo se escribe un número con ceros y unos, por qué el tope es 255 y qué pasa cuando un número no cabe.

Contar en binario

Piensa en cómo escribes el 236. Cada posición vale 10 veces la de su derecha: 236 = 2 × 100 + 3 × 10 + 6 × 1. Hay diez dígitos, del 0 al 9, porque contamos en base 10.

Un bit solo tiene dos estados, que se escriben 0 y 1 (en el chip son, por ejemplo, un voltaje bajo o uno alto). Por eso la computadora cuenta en base 2, el sistema binario: solo hay dos dígitos y cada posición vale el doble que la de su derecha. De derecha a izquierda, las posiciones de un byte valen 1, 2, 4, 8, 16, 32, 64 y 128.

Para escribir un número en binario, recorre las posiciones de la más grande a la más chica. Si su valor cabe en lo que te queda, pones un 1 y lo restas; si no, pones un 0.

El 236 en binario: el rojo del naranja de los tenisLa cuenta
Posición de 128cabe en 236: un 1
236 − 128
quedan 108
Posición de 64cabe: un 1
108 − 64
quedan 44
Posición de 32cabe: un 1
44 − 32
quedan 12
Posición de 16no cabe en 12: un 0
—
quedan 12
Posición de 8cabe: un 1
12 − 8
quedan 4
Posición de 4cabe: un 1
4 − 4
quedan 0
Posiciones de 2 y de 1ya no queda nada
—
0 y 0
Resultado
236 = 1110 1100 en binario, porque 128 + 64 + 32 + 8 + 4 = 236.

¿Cuántos números distintos caben en n bits? Cada bit que agregas duplica las combinaciones: con 1 bit hay 2 (0 y 1); con 2, cuatro (00, 01, 10 y 11); con 8, 2⁸ = 256. Por eso un byte guarda del 0 al 255: son 256 valores y el primero es el 0. Es la misma cuenta de potencias de 2 de la lección de unidades, donde 1 KiB son 2¹⁰ = 1,024 bytes.

python
for n in (5, 13, 236, 255):
    print(f"{n:>3} en binario es {n:08b}")

bits = "11101100"
sin_signo = int(bits, 2)                       # uint8: cada bit suma su valor
con_signo = sin_signo - 256 if bits[0] == "1" else sin_signo   # int8: el primero vale −128
print(bits, "como uint8 vale", sin_signo, "y como int8 vale", con_signo)

print("valores posibles con 1 a 8 bits:", [2**b for b in range(1, 9)])
Salida
  5 en binario es 00000101
 13 en binario es 00001101
236 en binario es 11101100
255 en binario es 11111111
11101100 como uint8 vale 236 y como int8 vale -20
valores posibles con 1 a 8 bits: [2, 4, 8, 16, 32, 64, 128, 256]

Enteros sin signo y con signo

Un entero (integer) es un número sin decimales. En la computadora cada entero tiene un tamaño fijo en bits, y el tamaño decide su rango.

Si todos los bits suman, el entero es sin signo (unsigned): es el uint8 de los píxeles, del 0 al 255.

Para tener negativos, todas las computadoras de hoy usan el mismo truco, el complemento a dos (two's complement): el primer bit, el de la izquierda, vale negativo. En un int8 vale −128 en lugar de 128:

  • 0111 1111 = 64 + 32 + 16 + 8 + 4 + 2 + 1 = 127, el más grande.
  • 1000 0000 = −128, el más chico.
  • 1111 1111 = −128 + 127 = −1.
  • 1110 1100 = −128 + 64 + 32 + 8 + 4 = −20.
Un byte, los mismos 8 bits leídos de dos maneras. Los bits son 1, 1, 1, 0, 1, 1, 0, 0. Sin signo, como uint8, cada posición vale de izquierda a derecha 128, 64, 32, 16, 8, 4, 2 y 1, y los bits prendidos suman 128 más 64 más 32 más 8 más 4, que da 236. Con signo, como int8, la primera posición vale menos 128 en vez de 128, y la suma da menos 128 más 64 más 32 más 8 más 4, que es menos 20. Los bits no dicen qué número son: lo dice el tipo de dato. UN BYTE, DOS LECTURAS Sin signo (uint8): cada bit suma su posición 1 128 1 64 1 32 0 16 1 8 1 4 0 2 0 1 128 + 64 + 32 + 8 + 4 = 236 Con signo (int8): el primero vale −128 1 −128 1 64 1 32 0 16 1 8 1 4 0 2 0 1 −128 + 64 + 32 + 8 + 4 = −20 Los bits no dicen qué número son: lo dice el tipo de dato. 236 o −20, según quién lea.
Los mismos 8 bits, leídos de dos maneras. Arriba, sin signo: cada bit suma su posición y salen 236. Abajo, con signo: el primer bit vale −128 en vez de 128 y salen −20.

Fíjate en el último: son los mismos bits que el 236. Los bits no dicen qué número son; lo dice el tipo de dato. Si un programa escribe un uint8 y otro lo lee como int8, el 236 se vuelve −20 sin ningún error. Por eso cada tensor lleva escrito su tipo, como verás más adelante.

¿Por qué ese truco y no un bit de signo aparte? Porque así sumar es igual con signo o sin signo, y el chip usa el mismo circuito para las dos cosas. Prueba −1 + 1: 1111 1111 + 0000 0001 = 1 0000 0000. El noveno bit no cabe en el byte y se pierde; quedan 0000 0000 = 0. Correcto.

TipoBitsBytesRangoDónde lo vas a ver
uint8810 a 255Los píxeles de una foto
int881−128 a 127Los pesos de un modelo cuantizado (INT8)
int440.5−8 a 7Los pesos de un LLM cuantizado (INT4)
int16162−32,768 a 32,767El audio: cada muestra de un WAV de 16 bits
int32324−2,147,483,648 a 2,147,483,647Contadores e índices
int64648≈ −9.2 × 10¹⁸ a 9.2 × 10¹⁸Etiquetas e ids de tokens: es el entero por omisión de PyTorch

La regla general: con n bits, un entero sin signo va de 0 a 2ⁿ − 1, y uno con signo, de −2ⁿ⁻¹ a 2ⁿ⁻¹ − 1. Pruébala.

El rango de un enteroCalculadora

Elige cuántos bits tiene el entero y si lleva signo. Después escribe un número y mira qué queda guardado si no cabe: si da la vuelta (overflow) o si se satura.

Valores distintos2 elevado a los bits256
El más chico0
El más grande255
Bytes que ocupa cada uno8 bits por byte1.0
Si da la vuelta, queda guardadolo que hacen los arreglos de NumPy y PyTorch, sin avisar0
Si se satura, queda guardadolo que hace cv2.add de OpenCV255

Con 8 bits sin signo, 256 da la vuelta a 0. Con signo, 128 queda en −128. Con 32 bits con signo, el más grande es 2,147,483,647: un contador de las fotos de C01 llega ahí en ≈ 214 días.

Un reto: ¿cuántos bits necesitas, como mínimo, para guardar 40,000 sin signo? ¿Y con signo?

Overflow: cuando el número no cabe

¿Qué pasa con 255 + 1 en un uint8? El resultado, 256, necesita 9 bits: 1 0000 0000. El noveno se pierde y queda 0. Eso es un overflow (desbordamiento): el resultado no cabe en el tipo de dato.

Hay dos maneras de manejarlo:

  • Dar la vuelta (wraparound): se pierde lo que no cabe y el número reaparece del otro lado. En uint8, 255 + 1 = 0 y 250 + 10 = 4. En int8, 127 + 1 = −128: del más grande al más negativo.
  • Saturarse (saturation): el número se queda en la orilla. 250 + 10 = 255.

Los arreglos de enteros de NumPy y de PyTorch dan la vuelta, y en silencio: no hay error ni aviso. La función cv2.add de OpenCV, en cambio, se satura. Los enteros propios de Python no se desbordan nunca: crecen lo que haga falta. Por eso el código de abajo imita el uint8 con % 256, el residuo de dividir entre 256.

Los 256 valores de un byte puestos en un círculo, como un reloj. Por fuera, como uint8: el 0 arriba, 64 a la derecha, 128 abajo y 192 a la izquierda; justo antes del 0 está el 255. Una flecha arriba muestra 250 más 10: pasa por encima del 255 y cae en el 4, porque 260 no cabe en 8 bits. Por dentro, los mismos bits leídos como int8: 0 arriba, 64 a la derecha, menos 64 a la izquierda, y abajo el salto de 127 a menos 128. El uint8 da la vuelta arriba; el int8, abajo. UN BYTE DA LA VUELTA 255 0 64 128 192 0 64 −64 127 −128 por fuera: uint8 por dentro: int8 250 4 +10 250 + 10 = 260 no cabe en 8 bits: queda 4. En int8, el salto está abajo: 127 + 1 = −128.
Los 256 valores de un byte, en un círculo. Por fuera, como uint8: después del 255 viene el 0, y 250 + 10 cae en el 4. Por dentro, los mismos bits como int8: ahí el salto está abajo, de 127 a −128.

Esto muerde de verdad al trabajar con fotos. Si quieres aclarar una foto sumándole 10 a cada píxel en uint8, un blanco de 250 queda en 4: casi negro. Si promedias dos fotos sumando primero, (200 + 100) ÷ 2 da 22 en vez de 150, porque la suma ya había dado la vuelta.

python
def uint8(x):                  # como un arreglo uint8 de NumPy o PyTorch: da la vuelta
    return x % 256

def saturar(x):                # como cv2.add de OpenCV: se queda en la orilla
    return max(0, min(255, x))

for a, b in ((250, 10), (200, 100), (10, -20)):
    print(f"{a} {'+' if b > 0 else '-'} {abs(b)}: da la vuelta → {uint8(a + b):>3} · se satura → {saturar(a + b)}")

print("promedio de 200 y 100 sumando en uint8:", uint8(200 + 100) // 2)
print("promedio de 200 y 100 con un int de Python:", (200 + 100) // 2)

maximo = 2**31 - 1             # el int32 más grande
print(f"un contador int32 llega a {maximo:,}: a 116 fotos por segundo, en {maximo / 116 / 86400:.0f} días")
Salida
250 + 10: da la vuelta →   4 · se satura → 255
200 + 100: da la vuelta →  44 · se satura → 255
10 - 20: da la vuelta → 246 · se satura → 0
promedio de 200 y 100 sumando en uint8: 22
promedio de 200 y 100 con un int de Python: 150
un contador int32 llega a 2,147,483,647: a 116 fotos por segundo, en 214 días

La salida es convertir a un tipo más grande antes de hacer cuentas (int32 o float32) y, al final, recortar a 0–255 antes de volver a uint8.

Los contadores también dan la vuelta: en la sección extra «En la vida real» está uno de C01, junto con los desbordes que tumbaron un cohete y el contador de YouTube.

Los números con decimales también se desbordan, pero de otra forma: se vuelven infinito. Lo ves en la siguiente sección.

02

Punto flotante: notación científica en base 2

Esencial

Floating point: scientific notation in base 2

Los números de un modelo casi nunca son enteros: un peso vale 0.0213 o −1.7, un logit 3.0, un gradiente 0.000004, la pérdida 2.3. Y en el mismo cálculo conviven números enormes y diminutos.

¿Y si guardamos siempre, digamos, 4 decimales? No alcanza: 0.000004 quedaría en 0.0000, y los decimales sobran cuando el número es 150,528. Lo que sirve es la notación científica de la lección de unidades: 150,528 = 1.50528 × 10⁵ y 0.000004 = 4 × 10⁻⁶. Se guardan dos cosas: los dígitos (1.50528) y el exponente (5), que dice dónde va el punto. Como el punto se mueve según el exponente, el formato se llama punto flotante (floating point).

La computadora hace lo mismo, pero en base 2: escribe cada número como «1.algo» por una potencia de 2. Para eso necesita las posiciones del binario después del punto, que valen 1/2, 1/4, 1/8, 1/16… Así, 0.101 en binario es 1/2 + 1/8 = 0.625. Para no confundirlos con los decimales, a los números en binario les pongo un ₂ chiquito: 0.101₂ = 0.625.

Un número de punto flotante se guarda en tres partes:

  1. 1

    El signo

    Un bit: 0 si es positivo, 1 si es negativo.

  2. 2

    El exponente

    Unos bits que dicen a qué potencia se eleva el 2: dónde va el punto. Se guarda sumándole un número fijo, el sesgo (bias), para no necesitar otro signo: en FP32, el exponente 2 se guarda como 2 + 127 = 129.

  3. 3

    La mantisa

    Los dígitos que van después del «1.». El 1 de adelante no se guarda: en binario, el primer dígito distinto de cero siempre es 1, así que se da por hecho y es un bit gratis.

El valor es: (−1)^signo × 1.mantisa × 2^(exponente guardado − sesgo).

El 6.5 en FP32, bit por bitLa cuenta
En binario6 = 110₂ y 0.5 = 0.1₂
4 + 2 + 1/2
110.1₂
En notación científicael punto se mueve 2 lugares
110.1₂ = 1.101₂ × 2²
exponente 2
Signopositivo
—
0
Exponente guardado8 bits, con un sesgo de 127
2 + 127 = 129
1000 0001
Mantisalo de después del «1.», con ceros hasta llegar a 23 bits
.101₂
101 y 20 ceros
Resultado
Los 32 bits: 0 · 10000001 · 10100000000000000000000. De vuelta: 1.101₂ = 1 + 1/2 + 1/8 = 1.625, y 1.625 × 2² = 6.5. Exacto, porque 6.5 se escribe en binario con pocos dígitos.

Los números de punto flotante no están parejos

En FP32, con 23 bits de mantisa, entre 1 y 2 caben 2²³ = 8,388,608 números, todos a la misma distancia: ≈ 0.00000012. Entre 2 y 4 caben otros tantos, así que quedan al doble de distancia. Entre 1,024 y 2,048, a ≈ 0.000122. Cada vez que el número se duplica, la separación también.

Eso es justo lo que se busca: el error es relativo. Un número cercano a 1 se guarda con un error menor que una diezmillonésima; uno cercano a un millón, con un error de centésimas; y los dos están igual de bien guardados para su tamaño: a menos de 0.000006 % de su valor. La figura lo muestra con un formato de 8 bits, donde se pueden dibujar todos los números que existen.

Todos los números positivos del formato FP8 E5M2 entre 0 y 16, sobre una recta. Entre 0 y 1 hay 59, tan juntos que forman un bloque. De 1 a 2 hay cuatro: 1, 1.25, 1.5 y 1.75, separados por 0.25. De 2 a 4 hay cuatro: 2, 2.5, 3 y 3.5, separados por 0.5. De 4 a 8: 4, 5, 6 y 7, separados por 1. De 8 a 16: 8, 10, 12 y 14, separados por 2. Cada tramo tiene la misma cantidad de números; la separación se duplica de tramo en tramo, pero el error relativo es el mismo. FP16 tiene 1,024 números por tramo y FP32, 8,388,608. FP8 E5M2: SUS NÚMEROS DE 0 A 16 59 entre 0 y 1 0 1 2 4 8 16 CADA TRAMO TIENE 4 NÚMEROS de 1 a 2 1 · 1.25 · 1.5 · 1.75 cada 0.25 de 2 a 4 2 · 2.5 · 3 · 3.5 cada 0.5 de 4 a 8 4 · 5 · 6 · 7 cada 1 de 8 a 16 8 · 10 · 12 · 14 cada 2 La separación se duplica en cada tramo, pero ningún número queda a más de 12.5 % del real. FP16 tiene 1,024 por tramo; FP32, 8,388,608.
Todos los números de FP8 E5M2, un formato de 8 bits con 2 de mantisa, entre 0 y 16. En cada tramo (de 1 a 2, de 2 a 4, de 4 a 8 y de 8 a 16) hay exactamente 4. La separación se duplica de tramo en tramo, pero el error relativo máximo es el mismo en todos: 12.5 %.

Infinito y NaN

Algunas combinaciones de bits están reservadas. Con el exponente todo en unos, el número es infinito (inf) o NaN (not a number, «no es un número»):

  • inf aparece cuando un resultado es demasiado grande para el formato: es el overflow de los números con decimales. También sale al dividir entre cero un número que no es 0, en NumPy o en PyTorch (Python, en cambio, da un error).
  • NaN aparece con cuentas que no tienen respuesta: 0 ÷ 0, infinito menos infinito, o un inf multiplicado por 0.

NaN contagia: cualquier suma, resta, multiplicación o división con un NaN da NaN. Y NaN es distinto de todo, hasta de sí mismo: x == x es falso si x es NaN. Basta un NaN en la pérdida para que la backpropagation llene de NaN todos los gradientes, y el siguiente paso, todos los pesos. Por eso «loss = nan» es la falla de entrenamiento más famosa, y muchas veces empieza con un overflow.

03

FP32, FP16 y BF16: el mismo truco con distintos bits

Esencial

FP32, FP16 and BF16: the same trick with different bits

Todos los formatos de punto flotante hacen lo mismo; lo que cambia es cuántos bits le tocan al exponente y cuántos a la mantisa. La regla para recordarlos: los bits de exponente dan el rango (qué tan grande o qué tan chico puede ser el número) y los de mantisa dan la precisión (cuántas cifras guarda).

Cómo reparte sus bits cada formato de punto flotante: un bit de signo, luego el exponente, que da el rango, y luego la mantisa, que da las cifras. FP32, de 4 bytes: 1 de signo, 8 de exponente y 23 de mantisa; llega hasta unos 3.4 por 10 a la 38 con unas 7 cifras. BF16, de 2 bytes: 1, 8 y 7, que son los primeros 16 bits de FP32; el mismo rango con unas 2 cifras. FP16, de 2 bytes: 1, 5 y 10; unas 3 cifras, pero solo llega hasta 65,504. FP8 E4M3, de 1 byte: 1, 4 y 3, hasta 448. FP8 E5M2, de 1 byte: 1, 5 y 2, hasta 57,344. CÓMO REPARTE SUS BITS CADA FORMATO signo exponente: rango mantisa: cifras FP32 · 4 bytes 8 23 hasta ≈ 3.4 × 10³⁸ · ≈ 7 cifras BF16 · 2 bytes 8 7 hasta ≈ 3.4 × 10³⁸ · ≈ 2 cifras los primeros 16 bits de FP32 FP16 · 2 bytes 5 10 hasta 65,504 · ≈ 3 cifras menos rango, más cifras FP8 E4M3 · 1 byte 4 3 hasta 448 FP8 E5M2 · 1 byte 5 2 hasta 57,344 Más exponente, más rango. Más mantisa, más cifras. Con pocos bits, ganar uno es perder el otro.
Cómo reparte sus bits cada formato. BF16 es la primera mitad de FP32: el mismo signo y el mismo exponente, con la mantisa recortada. FP16 cambia exponente por mantisa: más cifras que BF16, pero con tope en 65,504.
FormatoSigno · exponente · mantisaBytesEl más grandeCifrasSeparación junto al 1
FP64 (float64)1 · 11 · 528≈ 1.8 × 10³⁰⁸≈ 16≈ 2 × 10⁻¹⁶
FP32 (float32)1 · 8 · 234≈ 3.4 × 10³⁸≈ 7≈ 0.00000012
FP16 (float16)1 · 5 · 10265,504≈ 3≈ 0.001
BF16 (bfloat16)1 · 8 · 72≈ 3.4 × 10³⁸≈ 2≈ 0.008

FP32: la referencia

Es el float32 de la lección de imágenes. Con él se entrena por omisión, es el tipo que PyTorch usa si no le pides otro, y contra él se compara cualquier otro formato. Guarda ≈ 7 cifras y llega hasta ≈ 3.4 × 10³⁸: para casi todo, sobra. Su problema es el tamaño: 4 bytes por número.

FP16: la mitad de memoria y un techo de 65,504

FP16 (half precision, media precisión) usa 2 bytes. Con 5 bits de exponente, su potencia más grande es 2¹⁵, y el número más grande que guarda es (2 − 2⁻¹⁰) × 2¹⁵ = 65,504. Lo que pase de ahí se vuelve inf. Por abajo, el más chico con todas sus cifras es ≈ 0.000061; debajo quedan unos números especiales con menos cifras, los subnormales, que llegan a ≈ 0.00000006, y lo que sea más chico se vuelve 0.

Con 10 bits de mantisa guarda ≈ 3 cifras: junto al 1, la separación es 2⁻¹⁰ ≈ 0.001, así que 1.0001 no existe y se guarda como 1.

En una CNN como la de C01, los números se quedan lejos del techo, y FP16 es la elección normal para servir: C01 corre su modelo como un engine de TensorRT (el modelo ya convertido por la herramienta de NVIDIA para correr rápido en su GPU), y ese engine trabaja en FP16. En un transformer es otra historia. Dos ejemplos:

  • Exponenciales. e¹¹ ≈ 59,874 cabe, pero e¹² ≈ 162,755 ya no. Un softmax hecho sin cuidado en FP16 da inf con cualquier logit mayor que ≈ 11.09, que es el logaritmo natural de 65,504. Por eso las librerías le restan a todos los logits el más alto, el truco de la lección «Qué es un modelo».
  • Cuadrados. 255² = 65,025 cabe; 256² = 65,536 ya no. Medir el tamaño de un vector de activaciones suma cuadrados, y basta un valor de 256 para que el resultado sea inf.

La Ronda, en M04, lo resume: las activaciones de un transformer tienen valores atípicos que pueden pasar de 65,504, y en FP16 se vuelven inf y después NaN.

BF16: el rango de FP32 con menos precisión

BF16 (bfloat16, de brain floating point: se inventó en Google Brain) también usa 2 bytes, pero los reparte al revés que FP16: 8 bits de exponente, los mismos de FP32, y solo 7 de mantisa. De hecho, un BF16 son los primeros 16 bits de un FP32: para convertir, se redondea y se tira la segunda mitad.

El resultado es el rango de FP32 (hasta ≈ 3.4 × 10³⁸) con 2 o 3 cifras. Compara los tres con los mismos números:

NúmeroEn FP32En FP16En BF16
0.10.1000000010.09997560.1000977
1000.31000.299991000.51000
70,00070,000inf70,144
0.00000001 (10⁻⁸)1.0 × 10⁻⁸0≈ 1.001 × 10⁻⁸

BF16 se equivoca más en cada número, pero prácticamente nunca se desborda donde FP32 no lo haría. Y resulta que las redes neuronales toleran muy bien la imprecisión (el ruido del entrenamiento es mucho más grande que ese error), pero no el overflow. Por eso los LLMs se entrenan y se sirven en BF16, y por eso la Ronda (M04) dice que BF16 «le ganó» a FP16 en los LLMs.

Un detalle de hardware que la Ronda subraya: BF16 existe en las GPUs desde la generación Ampere (la A100 y la A10G) en adelante, pero no en la T4. Servir en una T4 un LLM entrenado en BF16 obliga a FP16, con riesgo de overflow, o a FP32, con el doble de memoria y unas 8 veces menos velocidad de cálculo.

En PyTorch: el dtype de cada tensor

Cada tensor de PyTorch guarda todos sus números en el mismo formato, y ese formato es su dtype (data type, tipo de dato). El dtype decide cuántos bytes ocupa cada número y cómo se leen sus bits.

En PyTorchFormatoBytes por número
torch.float32 o torch.floatFP324
torch.float16 o torch.halfFP162
torch.bfloat16BF162
torch.float64 o torch.doubleFP648
torch.uint8uint81
torch.int8int81
torch.int64 o torch.longint648
torch.boolverdadero o falso1

Lo que PyTorch elige solo: un tensor con decimales nace en float32; uno con enteros, en int64. La memoria de un tensor es su cantidad de números por los bytes de su dtype. El tensor de la foto de Ana, de shape [1, 3, 224, 224], tiene 150,528 números: ocupa 602,112 bytes (≈ 602 KB) en float32, la mitad en float16 y ≈ 151 KB en uint8.

Así se ve en código. Es una ilustración: necesita PyTorch, que no viene con Python, y por eso no lleva su salida; lo que imprime está en los comentarios.

python
import torch

x = torch.tensor([0.1, 2.5])          # con decimales: nace en float32
print(x.dtype, x.element_size())      # torch.float32 4  (4 bytes por número)
x16 = x.to(torch.float16)             # una copia en FP16, con la mitad de bytes
etiquetas = torch.tensor([3, 41])     # enteros: nacen en int64
print(etiquetas.dtype)                # torch.int64
foto = torch.zeros(1, 3, 224, 224, dtype=torch.uint8)
print(foto.float().div(255).dtype)    # torch.float32: así se convierte la foto
04

Redondeo: por qué 0.1 + 0.2 no da 0.3

Extra

Rounding: why 0.1 + 0.2 is not 0.3

Casi ningún número cabe exacto en los bits. La computadora guarda el más cercano que sí existe, y la diferencia es el error de redondeo (rounding error).

Piensa en 1/3 en decimal: es 0.3333…, con treses que no se acaban. En binario, 1/10 hace lo mismo: 0.1 = 0.000110011001100110011…₂, con «0011» repetido para siempre. FP32 lo corta a los 24 bits de dígitos y guarda 0.100000001490116…; FP64 lo corta a los 53 y guarda 0.1000000000000000055…

Con Python puedes ver los bits de verdad. El módulo struct convierte un número en los bytes de un FP32 ("f") o de un FP16 ("e"):

python
import struct

def bits_fp32(x):              # los 4 bytes de un float32, como texto de 0 y 1
    b = f'{struct.unpack(">I", struct.pack(">f", x))[0]:032b}'
    return f"{b[0]} {b[1:9]} {b[9:]}"

def bits_fp16(x):              # los 2 bytes de un float16 ("e" en struct)
    b = f'{struct.unpack(">H", struct.pack(">e", x))[0]:016b}'
    return f"{b[0]} {b[1:6]} {b[6:]}"

for x in (6.5, -2.75, 0.1, 65504.0):
    print(f"{x:>8}  FP32 {bits_fp32(x)}")
    print(f"{'':>8}  FP16 {bits_fp16(x)}")

try:
    bits_fp16(70000.0)
except OverflowError as error:
    print("70000 en FP16:", error)
Salida
     6.5  FP32 0 10000001 10100000000000000000000
          FP16 0 10001 1010000000
   -2.75  FP32 1 10000000 01100000000000000000000
          FP16 1 10000 0110000000
     0.1  FP32 0 01111011 10011001100110011001101
          FP16 0 01011 1001100110
 65504.0  FP32 0 10001110 11111111110000000000000
          FP16 0 11110 1111111111
70000 en FP16: float too large to pack with e format

Fíjate en el 0.1: su mantisa es «1001» repetido hasta que se acaban los bits, y en FP32 el último está redondeado hacia arriba. Y el 65,504 en FP16 tiene la mantisa toda en unos y el exponente en 11110: es el último número antes del infinito, que usa el exponente 11111.

FP64, el doble de grande que FP32, casi no se usa en ML (las GPUs lo calculan mucho más lento), pero te lo vas a encontrar en un lugar: los números con decimales de Python son FP64, igual que los arreglos de NumPy si no le dices otra cosa.

Como 0.1 y 0.2 ya vienen redondeados, en Python 0.1 + 0.2 da 0.30000000000000004, y 0.1 + 0.2 == 0.3 es falso. No es un error de Python: pasa en JavaScript, en C y en cualquier lenguaje que use estos formatos. En FP32, de pura suerte, 0.1 + 0.2 sí da el mismo número que 0.3, porque los dos errores se compensan; en FP16 vuelve a fallar. Que dos cuentas de punto flotante den exactamente lo mismo es cuestión de suerte.

¿Qué tan grande es el error? A lo mucho, media separación. En proporción al número:

FormatoError máximo al guardar cada número
FP32≈ 0.000006 %
FP16≈ 0.05 %
BF16≈ 0.4 %

De ahí salen las cifras de la tabla de formatos. FP32 tiene 24 bits de dígitos (23 de mantisa más el 1 gratis), y 2²⁴ ≈ 16.8 millones: unas 7 cifras decimales. FP16 tiene 11 bits, y 2¹¹ = 2,048: unas 3. BF16 tiene 8, y 2⁸ = 256: entre 2 y 3.

La regla: nunca compares con ==

Para comparar números de punto flotante se pregunta si están cerca: si la diferencia es menor que una tolerancia. En Python, math.isclose(a, b); en PyTorch, torch.allclose(a, b, rtol=…, atol=…). La tolerancia depende del formato: en la prueba de paridad de la Ronda (M08), la diferencia máxima esperable entre los logits de dos versiones del modelo es de ≈ 0.0001 en FP32 y de ≈ 0.01 en FP16. Si sale órdenes de magnitud más grande, es un bug.

Los enteros grandes también se redondean

Un FP32 guarda exacto cualquier entero hasta 2²⁴ = 16,777,216. Arriba de eso la separación ya es 2: el 16,777,217 no existe, y 16,777,216 + 1 = 16,777,216. FP16 se atora mucho antes, en 2,048 (2,048 + 1 = 2,048), y BF16 en 256. Un contador de fotos de C01 guardado en float32 se atoraría en menos de dos días, sin ningún error: la cuenta está en «En la vida real».

Los errores se acumulan

Cada suma redondea. Si sumas un número chiquito a uno grande, puede perderse completo: en FP16, 1 + 0.0001 da 1, porque 0.0001 es menos de media separación (≈ 0.0005). Sumar un millón de veces 0.0001 a un 1 en FP16 deja… el 1.

Por eso las GPUs, aunque multipliquen en FP16, acumulan las sumas en FP32: una multiplicación de matrices junta miles de productos, y en FP16 los últimos ya no moverían el total.

python
import math, struct

def fp32(x):                   # redondea x a float32 y lo regresa como float de Python
    return struct.unpack(">f", struct.pack(">f", x))[0]

def fp16(x):
    return struct.unpack(">e", struct.pack(">e", x))[0]

print(0.1 + 0.2, "· ¿igual a 0.3?", 0.1 + 0.2 == 0.3)   # Python usa FP64
print(f"0.1 guardado en FP32: {fp32(0.1):.20f}")
print("FP32: 16,777,216 + 1 =", fp32(16_777_216 + 1))
print("FP16: 2,048 + 1 =", fp16(2048 + 1))
print("FP16: 1 + 0.0001 =", fp16(1 + 0.0001))
print("FP16: 1 + 0.001 =", fp16(1 + 0.001))
print("¿cerca de 0.3?", math.isclose(0.1 + 0.2, 0.3))
Salida
0.30000000000000004 · ¿igual a 0.3? False
0.1 guardado en FP32: 0.10000000149011611938
FP32: 16,777,216 + 1 = 16777216.0
FP16: 2,048 + 1 = 2048.0
FP16: 1 + 0.0001 = 1.0
FP16: 1 + 0.001 = 1.0009765625
¿cerca de 0.3? True

El 1 + 0.001 sí se mueve en FP16, pero no a 1.001: al número de FP16 más cercano, 1.0009765625, que es 1 + 2⁻¹⁰. Un error así, repetido millones de veces, hizo fallar un misil antiaéreo en 1991: la historia está en «En la vida real».

05

Precisión mixta: entrenar en 16 bits sin perder lo importante

Extra

Mixed precision: training in 16 bits without losing what matters

Entrenar en 16 bits tiene dos premios. Las activaciones ocupan la mitad de memoria. Y las GPUs tienen núcleos especiales para multiplicar matrices en 16 bits, los tensor cores, muchísimo más rápidos: una T4 hace ≈ 65 billones (65 × 10¹²) de operaciones por segundo en FP16 contra ≈ 8.1 billones en FP32 (M04): 8 veces menos.

Pero entrenar todo en FP16 falla de tres maneras:

  1. Los cambios chicos se pierden. Con un learning rate de 0.001 y un gradiente de 0.1, el paso es de 0.0001. Un peso que vale 1 debería quedar en 1.0001, pero en FP16 queda en 1: el cambio es menor que media separación. El modelo deja de aprender sin ningún error.
  2. Los gradientes diminutos se vuelven 0. FP16 no baja de ≈ 6 × 10⁻⁸. Un gradiente de 10⁻⁸ se guarda como 0, y esa parte del modelo deja de aprender.
  3. Lo grande se desborda. Lo que pase de 65,504 es inf, y de ahí viene el NaN.
Para ir más hondoLos subnormales y el −0

Con el exponente todo en ceros, el 1 de adelante se vuelve 0 y la mantisa sigue contando hacia abajo: son los números subnormales, que llenan el hueco entre el más chico con todas sus cifras y el 0, con menos cifras. En FP32 llegan hasta ≈ 1.4 × 10⁻⁴⁵; en FP16, hasta ≈ 6 × 10⁻⁸. Algunas GPUs los convierten en 0 para ir más rápido (flush to zero). Ese mismo exponente en ceros con la mantisa en ceros es el 0, que puede tener signo: existe un −0, que al compararlo es igual a 0.

La precisión mixta (mixed precision; en PyTorch se llama AMP, de automatic mixed precision) resuelve las tres con tres ideas, publicadas en 2018 por investigadores de NVIDIA y de Baidu:

  1. 1

    Una copia maestra en FP32

    Los pesos de verdad viven en FP32. En cada paso se hace una copia en FP16 para las cuentas, y el cambio se le suma a la copia en FP32, donde 1 + 0.0001 sí da 1.0001.

  2. 2

    Lo pesado en FP16, lo delicado en FP32

    Las convoluciones y las multiplicaciones de matrices, que son casi todo el trabajo, van en FP16 en los tensor cores, acumulando en FP32. El softmax, la pérdida, las normalizaciones y las sumas largas van en FP32. En PyTorch, torch.autocast lo decide operación por operación.

  3. 3

    Una escala para la pérdida

    Antes de la backpropagation, la pérdida se multiplica por un número grande: al principio, 65,536 (2¹⁶). Por la regla de la cadena, todos los gradientes salen multiplicados por lo mismo y se alejan de la zona donde serían 0. Antes de actualizar, se dividen entre la escala, ya en FP32.

Un gradiente que se salva con la escalaLa cuenta
Gradiente real
0.00000001
10⁻⁸
En FP16, sin escalael más chico de FP16 es ≈ 6 × 10⁻⁸: se perdió
10⁻⁸ se redondea
0
Con la escalala pérdida × 65,536: el gradiente sale multiplicado igual, y esto sí cabe en FP16
10⁻⁸ × 65,536
≈ 0.000655
De vuelta, en FP32se divide antes de actualizar
0.000655 ÷ 65,536
≈ 0.9997 × 10⁻⁸
Resultado
Sin escala, el gradiente se pierde. Con escala, llega con un error de 0.03 %.
python
import struct

def fp16(x):
    try:
        return struct.unpack(">e", struct.pack(">e", x))[0]
    except OverflowError:
        return float("inf")                    # en la GPU, lo que no cabe se vuelve inf

gradiente = 1e-8                               # un gradiente chiquito, calculado en FP16
print("sin escala:", fp16(gradiente))
escala = 65_536                                # la escala inicial de GradScaler
g16 = fp16(gradiente * escala)                 # la pérdida se multiplicó antes del backward
print("con escala, en FP16:", g16)
print("de vuelta, dividido entre la escala:", g16 / escala)

grande = 4.0                                   # un gradiente normalito
print("4 × 65,536 en FP16:", fp16(grande * escala), "→ se salta el paso y la escala baja a 32,768")
Salida
sin escala: 0.0
con escala, en FP16: 0.0006551742553710938
de vuelta, dividido entre la escala: 9.997165761888027e-09
4 × 65,536 en FP16: inf → se salta el paso y la escala baja a 32,768

La última línea es el otro lado de la escala: si es demasiado grande, un gradiente normal se desborda. Lo vigila una pieza de PyTorch llamada GradScaler: si algún gradiente sale inf o NaN, se salta ese paso (no toca los pesos) y baja la escala a la mitad. Si pasan 2,000 pasos sin problemas, la vuelve a duplicar. Así la escala se ajusta sola, tan alta como se pueda sin desbordar.

Un paso de entrenamiento en precisión mixta · Toca cada caja para ver por qué está ahí, o usa «Recorrer» para seguir un paso completo y uno que se desborda.
  • Pesos maestros · FP32 · 4 B cada uno
    La copia de verdad de cada peso, en FP32. Con ella, cualquier cambio chico cuenta: 1 + 0.0001 sí da 1.0001. Es la que se guarda en el checkpoint.
  • Optimizador · suma el cambio en FP32
    El optimizador (en C01, Adam) calcula cuánto mover cada peso y se lo suma a los pesos maestros. Su estado, los promedios m y v, también vive en FP32.
  • Copia en FP16 · 2 B, solo para calcular
    Al empezar cada paso, los pesos se convierten a FP16: la mitad de bytes para leer y multiplicar. Es una copia de trabajo, no la que se guarda.
  • Forward · convs y matmuls en FP16
    Las convoluciones y multiplicaciones de matrices, casi todo el trabajo, van en FP16 en los tensor cores (en una T4, ≈ 8 veces más rápido que en FP32), acumulando las sumas en FP32. torch.autocast decide qué va en 16 bits y qué en 32.
  • Backward · gradientes en FP16
    La backpropagation calcula los gradientes en FP16. Como la pérdida venía multiplicada por la escala, un gradiente real de 10⁻⁸ sale como ≈ 0.000655, que FP16 sí guarda.
  • Pérdida × escala · en FP32 · ×65,536
    La pérdida y el softmax se calculan en FP32. Después la pérdida se multiplica por la escala (65,536 al principio) para que los gradientes salgan grandes y no se redondeen a 0.
  • ¿Hay inf o NaN? · ÷ escala, en FP32
    El GradScaler divide los gradientes entre la escala, en FP32, y revisa que ninguno sea inf o NaN. Si hay uno, se salta el paso y la escala baja a la mitad; tras 2,000 pasos sin problemas, se duplica.

Con BF16 la historia es más corta. Tiene el exponente de FP32, así que un gradiente de 10⁻⁸ cabe y no hace falta la escala. La copia maestra en FP32 sí hace falta: en BF16, ni siquiera 1 + 0.001 mueve el 1. Así se entrenan los LLMs: precisión mixta con BF16 y sin GradScaler.

¿Ahorra memoria? Menos de lo que parece. En la lección «Cómo aprende un modelo» viste que, con Adam en precisión mixta, cada parámetro ocupa ≈ 16 bytes: 2 del peso en 16 bits, 2 de su gradiente, 4 de la copia maestra y 8 de los dos promedios de Adam. Lo que baja a la mitad son las activaciones; el premio grande es la velocidad. C01 hace así su fine-tune mensual, con AMP.

En código, la precisión mixta son pocas líneas. Es una ilustración, sin salida, porque pide PyTorch y una GPU:

python
scaler = torch.amp.GradScaler("cuda")            # la escala empieza en 65,536
for fotos, etiquetas in datos:
    optimizador.zero_grad()
    with torch.autocast(device_type="cuda", dtype=torch.float16):
        perdida = criterio(modelo(fotos), etiquetas)   # convs en FP16; la pérdida, en FP32
    scaler.scale(perdida).backward()           # pérdida × escala, y la backpropagation
    scaler.step(optimizador)                   # divide entre la escala; con inf o NaN, se salta el paso
    scaler.update()                            # ajusta la escala para el siguiente paso

Si se entrena en BF16, el autocast lleva dtype=torch.bfloat16 y el scaler sobra.

06

Cuantización: enteros con una escala

Esencial

Quantization: integers with a scale

FP16 corta los bytes a la mitad. Para bajar a la cuarta parte o menos se usan enteros. Guardar los números de un modelo con menos bits se llama cuantización (quantization), y la versión más común es INT8: cada número se guarda como un entero de 8 bits, más una escala que comparte todo el grupo.

valor ≈ escala × entero

Para un grupo de números, por ejemplo los pesos de una capa:

  1. 1

    Elige la escala

    Que el valor más grande del grupo, sin importar el signo, caiga en el entero más grande: escala = máximo ÷ 127. Se llama cuantización simétrica, porque usa los enteros de −127 a 127.

  2. 2

    Cuantiza

    Divide cada número entre la escala y redondea al entero más cercano: entero = redondear(valor ÷ escala).

  3. 3

    Para usarlo, descuantiza

    Multiplica de vuelta: valor ≈ entero × escala. Si el número estaba dentro del rango, el error es, a lo mucho, medio escalón: escala ÷ 2.

Cuantizar a INT8 una capa con pesos de menos 2.5 a 2.5. Arriba, la recta de menos 2.5 a 2.5 con 255 escalones de 0.0197 cada uno y los cinco pesos marcados: menos 2.5, menos 0.73, 0.1, 1.26 y 2.04. En medio, un acercamiento entre 0 y 0.2 con los escalones 0 a 10: el peso 0.1 cae en el escalón 5, que vale 0.0984, a 0.0016 del valor real. Abajo, la misma capa con un peso atípico de 25: los escalones miden 0.197, diez veces más, y entre 0 y 0.4 solo hay tres, el 0, el 1 y el 2. El 0.1 salta al escalón 1 y vuelve como 0.197, con un error de 97 por ciento. INT8: CADA PESO CAE EN UN ESCALÓN La capa: pesos de −2.5 a 2.5 255 escalones de 2.5 ÷ 127 ≈ 0.0197 −2.5 −0.73 0.1 1.26 2.04 −2.5 0 2.5 Acercamiento entre 0 y 0.2 0 1 2 3 4 5 6 7 8 9 10 0.1 El 0.1 cae en el escalón 5: 5 × 0.0197 = 0.0984. Error: 0.0016, menos de medio escalón. Con un peso atípico de 25 escalones de 25 ÷ 127 ≈ 0.197: diez veces más anchos 0 1 = 0.197 2 0.1 salta El 0.1 vuelve como 0.197: un error de 97 %.
Arriba, los cinco pesos sobre los 255 escalones de la capa. En medio, un acercamiento entre 0 y 0.2: el 0.1 cae en el escalón 5. Abajo, la misma capa con un peso de 25: los escalones son 10 veces más anchos y el 0.1 brinca al escalón 1, que vale 0.197.

Un solo valor atípico arruina la escala

Ahora agrega a esa capa un solo peso de 25. La escala pasa a 25 ÷ 127 ≈ 0.197: diez veces más gruesa. El mismo 0.1 se guarda como redondear(0.508) = 1 y vuelve como 0.197, con un error de 97 %. Un valor extremo le quitó resolución a todos los demás. Casi todo el arte de cuantizar es elegir escalas que no desperdicien los 8 bits:

  • Una escala por canal (per-channel): en una convolución, cada filtro tiene sus propios pesos y su propia escala, en vez de una sola para toda la capa. Es lo normal para los pesos.
  • Recortar los extremos: elegir el rango con un percentil en vez del máximo, y aceptar que los poquísimos valores de afuera se saturen.
  • Dejar sin cuantizar las capas delicadas: a menudo, la primera y la última, que se quedan en FP16.
Cuantizar un númeroCalculadora

En la cuantización simétrica, escala = rango ÷ el entero más grande y entero = redondear(valor ÷ escala). Para usarlo, valor ≈ entero × escala. Empieza con el ejemplo de la lección: el 0.1 de una capa cuyos pesos van de −2.5 a 2.5, en INT8.

Entero más grande que se usa127 en INT8 · 7 en INT4127
Escalalo que vale cada escalón0.0197
Entero guardadosi el valor se sale del rango, se queda en el más grande5
Valor de vueltaentero × escala0.0984
Errora lo mucho media escala, si el valor está dentro del rango0.0016
Error en proporción al valor1.6 %
Bytes por númeromás la escala, que comparte todo el grupo1.00

Sube el rango a 25, como si la capa tuviera un solo peso atípico: el mismo 0.1 vuelve como 0.1969, casi el doble. Regresa el rango a 2.5 y baja a 4 bits: el 0.1 se vuelve 0. Por eso INT4 lleva una escala por cada grupo chico de pesos.

Prueba también los otros pesos del ejemplo (−0.73, 1.26 y 2.04): con el rango en 2.5, ninguno queda a más de medio escalón.

Calibrar: medir los rangos antes de cuantizar

Los pesos están fijos, así que su rango se mide una vez. Las activaciones cambian con cada foto. Para elegir sus escalas se calibra: se pasan por el modelo unos cientos de ejemplos reales y se anota el rango de cada capa.

Hay dos maneras de llegar a un modelo cuantizado:

  • PTQ (post-training quantization, cuantización después del entrenamiento): cuantizas el modelo ya entrenado, con la calibración. Tarda de minutos a horas.
  • QAT (quantization-aware training, entrenamiento que sabe que lo van a cuantizar): entrenas o haces fine-tuning simulando el redondeo en el forward, para que el modelo aprenda a tolerarlo. Da mejor calidad, pero son días de trabajo y un pipeline más que mantener.

La regla de la Ronda: primero PTQ, siempre. QAT, solo si PTQ no pasa el examen y el INT8 de verdad compra algo.

El punto cero, INT4 y FP8 están en la sección extra «Cuantizar a fondo».

07

Memoria del modelo

Esencial

Model memory

La cuenta que más vas a hacer en la Ronda es una multiplicación:

memoria de los pesos = parámetros × bytes por parámetro

La ResNet-50 de C01, en cada formatoLa cuenta
Parámetroscon su capa final de 500 categorías
24,532,532
≈ 24.5 M
FP32
24.5 M × 4 B
≈ 98 MB
FP16 o BF16
24.5 M × 2 B
≈ 49 MB
INT8más las escalas
24.5 M × 1 B
≈ 24.5 MB
INT4
24.5 M × 0.5 B
≈ 12 MB
Lo que midió la RondaM08: el archivo ONNX en FP32 contra INT8
98 MB → 25.1 MB
≈ 3.9 veces menos
Resultado
En una T4 de 16 GB, hasta en FP32 la ResNet-50 ocupa 0.6 % de la memoria. En una CNN, la memoria de los pesos casi nunca es el problema.

En un LLM es lo contrario. Un modelo de 70B (70 mil millones de parámetros) pesa 280 GB en FP32, 140 GB en FP16 o BF16, 70 GB en INT8 o FP8 (un punto flotante de 1 byte) y 35 GB en INT4. Con los 70,600 millones de Llama 3 70B, el que usa la Ronda, son ≈ 141 GB en BF16 y ≈ 70.6 GB en FP8. Una H100 tiene 80 GB: en BF16 no cabe; en FP8 caben los pesos, pero casi no queda lugar para el KV cache; en INT4 (≈ 35 GB, más ≈ 1.1 GB de escalas por grupo) sobra espacio.

Cuánto pesa un modeloCalculadora

Memoria de los pesos = parámetros × bytes por parámetro. Elige el tipo de dato y la GPU. Arranca con la ResNet-50 de C01 en FP32.

Bytes por parámetrolos bits entre 84.0
Bits por parámetro32
Memoria de los pesos98.0 MB
Parte de la GPU que ocupanmás de 100 %: no cabe en una0.6 %
Copias del modelo que caben en una GPUsolo los pesos: falta el runtime, las activaciones y el KV cache163
GPUs necesarias solo para los pesosel mínimo; en la práctica, más1

Escribe 70600 con la GPU en 80 y recorre los cuatro tipos: en BF16, 141 GB no caben en una H100; en FP8, 70.6 GB caben, pero casi no queda lugar; en INT4, ≈ 35 GB dejan margen. Con los 24.5 de C01, la memoria nunca es el problema.

Prueba también Llama 3 8B (8030) en una L4 de 24 GB: en BF16, los pesos solos ya ocupan dos terceras partes.

Menos bytes no siempre es más velocidad, y cada cambio de formato mueve un poco las respuestas: por eso ningún modelo convertido sale sin examen (M08). Las dos cosas, con números, están en la sección extra que sigue.

Los tipos de dato en el camino de C01 · Del checkpoint a la respuesta: qué formato tiene cada cosa. Usa «Recorrer» para seguir los pesos, la foto de Ana o una cuantización a INT8.
  • Checkpoint · pesos FP32 · ≈ 98 MB
    Los pesos maestros que dejó el entrenamiento en precisión mixta, en FP32: 24,532,532 parámetros × 4 bytes ≈ 98 MB. Es la versión de referencia contra la que se compara todo.
  • Export a ONNX · FP32 · ≈ 98 MB
    El modelo exportado a ONNX, un formato que entienden muchos servidores. Sigue en FP32 y pasa una primera paridad en CPU, con ONNX Runtime, antes de seguir.
  • Engine TensorRT · FP16 · ≈ 49 MB
    TensorRT convierte el modelo para la GPU exacta donde va a correr, la T4, y en FP16: ≈ 49 MB de pesos. Se construye en CI con trtexec --fp16 y viaja horneado en la imagen de Triton.
  • INT8 en CPU · por medir · con calibración
    Una opción que la Ronda deja por medir: la ResNet-50 en INT8 en CPU, que podría costar menos que las dos T4 si no pierde más de 0.5 puntos. Pide calibrar con fotos reales y pasar el mismo examen.
  • Examen · paridad y calidad
    La prueba de paridad contra PyTorch FP32 en ≈ 10,000 fotos: acuerdo del top-1 ≥ 99.5 % y del top-5 ≥ 99 %, con diferencias de logits de ≈ 0.01. Y la calidad: top-1 y top-5 a no más de 0.3 puntos del modelo actual, revisada también por categoría y por slice.
  • App de Ana · foto en JPEG
    La app manda la foto comprimida (≈ 150–250 KB en JPEG) y recibe las 5 categorías sugeridas, con su probabilidad.
  • Gateway · tensor FP32 · 602 KB
    Decodifica el JPEG a píxeles uint8, achica, recorta y normaliza a float32: 150,528 números × 4 B = 602 KB. Mandar uint8 (≈ 151 KB) y normalizar dentro del modelo es una mejora que la Ronda deja anotada.
  • Triton en la T4 · corre el engine FP16
    Triton corre el engine en FP16 con los tensor cores, en ≈ 3–6 ms de GPU. Su configuración declara la entrada y la salida como TYPE_FP32: la conversión a FP16 pasa adentro.
08

Cuantizar a fondo: punto cero, INT4, FP8 y el examen

Extra

Quantization in depth: zero point, INT4, FP8 and the exam

Con INT8 y su escala ya tienes lo esencial. Aquí van las piezas que la Ronda usa en M08 y M14: el punto cero, INT4, FP8, la velocidad que de verdad se gana y el examen que pasa todo modelo convertido.

El punto cero, para rangos que no están centrados

Si los números no están repartidos alrededor del 0, la versión simétrica desperdicia enteros. Después de una ReLU nada es negativo: con un rango de 0 a 6, la escala simétrica es 6 ÷ 127 ≈ 0.047 y la mitad negativa de los enteros nunca se usa. La cuantización asimétrica usa todos los enteros para el rango real, del mínimo al máximo, y agrega un punto cero (zero point): el entero que representa al 0 de verdad.

valor ≈ escala × (entero − punto cero)

INT4: dieciséis valores y una escala por grupo

INT4 usa 4 bits: solo 16 enteros, de −8 a 7 (la versión simétrica usa de −7 a 7). Es medio byte por número, y con una sola escala por capa no alcanza. En la capa del ejemplo, la escala sería 2.5 ÷ 7 ≈ 0.357, y el 0.1 se guardaría como redondear(0.28) = 0: desaparece.

La solución es una escala para cada grupo chico de pesos seguidos, por ejemplo cada 128: en un grupo chico el rango es más angosto y los escalones, más finos. Cuesta algo: si cada escala ocupa 2 bytes (en FP16), son 16 bits por cada 128 pesos, 0.125 bits más por peso: 4.125 bits por peso en vez de 4.

En los LLMs, INT4 casi siempre es weight-only («solo pesos»): los pesos se guardan en INT4 y se convierten de vuelta a 16 bits (BF16 o FP16) al vuelo, justo antes de multiplicar. Los métodos más usados se llaman AWQ y GPTQ (M14).

python
pesos = [0.1, -0.73, 1.26, 2.04, -2.5]

def cuantizar(xs, bits=8):
    qmax = 2 ** (bits - 1) - 1                 # 127 en INT8, 7 en INT4
    escala = max(abs(x) for x in xs) / qmax    # simétrica: el más grande cae en ±qmax
    enteros = [round(x / escala) for x in xs]
    return escala, enteros, [q * escala for q in enteros]

for nombre, xs, bits in (("INT8", pesos, 8), ("INT8 con un 25", pesos + [25.0], 8), ("INT4", pesos, 4)):
    escala, enteros, vuelta = cuantizar(xs, bits)
    error = max(abs(v - x) for v, x in zip(vuelta, xs))
    print(f"{nombre}: escala {escala:.4f} · enteros {enteros}")
    print(f"   de vuelta {[round(v, 4) for v in vuelta]}")
    print(f"   error máximo {error:.4f} · el 0.1 quedó en {vuelta[0]:.4f}")
Salida
INT8: escala 0.0197 · enteros [5, -37, 64, 104, -127]
   de vuelta [0.0984, -0.7283, 1.2598, 2.0472, -2.5]
   error máximo 0.0072 · el 0.1 quedó en 0.0984
INT8 con un 25: escala 0.1969 · enteros [1, -4, 6, 10, -13, 127]
   de vuelta [0.1969, -0.7874, 1.1811, 1.9685, -2.5591, 25.0]
   error máximo 0.0969 · el 0.1 quedó en 0.1969
INT4: escala 0.3571 · enteros [0, -2, 4, 6, -7]
   de vuelta [0.0, -0.7143, 1.4286, 2.1429, -2.5]
   error máximo 0.1686 · el 0.1 quedó en 0.0000

FP8: un byte que conserva un exponente

¿Se puede bajar a 8 bits sin dejar de ser punto flotante? Sí: es FP8, que existe en dos versiones, propuestas en 2022 por investigadores de NVIDIA, Arm e Intel:

VersiónSigno · exponente · mantisaEl más grandeSeparación entre 1 y 2Para qué
E4M31 · 4 · 34480.125Pesos y activaciones
E5M21 · 5 · 257,3440.25Gradientes, que necesitan más rango

El nombre dice el reparto: E4M3 es «exponente de 4 bits, mantisa de 3». La figura de los números que no están parejos era justo E5M2.

El número más grande de E4M3La cuenta
Exponente más grande4 bits en unos = 15, menos el sesgo de 7
15 − 7
8
Mantisa más grandela 111 se reserva para NaN
1.110₂ = 1 + 1/2 + 1/4
1.75
El más grande
1.75 × 2⁸
448
Resultado
Con 3 bits de mantisa, entre 1 y 2 solo hay 8 números: 1, 1.125, 1.25… hasta 1.875. Es ≈ 1 cifra decimal.

Con un rango tan corto, FP8 casi nunca se usa solo: cada tensor lleva una escala, un número en FP32 que lo agranda o lo achica para que su valor más grande caiga cerca del máximo antes de convertirlo. Es la misma idea de la escala de INT8. La diferencia con INT8 es que FP8 conserva un exponente: sus escalones son finos cerca del 0 y gruesos lejos de él, así que aguanta mejor los valores atípicos (M08).

¿Dónde existe? En las GPUs Ada (L4, L40S) y Hopper (H100), y no en la A100, la A10G ni la T4 (M04). La generación Blackwell (B200) ya agrega FP4. En la Ronda, C10 sirve su LLM de 70,600 millones de parámetros con los pesos en FP8, y M14 explica que el KV cache también puede ir en FP8: de 320 KiB por token baja a 160 KiB, y caben el doble de conversaciones a la vez.

Menos bytes, ¿más velocidad?

A veces. En una CNN con varias fotos por batch, lo que más tarda son las cuentas, no leer los pesos (la lección de FLOPs lo muestra capa por capa), así que el techo lo pone cuántas operaciones INT8 por segundo hace el chip contra las de FP16: en la T4, 130 billones contra 65 billones, el doble como máximo. La Ronda (M08) lo midió con la ResNet-50 de C01 en CPU, de FP32 a INT8, con 4 veces menos bytes: fue ≈ 2 veces más rápida con una foto a la vez y ≈ 3.5 veces con 8, no 4. Además, no todo se cuantiza, cada cambio entre INT8 y FP16 cuesta una conversión, y el preprocesamiento y la red siguen igual.

En un LLM que genera texto pasa otra cosa. Para cada token nuevo, la GPU relee todos los pesos, y casi todo el tiempo se va en leer, no en calcular: está limitado por la memoria (memory-bound), como explica la lección de FLOPs. Ahí, menos bytes son casi directo más tokens: Llama 3 8B en una L4, que lee 300 GB por segundo, no pasa de ≈ 19 tokens por segundo en BF16 (16 GB de pesos), ≈ 37 en INT8 y ≈ 52 en INT4 (≈ 5.7 GB), según M04.

El examen de un modelo convertido

Cada cambio de formato mueve un poco cada número, y miles de millones de cuentas encadenadas mueven los resultados. En la lección «Qué es un modelo» viste que pasar a FP16 cambia los logits del orden de 0.01: en una foto casi empatada, con Tenis 2.31 y Botas 2.30, eso basta para que cambie el argmax.

Por eso la Ronda (M08) no deja pasar un modelo convertido sin un examen. En C01, el engine de TensorRT en FP16 y el modelo original de PyTorch en FP32 responden las mismas ≈ 10,000 fotos del golden set, con el preprocesamiento del gateway:

El examen del engine FP16 de C01La cuenta
Fotos del examendel golden set
—
≈ 10,000
Acuerdo mínimo del top-1que los dos den la misma categoría
99.5 % de 10,000
9,950 fotos
Fotos que pueden cambiar su top-1
10,000 − 9,950
≤ 50
Acuerdo mínimo del top-5
—
99 %
Diferencia máxima de logitsse reporta; entre dos FP32 sería ≈ 0.0001, y órdenes de magnitud más es un bug
engine FP16 contra PyTorch FP32
≈ 0.01
Calidadtop-1 y top-5 en el golden set, contra el modelo actual
diferencia con el actual
≥ −0.3 puntos
Resultado
La paridad dice que el engine se porta como el modelo que evaluaste. La calidad, medida por categoría y por slice, dice que nadie salió perdiendo.

Para INT8 el acuerdo esperado es menor, y su umbral se fija con datos. Y ahí el promedio engaña. M08 lo muestra con un ejemplo ilustrativo: si el 80 % de las fotos (las categorías grandes) pierde 0.1 puntos y el 20 % de la cola larga pierde 1.1, el total baja 0.8 × 0.1 + 0.2 × 1.1 = 0.3 puntos, justo en la orilla del examen, aunque la cola perdió 11 veces más. Una categoría chica, con ≈ 60 fotos en el golden set, puede perder 5 puntos sin mover el total. Por eso el examen de INT8 mira el recall por categoría y por slice, no solo el promedio.

09

Errores comunes

Esencial

Common mistakes

1. El overflow de FP16

Un modelo que funciona en FP32 da inf o NaN en FP16: un softmax sin restar el máximo, una suma de cuadrados, una activación atípica de un transformer. Nada avisa hasta que la pérdida sale nan o las respuestas se vuelven basura.

Cómo evitarlo: para entrenar, precisión mixta con autocast (que deja en FP32 lo delicado) o BF16. Para servir, una prueba de paridad contra FP32 antes de publicar, y revisar que las salidas no traigan inf ni NaN. Un LLM entrenado en BF16 no se pasa a FP16 sin probarlo con el golden set.

2. Comparar números de punto flotante con ==

0.1 + 0.2 == 0.3 es falso, y una prueba que exige logits idénticos entre dos versiones del modelo falla siempre, o pasa por suerte.

Cómo evitarlo: comparar con tolerancia (math.isclose, torch.allclose), con una tolerancia que corresponda al formato: ≈ 0.0001 en FP32, ≈ 0.01 en FP16. Y para decidir si dos modelos «responden igual», medir el acuerdo del top-1, como la paridad de M08.

3. Olvidar que cuantizar cambia las salidas

«Es el mismo modelo, solo que en INT8.» No: cada respuesta cambia un poco, algunas cambian de categoría y la confianza de todas se mueve, y con ella cualquier umbral, como el de C01 que manda a etiquetar las fotos cuyo top-1 queda debajo de 0.3. La Ronda señala una trampa: trtexec --int8 (la herramienta de TensorRT que arma y mide engines) sin datos de calibración arma un engine que corre rápido, pero con escalas de relleno: sirve para medir la velocidad, y sus predicciones no valen nada.

Cómo evitarlo: calibrar con datos reales de producción y pasar el mismo examen que un modelo nuevo: paridad, calidad por categoría y por slice, y la ECE.

4. Confundir bits y bytes, o parámetros y bytes

INT4 son 4 bits: medio byte, no 4 bytes. Un modelo «8B» tiene 8 mil millones de parámetros: en BF16 pesa ≈ 16 GB, no 8. Y la red se mide en bits por segundo (Gb/s) y la memoria en bytes (GB): un factor de 8 que se pierde fácil.

Cómo evitarlo: escribir las unidades en cada paso de la cuenta (parámetros × bytes por parámetro = bytes) y revisar que la b chica sea bit y la B grande, byte.

5. Hacer cuentas con píxeles en uint8

Sumar, restar o promediar fotos en uint8 da la vuelta sin avisar: 250 + 10 = 4 y 10 − 20 = 246. Las fotos salen con manchas negras en lo blanco y blancas en lo negro.

Cómo evitarlo: convertir a float32 (o a int32) antes de operar, recortar a 0–255 al final y solo entonces volver a uint8. O usar funciones que se saturan, como cv2.add.

6. Mezclar dtypes sin darte cuenta

Un arreglo de NumPy nace en float64. Si lo conviertes en tensor y se lo pasas a un modelo en float32, PyTorch se queja de que los tipos no coinciden; si para callarlo conviertes todo a float64, ocupas el doble de memoria sin ganar nada. Al revés también muerde: las etiquetas de la entropía cruzada, cuando son números de categoría, tienen que ser int64.

Cómo evitarlo: revisar x.dtype en la frontera entre NumPy y PyTorch, convertir a propósito (.float(), .long()) y fijar el dtype en el contrato del servidor. En C01, la configuración de Triton dice TYPE_FP32 para la entrada y para la salida.

10

En la vida real

Extra

In real life

C01: un engine FP16 en una T4

El modelo de C01 se entrena con precisión mixta, se guarda y se exporta en FP32 (el ONNX pesa ≈ 98 MB) y se sirve como un engine de TensorRT en FP16: ≈ 49 MB de pesos en una T4 de 16 GB, cuyos tensor cores hacen ≈ 65 billones de operaciones por segundo en FP16. La entrada que manda el gateway es un tensor float32 de 602 KB, y la salida, 500 logits en float32.

La Ronda deja anotadas dos mejoras que son de esta lección. Una: mandar la entrada en uint8 (150,528 bytes en vez de 602,112) y normalizar dentro del modelo, para que el tráfico de red entre el gateway y la GPU baje 4 veces, de ≈ 2.4 Gb/s a ≈ 0.6 Gb/s en el pico de diseño de 500 fotos por segundo. Dos: medir la ResNet-50 en INT8 en CPU, porque la GPU está al ≈ 5 % en promedio. Si diera ≈ 15 ms por imagen por vCPU (la unidad de CPU que renta la nube; en las máquinas de Intel, medio núcleo) sin perder más de 0.5 puntos en el golden set, podría salir más barata que las dos T4 (≈ US$768 al mes). Para calibrarla, M08 pide de unos cientos a ≈ 1,000 fotos (la guía de NVIDIA, al menos 500 para CNNs), sacadas del tráfico reciente de producción, de todos los slices y con el mismo preprocesamiento del gateway. Es una medición pendiente, no un hecho.

C10: un LLM de 70B en FP8, y por qué no INT4

C10 sirve un modelo de ≈ 70,600 millones de parámetros a varios equipos de una empresa. En BF16 pesaría ≈ 141 GB, y cada réplica necesitaría 4 H100 para tener lugar para el KV cache. En FP8 pesa ≈ 70.6 GB y cada réplica usa 2 H100. vLLM, el programa que lo sirve, toma el 90 % de sus 160 GB: 144. Quitando los 70.6 de pesos y ≈ 5 del runtime, quedan ≈ 68 GB para el KV cache: ≈ 93 conversaciones de 2,250 tokens a la vez. Con 16 réplicas, son 32 H100.

¿Por qué no INT4, con ≈ 35 a 40 GB de pesos en una sola H100 por réplica? La Ronda lo descarta: le dejaría ≈ 30 GB de KV a cada GPU, menos conversaciones por réplica, y INT4 arriesga más calidad que FP8 cuando la memoria ya no es el límite. Y nada de esto lo decide el formato solo: cada versión cuantizada corre los golden sets de cada producto contra la versión en BF16.

C02 y el teléfono: modelos en el edge

En C02, cada centro de distribución tiene su propia caja con una GPU L4 que vigila 40 cámaras con un detector ligero (≈ 10 a 30 millones de parámetros) en TensorRT FP16: de 20 a 60 MB de pesos, y la Ronda estima el engine completo en ≈ 50 a 150 MB. Si algún día pasa a INT8, la Ronda pide calibrar con frames reales de día y de noche, porque las escalas se ajustan a lo que ven. En los equipos más chicos, como los Jetson de NVIDIA, algunos modelos traen además un acelerador que solo corre capas de CNN en INT8 o FP16 (M04).

En un teléfono, el límite es todavía más duro: lo que pesa la app y lo que aguanta la batería. Los chips de IA de los teléfonos (las NPU) trabajan sobre todo en FP16 e INT8, con herramientas como Core ML, de Apple, o LiteRT (antes TensorFlow Lite), de Google. Un modelo de 20 millones de parámetros pesa 40 MB en FP16 y 20 MB en INT8: a veces, cuantizar es lo que decide si cabe.

C07 y C08: los embeddings en int8

Cuantizar no es solo para pesos. En la lección de embeddings viste que C07 guarda 100 millones de vectores de 512 números: ≈ 205 GB en float32 y ≈ 51.2 GB en int8. C08, con vectores de 128, baja a 12.8 GB. Para el orden final, C07 recalcula la distancia de sus mejores candidatos con los vectores completos: el int8 sirve para buscar rápido, y la precisión se recupera donde importa.

Contadores, cohetes y relojes

Los contadores también dan la vuelta. Mira un contador int32 de las fotos de C01:

Un contador int32 de las fotos de C01La cuenta
Fotos por segundo, en promedio≈ 10 millones al día: ≈ 2.5 M publicaciones con ≈ 3 fotos, más otros equipos
10,000,000 ÷ 86,400 s
≈ 116
El int32 más grande
2³¹ − 1
2,147,483,647
Días hasta llegar
2,147,483,647 ÷ 116 ÷ 86,400
≈ 214 días
Con uint32sin signo: el doble
4,294,967,295 ÷ 116 ÷ 86,400
≈ 429 días
Con int64
9.2 × 10¹⁸ ÷ 116 ÷ 86,400 ÷ 365
≈ 2,500 millones de años
Resultado
En unos 7 meses, el contador int32 salta a −2,147,483,648 y la gráfica muestra fotos negativas. Con int64 no pasa nunca: por eso los contadores se guardan en 64 bits.

No es teoría. En diciembre de 2014, YouTube anunció que el video Gangnam Style había pasado de 2,147,483,647 vistas, el tope de un int32, y que había tenido que cambiar su contador a 64 bits. En 1996, el primer cohete Ariane 5 se autodestruyó unos 40 segundos después de despegar: su software convirtió un número con decimales en un entero de 16 bits con signo (tope: 32,767), el número no cabía y el programa de navegación se detuvo. Y muchos sistemas cuentan el tiempo en segundos desde el 1 de enero de 1970 con un int32: ese contador se llena el 19 de enero de 2038 a las 03:14:07 (hora UTC), el «problema del año 2038».

Un contador en float32 ni siquiera da la vuelta: se atora en 16,777,216, el último entero exacto de FP32.

Un contador de fotos guardado en float32La cuenta
Fotos por segundoel promedio de C01
10,000,000 ÷ 86,400
≈ 116
El último entero seguro de FP32
2²⁴
16,777,216
Horas hasta llegar
16,777,216 ÷ 116 ÷ 3,600
≈ 40 horas
Una foto másya no se mueve
16,777,216 + 1
16,777,216
Años hasta atorarse en FP64como los contadores de Prometheus: 53 bits de dígitos
2⁵³ ÷ 116 ÷ 86,400 ÷ 365
≈ 2.5 millones
Resultado
Un contador en float32 se atora en menos de dos días, sin ningún error. Contar es trabajo de enteros, o de FP64.

Y un error de redondeo diminuto, sumado millones de veces, también puede costar caro:

Fuera de ML: el dinero, la hoja de cálculo y Pac-Man

Los bancos y las tiendas no guardan el dinero en punto flotante: 0.1 + 0.2 pesos no puede dar 0.30000000000000004. Lo guardan en centavos, con enteros, o con tipos decimales exactos. Las hojas de cálculo como Excel guardan los números en FP64 y conservan a lo mucho 15 cifras: si escribes como número una tarjeta de 16 dígitos, el último se vuelve 0. Y en el Pac-Man original, el nivel 256 sale con media pantalla de basura: el contador del nivel es un solo byte, y ahí se desborda.

11

Ejercicios

Esencial

Exercises

Resuélvelos con lápiz antes de abrir la solución. Los de rangos, cuantización y memoria se pueden comprobar con las calculadoras.

  1. 1Fácil

    Escribe 77 en binario, con 8 bits. Después: ¿qué número es 1010 1010 como uint8 y como int8?

    Ver la solución paso a paso
    • 77: la posición de 64 cabe (quedan 13); 32 y 16 no; 8 cabe (quedan 5); 4 cabe (queda 1); 2 no; 1 sí. 0100 1101. Comprueba: 64 + 8 + 4 + 1 = 77.
    • 1010 1010 como uint8: 128 + 32 + 8 + 2 = 170.
    • Como int8, el primer bit vale −128: −128 + 32 + 8 + 2 = −86. Es lo mismo que 170 − 256.
  2. 2Fácil

    ¿Cabe 40,000 en un int16? ¿Y en un uint16? ¿Cuánto ocupa un millón de números int16?

    Ver la solución paso a paso
    • int16 va de −32,768 a 32,767: no cabe. Si sale de una cuenta en un arreglo int16 de NumPy (por ejemplo, 20,000 + 20,000), da la vuelta: 40,000 − 65,536 = −25,536.
    • uint16 va de 0 a 65,535: sí cabe.
    • Un millón de int16: 1,000,000 × 2 bytes = 2 MB.
  3. 3Fácil

    En uint8, ¿cuánto da 200 + 70? ¿Y 30 − 50? ¿Qué darían si se saturaran?

    Ver la solución paso a paso
    • 200 + 70 = 270, que no cabe: 270 − 256 = 14. Saturado: 255.
    • 30 − 50 = −20, que tampoco: −20 + 256 = 236. Saturado: 0.
    • Por eso las cuentas con píxeles se hacen en float32 o en int32, y se recorta al final.
  4. 4Fácil

    El detector YOLOv8m tiene ≈ 25.9 millones de parámetros. ¿Cuánto pesan sus pesos en FP32, en FP16 y en INT8?

    Ver la solución paso a paso
    • FP32: 25.9 M × 4 B ≈ 103.6 MB.
    • FP16: 25.9 M × 2 B ≈ 51.8 MB; la Ronda (M04) dice ≈ 52 MB.
    • INT8: 25.9 M × 1 B ≈ 25.9 MB, más las escalas. Del tamaño de la ResNet-50.
  5. 5Medio

    Estos 16 bits son un FP16: 0 10010 0101000000. ¿Qué número es?

    Ver la solución paso a paso
    • Signo 0: positivo.
    • Exponente: 10010₂ = 16 + 2 = 18. Menos el sesgo de FP16, que es 15: 3.
    • Mantisa: 1.0101₂ = 1 + 1/4 + 1/16 = 1.3125.
    • Valor: 1.3125 × 2³ = 1.3125 × 8 = 10.5.
  6. 6Medio

    Una capa tiene pesos entre −4 y 4. Con INT8 simétrico, ¿cuál es la escala? ¿Cómo se guardan 1.0 y 0.03, cómo vuelven y qué error tiene cada uno en proporción?

    Ver la solución paso a paso
    • Escala: 4 ÷ 127 ≈ 0.0315.
    • 1.0 ÷ 0.0315 = 31.75 → 32; de vuelta, 32 × 0.0315 ≈ 1.0079: error de 0.0079, 0.8 %.
    • 0.03 ÷ 0.0315 = 0.95 → 1; de vuelta, ≈ 0.0315: error de 0.0015, 5 %.
    • El error de los dos está debajo de media escala (≈ 0.0157), pero en proporción el número chico sale mucho peor. Por eso importan las escalas por canal o por grupo.
  7. 7Medio

    De la sección extra de precisión mixta: con la escala de pérdida en 65,536, un gradiente real de 3 sale escalado en el backward en FP16. ¿Qué pasa? Si el mismo gradiente se repitiera, ¿cuántos pasos se saltaría GradScaler antes de que quepa?

    Ver la solución paso a paso
    • 3 × 65,536 = 196,608, más que 65,504: inf. GradScaler salta el paso y baja la escala a 32,768.
    • 3 × 32,768 = 98,304: otra vez inf. Salta otro paso; la escala baja a 16,384.
    • 3 × 16,384 = 49,152: cabe. Se saltaron 2 pasos, y los pesos no se tocaron en ninguno.
  8. 8Medio

    Llama 3 8B tiene ≈ 8.03 mil millones de parámetros. ¿Cuánto pesa en BF16? Una L4 lee 300 GB por segundo de su memoria y, para cada token nuevo, relee todos los pesos: ¿cuántos tokens por segundo da como máximo? ¿Y en INT8?

    Ver la solución paso a paso
    • BF16: 8.03 × 10⁹ × 2 B ≈ 16.06 GB.
    • Máximo: 300 ÷ 16.06 ≈ 18.7, unos 19 tokens por segundo por conversación.
    • INT8: 8.03 GB, y 300 ÷ 8.03 ≈ 37 tokens por segundo: el doble, porque se leen la mitad de bytes. Son los números de M04.
  9. 9Reto

    Un LLM de 70,600 millones de parámetros va en INT4, con grupos de 128 pesos y una escala FP16 por grupo. (a) ¿Cuántos bits ocupa cada peso, contando su parte de la escala? (b) ¿Cuántos GB pesan los pesos? (c) ¿Caben en una H100 de 80 GB, si vLLM usa el 90 % de la memoria? ¿Y en BF16?

    Ver la solución paso a paso
    • (a) 4 bits + 16 bits ÷ 128 = 4 + 0.125 = 4.125 bits, es decir, 0.515625 bytes.
    • (b) 70.6 × 10⁹ × 0.515625 B ≈ 36.4 GB.
    • (c) vLLM usa 0.9 × 80 = 72 GB; quedan 72 − 36.4 ≈ 35.6 GB para el runtime y el KV cache: sí cabe, con margen. En BF16 son ≈ 141 GB: no caben en una, y en dos (144 GB útiles) apenas entran los pesos, sin lugar para el KV cache.
  10. 10Reto

    Diseña el contador de fotos de C01. Al pico de diseño, 500 fotos por segundo sin parar: ¿en cuántos días da la vuelta un int32? ¿Y un uint32? ¿En cuántas horas se atora un float32 (lo explica la sección extra «Redondeo»)? ¿Qué tipo eliges?

    Ver la solución paso a paso
    • int32: 2,147,483,647 ÷ 500 ÷ 86,400 ≈ 49.7 días.
    • uint32: 4,294,967,295 ÷ 500 ÷ 86,400 ≈ 99.4 días.
    • float32: 16,777,216 ÷ 500 ≈ 33,554 segundos ≈ 9.3 horas.
    • Elijo int64: 9.2 × 10¹⁸ ÷ 500 ÷ 86,400 ÷ 365 ≈ 585 millones de años. O FP64, como Prometheus: 2⁵³ ÷ 500 ÷ 86,400 ÷ 365 ≈ 570,000 años de enteros exactos.
  11. 11Reto

    La paridad del engine FP16 de C01 da 99.2 % de acuerdo en el top-1, con 10,000 fotos, y el examen pide al menos 99.5 %. ¿Cuántas fotos cambiaron? ¿Pasa? ¿Qué revisas, y en qué orden?

    Ver la solución paso a paso
    • Cambiaron 0.8 % de 10,000 = 80 fotos. El examen permite 50: no pasa.
    • FP16 en una CNN suele pasar el 99.5 % con holgura, así que sospecha de un bug, no del formato. Primero, el ONNX en FP32 con ONNX Runtime (un programa que corre archivos ONNX, sin TensorRT): si ahí ya falla, el problema está en el export (preprocesamiento distinto, una capa mal convertida, el orden de las categorías).
    • Si el ONNX pasa y el engine no, busca la capa cuyas salidas se separan comparando las intermedias, y déjala en FP32: puede ser un overflow de FP16. Revisa también si la diferencia máxima de logits es ≈ 0.01 o mucho más.
    • Lo que nunca se hace: bajar el umbral para que pase.
12

Lo que sigue

Esencial

What comes next

En la Ronda:

  • M01 · El mapa y la entrevista trae la primera tabla de bytes y avisa que el batch, la precisión (FP16 o INT8) y el ajuste automático del número de máquinas mueven la factura más que la arquitectura del modelo.
  • M04 · Hardware para inferencia arranca con «parámetros × bytes por parámetro» y su tabla de dtypes, con la GPU que soporta cada uno, explica por qué BF16 le ganó a FP16 en los LLMs y suma las activaciones y el KV cache a la memoria de la GPU.
  • M08 · Optimización de modelos cuantiza la ResNet-50 de C01 (98 MB → 25.1 MB), explica la escala por canal, el punto cero, la calibración, PTQ y QAT, y arma el examen: paridad, slices y latencia.
  • M14 · Sistemas con LLMs elige el formato de un LLM en producción: BF16, FP8, INT8 o INT4 weight-only (AWQ, GPTQ), y el KV cache en FP8.
  • Los casos. C01 (engine FP16, entrada en uint8 como mejora, INT8 en CPU por medir), C10 (70B en FP8 sobre 2 H100) y C02 (FP16 en el edge, INT8 calibrado con frames de día y de noche).

En el Dojo: la estación 5, «Export, engine y paridad», es esta lección hecha código: exportar la ResNet-50 a ONNX en FP32, construir el engine FP16 y correr la prueba de paridad. Cuando abra, ahí la vas a practicar. La estación 7, «Empaquetado», ya usa la cuenta de los pesos en FP16 para decidir que el modelo puede ir dentro de la imagen de Docker.

En Sensei: la siguiente lección, «Matrices, FLOPs y qué tan rápido calcula una GPU», usa los bytes de esta lección para saber si un modelo está limitado por las cuentas o por la memoria. Después, «Memoria, caché y disco» y «CPU y GPU» abren la máquina donde viven estos bytes, y «PyTorch en lo que toca a producción» vuelve a los dtypes, a autocast y a la paridad con código de verdad.

13

Palabras que vas a oír

Esencial

Words you will hear

Estos son los conceptos que presentó esta lección. Cada uno tiene su tarjeta con la definición completa.

Binariobinary · base 2La forma de escribir números con solo dos dígitos, 0 y 1, donde cada posición vale el doble que la de su derecha: 1, 2, 4, 8, 16… Es como cuenta una computadora, porque cada bit solo tiene dos estados.Más

En decimal, cada posición vale 10 veces la de su derecha; en binario, 2 veces. Con n bits hay 2ⁿ combinaciones: 256 con 8 bits, un byte. Para escribir un número en binario se recorren las posiciones de la más grande a la más chica y se pone un 1 donde su valor cabe en lo que queda. Después del punto, las posiciones valen 1/2, 1/4, 1/8…: por eso algunos decimales, como 0.1, no tienen una escritura finita en binario. Agrupando los bits de 4 en 4 sale el hexadecimal (0xFF = 255).

Analogía. Un tablero de focos donde cada foco vale el doble que el de su derecha: 128, 64, 32, 16, 8, 4, 2 y 1. El número es la suma de los focos prendidos. Se rompe con los negativos: en un int8, el primer foco no suma 128, resta 128.

Ejemplo. 236 = 1110 1100, porque 128 + 64 + 32 + 8 + 4 = 236. Es el rojo del naranja de los tenis de la lección de imágenes.

Ver en el glosario →

Enterointeger · intUn número sin decimales. En la computadora cada entero tiene un tamaño fijo en bits, y el tamaño le da un rango: un int8 va de −128 a 127; un int32, de −2,147,483,648 a 2,147,483,647.Más

Los enteros sin signo (uint) usan todos los bits para sumar: de 0 a 2ⁿ − 1. Los con signo (int) usan el complemento a dos: el primer bit vale negativo (−128 en un int8), y van de −2ⁿ⁻¹ a 2ⁿ⁻¹ − 1. Los mismos bits significan cosas distintas según el tipo: 1110 1100 es 236 como uint8 y −20 como int8. Si un resultado no cabe, hay overflow. En ML: uint8 para píxeles, int64 para etiquetas e ids de tokens (el entero por omisión de PyTorch), y int8 o int4 para pesos cuantizados.

Ejemplo. Un contador int32 de las fotos de C01, a ≈ 116 por segundo, llega a su tope en ≈ 214 días. Un int64 tardaría ≈ 2,500 millones de años.

Ver en el glosario →

Overflowoverflow · desbordamientoCuando el resultado de una cuenta no cabe en el tipo de dato. En un entero, el número da la vuelta (en uint8, 255 + 1 = 0) o se queda en la orilla; en punto flotante, se vuelve infinito.Más

Los arreglos de enteros de NumPy y PyTorch dan la vuelta sin avisar; funciones como cv2.add de OpenCV se saturan (250 + 10 = 255); los enteros de Python nunca se desbordan. En punto flotante, lo que pasa del máximo del formato se vuelve inf, y de ahí suele salir un NaN: en FP16, el máximo es 65,504, y por eso las activaciones atípicas de un transformer pueden desbordarse en FP16.

Analogía. Un cuentakilómetros de 6 dígitos: después de 999,999 marca 000,000. Se rompe en que la computadora no avisa, y en que un entero con signo salta del más grande al más negativo.

Ejemplo. En uint8, aclarar una foto sumando 10 vuelve casi negro un blanco: 250 + 10 = 4. En FP16, e¹² ≈ 162,755 da infinito.

Ver en el glosario →

Punto flotantefloating point · floatLa forma en que la computadora guarda números con decimales: como notación científica en base 2, con un bit de signo, un exponente que dice dónde va el punto y una mantisa con los dígitos.Más

El valor es (−1)^signo × 1.mantisa × 2^(exponente − sesgo). Los bits de exponente dan el rango; los de mantisa, la precisión. Entre cada potencia de 2 y la siguiente cabe la misma cantidad de números, así que la separación crece con el tamaño y el error de redondeo es relativo. Hay valores especiales: infinito (inf) y NaN (not a number), que contagia a toda cuenta en la que entra. Los formatos que vas a oír: FP64, FP32, FP16, BF16 y FP8.

Analogía. La notación científica de toda la vida, 1.50528 × 10⁵, pero con potencias de 2 y un número fijo de dígitos. Se rompe en que casi ningún decimal, como 0.1, tiene una escritura exacta en base 2: se guarda redondeado.

Ejemplo. 6.5 = 110.1₂ = 1.101₂ × 2². En FP32: signo 0, exponente 2 + 127 = 129 (1000 0001) y mantisa 101 seguida de 20 ceros.

Ver en el glosario →

FP16float16 · half precisionUn número de punto flotante de 16 bits (2 bytes): 5 de exponente y 10 de mantisa. Guarda ≈ 3 cifras y su máximo es 65,504: lo que pase de ahí se vuelve infinito.Más

Ocupa la mitad que FP32 y los tensor cores de las GPUs lo multiplican muchísimo más rápido: una T4 hace ≈ 65 × 10¹² operaciones por segundo en FP16 contra ≈ 8.1 × 10¹² en FP32. Es el formato normal para servir CNNs, como el engine de TensorRT de C01. Su debilidad es el rango: por arriba se desborda en 65,504 (exponenciales, cuadrados, activaciones atípicas de un transformer) y por abajo, lo menor que ≈ 6 × 10⁻⁸ se vuelve 0. Para entrenar con él hace falta precisión mixta.

Ejemplo. La ResNet-50 de C01 pesa ≈ 49 MB en FP16 contra ≈ 98 MB en FP32. En FP16, 1 + 0.0001 da 1, 2,048 + 1 da 2,048 y e¹² da infinito.

Ver en el glosario →

BF16bfloat16 · brain floating pointUn número de punto flotante de 16 bits con los mismos 8 bits de exponente que FP32 y solo 7 de mantisa: el rango de FP32 con ≈ 2 o 3 cifras. Es el formato normal de los LLMs.Más

Se inventó en Google Brain. Un BF16 son los primeros 16 bits de un FP32, así que convertir es redondear y tirar la otra mitad. Se equivoca más que FP16 en cada número, pero prácticamente no se desborda donde FP32 no lo haría, y las redes toleran bien la imprecisión pero no el overflow: por eso los LLMs se entrenan y se sirven en BF16, y con él la precisión mixta no necesita escala de la pérdida. Existe en las GPUs desde Ampere (A100, A10G) en adelante; la T4 no lo tiene.

Ejemplo. Llama 3 70B pesa ≈ 141 GB en BF16. En BF16, 1000.3 se guarda como 1000 y 70,000 como 70,144, sin volverse infinito; en FP16 serían 1000.5 e infinito.

Ver en el glosario →

dtypedata type · tipo de datoEl tipo de dato de un tensor: el formato en que están guardados todos sus números y, por lo tanto, cuántos bytes ocupa cada uno. Por ejemplo, torch.float32 (4 bytes) o torch.uint8 (1 byte).Más

Todos los números de un tensor comparten su dtype, y los bits se leen según él. PyTorch crea los tensores con decimales en float32 y los de enteros en int64; NumPy y Python usan FP64 para los decimales. La memoria de un tensor es su cantidad de números por los bytes de su dtype. Los errores típicos: mezclar dtypes (un float64 de NumPy contra un modelo en float32), etiquetas que no son int64 y cuentas con píxeles en uint8 que dan la vuelta.

Ejemplo. El tensor de la foto de Ana, [1, 3, 224, 224], ocupa 150,528 bytes en uint8, 301,056 en float16 y 602,112 en float32: los mismos números con otro dtype.

Ver en el glosario →

Redondeorounding · rounding errorGuardar el número representable más cercano cuando el valor exacto no cabe en los bits. La diferencia es el error de redondeo: chiquito en cada número, pero se acumula.Más

Casi ningún decimal cabe exacto: 0.1 en binario es 0.000110011…₂, sin fin. El error de cada número es, a lo mucho, media separación: ≈ 0.000006 % en FP32, ≈ 0.05 % en FP16 y ≈ 0.4 % en BF16. Por eso los números de punto flotante se comparan con tolerancia (math.isclose, torch.allclose), nunca con ==; por eso un número chico sumado a uno grande puede perderse; y por eso las GPUs acumulan las sumas en FP32 aunque multipliquen en FP16.

Ejemplo. En Python, 0.1 + 0.2 da 0.30000000000000004. En FP32, 16,777,216 + 1 da 16,777,216: un contador de fotos en float32 se atora en ≈ 40 horas.

Ver en el glosario →

Precisión mixtamixed precision · AMP (automatic mixed precision)Entrenar con las cuentas pesadas en 16 bits (FP16 o BF16) y lo delicado en FP32: una copia maestra de los pesos en FP32 y, con FP16, una escala de la pérdida para que los gradientes chicos no se vuelvan 0.Más

Tres ideas: (1) los pesos de verdad viven en FP32 y cada paso usa una copia en 16 bits, para que los cambios chicos no se pierdan; (2) convoluciones y multiplicaciones de matrices en 16 bits en los tensor cores, acumulando en FP32, y softmax, pérdida y normalizaciones en FP32 (en PyTorch lo decide torch.autocast); (3) con FP16, la pérdida se multiplica por una escala (al principio, 65,536) antes de la backpropagation y los gradientes se dividen después. El GradScaler salta los pasos con inf o NaN y ajusta la escala. Con BF16 no hace falta la escala. Con Adam ocupa ≈ 16 bytes por parámetro: no ahorra memoria fija, pero baja las activaciones a la mitad y acelera mucho.

Ejemplo. C01 hace su fine-tune mensual con AMP. Un gradiente de 10⁻⁸ se vuelve 0 en FP16; con la pérdida multiplicada por 65,536 queda en ≈ 0.000655, que sí cabe, y al dividirlo vuelve como ≈ 10⁻⁸.

Ver en el glosario →

FP88-bit floating point · E4M3 · E5M2Números de punto flotante de 1 byte, en dos versiones: E4M3 (4 bits de exponente y 3 de mantisa, máximo 448) y E5M2 (5 y 2, máximo 57,344). Se usan con una escala por tensor, sobre todo en LLMs.Más

Con tan pocos bits, cada tensor lleva una escala en FP32 que lo acomoda al rango antes de convertirlo. E4M3 sirve para pesos y activaciones; E5M2, con más rango, para gradientes. Como conserva un exponente, aguanta mejor los valores atípicos que INT8. Existe en las GPUs Ada (L4, L40S) y Hopper (H100), no en la A100, la A10G ni la T4. También se usa para el KV cache: de 320 a 160 KiB por token en un modelo de 70B.

Ejemplo. C10 sirve su modelo de 70,600 millones de parámetros en FP8: ≈ 70.6 GB de pesos repartidos entre 2 H100, contra ≈ 141 GB en BF16.

Ver en el glosario →

CuantizaciónquantizationGuardar los números de un modelo con menos bits, como enteros más una escala compartida: valor ≈ escala × entero. Baja la memoria y puede acelerar, a cambio de cambiar un poco las respuestas.Más

En la versión simétrica, escala = máximo ÷ 127 (en INT8) y entero = redondear(valor ÷ escala); el error es, a lo mucho, media escala. La asimétrica agrega un punto cero: valor ≈ escala × (entero − punto cero). Un valor atípico agranda la escala para todo el grupo, así que se usan escalas por canal o por grupo y rangos que recortan extremos. Para las activaciones se calibra con unos cientos de ejemplos reales (calibrar aquí es medir rangos, no la calibración de probabilidades). PTQ cuantiza un modelo ya entrenado; QAT entrena simulando el redondeo. Ningún modelo cuantizado sale sin prueba de paridad y sin medir la calidad por categoría y por slice.

Analogía. Medir con una regla: la escala es la separación entre sus rayitas. Con rayitas de un milímetro mides fino; si la regla tiene que llegar a un kilómetro con las mismas rayitas, cada una mide metros y lo chico se pierde. Se rompe en que una regla de verdad tiene las rayitas fijas; en la cuantización se eligen para cada grupo de números, según su rango.

Ejemplo. Con pesos de −2.5 a 2.5, la escala es 2.5 ÷ 127 ≈ 0.0197: el 0.1 se guarda como 5 y vuelve como 0.0984. La Ronda (M08) cuantizó la ResNet-50 de C01 de 98 MB a 25.1 MB.

Ver en el glosario →

INT88-bit integer · int8 quantizationEnteros de 8 bits con signo, de −128 a 127. En ML, sobre todo, un formato de cuantización: cada peso o activación se guarda como un int8 más una escala compartida, en 1 byte en vez de 4.Más

Lo aceleran los tensor cores de las GPUs desde la generación de la T4 (en ella, ≈ 130 × 10¹² operaciones por segundo, el doble que en FP16) y las CPUs modernas con instrucciones especiales (VNNI, AMX). En CNNs se cuantizan pesos y activaciones; en LLMs, a menudo solo los pesos. Pide cuantización con calibración y un examen antes de producción: el promedio puede esconder lo que pierde la cola larga.

Ejemplo. La ResNet-50 de C01 pasa de 98 MB en FP32 a ≈ 25 MB en INT8. En la Ronda (M08), en CPU, corrió ≈ 2 veces más rápido con una foto a la vez y ≈ 3.5 veces con 8.

Ver en el glosario →

INT44-bit integer · int4 quantizationEnteros de 4 bits: solo 16 valores, de −8 a 7, medio byte por número. Se usa para guardar los pesos de LLMs grandes, con una escala por cada grupo chico de pesos.Más

Con una sola escala por capa, los números chicos se vuelven 0; por eso cada grupo (por ejemplo, de 128 pesos seguidos) lleva su propia escala, que cuesta ≈ 0.125 bits más por peso. En LLMs casi siempre es weight-only: los pesos se guardan en INT4 y se convierten a 16 bits (BF16 o FP16) al vuelo, justo antes de multiplicar; los métodos más usados son AWQ y GPTQ. Ahorra mucha memoria y acelera la generación de texto, pero arriesga más calidad que FP8 o INT8.

Ejemplo. Un modelo de 70,600 millones de parámetros ocupa ≈ 35.3 GB en INT4, más ≈ 1.1 GB de escalas con grupos de 128: cabe en una H100 de 80 GB, donde en BF16 (≈ 141 GB) no cabría.

Ver en el glosario →

14

Chequeo

Esencial

Check

Intenta responder en tu cabeza antes de abrir cada respuesta.

  1. 1¿Por qué un uint8 llega hasta 255 y un int8 solo hasta 127?Ver respuesta

    Con 8 bits hay 2⁸ = 256 combinaciones. Sin signo van del 0 al 255. Con signo, el primer bit vale −128, y quedan de −128 a 127.

  2. 2¿Qué da 250 + 10 en un arreglo uint8 de NumPy, y por qué?Ver respuesta
    1. El resultado, 260, no cabe en 8 bits: se pierde el noveno bit y queda 260 − 256 = 4. NumPy da la vuelta sin avisar.
  3. 3Si FP16 y BF16 ocupan 2 bytes, ¿en qué se diferencian?Ver respuesta

    FP16 tiene 5 bits de exponente y 10 de mantisa: ≈ 3 cifras, pero su máximo es 65,504. BF16 tiene 8 y 7: el rango de FP32 con 2 o 3 cifras. BF16 prácticamente no se desborda donde FP32 no lo haría, y por eso es el formato de los LLMs.

  4. 4¿Por qué 0.1 + 0.2 no da exactamente 0.3 en Python?Ver respuesta

    Porque 0.1 y 0.2 no tienen una escritura finita en binario: se guardan redondeados y los errores se suman. Python usa FP64 y da 0.30000000000000004. Los números de punto flotante se comparan con tolerancia, no con ==.

  5. 5En la precisión mixta, ¿para qué sirven la copia maestra en FP32 y la escala de la pérdida?Ver respuesta

    La copia maestra evita que los cambios chicos se pierdan (en FP16, 1 + 0.0001 da 1). La escala evita que los gradientes diminutos se vuelvan 0: se multiplica la pérdida por 65,536 y los gradientes se dividen antes de actualizar.

  6. 6Una capa tiene pesos de −2.5 a 2.5. ¿Cómo se guarda 0.1 en INT8, y qué pasa si aparece un peso de 25?Ver respuesta

    Con escala 2.5 ÷ 127 ≈ 0.0197, el 0.1 se guarda como 5 y vuelve como 0.0984. Con un 25, la escala sube a ≈ 0.197: el 0.1 se guarda como 1 y vuelve como 0.197, con 97 % de error. Por eso hay escalas por canal o por grupo.

  7. 7¿Cuánto pesan los pesos de un modelo de 70B en FP16, en INT8 y en INT4? ¿Cabe en una H100 de 80 GB?Ver respuesta

    140 GB, 70 GB y 35 GB. En FP16 no cabe; en INT8 o FP8 caben los pesos, pero casi no queda lugar para el KV cache; en INT4 sobra espacio.

Sigue · 15Matrices, FLOPs y qué tan rápido calcula una GPUNúmeros y estadística para producción Léelo en la RondaM04 · Hardware para inferenciaLo que acabas de leer, con el ojo del entrevistador. ← 13 · Comparar distribuciones: cómo se mide el drift