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

00 millones estimados de Meta.

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

  1. 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).

  2. 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.

  3. 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).

  4. Construya un estimador de costos. Dado un tamaño de modelo, un recuento de tokens objetivo, un tipo de GPU (A100 a

/h, H100 a $3.50/h) y una estrategia de paralelismo, estime el costo total del entrenamiento en dólares. Valide con costos conocidos: Llama 3 405B supuestamente costó ~
00 millones, DeepSeek V3 costó ~$5.6 millones.

  • Agregue ZeRO-Offload a la calculadora de memoria. Suponga que la RAM de la CPU es de 512 GB por nodo y el NVMe es de 2 TB. Muestre cómo la descarga de los estados del optimizador a la CPU permite que un modelo 70B se entrene en 4 GPUs en lugar de 16, a costa de pasos del optimizador entre un 30% y un 50% más lentos.

  • Términos clave

    Term Qué dice la gente Qué significa realmente
    Paralelismo de datos "Copiar el modelo en cada GPU" Cada GPU procesa un fragmento de datos diferente; los gradientes se promedian a través de all-reduce después de cada paso
    Paralelismo de tensor "Dividir una capa entre GPUs" Particionar las matrices de peso de modo que cada GPU calcule parte de la matmul; requiere interconexión rápida NVLink
    Paralelismo de pipeline "Dividir capas entre GPUs" Cada GPU ejecuta un grupo diferente de capas; los datos fluyen a través del pipeline con micro-lotes para reducir las burbujas
    FSDP "Fragmentar todo" Fully Sharded Data Parallel — cada GPU contiene 1/N de los pesos, gradientes y estados del optimizador; all-gather antes del cómputo
    ZeRO "Versión de FSDP de DeepSpeed" Zero Redundancy Optimizer con 3 etapas: fragmentación del optimizador (Etapa 1), + gradientes (Etapa 2), + parámetros (Etapa 3)
    All-reduce "Promediar entre GPUs" Operación colectiva donde cada GPU termina con la suma (o promedio) de las entradas de todas las GPUs — típicamente implementada como ring all-reduce
    All-gather "Recopilar de todas las GPUs" Operación colectiva donde cada GPU termina con la concatenación de los datos de todas las GPUs — usada en FSDP para reconstruir los parámetros completos
    Reduce-scatter "Sumar y distribuir" Operación colectiva que reduce (suma) los datos y dispersa diferentes fragmentos a diferentes GPUs — usada en FSDP para la fragmentación de gradientes
    Precisión mixta "Entrenar en media precisión" Usar FP16/BF16 para el paso hacia adelante/atrás y FP32 para los estados del optimizador — ahorra ~25% de memoria, no el 50%, porque el optimizador domina
    Burbuja de pipeline "Tiempo de inactividad en el pipeline" Fracción de tiempo que las GPUs se quedan inactivas esperando datos de la etapa anterior — se reduce al usar más micro-lotes

    Lectura adicional

    0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).