221 lines
5.6 KiB
Markdown
221 lines
5.6 KiB
Markdown
# Optimization & Training Systems
|
|
|
|
Once the loss is defined, training is an engineering problem: move parameters in the right direction
|
|
without numerical instability, memory blowups, or throughput collapse.
|
|
|
|
The main ingredients in this repo are:
|
|
|
|
- AdamW;
|
|
- linear warmup plus cosine learning-rate decay;
|
|
- gradient accumulation;
|
|
- gradient clipping;
|
|
- bf16 autocast;
|
|
- DistributedDataParallel for multi-GPU training.
|
|
|
|
## The training step
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
B["batch"] --> F["forward under autocast"]
|
|
F --> L["loss / grad_accum"]
|
|
L --> BW["backward"]
|
|
BW --> GA{"more microsteps?"}
|
|
GA -- yes --> B
|
|
GA -- no --> CLIP["clip grad norm"]
|
|
CLIP --> LR["set scheduled LR"]
|
|
LR --> STEP["AdamW step"]
|
|
STEP --> ZERO["zero gradients"]
|
|
```
|
|
|
|
The pretraining loop in `scripts/pretrain_base.py` uses this pattern:
|
|
|
|
```python
|
|
for micro in range(cfg.grad_accum):
|
|
xb, yb = next(batch_iter)
|
|
with amp_autocast(cfg.amp_dtype, ctx.device):
|
|
_, loss = model(xb, yb)
|
|
loss = loss / cfg.grad_accum
|
|
loss.backward()
|
|
|
|
torch.nn.utils.clip_grad_norm_(model.parameters(), cfg.grad_clip)
|
|
optimizer.step()
|
|
optimizer.zero_grad(set_to_none=True)
|
|
```
|
|
|
|
Dividing the loss by `grad_accum` keeps the gradient scale the same as if the full effective batch had
|
|
fit in memory.
|
|
|
|
## AdamW
|
|
|
|
Adam keeps exponential moving averages of gradients and squared gradients:
|
|
|
|
\[
|
|
m_t = \beta_1 m_{t-1} + (1-\beta_1)g_t
|
|
\]
|
|
|
|
\[
|
|
v_t = \beta_2 v_{t-1} + (1-\beta_2)g_t^2
|
|
\]
|
|
|
|
After bias correction, parameters are updated approximately by:
|
|
|
|
\[
|
|
\theta_{t+1}
|
|
= \theta_t - \eta \frac{\hat{m}_t}{\sqrt{\hat{v}_t}+\epsilon}
|
|
\]
|
|
|
|
AdamW decouples weight decay from the gradient update:
|
|
|
|
\[
|
|
\theta_{t+1}
|
|
= \theta_t - \eta \left(
|
|
\frac{\hat{m}_t}{\sqrt{\hat{v}_t}+\epsilon}
|
|
+ \lambda \theta_t
|
|
\right)
|
|
\]
|
|
|
|
The repo applies weight decay only to matrix-like parameters:
|
|
|
|
```python
|
|
if p.dim() >= 2:
|
|
decay.append(p)
|
|
else:
|
|
no_decay.append(p)
|
|
```
|
|
|
|
This is the standard GPT recipe: decay large weight matrices, but not biases, LayerNorm scales, or
|
|
one-dimensional parameters.
|
|
|
|
## Learning-rate warmup and cosine decay
|
|
|
|
The learning rate is small at the start, ramps up, then decays:
|
|
|
|
\[
|
|
\eta(s) =
|
|
\eta_{\max}\frac{s+1}{S_{\text{warmup}}}
|
|
\quad \text{if } s < S_{\text{warmup}}
|
|
\]
|
|
|
|
After warmup:
|
|
|
|
\[
|
|
\eta(s) =
|
|
\eta_{\min}
|
|
+ \frac{1}{2}(1+\cos(\pi p))(\eta_{\max}-\eta_{\min})
|
|
\]
|
|
|
|
where:
|
|
|
|
\[
|
|
p = \frac{s-S_{\text{warmup}}}{S_{\max}-S_{\text{warmup}}}
|
|
\]
|
|
|
|
Implementation in `src/post_training/optim.py`:
|
|
|
|
```python
|
|
if step < warmup_steps:
|
|
return lr * (step + 1) / max(1, warmup_steps)
|
|
progress = (step - warmup_steps) / max(1, max_steps - warmup_steps)
|
|
coeff = 0.5 * (1.0 + math.cos(math.pi * progress))
|
|
return min_lr + coeff * (lr - min_lr)
|
|
```
|
|
|
|
Warmup prevents early unstable updates while weights are still poorly calibrated. Cosine decay reduces
|
|
step size as training approaches the end of the budget.
|
|
|
|
## Gradient accumulation
|
|
|
|
If one batch is too large for GPU memory, split it into microbatches:
|
|
|
|
\[
|
|
B_{\text{effective}} = B_{\text{micro}} \times N_{\text{accum}} \times N_{\text{gpus}}
|
|
\]
|
|
|
|
Example:
|
|
|
|
- microbatch size: 8;
|
|
- accumulation steps: 12;
|
|
- GPUs: 2.
|
|
|
|
\[
|
|
B_{\text{effective}} = 8 \times 12 \times 2 = 192
|
|
\]
|
|
|
|
The optimizer steps once after all microbatches have contributed gradients.
|
|
|
|
## Gradient clipping
|
|
|
|
Gradient clipping limits the global norm:
|
|
|
|
\[
|
|
g \leftarrow g \cdot \min\left(1, \frac{c}{\|g\|_2}\right)
|
|
\]
|
|
|
|
If the gradient norm is below the threshold \(c\), nothing changes. If it is too large, the whole
|
|
gradient vector is scaled down. This is a stability guard, especially useful in RL and long-sequence
|
|
training.
|
|
|
|
## bf16 autocast
|
|
|
|
`bf16` uses fewer bits than fp32, but keeps an 8-bit exponent like fp32. That makes it much more
|
|
forgiving than fp16 for deep learning training.
|
|
|
|
The repo uses autocast for forward computation:
|
|
|
|
```python
|
|
with amp_autocast(cfg.amp_dtype, ctx.device):
|
|
logits, _ = model(tokens)
|
|
loss = sft_loss(logits, tokens, mask)
|
|
```
|
|
|
|
Model parameters usually remain fp32. Many matrix multiplications run in bf16, improving memory and
|
|
throughput on supported GPUs.
|
|
|
|
## DistributedDataParallel
|
|
|
|
DDP creates one process per GPU. Each process:
|
|
|
|
1. owns a full copy of the model;
|
|
2. receives a different shard or random stream of data;
|
|
3. computes gradients locally;
|
|
4. synchronizes gradients across processes before the optimizer step.
|
|
|
|
With gradient accumulation, synchronization is needed only on the last microstep. The repo uses
|
|
`model.no_sync()` for earlier microsteps to avoid unnecessary communication.
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
R0["rank 0 GPU"] --> G["gradient all-reduce"]
|
|
R1["rank 1 GPU"] --> G
|
|
G --> U0["rank 0 optimizer step"]
|
|
G --> U1["rank 1 optimizer step"]
|
|
```
|
|
|
|
## What to watch during training
|
|
|
|
| Metric | Healthy behavior | Problem signal |
|
|
|---|---|---|
|
|
| train loss | falls steadily | flat near random baseline |
|
|
| dev loss | falls, then stabilizes | rises while train loss falls |
|
|
| grad norm | finite, bounded after clipping | NaN or repeated huge spikes |
|
|
| tokens/sec | stable for same config | sudden drop or dataloader stall |
|
|
| KL in RL stages | bounded | runaway drift from reference |
|
|
| reward in RL stages | rises with variance | zero signal for many iterations |
|
|
|
|
## Memory levers
|
|
|
|
If a config does not fit, reduce in this order:
|
|
|
|
1. `batch_size`;
|
|
2. `context_length`;
|
|
3. `n_blocks`;
|
|
4. `n_embed`;
|
|
5. `n_head` only if it still divides `n_embed`.
|
|
|
|
Context length is especially expensive because attention uses a \(T \times T\) score matrix.
|
|
|
|
## Next
|
|
|
|
After training, the model still only emits logits. Generation turns those logits into text. Continue
|
|
to [Generation & Sampling](generation.md).
|