Curso / PyTorch / Por qué ganó
PyTorch · 5 de 5

PyTorch · Fundamentos (cierre)

Por qué PyTorch ganó la carrera de frameworks

Todo lo que usaste en HF y fast.ai —Transformers, fastai, miniai, vLLM— corre sobre PyTorch, no por casualidad ni por moda. Esta página cierra el círculo con el contexto histórico y de ecosistema.

Antes de leerPyTorch 4 §4, torch.compile · HF cap. 3, fine-tuning (para la cuenta de memoria)

🎯 Al terminar vas a poder

  • Contar la cronología 2015-2025 de PyTorch y TensorFlow con fechas, y explicar por qué el orden en que cada uno resolvió el trade-off ergonomía/performance fue decisivo.
  • Explicar qué es "define-by-run" y qué costaba, en debugging concreto, el modelo de grafo estático.
  • Escribir el esqueleto mínimo de un entrenamiento con DDP (torchrun, init_process_group, DistributedSampler) y saber cuándo pasar a FSDP.
  • Estimar la memoria por GPU de DDP vs FSDP para un modelo dado, y reconocer los cinco bugs clásicos del entrenamiento multi-GPU.

1. La guerra de frameworks que ya casi nadie recuerda

A mediados de la década de 2010, TensorFlow (Google, 2015) dominaba investigación e industria con un modelo de grafo estático: se definía el grafo de cómputo completo primero (tf.placeholder, sesiones, sess.run()), y recién después se ejecutaba. Esto permitía optimizaciones agresivas antes de correr una sola operación, pero hacía que debuggear fuera una pesadilla — un error de shape solo aparecía al ejecutar la sesión, lejos del código que realmente tenía el bug, y no se podía usar un print() ni un debugger de Python en medio del grafo.

PyTorch (Meta/Facebook, 2017) apostó por lo opuesto desde el día uno: ejecución eager — cada operación corre inmediatamente, como cualquier código Python normal. Se puede poner un print(tensor) en medio del forward pass, usar pdb, y ver los valores reales en cada paso. Investigadores lo adoptaron masivamente porque iterar experimentos era radicalmente más rápido — la fracción de papers de NeurIPS/ICML que usaban PyTorch pasó de ser minoritaria a dominante en apenas unos años.

La idea no era original de PyTorch: Chainer (Preferred Networks, 2015) ya había bautizado el enfoque como define-by-run — el grafo se define mientras corre, no antes — y DyNet lo exploraba para NLP. PyTorch la tomó, la combinó con la base numérica de Torch7 (la librería en Lua que Facebook AI Research ya usaba) y con una API que se sentía como NumPy, y le agregó el ecosistema de una empresa grande detrás. El paper de NeurIPS 2019 lo dice explícitamente: el objetivo era "usabilidad y velocidad", en ese orden, sin ceder ninguna de las dos.

📐 Arquitectura del Caching Allocator de PyTorch en CUDA

Llamar de forma repetida a las funciones del driver cudaMalloc() y cudaFree() impone una penalización severa de rendimiento porque son operaciones síncronas que detienen la cola de comandos del procesador de la GPU.

Para solventar este obstáculo, PyTorch implementa un Caching Allocator basado en arenas de memoria. En la primera solicitud, reserva bloques continuos masivos de memoria virtual en VRAM mediante cudaMalloc. Posteriormente, gestiona las reservas y liberaciones de memoria de los tensores de Python en tiempo constante O(1) mediante segmentación interna, evitando sincronizaciones de bajo nivel y reutilizando buffers liberados entre iteraciones de entrenamiento.

CuándoQué pasóPor qué importa
Noviembre 2015Google libera TensorFlow como open source.Grafo estático, sesiones; se convierte en el estándar de facto en industria en un año.
2015-2016Chainer (define-by-run) y DyNet; Torch7 en FAIR.Las ideas y la base de código de las que PyTorch va a salir.
Enero 2017Primer release público de PyTorch (0.1), "beta".Eager por defecto, autograd por cinta, API tipo NumPy. Adopción rápida en investigación.
2018PyTorch 1.0: se fusiona con Caffe2 (el framework de producción de Facebook) y aparece TorchScript.Un solo framework para investigar y servir; el primer intento de "grafo cuando hace falta".
Septiembre 2019TensorFlow 2.0: eager execution por defecto, Keras como API principal.Google concede el punto. Pero la migración de TF1 a TF2 rompe código y el ecosistema de investigación ya está en PyTorch.
Diciembre 2019Paper de PyTorch en NeurIPS (arXiv:1912.01703).Documenta los principios de diseño: imperativo, Pythonic, con performance comparable a los frameworks de grafo.
Septiembre 2022Se crea la PyTorch Foundation bajo la Linux Foundation.Gobernanza neutral (Meta, AMD, AWS, Google, Microsoft, NVIDIA entre los fundadores): deja de ser "el framework de Facebook".
Diciembre 2022 → marzo 2023Se anuncia (PyTorch Conference) y se libera PyTorch 2.0 con torch.compile.Resuelve el costo de eager sin abandonarlo — ver la página anterior.
2024-2025Releases 2.x sucesivos: FSDP2, FlexAttention, torch.export estable, weights_only por defecto (2.6), soporte Blackwell (2.7).El foco pasa a entrenamiento distribuido a escala de LLM y a deployment; el framework ya no compite, define el estándar.
Cronología 2015 a 2025: TensorFlow, PyTorch y el momento en que cada uno resolvió ergonomía y performance Línea de tiempo con diez hitos: Chainer y TensorFlow 1.0 en 2015, PyTorch 0.1 en 2017, PyTorch 1.0 con Caffe2 y TorchScript en 2018, TensorFlow 2.0 con eager por defecto y el paper de NeurIPS en 2019, la PyTorch Foundation en 2022, PyTorch 2.0 con torch.compile y Keras 3 en 2023, y las versiones 2.6 y 2.7 en 2025. 2015 → 2025 · DOS APUESTAS Y UNA CONVERGENCIA 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 Chainer · define-by-run la idea que PyTorch toma TensorFlow 1.0 grafo estático, sesiones PyTorch 0.1 eager por defecto, autograd por cinta PyTorch 1.0 + Caffe2 TorchScript: primer intento de grafo TensorFlow 2.0 eager por defecto paper NeurIPS arXiv 1912.01703 PyTorch Foundation gobernanza neutral PyTorch 2.0 torch.compile: eager + grafo opcional Keras 3 corre sobre JAX, TF o PyTorch 2.6 / 2.7 weights_only por defecto · Blackwell ámbar: PyTorch · violeta: TensorFlow y Keras · el efecto de red (papers → tutoriales → gente formada) ya estaba del lado de PyTorch cuando TF 2.0 adoptó eager, y la ruptura de compatibilidad TF1 → TF2 empujó en la misma dirección Google terminó construyendo JAX, otro estilo, para investigación
El orden importa más que las fechas: PyTorch eligió la ergonomía primero y resolvió la performance seis años después sin cambiar de paradigma; TensorFlow eligió la performance primero y tuvo que romper compatibilidad para adoptar eager, cuando la investigación ya había migrado.

2. Por qué "eager por defecto" fue la apuesta correcta

La presión de TensorFlow fue tan fuerte que su propia versión 2.0 (2019) adoptó eager execution por defecto, validando retroactivamente la apuesta de PyTorch. Pero para ese momento el ecosistema de investigación ya había migrado: los papers nuevos salían con código de referencia en PyTorch, lo cual generaba más tutoriales, más Stack Overflow, más ingenieros formados en PyTorch — un efecto de red que se retroalimentaba solo.

El costo de performance de eager (visto en la página anterior) se resolvió después, con torch.compile — pero se resolvió sin abandonar la experiencia eager por defecto: el modelo se sigue escribiendo y debuggeando en modo eager, y la compilación es un paso opcional y aditivo al final. TensorFlow resolvió el mismo problema al revés: empezó grafo-primero y agregó eager después. La secuencia en la que cada framework resolvió el trade-off performance/ergonomía terminó siendo la diferencia decisiva.

Vale ser concreto sobre qué significa "ergonomía", porque suena a gusto personal y no lo es. En un grafo estático, un modelo con un loop cuyo largo depende de los datos (un decoder que genera hasta encontrar el token de fin, un árbol recursivo, un algoritmo de búsqueda) requería construir el control de flujo dentro del grafo con primitivas especiales (tf.while_loop, tf.cond), que no eran Python y no se podían depurar como Python. En eager, es un while. Para un campo cuyo trabajo diario es probar arquitecturas nuevas, esa diferencia se mide en experimentos por semana. Y el argumento de performance a favor del grafo estático resultó menos fuerte de lo que parecía: la mayoría del tiempo se va en matmuls que ejecutan librerías de NVIDIA (cuBLAS, cuDNN) igual de rápido desde cualquier framework — el grafo estático solo ganaba en el pegamento alrededor, que es justamente lo que torch.compile recuperó después.

Hay un segundo factor menos comentado: TensorFlow 2.0 rompió la compatibilidad con TF1 en un momento en que buena parte del código de producción de la industria era TF1. Migrar costaba tanto como cambiar de framework, y si había que cambiar, PyTorch era donde estaban los papers. El propio Google terminó construyendo JAX (grafo funcional + compilación XLA) para su investigación interna — excelente para TPUs y para ciertos estilos de código, pero un tercer framework, no una resurrección de TensorFlow. En 2026, la división de facto es: PyTorch para casi todo, JAX en una parte de la investigación y en la infraestructura de Google/DeepMind, TensorFlow en sistemas heredados y en algunos nichos de deployment móvil.

3. El ecosistema que se construyó encima

Prácticamente todo lo que recorriste en este curso corre sobre PyTorch como capa base:

  • Hugging Face Transformers — la librería central de todo el HF LLM Course — usa PyTorch como backend principal (en versiones recientes, el único backend de primera clase; los de TensorFlow y JAX quedaron en mantenimiento).
  • fastai y miniai — toda la Parte 1 y Parte 2 de fast.ai — son capas de conveniencia sobre torch.nn, torch.optim y torch.utils.data.
  • vLLM, el motor de inferencia detrás de tus propios fixes upstream, está construido sobre PyTorch (con kernels custom en CUDA/Triton para las partes críticas de performance).
  • Diffusers de HF, que usaste en la Unidad 5 de fast.ai Parte 2, es PyTorch puro.
  • torchtitan, la librería de referencia de Meta para entrenar LLMs desde cero a escala de producción, vive directamente dentro del ecosistema oficial de PyTorch.
  • PEFT, TRL, Accelerate, bitsandbytes, torchao, Triton — todo lo que usaste para LoRA, cuantización y kernels custom asume tensores y módulos de PyTorch como moneda común. Triton, el lenguaje en que están escritos los kernels de torch.compile y buena parte de los de vLLM, nació independiente y hoy es un pilar del stack.

Esto no es casualidad: cuando el framework de investigación de facto y el framework de producción de facto son el mismo, el costo de llevar un modelo de "funciona en un notebook" a "sirve tráfico real" baja drásticamente. Es una de las razones estructurales por las que el ciclo de investigación → producción en LLMs se acortó tanto en los últimos años.

El mecanismo es concreto: un state_dict de PyTorch (o su safetensors) es la interfaz universal. El mismo checkpoint que sale de un entrenamiento con torchtitan o con Trainer lo carga vLLM para servir, lo cuantiza torchao, lo fine-tunea PEFT, lo exporta ExecuTorch. Ninguna de esas librerías tuvo que ponerse de acuerdo con las demás — solo con PyTorch.

4. Entrenamiento distribuido: DDP y FSDP

Todo lo visto hasta acá asume un solo dispositivo. Entrenar modelos del tamaño de los que usás en tus propios proyectos (Gemma-4, modelos de 30B+ parámetros) requiere repartir el trabajo entre varias GPUs — dos estrategias nativas de PyTorch, con roles distintos:

  • DDP (DistributedDataParallel): cada GPU tiene una copia completa del modelo y procesa un subconjunto distinto del batch; los gradientes se promedian entre todas las copias antes de cada step. Simple y eficiente cuando el modelo entero entra en la memoria de una sola GPU.
  • FSDP (Fully Sharded Data Parallel): cuando el modelo no entra en una sola GPU, FSDP parte (shardea) los parámetros, gradientes y estados del optimizador entre todas las GPUs disponibles, reconstruyendo cada capa completa solo momentáneamente durante su forward/backward. Es el mecanismo detrás de entrenar modelos de decenas o cientos de miles de millones de parámetros sin necesitar una sola GPU con memoria imposible.

Ninguna de las dos requiere reescribir la arquitectura del modelo — se aplican como una capa que envuelve el nn.Module ya definido, otra vez el mismo patrón de "agregar una capa sin tocar el modelo" que ya viste con torch.compile.

El esqueleto de DDP

El modelo mental: torchrun lanza N procesos idénticos (uno por GPU), cada uno con su RANK y LOCAL_RANK en variables de entorno; init_process_group los conecta; DistributedSampler le da a cada uno una porción distinta del dataset; y el wrapper DDP engancha un hook al backward que hace all-reduce de los gradientes (promedio entre procesos) solapado con el cómputo. Después de backward(), las N copias tienen el mismo gradiente y cada una hace el mismo step() — siguen idénticas sin sincronizar pesos.

# torchrun --nproc_per_node=4 train.py        ← 4 procesos, uno por GPU
import os, torch, torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data import DataLoader, DistributedSampler

dist.init_process_group("nccl")                      # lee RANK/WORLD_SIZE/MASTER_ADDR del entorno
rank, local_rank = dist.get_rank(), int(os.environ["LOCAL_RANK"])
torch.cuda.set_device(local_rank)                     # sin esto, todos los ranks van a cuda:0

model = MiModelo().to(local_rank)
model = DDP(model, device_ids=[local_rank])           # model.module es el modelo real

sampler = DistributedSampler(dataset)                 # cada rank ve 1/N del dataset, sin solaparse
loader = DataLoader(dataset, batch_size=32, sampler=sampler)   # 32 por GPU → 128 global
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)

for epoch in range(epochs):
    sampler.set_epoch(epoch)                          # sin esto, el shuffle es idéntico cada epoch
    for X, y in loader:
        X, y = X.to(local_rank), y.to(local_rank)
        loss = loss_fn(model(X), y)
        loss.backward()                               # all-reduce de gradientes, solapado
        optimizer.step(); optimizer.zero_grad()
    if rank == 0:                                     # un solo proceso loggea y guarda
        print(epoch, loss.item())
        torch.save(model.module.state_dict(), "ckpt.pt")
    dist.barrier()                                    # que nadie siga hasta que rank 0 terminó de guardar

dist.destroy_process_group()

El esqueleto de FSDP

FSDP tiene dos APIs. La original (FullyShardedDataParallel, "FSDP1") envuelve el modelo con una política de qué submódulos shardear; la nueva (fully_shard, "FSDP2", construida sobre DTensor) se aplica capa por capa, es más simple de componer con tensor parallelism y torch.compile, y es la recomendada en versiones recientes. El resto del loop es idéntico al de DDP — lo que cambia es la línea del wrapper:

# FSDP1
from torch.distributed.fsdp import FullyShardedDataParallel as FSDP, MixedPrecision
from torch.distributed.fsdp.wrap import transformer_auto_wrap_policy
import functools
policy = functools.partial(transformer_auto_wrap_policy, transformer_layer_cls={TransformerBlock})
model = FSDP(model, auto_wrap_policy=policy, device_id=local_rank,
             mixed_precision=MixedPrecision(param_dtype=torch.bfloat16, reduce_dtype=torch.float32))

# FSDP2 (versiones recientes): shardear cada bloque y después la raíz
from torch.distributed.fsdp import fully_shard, MixedPrecisionPolicy
mp = MixedPrecisionPolicy(param_dtype=torch.bfloat16, reduce_dtype=torch.float32)
for block in model.blocks:
    fully_shard(block, mp_policy=mp)
fully_shard(model, mp_policy=mp)                      # el modelo sigue siendo el mismo nn.Module

La política de wrap importa porque define la unidad de shardeo: FSDP reconstruye (all-gather) los parámetros de una unidad justo antes de su forward, los descarta después, y los vuelve a reunir en el backward. Shardear por bloque de transformer es el equilibrio habitual — unidades más chicas gastan más comunicación, una sola unidad gigante no ahorra memoria durante el cómputo.

DDP vs FSDP: la cuenta de memoria

Para decidir, la cuenta que hay que hacer es de bytes por parámetro entrenado con AdamW en precisión mixta: 2 (peso bf16) + 2 (gradiente bf16) + 4 (master weight fp32) + 8 (momento y varianza fp32) = 16 bytes por parámetro, sin contar activaciones. Un modelo de 7B son 112 GB de estado de entrenamiento — no entra en ninguna GPU sola, y esa es la razón de FSDP.

📐 Formulación matemática y volumen de comunicación: DDP vs FSDP

En DDP (DistributedDataParallel), cada uno de los N aceleradores mantiene una copia idéntica de los parámetros W y del optimizador. La sincronización se realiza mediante una operación AllReduce de gradientes tras el backward pass. El volumen de comunicación por GPU es:

VDDP=2×N-1N×|W|≈2|W| bytes

En FSDP (Fully Sharded Data Parallel / ZeRO-3), los tres componentes de memoria se particionan equitativamente entre las N GPUs: estados del optimizador (1N), gradientes (1N) y pesos (1N). En el forward pass, se ejecuta un AllGather para reconstruir transitoriamente los pesos de cada capa; en el backward pass, se ejecuta un segundo AllGather y una operación ReduceScatter para reducir los gradientes. El volumen de comunicación resultante es:

VFSDP=3×N-1N×|W|≈3|W| bytes

Se incrementa la comunicación en un 1.5× respecto a DDP, pero la memoria de pesos se reduce en un factor de N, permitiendo entrenar LLMs que exceden la VRAM de un solo nodo.

Memoria de estado de entrenamiento por GPU: DDP replica todo, FSDP shardea todo Cuatro GPUs a cada lado. DDP: cada una guarda pesos bf16, gradientes bf16, master weights fp32 y los dos momentos de Adam en fp32, 16 bytes por parámetro, 112 GB para 7B. FSDP: cada una guarda un cuarto de cada cosa, 28 GB, más el bloque que está reconstruido en ese momento; con 8 GPUs, unos 14 GB. Las activaciones son iguales en ambos. 7B PARÁMETROS · AdamW · MIXED PRECISION · 16 BYTES POR PARÁMETRO DE ESTADO DDP · CADA GPU TIENE TODO GPU 0 pesos bf16 grad bf16 master fp32 Adam m, v fp32 112 GB GPU 1 pesos bf16 grad bf16 master fp32 Adam m, v fp32 112 GB GPU 2 pesos bf16 grad bf16 master fp32 Adam m, v fp32 112 GB GPU 3 pesos bf16 grad bf16 master fp32 Adam m, v fp32 112 GB copia completa por GPU: 16 B × 7B = 112 GB; no entra en ninguna GPU sola comunicación: un all-reduce de gradientes por step, solapado con el backward FSDP · 1/N DE TODO, MÁS EL BLOQUE EN USO GPU 0 28 GB + bloque GPU 1 28 GB + bloque GPU 2 28 GB + bloque GPU 3 28 GB + bloque cada franja es 1/4 de la de DDP · el rectángulo ámbar es el bloque de transformer reconstruido para su forward o backward con 8 GPUs: 16/8 = 2 B/param ≈ 14 GB por GPU all-gather de los parámetros del bloque antes de usarlo; después se descartan · reduce-scatter de gradientes ≈1,5× el tráfico de DDP, sensible a NVLink vs PCIe activaciones: iguales en las dos (se reducen con activation checkpointing, no con sharding) · LoRA sobre base congelada: el estado de Adam es despreciable y el caso vuelve a ser DDP o una sola GPU · unidad de shardeo = bloque de transformer (auto_wrap_policy): más chica gasta más comunicación
La cuenta que decide entre los dos: 2 + 2 + 4 + 8 bytes por parámetro entrenado con AdamW en precisión mixta. DDP necesita que todo eso entre en una GPU; FSDP lo reparte y paga en comunicación, reconstruyendo cada bloque solo el rato que lo usa. Las activaciones no las reduce ninguno: eso es activation checkpointing.
DDPFSDP (full shard)
Parámetros, gradientes y estado del optimizador por GPUCopia completa: ~16 bytes/parámetro con AdamW en mixed precision (7B → 112 GB por GPU).1/N de todo: con 8 GPUs, ~2 bytes/parámetro (7B → 14 GB por GPU) más la capa que está reconstruida en ese momento.
Activaciones por GPULas de su micro-batch.Las de su micro-batch (igual; se reducen con activation checkpointing, no con el sharding).
Comunicación por stepUn all-reduce de gradientes, solapado con el backward. Tráfico ≈ 2× el tamaño de los gradientes.All-gather de parámetros en el forward, otro en el backward, reduce-scatter de gradientes. Tráfico ≈ 1,5× el de DDP; más sensible al interconnect (NVLink vs PCIe).
Cambios en el códigoUn wrapper. El state_dict se guarda desde model.module.Un wrapper por unidad. Guardar y cargar requiere las utilidades de torch.distributed.checkpoint (cada rank tiene solo su shard).
CuándoEl modelo con su estado de optimizador entra en una GPU: fine-tuning de modelos chicos, LoRA sobre un modelo base congelado (el base ocupa solo 2 bytes/parámetro), visión, la mayoría de los casos de menos de ~2B parámetros entrenables.No entra, o entra tan justo que no queda lugar para batch: full fine-tuning y pre-entrenamiento de modelos de 7B para arriba. Equivale a ZeRO stage 3 de DeepSpeed.

Y cuando ni con FSDP alcanza — porque una sola capa no entra en una GPU, o porque el all-gather por capa domina el tiempo — entran las otras dimensiones: tensor parallelism parte cada matmul entre GPUs (cada una calcula una porción de las columnas de la proyección) y pipeline parallelism asigna bloques consecutivos del modelo a GPUs distintas y las encadena con micro-batches. PyTorch las ofrece como torch.distributed.tensor.parallel y torch.distributed.pipelining, y torchtitan las compone con FSDP en lo que se llama paralelismo "3D" o "4D" — la receta con la que hoy se entrenan los modelos de cientos de miles de millones de parámetros.

🔧 En tu proyecto

Tu fine-tuning con LoRA sobre una sola RTX 5090 es el caso que la tabla ubica del lado de DDP — o de ninguna de las dos: con el modelo base congelado en 4 bits y solo los adapters en fp32, el estado de AdamW es despreciable y todo el presupuesto de memoria va a activaciones y KV cache. El día que quieras full fine-tuning de un modelo de 7B o más en varias GPUs, la cuenta de 16 bytes por parámetro de arriba es lo primero que hay que hacer, y FSDP2 con MixedPrecisionPolicy es por donde empezar.

5. Errores comunes en entrenamiento distribuido

Los bugs multi-GPU rara vez son de matemática — son de coordinación entre procesos que corren el mismo script.

SíntomaCausa probableQué hacer
La loss de cada epoch se parece sospechosamente a la del anterior; el modelo generaliza peor que en una GPUDistributedSampler sin sampler.set_epoch(epoch): usa el mismo seed cada epoch, así que cada rank ve los mismos batches en el mismo orden.sampler.set_epoch(epoch) al principio de cada epoch, antes de iterar (§4).
Con 8 GPUs el entrenamiento converge distinto que con 1, a pesar de "los mismos hiperparámetros"batch_size del DataLoader es por proceso: 32 por GPU son 256 globales, y el lr, el warmup y los steps por epoch se calcularon para 32.Pensá siempre en batch global = por GPU × world size; ajustá lr (regla lineal o raíz cuadrada) y contá steps por epoch como len(dataset) / batch_global (§4).
Cada línea de log aparece N veces; N barras de progreso; N runs en W&B; el checkpoint se corrompe o se escribe N vecesLos N procesos ejecutan el mismo código de logging y guardado.if rank == 0: para loggear, guardar y crear directorios; dist.barrier() después de guardar para que los demás no sigan (o no terminen) mientras rank 0 escribe (§4).
RuntimeError: Expected to have finished reduction in the prior iteration before starting a new one...Algún parámetro no participó en el cómputo de la loss en esa iteración (una rama condicional, una cabeza auxiliar que solo se usa a veces), y DDP espera un gradiente que nunca llega.Idealmente, que todos los parámetros se usen en cada forward; si no es posible, DDP(model, find_unused_parameters=True), con un costo de un recorrido extra del grafo por iteración (§4).
OOM en cuda:0 mientras las otras GPUs están vacíasFalta torch.cuda.set_device(local_rank): todos los procesos asignan en la GPU 0 por defecto.Primera línea después de init_process_group; y .to(local_rank), no .to("cuda") (§4).
El entrenamiento se congela sin error y minutos después muere con un timeout de NCCLUn rank tomó un camino distinto (evaluó en rank 0 mientras los demás esperaban una colectiva, o un rank tuvo un batch menos). Las colectivas necesitan que todos participen.Mismo número de iteraciones en todos los ranks (drop_last=True ayuda); evaluación en todos los ranks o con barreras explícitas; TORCH_DISTRIBUTED_DEBUG=DETAIL para localizarlo (§4).
Cargar el checkpoint de DDP en un modelo sin wrapper falla con claves module.*Se guardó model.state_dict() en vez de model.module.state_dict().Ver la tabla de errores de la página anterior: guardar desde .module o quitar el prefijo al cargar (§4).
Con FSDP, model.state_dict() en rank 0 tiene solo una fracción de los pesos, o el guardado se queda sin memoriaCada rank tiene solo su shard; pedir el state_dict completo exige reunir todo en una GPU.Usá torch.distributed.checkpoint (guarda shardeado, carga con cualquier world size) o las utilidades de get_model_state_dict con la opción de full state dict descargada a CPU (§4).

6. Resumen — cierre de PyTorch

  1. PyTorch ganó apostando por ejecución eager cuando TensorFlow apostaba por grafos estáticos — más fácil de debuggear, más rápido de iterar para investigación. La idea (define-by-run) venía de Chainer; la ejecución y el ecosistema fueron de PyTorch.
  2. El efecto de red (papers → tutoriales → ingenieros formados) se retroalimentó solo, incluso después de que TensorFlow 2.0 adoptara eager también — y la migración rota de TF1 a TF2 empujó en la misma dirección.
  3. PyTorch resolvió el costo de performance de eager sin sacrificar la ergonomía — torch.compile es opcional y aditivo, no un cambio de paradigma forzado. La cronología: 2015 TF, 2017 PyTorch, 2018 1.0, 2019 TF2, 2022 Foundation, 2023 2.0.
  4. El ecosistema completo de este curso (HF Transformers, fastai/miniai, vLLM, Diffusers, PEFT, torchao) corre sobre la misma base — investigación y producción comparten framework, y el state_dict es la interfaz común.
  5. DDP y FSDP escalan el mismo nn.Module sin reescribirlo — de una GPU a un cluster, la arquitectura del modelo no cambia. DDP replica (16 bytes/parámetro con AdamW); FSDP shardea (1/N); tensor y pipeline parallelism cubren lo que ni FSDP alcanza.
  6. Los bugs distribuidos son de coordinación: set_epoch, batch global vs por GPU, logging y checkpoints solo desde rank 0, parámetros sin usar, ranks que divergen.

7. Ponete a prueba

Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.

1. TensorFlow 2.0 adoptó eager en 2019. ¿Por qué eso no le devolvió el liderazgo?

Porque para entonces el efecto de red ya estaba del otro lado: papers con código en PyTorch, tutoriales, ingenieros formados. Además TF2 rompió compatibilidad con TF1, así que migrar costaba tanto como cambiar de framework. Y PyTorch resolvió después el problema de performance (torch.compile, 2023) sin cambiar la experiencia por defecto, mientras que TF había hecho el cambio al revés. (§1, §2)

2. ¿Qué es "define-by-run" y qué ejemplo concreto de modelo era penoso de escribir sin él?

El grafo se construye mientras el código corre, en vez de definirse antes y ejecutarse después. Cualquier modelo con control de flujo que depende de los datos — un decoder que genera hasta el token de fin, estructuras recursivas — necesitaba primitivas especiales (tf.while_loop, tf.cond) en grafo estático; en eager es un while de Python, con print y pdb disponibles. (§1, §2)

3. Querés hacer full fine-tuning de un modelo de 13B con AdamW en bf16 mixed precision sobre 8 GPUs de 80 GB. ¿DDP o FSDP? Mostrá la cuenta.

16 bytes/parámetro × 13B = 208 GB de estado de entrenamiento. Con DDP cada GPU necesitaría los 208 GB: imposible. Con FSDP full shard sobre 8 GPUs, 208/8 = 26 GB por GPU más la capa reconstruida y las activaciones: entra con margen para batch. FSDP. (§4)

4. Entrenás con DDP y la loss por epoch parece repetirse. ¿Qué línea falta y por qué?

sampler.set_epoch(epoch). DistributedSampler genera la permutación con un seed fijo más el número de epoch; sin actualizarlo, cada epoch reparte exactamente los mismos batches en el mismo orden a cada rank, así que el "shuffle" es nulo entre epochs. (§4, §5)

5. ¿Por qué DDP no necesita sincronizar los pesos después de cada optimizer.step()?

Porque todas las copias arrancan idénticas (DDP transmite los pesos del rank 0 al construirse) y después de cada backward tienen el mismo gradiente (all-reduce promedia). Aplicar el mismo optimizador determinístico al mismo estado con el mismo gradiente da los mismos pesos en todos los ranks — la sincronización es de gradientes, no de pesos. (§4)

6. ¿Cuándo find_unused_parameters=True es la solución correcta y cuándo es un parche?

Es correcto cuando de verdad hay parámetros que legítimamente no participan en algunas iteraciones (una cabeza auxiliar que se usa cada K steps, ramas por tipo de dato). Es un parche cuando el parámetro sin usar es un bug (una capa declarada y nunca llamada en forward) — ahí lo que corresponde es borrar la capa, porque el flag cuesta un recorrido extra del grafo en cada iteración. (§5)

8. Para practicar

✏️ Ejercicio 1 — DDP real en tu CPU, con dos procesos

No hace falta GPU: el backend gloo corre DDP sobre CPU. Guardá esto como ddp_cpu.py y lanzalo con torchrun --nproc_per_node=2 ddp_cpu.py (Linux o macOS). Observá qué imprime cada rank, y después comentá set_epoch y mirá qué pasa con los índices.

import os, torch, torch.nn as nn, torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data import DataLoader, DistributedSampler, TensorDataset

dist.init_process_group("gloo")
rank, world = dist.get_rank(), dist.get_world_size()
torch.manual_seed(0)                                  # DDP igual copia los pesos de rank 0

ds = TensorDataset(torch.arange(16).float().unsqueeze(1), torch.arange(16).float().unsqueeze(1) * 2)
sampler = DistributedSampler(ds)
loader = DataLoader(ds, batch_size=4, sampler=sampler)
model = DDP(nn.Linear(1, 1))
opt = torch.optim.SGD(model.parameters(), lr=1e-3)

for epoch in range(2):
    sampler.set_epoch(epoch)
    vistos = []
    for X, y in loader:
        vistos += X.flatten().int().tolist()
        loss = ((model(X) - y) ** 2).mean()
        loss.backward(); opt.step(); opt.zero_grad()
    print(f"epoch {epoch} rank {rank}/{world} vio {vistos}  peso={model.module.weight.item():.5f}")

dist.destroy_process_group()
Pista / qué esperar

Cada rank ve 8 de los 16 índices, sin solaparse con el otro, y en un orden distinto en el epoch 1 que en el 0. El peso impreso es idéntico en los dos ranks después de cada epoch — prueba de que el all-reduce de gradientes mantiene las copias sincronizadas. Sin set_epoch, el orden del epoch 1 repite el del 0. Si lanzás el script con python en vez de torchrun, falla al no encontrar las variables de entorno — así se ve que torchrun es quien crea los procesos.

✏️ Ejercicio 2 — la cuenta de memoria para tres escenarios

Sin código: completá la tabla para (a) LoRA sobre un 8B base en bf16 con 20M de parámetros entrenables, (b) full fine-tuning de un 8B, (c) full fine-tuning de un 70B, en cada caso con AdamW en mixed precision. Calculá los GB de estado de entrenamiento por GPU con DDP y con FSDP sobre 4 y sobre 8 GPUs, y decidí qué estrategia va y si entra en GPUs de 80 GB (ignorá activaciones).

Pista / qué esperar

(a) Base congelado: 8B × 2 bytes = 16 GB, más 20M × 16 = 0,32 GB de estado entrenable — entra en una GPU; DDP si querés más throughput, FSDP no aporta casi nada. (b) 8B × 16 = 128 GB — DDP no entra; FSDP/4 = 32 GB, FSDP/8 = 16 GB por GPU, entra. (c) 70B × 16 = 1 120 GB — FSDP/8 = 140 GB por GPU, no entra en 80 GB: hacen falta más GPUs (16+), o combinar con tensor/pipeline parallelism, o cuantizar el estado del optimizador. Esa última fila es por qué torchtitan compone FSDP con TP y PP.

9. Fuentes y para seguir