Curso / PyTorch / Guardar, cargar y compilar
PyTorch · 4 de 5

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.compile en 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.

Lo que pesa un checkpoint de entrenamiento frente a los pesos de inferencia, para un modelo de 7B Barras a escala. Para inferencia: pesos en bf16, 14 GB en safetensors. Para reanudar: model_state_dict en fp32, 28 GB, más el estado de AdamW, dos tensores fp32 por parámetro, 56 GB, más scheduler, scaler, epoch y rng, unos 86 GB. Debajo, la secuencia de carga: torch.load a CPU, load_state_dict sobre la misma clase, y recién después to(device). MODELO DE 7B · QUÉ SE GUARDA PARA CADA COSA para inferencia · model.safetensors pesos bf16 14 GB 14 GB: header JSON + bytes crudos, sin pickle carga lazy y memory-mapped; device_map='auto' reparte sin cargar todo para reanudar · ckpt.pt model_state_dict fp32 28 GB optimizer: m y v de AdamW 56 GB sched · scaler · epoch · rng · 2 GB ≈ 86 GB: dos tensores fp32 por parámetro para Adam, el doble que los pesos en bf16 · sin el optimizador reanudás con Adam «frío» y la loss salta CARGAR SIN SORPRESAS torch.load(path, map_location='cpu') weights_only=True es el default desde 2.6 model.load_state_dict(sd) la misma clase, instanciada antes model.to(device) después de cargar, nunca antes guardar atómico: torch.save(ckpt, path + '.tmp') y os.replace(tmp, path), para que un corte a mitad de escritura no deje un archivo truncado
Por qué un checkpoint de entrenamiento pesa seis veces más que el modelo que sirve: Adam guarda momento y varianza en fp32 por cada parámetro, y sin ellos reanudar es empezar el optimizador de cero. Los pesos solos, en bf16 y en safetensors, alcanzan para inferir y son lo que se publica.

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:

  1. Primeros 8 bytes (entero sin signo de 64 bits little-endian, N): especifica la longitud exacta en bytes de la cabecera de metadatos JSON.
  2. Siguientes N 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].
  3. Bytes 8+N 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 y=RMSNorm(x+b)⊙σ(z), 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.

Las cuatro piezas de torch.compile, los guards que disparan recompilaciones y qué es un graph break Cadena: el bytecode de Python pasa por TorchDynamo, que captura las operaciones sobre tensores en un grafo FX con un hook del intérprete; AOTAutograd agrega el grafo del backward; PrimTorch reduce a unas 250 primitivas; TorchInductor fusiona y genera Triton o C++. Un guard fija shape y dtype: un shape nuevo recompila, salvo con dynamic=True. Donde Dynamo no puede capturar, corta el grafo y ese pedazo corre en Python. model = torch.compile(model) · lo que pasa en la primera llamada con inputs reales bytecode el forward de Python, sin cambios TorchDynamo hook PEP 523: captura las ops sobre tensores en un grafo FX AOTAutograd genera también el grafo del backward PrimTorch 2000+ ops → ~250 primitivas TorchInductor fusiona y genera Triton (GPU) o C++/OpenMP (CPU) GUARDS Y RECOMPILACIONES guard: shape (64, 128), dtype bf16 el código compilado vale mientras se cumpla llega (17, 128): recompila último batch del epoch dynamic=True default: recompila solo al 2º shape distinto GRAPH BREAK · donde Dynamo no puede, corta en vez de fallar grafo compilado 1 if x.sum() > 0: · .item() · print vuelve a Python eager para este pedazo grafo compilado 2 TORCH_LOGS=graph_breaks modos: default (fusión) · reduce-overhead (además CUDA graphs: sin Python ni dispatcher entre kernels; para decode y batches chicos) max-autotune prueba variantes de cada kernel y compila mucho más lento · la primera iteración paga la compilación: no la midas torch.export captura el grafo completo, sin graph breaks, para correr sin Python (AOTInductor, ExecuTorch)
Una línea, pero cuatro tecnologías detrás. Dynamo es la pieza nueva: en vez de trazar con tensores de mentira o parsear Python, intercepta el bytecode justo antes de ejecutarse y extrae lo que es tensor a un grafo. Lo que no puede capturar no rompe nada: parte el grafo en dos y ese pedazo corre en eager, y los guards de shape son la razón de las recompilaciones que ves en los logs.

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étricaResultado medido
Tasa de éxito de compilación93% 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

ModoQué haceCuá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íntomaCausa probableQué 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 verdadArquitectura 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 cargarSe 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 memorysave_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 recompilacionesUn 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 arribaCargaste 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

  1. state_dict es 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, y weights_only=True ya es el default desde 2.6.
  2. 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.
  3. 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.
  4. torch.compile no 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.
  5. 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.
  6. En la práctica: la primera llamada paga la compilación; dynamic=True para shapes variables; los graph breaks y recompilaciones se ven con TORCH_LOGS; torch.export + ExecuTorch es el camino cuando el destino no tiene Python.
  7. 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