Files
wehub-resource-sync a8262fc01e
docs / build (push) Has been cancelled
docs / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:22 +08:00

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