PyTorch · Fundamentos
Guardar, cargar, y el cambio de reglas de PyTorch 2.0
La parte aburrida (persistir un modelo) y la parte que cambió todo (compilarlo) — ambas resuelven el mismo problema de fondo: sacarle a Python de encima lo que no necesita estar ahí.
Antes de leerPyTorch 3 §4, el training loop · HF cap. 2 §2, from_pretrained
🎯 Al terminar vas a poder
- Guardar y cargar un modelo de las tres formas (
state_dict, objeto completo, safetensors) y explicar por qué la primera y la tercera son las que se usan. - Escribir un checkpoint completo para reanudar un entrenamiento (modelo, optimizador, scheduler, epoch) y cargarlo en una máquina distinta con
map_location. - Explicar qué hacen TorchDynamo, AOTAutograd, PrimTorch y TorchInductor, y qué esperar de
torch.compileen tiempo de compilación, recompilaciones y speedup según la GPU. - Elegir el modo de compilación y decidir cuándo
dynamic=True, y reconocer un graph break en los logs. - Diagnosticar los errores de carga clásicos: prefijo
module.,Missing keys, CUDA inexistente, pickle roto.
1. state_dict vs. guardar el modelo completo
Un modelo entrenado guarda sus parámetros aprendidos en un diccionario interno, el
state_dict — mapea el nombre de cada parámetro (y de cada buffer persistente, como viste
en la página 2) a su tensor de valores. Es la forma
recomendada de persistir un modelo:
torch.save(model.state_dict(), 'model_weights.pth')
# para cargar: primero instanciar la MISMA clase, después cargar los pesos
model = models.vgg16()
model.load_state_dict(torch.load('model_weights.pth', weights_only=True))
model.eval() # no lo olvides — ver la página anterior
Notá que cargar el state_dict requiere tener antes una instancia de exactamente la misma
clase de modelo — el state_dict son solo números, no sabe nada de la arquitectura que los
usa. La alternativa, torch.save(model, 'model.pth'), guarda el objeto completo (arquitectura
incluida) usando pickle de Python — más cómodo, pero acopla el archivo guardado a que la
definición exacta de la clase siga existiendo y siendo importable cuando se cargue de vuelta. La
documentación oficial es clara: state_dict es la práctica recomendada; guardar el objeto
completo es "un caso de uso legado".
weights_only y map_location
torch.load usa pickle por dentro, y pickle puede ejecutar código
arbitrario al deserializar: un archivo .pth de origen desconocido es, literalmente, un
programa. weights_only=True restringe la carga a tensores, diccionarios y tipos primitivos.
Desde PyTorch 2.6 es el default — así que el weights_only=True explícito del
ejemplo ya es redundante, y lo que cambia es lo contrario: un checkpoint viejo que contenía objetos
arbitrarios (un argparse.Namespace, una clase propia) ahora falla con un
UnpicklingError que te dice qué tipo rechazó. Las salidas: weights_only=False
solo si confiás en el archivo, o registrar el tipo con
torch.serialization.add_safe_globals([MiClase]).
map_location decide en qué device aterrizan los tensores. Un state_dict guardado
desde GPU recuerda que vivía en cuda:0; cargarlo en una máquina sin GPU (o con menos GPUs)
sin map_location falla. La costumbre segura es cargar siempre a CPU y dejar que
model.to(device) mueva después:
sd = torch.load("model_weights.pth", map_location="cpu") # weights_only=True implícito en 2.6+
model.load_state_dict(sd)
model.to(device)
El checkpoint completo para reanudar
Los pesos solos alcanzan para inferencia, pero no para continuar un entrenamiento: el estado de Adam (momento y varianza por parámetro), la posición del scheduler y el epoch en que ibas también son estado. El patrón es un diccionario con todo, guardado de forma atómica (escribir a un archivo temporal y renombrar, para que un corte a mitad de escritura no deje un checkpoint corrupto):
def save_checkpoint(path, model, optimizer, scheduler, epoch, step, scaler=None):
ckpt = {
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
"scheduler_state_dict": scheduler.state_dict(),
"scaler_state_dict": scaler.state_dict() if scaler else None,
"epoch": epoch, "step": step,
"rng": torch.get_rng_state(),
}
tmp = path + ".tmp"
torch.save(ckpt, tmp)
os.replace(tmp, path) # atómico en el mismo filesystem
def load_checkpoint(path, model, optimizer, scheduler, scaler=None):
ckpt = torch.load(path, map_location="cpu")
model.load_state_dict(ckpt["model_state_dict"])
optimizer.load_state_dict(ckpt["optimizer_state_dict"]) # mueve el estado al device de los params
scheduler.load_state_dict(ckpt["scheduler_state_dict"])
if scaler and ckpt["scaler_state_dict"]: scaler.load_state_dict(ckpt["scaler_state_dict"])
torch.set_rng_state(ckpt["rng"])
return ckpt["epoch"] + 1, ckpt["step"]
El estado del optimizador pesa: con AdamW son dos tensores fp32 por parámetro entrenable, o sea el doble que los pesos en bf16. Por eso los checkpoints de entrenamiento de un modelo de 7B rondan las decenas de GB, mientras que los pesos para inferencia del mismo modelo ocupan 14. Es también la razón por la que un checkpoint de LoRA es diminuto: solo el adapter y su estado de Adam.
2. safetensors: el formato del ecosistema HF
Cuando bajás un modelo del Hub, el archivo se llama model.safetensors, no
pytorch_model.bin. safetensors es el formato que Hugging Face creó para
reemplazar a pickle, y resuelve tres problemas de torch.save de una vez:
- Seguridad: el archivo es un header JSON (nombre, dtype, shape y offset de cada tensor) seguido de los bytes crudos. No hay código que ejecutar al cargar — es por diseño imposible que un archivo malicioso haga algo.
- Carga lazy y memory-mapped: se puede leer el header sin tocar los datos, cargar un solo tensor, o mapear el archivo directamente a memoria. Cargar un modelo grande es mucho más rápido que despicklear, y es lo que permite que
device_map="auto"reparta tensores entre GPUs y CPU sin cargar todo antes. - Interoperable: el mismo archivo se lee desde PyTorch, JAX, TensorFlow, Rust (candle) o cualquier lenguaje con un parser JSON.
📐 Especificación binaria de Safetensors: alineación de 8 bytes y zero-copy mmap
A diferencia del protocolo Pickle de Python (que es una máquina virtual basada en una pila Turing-completa capaz de ejecutar código arbitrario como os.system), Safetensors es una estructura binaria estrictamente tipada y sin código ejecutable:
- Primeros 8 bytes (entero sin signo de 64 bits little-endian, ): especifica la longitud exacta en bytes de la cabecera de metadatos JSON.
- Siguientes bytes: cadena de texto JSON codificada en UTF-8 con la estructura de cada tensor: nombre, tipo de dato (
F32,BF16,F8_E4M3,U8), dimensiones (shape) y offsets relativos dentro del buffer contiguo[offset_inicio, offset_fin]. - Bytes en adelante: datos tensoriales contiguos alineados a bloques de 8 bytes.
Al cargar los pesos con mmap(), las páginas del archivo se proyectan directamente en el espacio de memoria virtual del proceso. La transferencia de los pesos hacia la GPU se realiza vía DMA desde la controladora NVMe sin generar duplicación de memoria en el heap de Python.
from safetensors.torch import save_file, load_file, save_model, load_model
# nivel state_dict: un dict {nombre: tensor}
save_file(model.state_dict(), "model.safetensors")
sd = load_file("model.safetensors", device="cpu") # dict listo para load_state_dict
model.load_state_dict(sd)
# nivel modelo: maneja pesos compartidos (tied embeddings) que save_file rechaza
save_model(model, "model.safetensors")
load_model(model, "model.safetensors")
# inspeccionar sin cargar
from safetensors import safe_open
with safe_open("model.safetensors", framework="pt") as f:
for k in list(f.keys())[:3]:
print(k, f.get_slice(k).get_shape(), f.get_slice(k).get_dtype())
Dos restricciones de save_file que aparecen en la práctica: los tensores tienen que ser
contiguos (.contiguous() antes de guardar un peso transpuesto) y no puede haber dos claves
que compartan memoria — el caso típico es el embedding de entrada y la cabeza de salida atados
(tied weights); save_model lo resuelve guardando una sola copia. Y lo que
safetensors no guarda: el estado del optimizador ni ningún objeto que no sea un tensor. Para
el checkpoint de reanudación de §1, torch.save sigue siendo la herramienta; para publicar
pesos, safetensors. model.save_pretrained() de Transformers y PEFT hace safetensors por
defecto, más un config.json que reemplaza a "instanciar la misma clase".
🔧 En tu proyecto
El header JSON de un .safetensors es el lugar donde mirás qué dtype tiene realmente cada
tensor de un checkpoint cuantizado: en un modelo NVFP4 vas a ver los pesos empaquetados (dos valores
por byte, típicamente como U8 con una dimensión a la mitad) al lado de tensores de escala
en F8_E4M3, y los nombres te dicen qué capa se cuantizó y cuál quedó en bf16. Un script de
diez líneas con safe_open vale más que cualquier documentación del formato — es el
ejercicio 1 de esta página.
3. El problema que PyTorch cargó durante 5 años: eager es lento
La fortaleza histórica de PyTorch —ejecución eager, cada línea corre inmediatamente en Python, sin un grafo estático de por medio— es también su límite de performance. Desde que se lanzó PyTorch en 2017, el hardware (GPUs) se volvió ~15× más rápido en cómputo, pero mantener esa velocidad en modo eager obligó a mover cada vez más lógica interna de Python a C++, lo cual va en contra del valor central de PyTorch: que el código sea hackeable y fácil de extender.
El problema concreto tiene dos caras. Una es el overhead de Python: cada operación
pasa por el intérprete, el dispatcher y el lanzamiento de un kernel de CUDA; cuando el kernel dura
microsegundos (un modelo chico, un batch chico, la fase de decode de un LLM que procesa un token por
paso), la GPU pasa más tiempo esperando órdenes que calculando. La otra es la falta de
fusión: x.mul(2).add(1).relu() en eager son tres kernels que leen y escriben el
tensor completo tres veces desde la memoria de la GPU, cuando un kernel fusionado lo leería y escribiría
una sola vez. En la mayoría de las capas que no son matmuls, el cuello de botella es el ancho de banda
de memoria, no el cómputo — y ahí la fusión es la única forma de ir más rápido.
El equipo de PyTorch probó varios enfoques de compilación a lo largo de 5 años —
torch.jit.trace, TorchScript, FX tracing, Lazy Tensors— y ninguno resolvía el problema
completo: algunos eran flexibles pero lentos, otros rápidos pero exigían reescribir el modelo de forma
no trivial.
4. torch.compile: un decorador, no una reescritura
torch.compile (PyTorch 2.0, 2023) resolvió esto sin pedirle al usuario que cambie una sola
línea de su modelo:
model = torch.compile(model) # eso es todo — el resto del código no cambia
Por dentro corren cuatro tecnologías nuevas, cada una resolviendo una parte distinta del problema de compilación:
- TorchDynamo: captura el grafo de cómputo de forma segura usando un hook del intérprete de CPython (PEP-0523) — sobre 7.000+ proyectos reales de GitHub usados como validación, acertó a capturar el grafo correctamente el 99% de las veces, contra menos del 50% de TorchScript.
- AOTAutograd: genera de antemano ("ahead-of-time") el grafo del backward pass, no solo el forward.
- PrimTorch: reduce los más de 2000 operadores de PyTorch a un conjunto cerrado de ~250 operaciones primitivas — simplifica muchísimo escribir un backend nuevo.
- TorchInductor: el compilador final, que genera código Triton para GPU y C++/OpenMP para CPU.
📐 Anatomía interna de torch.compile y fusión de operadores
torch.compile se articula mediante tres subsistemas complementarios:
- TorchDynamo: intercepta el bytecode de CPython mediante la API de evaluación de frames (PEP 523) y extrae grafos de operaciones FX estables.
- AOTAutograd: captura el grafo conjunto hacia adelante y hacia atrás antes de la ejecución para optimizar la retención de activaciones.
- TorchInductor: backend generador de código que compila kernels de bajo nivel en C++ OpenMP (CPU) y Triton (GPU).
La aceleración principal proviene de la fusión de operadores (kernel fusion). En operaciones elemento a elemento encadenadas como , el modo eager ejecuta múltiples kernels independientes, obligando a escribir y releer tensores intermedios en la memoria global HBM. TorchInductor genera un único kernel Triton donde los valores intermedios se preservan en los registros de los procesadores de flujo (SMs) y en la memoria compartida SRAM, reduciendo drásticamente los accesos a la memoria global.
La pieza conceptualmente nueva es Dynamo: en vez de trazar el modelo ejecutándolo con tensores
de mentira (lo que rompía con cualquier if sobre valores) o de parsear un subconjunto
de Python (TorchScript), intercepta el bytecode de cada función justo antes de ejecutarse, extrae las
operaciones sobre tensores a un grafo FX y deja el resto del Python como estaba. Cuando encuentra algo
que no puede capturar (un print, una llamada a una librería externa, un if que
depende del valor de un tensor), corta el grafo ahí — un graph break — compila el pedazo anterior,
ejecuta esa línea en eager y sigue capturando después. Por eso "funciona" en el 99 % de los casos: el
peor escenario no es un error, es un modelo con más pedazos compilados de los que te gustaría.
5. Los números reales, y el matiz importante
Sobre un benchmark de 163 modelos open-source (46 de Hugging Face Transformers, 61 de TIMM, 56 de
TorchBench), sin modificar el código de esos modelos más allá de envolverlos en
torch.compile:
| Métrica | Resultado medido |
|---|---|
| Tasa de éxito de compilación | 93% de los modelos |
| Speedup en FP32, A100 | +21% en promedio |
| Speedup con mixed precision (AMP), A100 | +51% en promedio |
Sylvain Gugger, mantenedor principal de Hugging Face Transformers en ese momento, lo resumió así:
"con solo una línea de código, PyTorch 2.0 da un speedup de entre 1.5x y 2x entrenando modelos
Transformer — lo más emocionante desde que se introdujo mixed precision training". El matiz honesto que
la propia documentación marca: los speedups son menores en GPUs de consumo (una RTX 3090, por ejemplo)
que en GPUs de servidor como A100, y TorchInductor no soporta todavía todas las
arquitecturas de GPU por igual.
Otro matiz que los números de arriba no muestran: el speedup depende de dónde estaba el cuello de
botella. Un modelo grande con batches grandes ya pasa casi todo su tiempo en matmuls que cuBLAS ejecuta
igual de rápido compilado o no — ahí la ganancia viene solo de fusionar lo que rodea a las matmuls
(norms, activaciones, residuales) y suele ser del orden del 10-30 %. Un modelo chico, o la fase de
generación token a token de un LLM, está dominado por overhead de lanzamiento, y ahí
mode="reduce-overhead" (ver §6) puede dar 2× o más. Medir antes y después con el mismo
input, y después de calentar, es la única forma de saber.
🔧 El mismo espíritu que ya conocés de NVFP4
torch.compile y la cuantización NVFP4 de
tu pipeline atacan el mismo
problema desde ángulos distintos: ambos buscan que el hardware haga más trabajo útil por unidad de
tiempo/memoria, sin que el usuario tenga que reescribir el modelo. Uno lo hace generando mejor código
(kernels fusionados, menos overhead de Python); el otro, usando menos bits por número. En producción
real, ambos se combinan: un modelo cuantizado y compilado no son técnicas competidoras, son
ortogonales. vLLM, de hecho, compila el modelo con torch.compile y captura CUDA graphs
por defecto en sus versiones recientes — y los kernels de NVFP4 entran como operaciones custom que
Dynamo trata como cajas negras dentro del grafo.
6. torch.compile en la práctica: modos, shapes dinámicos, recompilaciones
"Una línea" es cierto, pero hay cuatro cosas que conviene saber antes de agregarla a un entrenamiento o a un servidor.
El costo de la primera iteración
torch.compile es lazy: no compila nada hasta la primera llamada con inputs reales. Esa
primera iteración tarda desde unos segundos (un MLP) hasta varios minutos (un LLM grande con
max-autotune), porque Dynamo captura, Inductor genera código Triton y lo compila. Todo
benchmark que incluya la primera llamada está midiendo compilación, no ejecución: calentar con dos o tres
iteraciones y recién después cronometrar. Para servidores, la caché en disco (habilitada por defecto
en versiones recientes) amortiza el costo entre reinicios.
Modos
| Modo | Qué hace | Cuándo |
|---|---|---|
"default" | Fusión de kernels vía Inductor, sin extras. Compilación relativamente rápida. | Entrenamiento; el punto de partida siempre. |
"reduce-overhead" | Además captura CUDA graphs: graba la secuencia de lanzamientos de kernels una vez y la reproduce sin pasar por Python ni por el dispatcher. Usa algo más de memoria (buffers estáticos). | Inferencia con batches chicos, decode de LLMs — cualquier caso dominado por overhead de lanzamiento. Necesita shapes estáticos. |
"max-autotune" | Prueba varias configuraciones de cada kernel Triton (y matmuls alternativas) y se queda con la más rápida en tu GPU. Compilación mucho más larga. | Cuando el modelo va a correr millones de veces con el mismo shape y cada por ciento importa. "max-autotune-no-cudagraphs" si los CUDA graphs molestan. |
Shapes dinámicos y recompilaciones
El código compilado está especializado en los shapes que vio. Si la próxima llamada trae un shape
distinto (un batch más chico al final del epoch, una secuencia más larga), Dynamo tiene que
recompilar. El comportamiento por defecto (dynamic=None) es pragmático: la
primera vez asume shapes estáticos; al ver un segundo shape, marca esa dimensión como dinámica y compila
una versión que sirve para cualquier valor. Si sabés de antemano que el shape varía (largo de secuencia
en texto), dynamic=True lo hace desde el principio y te ahorra una recompilación; si sabés
que no varía nunca, dynamic=False genera código algo más rápido. Hay un límite de
recompilaciones por función (8 por defecto) tras el cual Dynamo se rinde y esa función vuelve a
eager sin avisar en la salida normal — por eso el primer diagnóstico cuando "compilé y no fue
más rápido" es mirar los logs:
model = torch.compile(model, mode="reduce-overhead", dynamic=True) # o fullgraph=True para que un graph break sea un error
# en la shell, antes de correr:
# TORCH_LOGS="graph_breaks,recompiles" python train.py
# o desde Python, para ver cuántos grafos y por qué se cortaron:
explicacion = torch._dynamo.explain(model)(x)
print(explicacion.graph_count, explicacion.graph_break_count)
print(explicacion.break_reasons)
De torch.compile al deployment: torch.export y ExecuTorch
torch.compile necesita Python en runtime — es un acelerador del programa que ya tenés.
Cuando el destino no tiene Python (un servidor en C++, un teléfono, un microcontrolador),
torch.export captura el grafo completo (sin graph breaks: si hay uno, es un error) en
un ExportedProgram serializable; AOTInductor lo compila a una librería que se carga desde
C++, y ExecuTorch es el runtime liviano de PyTorch para móvil y edge que consume ese mismo
artefacto. Es el sucesor de lo que TorchScript prometía, construido sobre las mismas piezas de 2.0.
7. Errores comunes
Los de carga vienen con mensajes largos pero precisos; los de compilación son más silenciosos.
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
Missing key(s) in state_dict: "fc.weight"... Unexpected key(s): "module.fc.weight"... — todas las claves con prefijo module. | El checkpoint se guardó desde un modelo envuelto en DataParallel/DistributedDataParallel, que agrega ese prefijo, y lo cargás en el modelo pelado. | Guardá model.module.state_dict() desde DDP; o al cargar, torch.nn.modules.utils.consume_prefix_in_state_dict_if_present(sd, "module.") (o {k.removeprefix("module."): v for k, v in sd.items()}) (§1). |
RuntimeError: Error(s) in loading state_dict... Missing key(s)... Unexpected key(s)... con claves distintas de verdad | Arquitectura distinta a la del checkpoint: una cabeza nueva, un buffer que pasó de persistente a no persistente, una versión de la librería que renombró capas. | Leé las dos listas antes de tocar nada. strict=False solo si las claves faltantes son exactamente las que querés inicializar de cero (una cabeza nueva) — y revisá el objeto que devuelve load_state_dict, que lista lo que quedó sin cargar (§1). |
RuntimeError: Attempting to deserialize object on a CUDA device but torch.cuda.is_available() is False (o invalid device ordinal) | torch.load sin map_location de un checkpoint guardado en una GPU que esta máquina no tiene. | map_location="cpu" siempre; model.to(device) después (§1). |
AttributeError: Can't get attribute 'MiModelo' on <module '__main__'> o ModuleNotFoundError al cargar | Se guardó el objeto completo con pickle y la clase ya no está en el mismo módulo/ruta (moviste el archivo, renombraste la clase, cargás desde otro script). | Volvé a guardar como state_dict (cargando una vez con la clase en su lugar original); a futuro, nunca torch.save(model) (§1). |
UnpicklingError: Weights only load failed... con PyTorch 2.6+ | El checkpoint contiene objetos que no son tensores (namespaces, clases propias) y weights_only=True ahora es el default. | Si confiás en el archivo, weights_only=False; mejor, add_safe_globals para el tipo concreto, y no guardar objetos arbitrarios en checkpoints nuevos (§1). |
safetensors_rust.SafetensorError: Some tensors share memory | save_file sobre un state_dict con pesos atados (embedding y lm_head) o vistas del mismo tensor. | save_model(model, path), que deduplica; o eliminá del dict la copia (§2). |
torch.compile no acelera nada; en los logs, muchos graph breaks o recompilaciones | Un if que depende del valor de un tensor (if x.sum() > 0:, if mask.any():), .item(), print en el forward, o shapes distintos en cada llamada. | TORCH_LOGS="graph_breaks,recompiles"; reemplazá el if por torch.where; dynamic=True para shapes variables; fullgraph=True para que la captura falle ruidosamente en vez de partirse en silencio (§6). |
| El primer step tarda minutos y creés que se colgó | Es la compilación, sobre todo con max-autotune. | Esperá; calentá antes de medir; usá "default" mientras desarrollás (§6). |
| Reanudás un entrenamiento y la loss salta hacia arriba | Cargaste los pesos pero no el estado del optimizador ni el scheduler (Adam arranca "frío", el lr vuelve al warmup). | Checkpoint completo con las cuatro cosas; el lr después de reanudar tiene que ser el mismo que antes del corte (§1). |
8. Resumen
state_dictes la forma recomendada de guardar un modelo — solo números, requiere reinstanciar la clase para cargar; guardar el objeto completo con pickle es un caso de uso legado.map_location="cpu"siempre, yweights_only=Trueya es el default desde 2.6.- Para reanudar un entrenamiento hace falta un checkpoint completo (modelo, optimizador, scheduler, epoch, scaler); para publicar pesos, safetensors: seguro por construcción, lazy, memory-mapped, y el formato de todo el ecosistema HF.
- La ejecución eager de PyTorch es flexible pero deja rendimiento sobre la mesa — overhead de Python por operación y kernels sin fusionar — el problema que PyTorch 2.0 resolvió después de 5 años de intentos previos.
torch.compileno requiere reescribir el modelo — TorchDynamo captura el grafo con 99% de éxito sin tocar el código original, y donde no puede, corta (graph break) en vez de fallar.- Los números reales: 93% de modelos compilan sin problema, con speedups de 21% (FP32) a 51% (mixed precision) en GPUs de servidor; menos en GPUs de consumo, más en cargas dominadas por overhead con
reduce-overhead. - En la práctica: la primera llamada paga la compilación;
dynamic=Truepara shapes variables; los graph breaks y recompilaciones se ven conTORCH_LOGS;torch.export+ ExecuTorch es el camino cuando el destino no tiene Python. - Compilación y cuantización no compiten — atacan ejes distintos del mismo problema (velocidad vs. memoria) y se combinan en producción real.
9. Ponete a prueba
Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.
1. Guardaste model.state_dict() cada epoch. Se corta el entrenamiento en el epoch 7 y reanudás cargando esos pesos. ¿Qué te falta y qué síntoma vas a ver?
Falta el estado del optimizador (momento y varianza de Adam), el scheduler (el lr vuelve al principio del warmup o de la curva) y el contador de epoch. Síntoma típico: la loss salta hacia arriba en las primeras iteraciones después de reanudar. El checkpoint completo guarda las cuatro cosas en un dict. (§1)
2. ¿Por qué un .pth de origen desconocido es peligroso y un .safetensors no?
torch.save usa pickle, y pickle ejecuta código al deserializar: un archivo puede contener instrucciones arbitrarias. weights_only=True (default desde 2.6) lo mitiga restringiendo los tipos. safetensors es un header JSON más bytes crudos — no hay ningún mecanismo de ejecución, así que el parser no puede hacer otra cosa que leer tensores. (§1, §2)
3. Cargás un checkpoint y todas las claves aparecen como Unexpected con prefijo module.. ¿Qué pasó y cuál es la solución de una línea?
Se guardó desde un modelo envuelto en DDP/DataParallel, que expone el modelo real como .module y por eso las claves llevan ese prefijo. Solución: consume_prefix_in_state_dict_if_present(sd, "module.") antes de load_state_dict, o guardar model.module.state_dict() la próxima vez. (§7)
4. Compilaste el modelo, medís la primera llamada y da 40 segundos contra 30 ms en eager. ¿Se rompió algo?
No: la primera llamada incluye la captura de Dynamo y la generación y compilación del código de Inductor. Hay que calentar con dos o tres llamadas y recién después cronometrar. Si después de calentar sigue lento, entonces sí: mirá TORCH_LOGS="graph_breaks,recompiles". (§6)
5. Tu forward tiene if attention_mask.any(): .... ¿Qué hace torch.compile con eso y qué hace torch.export?
Es un if que depende del valor de un tensor. Dynamo no puede resolverlo al capturar, así que corta el grafo ahí (graph break), compila lo anterior, evalúa el if en eager y sigue: funciona, pero con menos fusión. torch.export exige el grafo completo y falla con un error. La solución en ambos casos es reescribir sin control de flujo sobre valores (torch.where, aplicar la máscara siempre). (§4, §6)
6. ¿En qué caso mode="reduce-overhead" da mucho más que "default", y qué condición impone?
Cuando la carga está dominada por el overhead de lanzar kernels: batches chicos, modelos chicos, el decode token a token de un LLM. Captura CUDA graphs y reproduce la secuencia de kernels sin pasar por Python. Impone shapes estáticos (y usa algo más de memoria por los buffers fijos); con shapes variables recompila o cae a eager. (§6)
10. Para practicar
✏️ Ejercicio 1 — leer el header de un safetensors a mano
Guardá un modelo chico con safetensors y leé el archivo con la especificación del formato: 8 bytes little-endian con el largo del header, después el JSON, después los datos. Después hacé lo mismo con cualquier model.safetensors que tengas en la caché de HF (~/.cache/huggingface/hub/) — sin cargarlo. Necesita pip install safetensors.
import json, struct, torch, torch.nn as nn
from safetensors.torch import save_file
m = nn.Sequential(nn.Linear(16, 32), nn.ReLU(), nn.Linear(32, 4)).bfloat16()
save_file(m.state_dict(), "mini.safetensors")
def header(path):
with open(path, "rb") as f:
(n,) = struct.unpack("<Q", f.read(8)) # largo del header en bytes
return json.loads(f.read(n))
for k, v in header("mini.safetensors").items():
if k != "__metadata__":
nbytes = v["data_offsets"][1] - v["data_offsets"][0]
print(f"{k:12s} {v['dtype']:5s} {v['shape']} {nbytes} bytes")
Pista / qué esperar
Cuatro entradas (0.weight, 0.bias, 2.weight, 2.bias) con dtype BF16, y los bytes iguales a 2 × número de elementos (0.weight: 32×16×2 = 1024). En un checkpoint del Hub vas a ver cientos de claves y, si es cuantizado, dtypes distintos por tensor — eso es lo que la caja "En tu proyecto" de §2 describe.
✏️ Ejercicio 2 — checkpoint completo, corte y reanudación
Entrená 200 steps con AdamW + CosineAnnealingLR, guardá el checkpoint completo de §1 en el step 100, y verificá que reanudar desde ahí reproduce exactamente el estado del step 200 sin corte (misma loss, mismo lr). Después repetí cargando solo los pesos y compará. Corre en CPU.
import os, torch, torch.nn as nn
def setup(seed=0):
torch.manual_seed(seed)
m = nn.Linear(8, 1); opt = torch.optim.AdamW(m.parameters(), lr=1e-2)
sch = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=200)
return m, opt, sch
X, y = torch.randn(64, 8), torch.randn(64, 1)
def step(m, opt, sch):
loss = ((m(X) - y) ** 2).mean(); opt.zero_grad(); loss.backward(); opt.step(); sch.step()
return loss.item()
# corrida de referencia, sin corte
m, opt, sch = setup()
for i in range(200):
if i == 100:
torch.save({"model": m.state_dict(), "opt": opt.state_dict(), "sch": sch.state_dict()}, "ckpt.pt")
ref = step(m, opt, sch)
print("referencia:", ref, sch.get_last_lr())
# reanudar con checkpoint completo
m, opt, sch = setup(seed=1) # pesos iniciales distintos a propósito
ck = torch.load("ckpt.pt", map_location="cpu")
m.load_state_dict(ck["model"]); opt.load_state_dict(ck["opt"]); sch.load_state_dict(ck["sch"])
for i in range(100, 200): full = step(m, opt, sch)
print("completo: ", full, sch.get_last_lr())
# reanudar solo con pesos
m, opt, sch = setup(seed=1)
m.load_state_dict(ck["model"])
for i in range(100, 200): solo = step(m, opt, sch)
print("solo pesos:", solo, sch.get_last_lr())
Pista / qué esperar
"referencia" y "completo" tienen que coincidir en loss y en lr (el lr final del coseno, cerca de 0). "solo pesos" termina con un lr distinto (el scheduler arrancó de cero y recorrió solo la mitad de la curva) y una loss distinta, porque Adam arrancó sin momento. Es la versión en miniatura del "la loss salta al reanudar" de la tabla de §7.
✏️ Ejercicio 3 — ver un graph break sin GPU ni compilador
Con backend="eager", Dynamo captura el grafo pero no genera código, así que corre en cualquier CPU con Linux o macOS (Dynamo no está soportado en Windows en todas las versiones). Compará una función con un if sobre un valor y su versión con torch.where.
import torch
def con_if(x):
if x.sum() > 0: # depende del VALOR del tensor
return x * 2
return x - 1
def sin_if(x):
return torch.where(x.sum() > 0, x * 2, x - 1)
x = torch.randn(8)
for fn in (con_if, sin_if):
e = torch._dynamo.explain(fn)(x)
print(fn.__name__, "grafos:", e.graph_count, "breaks:", e.graph_break_count)
torch.compile(sin_if, backend="eager", fullgraph=True)(x) # ok
torch.compile(con_if, backend="eager", fullgraph=True)(x) # error: no puede capturar el grafo completo
Pista / qué esperar
con_if muestra más de un grafo y al menos un break (la razón menciona el control de flujo dependiente de datos); sin_if muestra un solo grafo y cero breaks. Con fullgraph=True, con_if lanza una excepción cuyo mensaje explica exactamente qué línea no pudo capturar — es el mismo mensaje que torch.export te daría. Los nombres exactos de los atributos de explain pueden variar entre versiones; print(e) muestra todo.
11. Fuentes y para seguir
- Save and Load the Model — Learn the Basics — el tutorial original de §1, con el ejemplo de VGG16.
- Serialization semantics — la nota oficial sobre
torch.save/torch.load: tensores que comparten memoria,weights_only,add_safe_globalsy compatibilidad entre versiones. - safetensors — documentación — API de Python, especificación del formato (header + datos) y las comparaciones de velocidad de carga contra pickle.
- PyTorch 2.0 — anuncio y overview técnico — la fuente de §3 a §5: los benchmarks, la explicación de Dynamo/AOTAutograd/PrimTorch/Inductor y la cita de Gugger.
- Introduction to torch.compile — el tutorial oficial: modos,
dynamic, cómo medir el speedup correctamente y cómo leer graph breaks. - torch.export — la referencia del camino a deployment sin Python; y ExecuTorch, el runtime para móvil y edge que consume ese artefacto.