Phase 10 - Lesson 05
Escalamiento: Entrenamiento Distribuido, FSDP, DeepSpeed
Su modelo de 124M se entrenó en una sola GPU. Ahora intente con 7 mil millones de parámetros. El modelo no cabe en la memoria. El procesamiento de los datos toma semanas en una sola máquina. El entrenamiento distribuido no es opcional a gran escala. Es el único camino a seguir.
Tipo: Construcción Idiomas: Python Requisitos previos: Fase 10, Lección 04 (Pre-entrenamiento de un Mini GPT) Tiempo: ~120 minutos
Objetivos de aprendizaje
- Explicar los tres tipos de paralelismo (de datos, de tensor y de pipeline) y cuándo es necesario cada uno según el tamaño del modelo y del cluster
- Implementar el entrenamiento paralelo de datos usando PyTorch DDP con sincronización de gradientes en múltiples GPUs
- Calcular el presupuesto de memoria para un tamaño de modelo determinado (pesos + estados del optimizador + gradientes + activaciones) para determinar el hardware mínimo
- Configurar FSDP o etapas de DeepSpeed ZeRO para fragmentar (shard) los estados del modelo entre las GPUs y ajustar modelos que superen la memoria de una sola GPU
El problema
Un modelo de 7B parámetros en FP16 necesita 14 GB solo para los pesos. El optimizador Adam almacena dos copias adicionales de cada parámetro (estimaciones de primer y segundo momento). Eso equivale a otros 28 GB. Los gradientes durante la propagación hacia atrás (backpropagation) agregan 14 GB más. Ya se encuentra en 56 GB antes de almacenar una sola activación.
Una NVIDIA A100 tiene 80 GB de memoria.
56 GB de los 80 GB consumidos. Eso deja 24 GB para activaciones: los valores intermedios calculados durante el paso hacia adelante (forward pass) que deben mantenerse activos para la propagación hacia atrás. Para una secuencia de 2048 tokens con un modelo de 4096 dimensiones, las activaciones de una sola capa consumen alrededor de 64 MB. Con 32 capas, necesita 2 GB por muestra. Un tamaño de lote (batch size) de 8 requiere 16 GB. Tiene 24 GB. Un tamaño de lote de 12 explota (blows up).
Ahora intente con 70B parámetros. Pesos solos: 140 GB en FP16. No cabe en una sola GPU. Necesita al menos 2 A100s (2 x 80 GB = 160 GB) solo para sostener los pesos. Agregue los estados del optimizador y gradientes y necesitará mucho más: un mínimo de 3+ GPUs, y de forma realista entre 8 y 16 dependiendo de la estrategia de fragmentación (sharding).
Llama 3 405B fue entrenado en 16,384 GPUs NVIDIA H100. La ejecución del entrenamiento costó un estimado de 100 millones de dólares en cómputo. DeepSeek V3 entrenó un modelo comparable por aproximadamente 5.6 millones de dólares al ser inteligente con la arquitectura (la Mezcla de Expertos, o Mixture of Experts, significa que solo una fracción de los parámetros se activa por token) y la eficiencia del entrenamiento.
Esta lección cubre las cuatro estrategias que hacen posible el entrenamiento a gran escala: paralelismo de datos, paralelismo de tensor, paralelismo de pipeline y paralelismo de datos totalmente fragmentado (Fully Sharded Data Parallelism). Simulará cada una en Python puro para comprender la mecánica antes de tocar cualquier framework de entrenamiento distribuido.
El concepto
Por qué se requiere la distribución
Aquí está la matemática de memoria para modelos reales. Cada número está calculado, no estimado.
| Modelo | Parámetros | Pesos (FP16) | Estados de Adam | Gradientes (FP16) | Total (sin activaciones) |
|---|---|---|---|---|---|
| GPT-2 Small | 124M | 248 MB | 992 MB | 248 MB | 1.5 GB |
| Llama 3 8B | 8B | 16 GB | 64 GB | 16 GB | 96 GB |
| Llama 3 70B | 70B | 140 GB | 560 GB | 140 GB | 840 GB |
| Llama 3 405B | 405B | 810 GB | 3,240 GB | 810 GB | 4,860 GB |
La columna "Estados de Adam" es la asesina. Adam almacena una media móvil (m) y una varianza móvil (v) para cada parámetro, ambas en FP32. Para un modelo de 70B, eso es 70B x 4 bytes x 2 = 560 GB. El optimizador solo necesita siete A100s.
Una sola H100 tiene 80 GB. Llama 3 405B necesita al menos 61 H100s para sostener los pesos, el optimizador y los gradientes. Agregue las activaciones y el número crece aún más. Meta usó 16,384 GPUs no porque quisieran, sino porque tenían que hacerlo.
Paralelismo de datos
La estrategia distribuida más simple. Copie todo el modelo en N GPUs. Divida cada lote de entrenamiento en N partes iguales. Cada GPU ejecuta un paso hacia adelante y hacia atrás en su fragmento de los datos. Después del paso hacia atrás, promedie los gradientes en todas las GPUs. Cada GPU actualiza su copia de los pesos con los mismos gradientes promediados, manteniendo todas las copias sincronizadas.
Lo bueno: Escalabilidad lineal del rendimiento (throughput). N GPUs procesan N veces más datos por paso. La comunicación se limita al promedio de gradientes, que se superpone con el cómputo.
Lo malo: Cada GPU contiene una copia completa del modelo, los estados del optimizador y los gradientes. Para un modelo de 70B, cada GPU necesita 840 GB. El paralelismo de datos no hace nada para reducir la memoria por GPU. Solo reduce el tiempo de entrenamiento.
La matemática: Tamaño de lote efectivo = tamaño_de_lote_por_gpu x N. Para N=64 GPUs con un lote por GPU de 16, el lote efectivo es 1,024. Llama 3 usó un tamaño de lote efectivo de 16 millones de tokens por paso.
graph TD
subgraph DataParallel["Paralelismo de datos (N=4 GPUs)"]
B["Lote Completo\n(1024 muestras)"] --> S["Dividir"]
S --> G1["GPU 1\nCopia Completa del Modelo\n256 muestras"]
S --> G2["GPU 2\nCopia Completa del Modelo\n256 muestras"]
S --> G3["GPU 3\nCopia Completa del Modelo\n256 muestras"]
S --> G4["GPU 4\nCopia Completa del Modelo\n256 muestras"]
G1 --> AR["AllReduce\nPromediar Gradientes"]
G2 --> AR
G3 --> AR
G4 --> AR
AR --> U["Actualización\n(idéntica en todas las GPUs)"]
end
style B fill:#1a1a2e,stroke:#e94560,color:#fff
style G1 fill:#1a1a2e,stroke:#0f3460,color:#fff
style G2 fill:#1a1a2e,stroke:#0f3460,color:#fff
style G3 fill:#1a1a2e,stroke:#0f3460,color:#fff
style G4 fill:#1a1a2e,stroke:#0f3460,color:#fff
style AR fill:#1a1a2e,stroke:#51cf66,color:#fff
style U fill:#1a1a2e,stroke:#51cf66,color:#fff
Paralelismo de tensor
Divida capas individuales entre las GPUs. Una sola multiplicación de matrices se divide entre GPUs, cada una computando parte del resultado.
Considere una matriz de pesos de forma (8192, 8192) en una capa feedforward. Con paralelismo de tensor de 4 vías, cada GPU contiene un fragmento de (8192, 2048). Cada GPU multiplica la entrada por su fragmento, producing un resultado parcial. Los resultados parciales se combinan (a través de all-reduce o all-gather) para producir la salida completa.
Lo bueno: Reduce la memoria por GPU para los pesos del modelo. Un modelo de 70B dividido en 8 GPUs significa que cada GPU contiene aproximadamente ~8.75B parámetros de peso.
Lo malo: Requiere una comunicación rápida entre GPUs después de cada capa. El all-reduce después de cada matmul añade latencia. Esto funciona bien con NVLink (900 GB/s entre GPUs en el mismo nodo) pero mal entre nodos conectados por InfiniBand (400 Gb/s, aproximadamente 50 GB/s). El paralelismo de tensor casi siempre se limita al interior de un solo nodo (8 GPUs).
Uso real: Megatron-LM fue pionero en el paralelismo de tensor. Llama 3 405B utiliza paralelismo de tensor de 8 vías dentro de cada nodo.
Paralelismo de pipeline
Divida el modelo por capas. GPU 1 ejecuta las capas 1-8. GPU 2 ejecuta las capas 9-16. GPU 3 ejecuta las capas 17-24. GPU 4 ejecuta las capas 25-32. Los datos fluyen a través del pipeline: GPU 1 calcula sus capas y envía las activaciones a GPU 2, que calcula sus capas y envía a GPU 3, y así sucesivamente.
Lo bueno: Comunicación mínima entre GPUs: solo las activaciones en los límites de las capas, que son pequeñas en comparación con los gradientes o pesos. Funciona entre nodos porque los requisitos de ancho de banda son bajos.
Lo malo: Burbujas de pipeline (pipeline bubbles). Cuando GPU 4 está computando el paso hacia adelante en el micro-lote 1, las GPUs 1, 2 y 3 están inactivas (ya han enviado su porción). Durante el paso hacia atrás, el patrón se invierte. Con un pipeline ingenuo, la utilización de la GPU es de solo 1/N para N etapas de pipeline.
GPipe y PipeDream resuelven el problema de las burbujas al dividir el lote en micro-lotes. GPU 1 comienza con el micro-lote 2 tan pronto como termina de enviar el micro-lote 1. Esto superpone la computación entre las etapas del pipeline. Con M micro-lotes y N etapas, la fracción de burbuja cae a (N-1)/M. Use M=16 micro-lotes con N=4 etapas y la burbuja es 3/16 = 18.75% de tiempo de inactividad.
FSDP: Fully Sharded Data Parallel
FSDP combina la escalabilidad del paralelismo de datos con la eficiencia de memoria de la fragmentación (sharding). En lugar de que cada GPU contenga una copia completa del modelo, cada GPU contiene solo 1/N de los parámetros, gradientes y estados del optimizador.
Antes del paso hacia adelante de una capa, FSDP ejecuta un all-gather para recopilar los parámetros completos de todas las GPUs en la memoria de cada GPU. Después del paso hacia adelante, cada GPU descarta los parámetros no locales. Durante el paso hacia atrás, el all-gather se ejecuta nuevamente para reconstruir los parámetros para la computación del gradiente. Después del paso hacia atrás, un reduce-scatter distribuye los fragmentos de gradiente para que cada GPU solo almacene 1/N de los gradientes.
La matemática para un modelo de 70B en 8 GPUs:
| Componente | Sin FSDP | Con FSDP |
|---|---|---|
| Weights (FP16) | 140 GB per GPU | 17.5 GB per GPU |
| Adam States (FP32) | 560 GB per GPU | 70 GB per GPU |
| Gradients (FP16) | 140 GB per GPU | 17.5 GB per GPU |
| Total | 840 GB per GPU | 105 GB per GPU |
Sin FSDP, no puede meter un modelo de 70B en una sola GPU de 80 GB. Con FSDP en 8 GPUs, cada GPU usa 105 GB — espere, eso todavía no cabe. Necesita al menos 16 GPUs para estar por debajo de 80 GB por GPU, o puede combinar FSDP con checkpointing de activación (volver a calcular las activaciones durante el paso hacia atrás en lugar de almacenarlas).
El costo de comunicación es mayor que en el paralelismo de datos convencional debido al all-gather antes de cada capa. Pero los ahorros de memoria hacen posibles ejecuciones de entrenamiento que antes eran imposibles.
graph TD
subgraph FSDP["FSDP: Fully Sharded Data Parallel (4 GPUs)"]
direction TB
S["Modelo: 4 capas, fragmentado"]
subgraph GPU1["GPU 1"]
G1S["Fragmento: 1/4 params\n1/4 optimizador\n1/4 gradientes"]
end
subgraph GPU2["GPU 2"]
G2S["Fragmento: 1/4 params\n1/4 optimizador\n1/4 gradientes"]
end
subgraph GPU3["GPU 3"]
G3S["Fragmento: 1/4 params\n1/4 optimizador\n1/4 gradientes"]
end
subgraph GPU4["GPU 4"]
G4S["Fragmento: 1/4 params\n1/4 optimizador\n1/4 gradientes"]
end
AG["All-Gather\n(reconstruye params completos\nantes de cada capa)"]
FW["Paso hacia Adelante\n(params completos temporalmente)"]
RS["Reduce-Scatter\n(distribuye fragmentos de gradiente\ndespués del paso hacia atrás)"]
S --> GPU1
S --> GPU2
S --> GPU3
S --> GPU4
GPU1 --> AG
GPU2 --> AG
GPU3 --> AG
GPU4 --> AG
AG --> FW
FW --> RS
end
style G1S fill:#1a1a2e,stroke:#0f3460,color:#fff
style G2S fill:#1a1a2e,stroke:#0f3460,color:#fff
style G3S fill:#1a1a2e,stroke:#0f3460,color:#fff
style G4S fill:#1a1a2e,stroke:#0f3460,color:#fff
style AG fill:#1a1a2e,stroke:#e94560,color:#fff
style FW fill:#1a1a2e,stroke:#51cf66,color:#fff
style RS fill:#1a1a2e,stroke:#e94560,color:#fff
DeepSpeed ZeRO
ZeRO (Zero Redundancy Optimizer) de DeepSpeed es conceptualmente idéntico a FSDP pero fue desarrollado de forma independiente por Microsoft. Define tres etapas, cada una fragmentando de forma más agresiva:
| Etapa | Fragmentos (Shards) | Ahorro de Memoria | Comunicación |
|---|---|---|---|
| ZeRO-1 | Solo estados del optimizador | ~4x reduction | Same as data parallel |
| ZeRO-2 | + Gradients | ~8x reduction | Slightly more |
| ZeRO-3 | + Parameters | ~Nx reduction (N GPUs) | All-gather per layer |
ZeRO-3 es equivalente a FSDP. El nombre es diferente, el mecanismo es el mismo. PyTorch agregó FSDP como una implementación nativa después de que DeepSpeed demostrara el concepto.
DeepSpeed también introdujo ZeRO-Offload (descargar estados del optimizador a la memoria RAM de la CPU, que es más barata y más grande) y ZeRO-Infinity (descargar a SSDs NVMe). Estos intercambian la velocidad de cómputo por capacidad de memoria: las operaciones descargadas son más lentas pero liberan memoria de la GPU.
Entrenamiento con precisión mixta
El entrenamiento moderno utiliza múltiples formatos de punto flotante simultáneamente:
- Paso hacia adelante: FP16 o BF16 (16 bits). La mitad de la memoria de FP32. Las matmuls se ejecutan 2 veces más rápido en tensor cores.
- Pesos maestros: FP32 (32 bits). Mantenidos por el optimizador para precisión numérica durante las actualizaciones de pesos.
- Escalamiento de pérdida (loss scaling): Multiplica la pérdida por una constante grande antes del paso hacia atrás para evitar que los gradientes FP16 sufran de subflujo (underflow) a cero. Divide por la misma constante antes del paso del optimizador.
BF16 (Brain Float 16) tiene el mismo rango de exponente que FP32 (8 bits de exponente) pero precisión reducida (7 bits de mantisa frente a los 23 de FP32). Rara vez necesita escalamiento de pérdida porque puede representar el mismo rango de valores. FP16 tiene 5 bits de exponente y 10 bits de mantisa: puede representar valores detallados pero se desborda/subdesborda en magnitudes extremas.
Las TPUs de Google usan BF16 de forma nativa. Las A100 y H100 de NVIDIA admiten tanto FP16 como BF16. La industria se ha movido en gran medida a BF16 porque elimina los dolores de cabeza del escalamiento de pérdida.
Comparación de memoria para un modelo de 7B:
| Precisión | Pesos | Optimizador | Gradientes | Total |
|---|---|---|---|---|
| FP32 everywhere | 28 GB | 56 GB | 28 GB | 112 GB |
| Mixed (BF16 + FP32 master) | 14 GB | 56 GB | 14 GB | 84 GB |
La precisión mixta ahorra 28 GB en este modelo. Los estados del optimizador permanecen en FP32 de todos modos; aquí es donde se va la mayor parte de la memoria.
Megatron-LM y paralelismo 3D
El entrenamiento a gran escala real combina los tres paralelismos:
- Paralelismo de datos entre grupos de nodos (escalar el tamaño del lote)
- Paralelismo de tensor dentro de un nodo (dividir capas en 8 GPUs)
- Paralelismo de pipeline entre nodos (dividir grupos de capas entre máquinas)
Llama 3 405B en 16,384 H100s:
- 8-way tensor parallelism within each node (8 GPUs per node)
- 16-way pipeline parallelism across nodes (16 pipeline stages)
- 128-way data parallelism across the remaining dimension (16,384 / 8 / 16 = 128)
Esta descomposición 3D (8 x 16 x 128 = 16,384) es cómo se escala a miles de GPUs. Cada GPU ve un fragmento de datos diferente (paralelo de datos), contiene una porción de cada capa (paralelo de tensor) y calcula un conjunto diferente de capas (paralelo de pipeline).
DeepSeek V3 adoptó un enfoque diferente. Su arquitectura Mixture of Experts activa solo 37B de 671B parámetros por token. Esto significa que cada GPU solo necesita calcular (y almacenar activaciones para) los parámetros activos. Entrenaron en 2,048 GPUs H800 — menos de 1/8 de la cantidad de GPUs de Meta — por $5.6 millones frente a los
graph TD
subgraph ThreeD["Paralelismo 3D (Llama 3 405B)"]
direction TB
subgraph DP["Paralelismo de Datos (128 vías)\nDividir lote entre 128 grupos"]
subgraph PP["Paralelismo de Pipeline (16 vías)\nDividir capas entre 16 etapas"]
subgraph TP["Paralelismo de Tensor (8 vías)\nDividir cada capa entre 8 GPUs"]
G1["GPU 1\nPorción de capas 1-N"]
G2["GPU 2\nPorción de capas 1-N"]
G8["GPU 8\nPorción de capas 1-N"]
end
end
end
end
N1["Total: 8 x 16 x 128 = 16,384 GPUs"]
style G1 fill:#1a1a2e,stroke:#0f3460,color:#fff
style G2 fill:#1a1a2e,stroke:#0f3460,color:#fff
style G8 fill:#1a1a2e,stroke:#0f3460,color:#fff
style N1 fill:#1a1a2e,stroke:#e94560,color:#fff
Constrúyelo
Paso 1: Simular paralelismo de datos
Divida un lote entre GPUs simuladas. Cada GPU calcula un paso hacia adelante en su fragmento. Promedie los "gradientes" (los simulamos como los valores de pérdida).
import numpy as np
def simulate_data_parallelism(data, num_gpus, model_fn):
batch_size = len(data)
shard_size = batch_size // num_gpus
remainder = batch_size % num_gpus
gpu_losses = []
gpu_gradients = []
offset = 0
for gpu_id in range(num_gpus):
extra = 1 if gpu_id < remainder else 0
shard = data[offset:offset + shard_size + extra]
offset += shard_size + extra
loss, grad = model_fn(shard)
gpu_losses.append(loss)
gpu_gradients.append(grad)
avg_loss = np.mean(gpu_losses)
avg_gradient = np.mean(gpu_gradients, axis=0)
return avg_loss, avg_gradient
La operación all-reduce (promediar gradientes) es la única comunicación en el paralelismo de datos. En la práctica, esto usa la biblioteca NCCL en GPUs NVIDIA, que implementa ring all-reduce: cada GPU envía 1/N de sus gradientes a su vecino, recibe 1/N del otro vecino y, después de N-1 pasos, cada GPU tiene el promedio completo. Volumen total de comunicación: 2 x gradient_size x (N-1)/N, acercándose a 2 veces el tamaño del gradiente para valores grandes de N.
Paso 2: Simular paralelismo de tensor
Divida una matriz de pesos entre GPUs. Cada GPU calcula una multiplicación parcial de matrices. Combine los resultados.
def simulate_tensor_parallelism(input_data, weight_matrix, num_gpus):
d_in, d_out = weight_matrix.shape
assert d_out % num_gpus == 0, f"d_out {d_out} not divisible by num_gpus {num_gpus}"
shard_size = d_out // num_gpus
partial_results = []
for gpu_id in range(num_gpus):
start = gpu_id * shard_size
end = start + shard_size
weight_shard = weight_matrix[:, start:end]
partial = input_data @ weight_shard
partial_results.append(partial)
full_output = np.concatenate(partial_results, axis=-1)
direct_output = input_data @ weight_matrix
error = np.abs(full_output - direct_output).max()
return full_output, error
El error debe ser exactamente cero (o el épsilon de la máquina). El paralelismo de tensor es matemáticamente exacto: produce el mismo resultado que calcular la matmul completa en una sola GPU. La división se realiza a lo largo de la dimensión de salida, por lo que cada GPU produce un fragmento diferente de columnas y la concatenación reconstruye el resultado completo.
Para capas lineales paralelas en columna (dividir la dimensión de salida), se concatena. Para paralelas en fila (dividir la dimensión de entrada), se suma. En una FFN de transformer, la primera lineal (expansión) utiliza paralela en columna y la segunda lineal (contracción) utiliza paralela en fila. Esto evita un all-reduce entre las dos capas.
Paso 3: Simular paralelismo de pipeline
Divida las capas de un modelo entre GPUs virtuales. Muestre el problema de la burbuja (bubble) donde las primeras etapas se quedan inactivas mientras las etapas posteriores computan.
def simulate_pipeline_parallelism(num_layers, num_stages, num_microbatches):
layers_per_stage = num_layers // num_stages
timeline = {}
clock = 0
for mb in range(num_microbatches):
for stage in range(num_stages):
start_time = max(
timeline.get((stage, mb - 1, "fwd"), (0, 0))[1] if mb > 0 else 0,
timeline.get((stage - 1, mb, "fwd"), (0, 0))[1] if stage > 0 else 0,
)
end_time = start_time + layers_per_stage
timeline[(stage, mb, "fwd")] = (start_time, end_time)
last_fwd_end = max(v[1] for v in timeline.values())
for mb in range(num_microbatches - 1, -1, -1):
for stage in range(num_stages - 1, -1, -1):
deps = [last_fwd_end]
if mb < num_microbatches - 1 and (stage, mb + 1, "bwd") in timeline:
deps.append(timeline[(stage, mb + 1, "bwd")][1])
if stage < num_stages - 1 and (stage + 1, mb, "bwd") in timeline:
deps.append(timeline[(stage + 1, mb, "bwd")][1])
start_time = max(deps)
end_time = start_time + layers_per_stage
timeline[(stage, mb, "bwd")] = (start_time, end_time)
total_time = max(v[1] for v in timeline.values())
compute_time = num_microbatches * num_stages * layers_per_stage * 2
bubble_fraction = 1.0 - compute_time / (total_time * num_stages)
return timeline, total_time, bubble_fraction
Con 4 etapas y 1 micro-lote, la fracción de burbuja es del 75%: tres de cada cuatro GPUs están inactivas en cualquier momento. Con 16 micro-lotes, cae a alrededor del 19%. El costo de eliminar las burbujas es la memoria: debe almacenar activaciones para todos los micro-lotes en vuelo simultáneamente.
Paso 4: Calculadora de memoria
Calcule los requisitos de memoria exactos para entrenar cualquier tamaño de modelo.
def memory_calculator(
params_billions,
precision_bytes=2,
optimizer="adam",
num_gpus=1,
sharding="none",
sequence_length=2048,
batch_size_per_gpu=1,
hidden_dim=None,
num_layers=None,
):
params = params_billions * 1e9
weight_memory = params * precision_bytes
if optimizer == "adam":
optimizer_memory = params * 4 * 2
elif optimizer == "sgd":
optimizer_memory = params * 4
else:
optimizer_memory = 0
gradient_memory = params * precision_bytes
total_no_activation = weight_memory + optimizer_memory + gradient_memory
if hidden_dim and num_layers:
activation_per_layer = (
sequence_length * batch_size_per_gpu * hidden_dim * precision_bytes * 4
)
activation_memory = activation_per_layer * num_layers
else:
activation_memory = params * precision_bytes * 0.5
if sharding == "fsdp" or sharding == "zero3":
weight_memory /= num_gpus
optimizer_memory /= num_gpus
gradient_memory /= num_gpus
elif sharding == "zero2":
optimizer_memory /= num_gpus
gradient_memory /= num_gpus
elif sharding == "zero1":
optimizer_memory /= num_gpus
per_gpu_total = weight_memory + optimizer_memory + gradient_memory + activation_memory
return {
"params_billions": params_billions,
"weights_gb": weight_memory / 1e9,
"optimizer_gb": optimizer_memory / 1e9,
"gradients_gb": gradient_memory / 1e9,
"activations_gb": activation_memory / 1e9,
"per_gpu_total_gb": per_gpu_total / 1e9,
"total_across_gpus_gb": per_gpu_total * num_gpus / 1e9,
"fits_on_80gb": per_gpu_total / 1e9 <= 80,
"num_gpus": num_gpus,
"sharding": sharding,
}
Esta calculadora responde a la pregunta que todo ingeniero de ML se hace: "¿Cuántas GPUs necesito?". Proporcione el tamaño del modelo y vea si cabe. Ajuste la estrategia de fragmentación hasta que el total por GPU caiga por debajo de 80 GB.
Paso 5: Simulación de precisión mixta
Compare el uso de memoria entre el entrenamiento en FP32, FP16 y precisión mixta.
def mixed_precision_comparison(params_billions):
params = params_billions * 1e9
fp32_weights = params * 4
fp32_optimizer = params * 4 * 2
fp32_gradients = params * 4
fp32_total = fp32_weights + fp32_optimizer + fp32_gradients
fp16_weights = params * 2
fp16_master = params * 4
fp16_optimizer = params * 4 * 2
fp16_gradients = params * 2
fp16_total = fp16_weights + fp16_master + fp16_optimizer + fp16_gradients
mixed_weights = params * 2
mixed_optimizer = params * 4 * 2
mixed_gradients = params * 2
mixed_total = mixed_weights + mixed_optimizer + mixed_gradients
return {
"fp32_total_gb": fp32_total / 1e9,
"fp16_with_master_gb": fp16_total / 1e9,
"mixed_bf16_gb": mixed_total / 1e9,
"savings_vs_fp32": 1 - mixed_total / fp32_total,
}
La mayor sorpresa para la mayoría de las personas: la precisión mixta no reduce la memoria a la mitad. Los estados del optimizador (m y v de Adam) permanecen en FP32 independientemente de la precisión. Para un modelo 7B, el entrenamiento FP32 utiliza 112 GB. La precisión mixta utiliza 84 GB. Esa es una reducción del 25%, no del 50%. El optimizador domina.
Úsalo
Ejecutar todas las simulaciones
def run_all_demos():
print("=" * 70)
print("DATA PARALLELISM SIMULATION")
print("=" * 70)
np.random.seed(42)
data = np.random.randn(64, 32)
weight = np.random.randn(32, 16)
def model_fn(batch):
output = batch @ weight
loss = np.mean(output ** 2)
grad = 2 * batch.T @ (batch @ weight) / len(batch)
return loss, grad
for n_gpus in [1, 2, 4, 8]:
loss, grad = simulate_data_parallelism(data, n_gpus, model_fn)
print(f" {n_gpus} GPUs: loss={loss:.4f}, grad_norm={np.linalg.norm(grad):.4f}")
print()
print("=" * 70)
print("TENSOR PARALLELISM SIMULATION")
print("=" * 70)
x = np.random.randn(4, 8192)
W = np.random.randn(8192, 8192)
for n_gpus in [1, 2, 4, 8]:
output, error = simulate_tensor_parallelism(x, W, n_gpus)
print(f" {n_gpus} GPUs: output_shape={output.shape}, max_error={error:.2e}")
print()
print("=" * 70)
print("PIPELINE PARALLELISM SIMULATION")
print("=" * 70)
for n_mb in [1, 4, 8, 16, 32]:
_, total_t, bubble = simulate_pipeline_parallelism(32, 4, n_mb)
print(f" {n_mb:2d} micro-batches: total_time={total_t:4d}, bubble={bubble:.1%}")
print()
print("=" * 70)
print("MEMORY CALCULATOR")
print("=" * 70)
configs = [
(7, "none", 1),
(7, "fsdp", 8),
(70, "none", 1),
(70, "fsdp", 8),
(70, "fsdp", 16),
(405, "fsdp", 64),
(405, "fsdp", 128),
]
print(f" {'Model':>8} {'Sharding':>8} {'GPUs':>5} {'Per-GPU':>10} {'Fits 80GB':>10}")
print(" " + "-" * 50)
for params, shard, gpus in configs:
result = memory_calculator(params, num_gpus=gpus, sharding=shard)
fits = "Yes" if result["fits_on_80gb"] else "No"
print(f" {params:>6}B {shard:>8} {gpus:>5} {result['per_gpu_total_gb']:>8.1f}GB {fits:>10}")
print()
print("=" * 70)
print("MIXED PRECISION COMPARISON")
print("=" * 70)
for params_b in [7, 13, 70, 405]:
result = mixed_precision_comparison(params_b)
print(f" {params_b}B: FP32={result['fp32_total_gb']:.0f}GB, "
f"Mixed BF16={result['mixed_bf16_gb']:.0f}GB, "
f"Savings={result['savings_vs_fp32']:.0%}")
Envíalo
Esta lección produce outputs/prompt-distributed-training-planner.md — un prompt que toma un tamaño de modelo y el hardware disponible, y luego produce un plan de entrenamiento distribuido completo: estrategia de paralelismo, presupuesto de memoria, sobrecarga de comunicación y rendimiento esperado.
Ejercicios
Modifique la calculadora de memoria para incluir el checkpointing de activación. Con el checkpointing, solo guarde activaciones en cada K-ésima capa (típico K=1, lo que significa volver a calcular todo). Muestre la relación de compromiso (trade-off) entre memoria y cómputo: cuánta memoria ahorra el checkpointing y cuánto ralentiza el entrenamiento (aproximadamente un 33% más de cómputo para un checkpointing completo).
Extienda la simulación de paralelismo de pipeline para implementar el cronograma 1F1B (uno forward, uno backward) utilizado por PipeDream. Compare la fracción de burbuja con el cronograma ingenuo para 4 etapas y 8 micro-lotes. El cronograma 1F1B debería tener una memoria pico más pequeña porque comienza los pasos hacia atrás antes.
Implemente un simulador de acumulación de gradiente (gradient accumulation). En lugar de aplicar all-reduce después de cada micro-lote, acumule gradientes localmente durante K pasos, luego aplique all-reduce. Muestre cómo esto reduce la comunicación en K veces pero produce gradientes finales idénticos (y por lo tanto un entrenamiento idéntico).
Construya un estimador de costos. Dado un tamaño de modelo, un recuento de tokens objetivo, un tipo de GPU (A100 a