Los Gradientes Locales se Multiplican
La Pasada Hacia Adelante
La pasada hacia adelante de ANDREA-120M recorre la entrada a través de una secuencia de operaciones:
x = embed(token_ids) # incrustaciones de tokens
for layer in 12_layers:
x = x + attn(LN(x)) # subcapa de atención
x = x + mlp(LN(x)) # subcapa MLP
logits = LN(x) @ embed.T # proyección de salida compartida
loss = cross_entropy(logits, targets)
Cada operación lee tensores de entrada y produce tensores de salida. La pasada hacia adelante termina en un escalar único: la pérdida de entropía cruzada para este lote.
La Pasada Hacia Atrás
El entrenamiento actualiza los pesos en la dirección que disminuye la pérdida. Para obtener las direcciones de actualización, el motor necesita:
dL/dW para cada W entrenable en el modelo
La regla de la cadena lo proporciona. Para una cadena loss = f(g(h(x))):
dL/dx = (dL/df) * (df/dg) * (dg/dh) * (dh/dx)
Cada factor es un gradiente local: cómo cambia la salida de una operación cuando su entrada cambia por una pequeña cantidad. Multiplicar gradientes locales hacia atrás a través del grafo propaga la señal de pérdida a cada peso.
Diferenciación en Modo Reverso
Backprop calcula gradientes en orden reverso: comenzando desde dL/dlogits = 1, luego retrocediendo a través de la entropía cruzada, luego la proyección de salida, luego la normalización de capa, luego doce bloques transformer, luego las incrustaciones. En cada paso, multiplica el gradiente entrante por el Jacobiano local.
El modo reverso es eficiente cuando la salida es un escalar único (la pérdida) y hay muchas entradas (los pesos). Una pasada hacia atrás produce gradientes para cada peso en el modelo. El modo forward necesitaría una pasada por peso; para ANDREA-120M con ~120M pesos, el modo forward es inviable.
Por qué Modo Reverso
Cada Op Forward Obtiene Su Gemelo Backward
La Disciplina de Emparejamiento
microgpt_cuda.cu incluye dos kernels CUDA para cada operación: uno que calcula la salida forward, uno que calcula los gradientes de entrada dados los gradientes de salida. El emparejamiento es uno a uno:
| Kernel forward | Kernel backward | Operación |
|---|---|---|
k_embed_fwd | k_embed_bwd | Búsqueda de embedding de token |
k_layernorm_fwd | k_layernorm_bwd | Normalización de capa |
k_attn_qkv_fwd | k_attn_qkv_bwd | Proyecciones Q, K, V |
k_attn_fwd | k_attn_bwd | Atención producto punto escalado |
k_attn_out_fwd | k_attn_out_bwd | Proyección de salida W_O |
k_mlp_fwd | k_mlp_bwd | MLP (con GELU) |
k_residual_add | k_residual_add_bwd | Conexión residual |
k_loss_fwd | k_loss_bwd | Pérdida de entropía cruzada |
Ocho pares de operaciones cubren el transformer completo. Más unos pocos kernels de utilidad: k_grad_norm_partial, k_grad_norm_final, k_grad_scale para el recorte de gradientes (ver actividad 75).
El Trabajo de un Kernel Backward
Dado el gradiente que fluye desde las capas posteriores (grad_output), un kernel backward calcula:
1. grad_input: el gradiente con respecto al tensor de entrada de la operación. Esto se pasa más hacia atrás.
2. grad_weight: el gradiente con respecto a los parámetros entrenables en la operación. Esto va al estado del optimizador.
Ambos se computan en un solo lanzamiento de kernel. Los hilos CUDA cooperan en teselas del tensor de gradiente en paralelo.
Tensores Guardados
La computación hacia atrás a menudo necesita valores del pase hacia adelante. Por ejemplo, k_layernorm_bwd necesita la media y varianza computadas durante el forward; k_mlp_bwd necesita la pre-activación GELU. El motor de entrenamiento los almacena en buffers dedicados durante el forward, luego los lee durante el backward.
Costo de memoria: aproximadamente la misma forma que la salida del forward para cada tensor guardado. Para ANDREA-120M con batch=8, seq=1024, d_model=768, un tensor guardado es 8 × 1024 × 768 × 4 bytes = 25 MB. A través de 12 capas & múltiples tensores guardados por capa, las activaciones dominan la VRAM durante el entrenamiento (~5-10 GB en una tarjeta de 24 GB).
Trazado de Un Paso de Backward
Dónde Viven los Gradientes en Memoria
Un Tensor de Gradiente por Tensor de Peso
Cada tensor de peso entrenable en ANDREA-120M tiene un tensor de gradiente correspondiente de forma idéntica. Para cada bloque:
W_Q [768, 768] ↔ grad_W_Q [768, 768]
W_K [768, 768] ↔ grad_W_K [768, 768]
W_V [768, 768] ↔ grad_W_V [768, 768]
W_O [768, 768] ↔ grad_W_O [768, 768]
W_1 [768, 3072] ↔ grad_W_1 [768, 3072]
W_2 [3072, 768] ↔ grad_W_2 [3072, 768]
LN1.gamma [768] ↔ grad_LN1.gamma [768]
LN1.beta [768] ↔ grad_LN1.beta [768]
LN2.gamma [768] ↔ grad_LN2.gamma [768]
LN2.beta [768] ↔ grad_LN2.beta [768]
Más token embeddings, position embeddings y una layer norm final. La memoria total del buffer de gradientes coincide con la memoria de pesos: ~120M floats, ~480 MB en FP32, ~240 MB en FP16.
Acumulación a Través de Micro-lotes
El batch_size = 8 de ANDREA cabe en VRAM en FP16. Lotes efectivos más grandes requieren acumulación de gradientes: ejecutar múltiples pases forward+backward en lotes pequeños, sumando gradientes en el mismo buffer, luego realizar un paso del optimizador.
for microbatch in range(n_microbatches):
forward(microbatch)
backward() # AÑADE a los buffers de gradientes, no sobrescribe
scale_grads(1.0 / n_microbatches) # promedia entre microbatches
optimizer_step()
zero_grads() # reinicia para el siguiente paso de entrenamiento
Los kernels de backward usan semántica +=, no =. Cada llamada añade contribuciones de gradiente al buffer existente; el buffer mantiene la suma acumulada hasta que zero_grads() lo limpia.
El Estado del Optimizador
AdamW (actividad 73) mantiene dos buffers más por peso: primer momento m y segundo momento v. Memoria total durante el entrenamiento:
pesos: 1× conteo de pesos
gradientes: 1× conteo de pesos
Adam m: 1× conteo de pesos
Adam v: 1× conteo de pesos
acts guardados: ~2-4× dependiendo de capas & lote
──────────────────────────────────────────
total: ~6-8× conteo de pesos
ANDREA-120M en FP16: ~240 MB × 4 buffers (peso, grad, m, v) + ~5-10 GB activaciones = ~10-12 GB total. Cómodamente por debajo del límite de 24 GB de la RTX 4090. ANDREA-12M entrenado en 1.4 GB; el escalado de parámetros 10× trae ~10× memoria.
Dimensionamiento de Buffers de Gradiente
Control total de memoria y precisión
Qué cuestan los frameworks genéricos
PyTorch y JAX hacen que autograd sea conveniente: escribe código Python, obtén gradientes automáticamente. El costo: una capa de despacho genérica entre tu código y CUDA. Cada operación pasa por sobrecarga del intérprete Python, contabilidad del framework y selección dinámica de kernels. Para entrenar un modelo de lenguaje pequeño en una GPU, esa sobrecarga importa.
Costos concretos que ANDREA evita:
1. Latencia del intérprete de Python. Cada operación de PyTorch cruza el límite Python/C++. Para ~100 lanzamientos de kernels por paso de entrenamiento a ~9 pasos/min, eso son ~900 cruces de límite por minuto. El despacho a nivel C elimina esto.
2. Impredecibilidad del asignador del framework. El asignador de caché de PyTorch da buen rendimiento promedio pero memoria máxima impredecible. El motor de entrenamiento de ANDREA pre-asigna cada buffer al inicio; no reasignación durante el entrenamiento, no fragmentación, no OOM sorpresa en el paso 100K.
3. Selección genérica de kernels. PyTorch elige kernels en tiempo de ejecución vía heurísticas. ANDREA elige kernels en tiempo de compilación, ajustados a los tamaños de tesela del tensor core de RTX 4090.
4. Fontanería de precisión mixta. La ruta cuBLAS FP16 de ANDREA-120M y los experimentos de núcleos tensoriales FP8 E4M3 de ANDREA requieren un control preciso sobre qué tensores viven en qué precisión. Los frameworks genéricos exponen este control a través de APIs en capas; las escrituras personalizadas de CUDA lo escriben directamente.
El Compromiso
Costos de CUDA personalizado: más código para escribir, más errores para encontrar, sin ecosistema comunitario. El archivo microgpt_cuda.cu de ANDREA tiene ~6000 líneas de CUDA escrito a mano que tomó meses depurar. Cada nueva operación requiere escribir un kernel forward, un kernel backward y pruebas.
Lo que gana ANDREA:
- Reproducibilidad total. El pipeline de entrenamiento es un binario C más un proxy de Python. Sin deriva de versiones entre lanzamientos de PyTorch, sin desajustes de versión de CUDA con las ruedas del framework.
- Reanudaciones bit-exactas. SIGTERM activa la escritura de un checkpoint que captura cada tensor exactamente como lo ve la GPU. La reanudación retoma la misma trayectoria de pérdida en la que estaba la ejecución.
- Memoria predecible. ANDREA-120M entrenado durante 200K pasos sin OOM. La memoria se contabilizó al inicio del motor.
- Acceso directo al hardware. Tamaños de teselas del tensor core, configuraciones FP8 E4M3, copias de memoria asíncronas: todo directamente direccionable en CUDA, opaco en frameworks genéricos.
Reproducibilidad Como Misión
La sección 9 del whitepaper de ANDREA lista la pila completa de reproducibilidad:
Motor de entrenamiento: microgpt/microgpt_cuda.cu
Proxy de entrenamiento: microgpt/training_proxy.py
Configuraciones de experimentos: experiments/ANDREA-*-TRAIN.json
Pipeline de datos: scripts/pull-hermes3.py, scripts/prep-megachat.py
Panel de control: scripts/live-loss-dashboard.html
Especificación de Bandit: docs/FIREHOSE-BANDIT.md
Documentación del modelo: docs/ANDREA.md
Requisito de hardware: una GPU NVIDIA con ≥8 GB de VRAM (RTX 3060 o mejor). Cualquiera puede reproducir ANDREA-12M a partir de estos artefactos. El camino CUDA personalizado es parte de por qué: no hay congelamientos de versión de framework, no hay sorpresas de dependencias dentro de cinco años.
Señales & Checkpoints
El bucle de entrenamiento CUDA responde a dos señales POSIX:
- SIGTERM: escribe un checkpoint inmediato, luego sale. Se usa al detener el entrenamiento de manera limpia.
- SIGUSR1: escribe un checkpoint inmediato, continúa el entrenamiento. Usado durante el pivote de pulido en v3 para capturar el estado sin interrumpir la ejecución.
Formato de checkpoint: [int32 step][int32 n_params][n_params × float32 weights][n_params × float32 m][n_params × float32 v]. Contador de pasos, conteo de pesos, luego pesos seguidos de momentos de Adam. Reanuda bit-exactamente. El proxy archiva .samples.json & .state.json por separado en pulido; .loss.json nunca se archiva (acumula el historial completo del entrenamiento).