AI tutorialsMighty Professional
Build a Language Model · Training at depth

Training Deep Networks from Scratch

Gradient descent on a two-layer network needs a learning rate and patience. Gradient descent on a forty-layer transformer needs the signal to survive forty matrix multiplies, an optimizer that copes with curvature that varies by orders of magnitude across parameters, a schedule that avoids blowing up at the start and jittering at the end, arithmetic that is fast without silently losing the gradients, and a plan for how big to make the model at all. This page builds each piece and measures it.

Time~55 minLevelIntermediatePrereqsNeural Networks, Automatic Differentiation, Floating Point for the precision section.StackC++20 & Rust · browser demos
◂ Build a Language ModelPhase 2 · Training at depthNext · Evaluation and generalization ▸

01Keeping the signal alive through depth

Each layer of a network multiplies its input by a weight matrix and applies a nonlinearity. Stack forty of them and the activations are the product of forty random factors: if each layer scales the signal by 0.7 on average the output is 10⁻⁶ of the input, and the gradients, which pass back through the same factors, vanish with it; at 1.4 per layer the output is 10⁶ times the input and the gradients explode.[1] are the first thing depth breaks, and the initialization scale is the first lever.

Var[oi] = nin σ² γ²

So a linear layer preserves variance when ninσ² = 1, and the gradient's variance is preserved when noutσ² = 1. Xavier initialization splits the difference with σ² = 2/(nin + nout).[1] A ReLU zeroes half of its inputs and halves the variance of what passes, which the Kaiming scheme compensates with a gain of √2: σ = √2/√nin.[2]

Push a signal through forty random layers

Width 64, a fresh seeded weight matrix per layer, actual activations measured. ReLU with the 1/√n scale loses about half the variance per layer and reaches 10⁻¹² by layer 40; the √2 gain keeps it between about 0.03 and 7 at every seed, a random drift instead of a steady decay; 2/√n doubles it per layer, to about 10¹² at layer 40. With no activation the 1/√n scale wanders but stays within a few orders of magnitude, because the mean factor is 1 and only the fluctuations accumulate. tanh at the 1/√n scale shrinks steadily, because |tanh x| < |x|; PyTorch's recommended tanh gain is 5/3.[2] Either normalization pins the variance at 1 whatever the scale; §2 says how.

The scale is not the only lever. The sigmoid's derivative is at most 1/4 and is close to zero away from the origin, so a sigmoid stack shrinks gradients at every layer; ReLU's derivative is 1 wherever the unit is active, which is a large part of why it displaced the sigmoid.[1] Initialization must also break symmetry: if every unit in a layer starts with the same weights it receives the same gradient forever, so the weights are drawn at random even when their scale is chosen with care.[1]

02Normalization layers

Rather than hoping the initialization keeps activations at a usable scale for the whole run, a normalization layer forces it. Layer normalization computes the mean and variance over the feature dimension of each example and rescales to zero mean and unit variance, followed by a learned gain and bias.[3] It is the normalization used in the original transformer and in GPT-2; the transformer page covers where it sits in the block.

yi = gi · xi / √(mean(x²) + ε)

RMSNorm drops the mean subtraction and the bias and keeps only the rescaling; Llama's reference implementation is this formula; its RMSNorm class defaults to ε = 10⁻⁶ and the model is built with norm_eps = 10⁻⁵.[4] Both are per-example operations, so they behave the same in training and inference, unlike batch normalization, which uses statistics across the batch during training.[5]

In the signal widget, switch any exploding or vanishing configuration to LayerNorm or RMSNorm: the variance sits at 1 after every layer regardless of the weight scale. Normalization does not remove the need for a sensible initialization, since the gradient scale and the pre-normalization values still depend on it, but it removes the exponential dependence on depth that makes forty layers untrainable without it.

03Momentum and Adam

The linear models page showed plain gradient descent crawling along the flat direction of an ill-conditioned bowl because the steep direction caps the learning rate. Momentum replaces the gradient with a leaky average of past gradients: aligned components add up to an effective step of η/(1 − β), oscillating ones cancel, and the stable range of ηλ widens from 2 to 2 + 2β.[6]

goes further and normalizes each parameter's step by a running estimate of its own gradient magnitude, so a coordinate with gradients a thousand times larger does not get steps a thousand times larger.[7] The defaults β₁ = 0.9 and β₂ = 0.999 make the second moment move much more slowly than the first.

mt = β₁mt−1 + (1 − β₁)gt,   vt = β₂vt−1 + (1 − β₂)gt²,   θt = θt−1 − η m̂t / (√v̂t + ε)

AdamW applies weight decay as a separate shrink of θ before the moment update, so the decay does not pass through the moment averages.[8] That is the variant nanoGPT and many other language-model trainers use.[9]

Race three optimizers down a stretched bowl

Each Step runs five updates. At condition number 50 and η = 0.03, plain SGD zigzags across the steep axis and reaches a loss near 10⁻⁵ after 200 steps; momentum with β = 0.9 reaches about 5×10⁻⁸ at the same η; Adam at η = 0.1 is near 10⁻⁸. Raise η past 0.04 and SGD diverges while momentum converges until just below 0.076, the (2 + 2β)/λ bound. Adam's path is almost a straight diagonal for its first steps: each coordinate moves by about η per step whatever its curvature, so the step length is set by η and not by the gradient's scale.

The bias correction matters more than it looks. Both moving averages start at zero, so for the first few steps m is a fraction of the true gradient and v is a smaller fraction of its square. The ratio m/√v is therefore biased upward, not downward: without correction the first Adam step with the default betas is about three times η, and it grows to about 6.6η by step 12 because m warms up faster than v. The overshoot is still about 1.26η at step 1,000.[7]

Watch Adam's moments warm up with and without correction

With a constant gradient of 1 the correct step is exactly η every time, and the corrected curve is flat at 1. The raw curve starts at (1 − β₁)/√(1 − β₂) = 3.16 for the defaults, climbs to 6.57 at step 12 while m catches up, and is still 4.14 at step 60; it decays toward 1 only as v catches up, which takes thousands of steps at β₂ = 0.999. Lower β₂ toward 0.9 and the bias disappears within a few dozen steps. nanoGPT uses β₂ = 0.95, whose average has a time constant of about 20 steps.[9]

04Warmup, decay and clipping

A fixed learning rate is wrong at both ends of a run. At the start the weights are random and the gradient directions are poor, so large steps can diverge; a warmup ramps η up over the first steps and limits early divergence in deep networks.[10] At the end, minibatch noise keeps the iterate jittering at a scale set by η, so the rate must decay for the loss to settle; a cosine decay, proposed by Loshchilov and Hutter, holds η high for a while and then anneals it smoothly.[10] nanoGPT's schedule is exactly these two pieces: linear warmup, then cosine from the peak to a floor of a tenth of it.[9]

Shape a warmup-plus-cosine schedule and run noisy SGD under it

The bowl is the condition-50 quadratic from §3 with seeded Gaussian noise added to every gradient, standing in for minibatch sampling. With noise σ = 1 the constant-rate run flattens out near 3×10⁻² because each step kicks the iterate by about η times the noise, while the scheduled run keeps descending as η shrinks and ends near 2×10⁻³. Set the noise to 0 and the picture reverses: on a noise-free bowl the constant rate reaches 10⁻⁶ and the decayed one stalls at 10⁻³, because decay only slows a clean descent.

Gradient clipping is the safety rail for the rare step whose gradient is enormous. PyTorch's clip_grad_norm_ computes the norm over all parameters' gradients as if concatenated into one vector and scales them down together when it exceeds the maximum, so the update direction is kept and only its length is capped; nanoGPT clips at 1.0 on every step.[11][9]

05Mixed precision and loss scaling

Tensor hardware runs 16-bit matrix multiplies several times faster than 32-bit ones and moves half the bytes. training runs the heavy operations in a 16-bit format under an autocast rule and keeps a 32-bit master copy of the parameters for the optimizer update.[12] Which 16-bit format decides what can go wrong.

float16 has 10 fraction bits and 5 exponent bits: its smallest normal number is 2⁻¹⁴ ≈ 6.1×10⁻⁵, its smallest subnormal 2⁻²⁴ ≈ 6×10⁻⁸, and its largest finite value 65,504. Small gradients can fall below the subnormal floor and flush to zero, so the update for those parameters is lost. The fix is loss scaling: multiply the loss by a scale before the backward pass, which multiplies every gradient by the same factor, then divide the gradients by it before the optimizer step.[12] PyTorch's GradScaler picks the scale dynamically: it starts at 2¹⁶, skips the step and halves the scale when an inf or NaN appears, and doubles it after 2,000 consecutive clean steps.[13] bfloat16 keeps float32's 8 exponent bits with only 7 fraction bits, so it has the same range as float32, needs no scaling, and pays in resolution; it is the format nanoGPT uses when the hardware supports it, falling back to float16 with a GradScaler otherwise.[9] The bit layouts and rounding rules are on the floating point page.

Round a cloud of gradients to 16 bits

Two thousand seeded gradients spread log-normally around 10ᵏ with a standard deviation of 0.6 decades are multiplied by the loss scale and rounded with the format's real rounding rule, including its subnormal range. Around 10⁻⁸ in float16 with no scaling, 1,584 of 2,000 vanish; a scale of 2¹⁶ lifts them all into range. Push the typical gradient to 10⁰ with the same scale and about half overflow, which is the failure a dynamic scaler backs off from. bfloat16 keeps every one of them at any setting on this slider, at a mean relative rounding error about eight times float16's.

06Scaling laws and the compute budget

Training cost for a transformer is close to 6ND floating-point operations: 2ND for the forward pass over D tokens with N parameters, and twice that for the backward.[15] A compute budget C therefore fixes the product ND and leaves one question: a larger model on fewer tokens, or a smaller one on more. answer it empirically. Hoffmann et al. fit the final loss of hundreds of training runs to a function of N and D and found that, at a fixed budget, model size and token count should grow together, which implied that models of the time were under-trained for their size.[14]

L(N, D) = E + A/Nα + B/Dβ

The constants are the paper's third-approach fit as reproduced in nanoGPT's scaling-law notebook, which notes that it cannot make the fit reproduce the paper's tabulated optima exactly.[15] The widget treats the formula as what it is: a fit whose shape is the lesson, with the paper's own second-approach table drawn alongside for comparison.

Spend a compute budget between parameters and tokens

The left curve is L(N, C/6N): too small a model wastes the budget on tokens it cannot use, too large a model sees too few tokens. At 10²¹ FLOPs the fit's minimum is near 1.8 billion parameters on 90 billion tokens. The right panel sweeps budgets from 10¹⁷ to 10²⁶ and traces the fit's optimum against the paper's second-approach table, which lists 20 billion tokens for a 1-billion-parameter model and 220 billion for 10 billion. Both grow tokens at least in proportion to parameters, but at different rates: the fit's optimum moves from about 26 tokens per parameter at 10¹⁸ FLOPs to about 155 at 10²⁶, while the table stays between 19 and 29. The notebook reports the same kind of mismatch between the fit and the paper's tabulated optima and leaves it unresolved.

07AdamW, schedule and clipping, checked

These complete programs implement AdamW with bias correction, global-norm gradient clipping, and the warmup-plus-cosine schedule, then check them: the first Adam step has magnitude exactly η per coordinate regardless of the gradient's scale, clipping preserves direction and caps the norm, the schedule hits its documented values at the boundaries, and AdamW drives the condition-50 quadratic below 10⁻⁸.

AdamW, clipping and schedule, complete programs
#include <cassert>
#include <cmath>
#include <cstddef>
#include <numbers>
#include <vector>
using Vector = std::vector<double>;
struct AdamW {
    double beta1, beta2, epsilon, weightDecay;
    Vector firstMoment, secondMoment;
    int step = 0;
    AdamW(std::size_t size, double decay)
        : beta1(0.9), beta2(0.999), epsilon(1e-8), weightDecay(decay), firstMoment(size, 0), secondMoment(size, 0) {}
    void update(Vector& parameters, const Vector& gradient, double scheduledRate) {
        ++step;
        const double correction1 = 1 - std::pow(beta1, step), correction2 = 1 - std::pow(beta2, step);
        for (std::size_t index = 0; index < parameters.size(); ++index) {
            parameters[index] -= scheduledRate * weightDecay * parameters[index]; // Decoupled decay, applied to θ directly.
            firstMoment[index] = beta1 * firstMoment[index] + (1 - beta1) * gradient[index];
            secondMoment[index] = beta2 * secondMoment[index] + (1 - beta2) * gradient[index] * gradient[index];
            const double correctedFirst = firstMoment[index] / correction1, correctedSecond = secondMoment[index] / correction2;
            parameters[index] -= scheduledRate * correctedFirst / (std::sqrt(correctedSecond) + epsilon);
        }
    }
};
// Scale the whole gradient so its global ℓ2 norm is at most maxNorm; returns the norm before clipping.
double clipGlobalNorm(Vector& gradient, double maxNorm) {
    double sumSquares = 0;
    for (double value : gradient) sumSquares += value * value;
    const double norm = std::sqrt(sumSquares);
    if (norm > maxNorm) for (double& value : gradient) value *= maxNorm / norm;
    return norm;
}
// Linear warmup to peak, cosine decay to floor, constant floor afterwards.
double warmupCosine(int iteration, int warmup, int total, double peak, double floor) {
    if (iteration < warmup) return peak * (iteration + 1) / (warmup + 1);
    if (iteration > total) return floor;
    const double ratio = double(iteration - warmup) / (total - warmup);
    return floor + 0.5 * (1 + std::cos(std::numbers::pi * ratio)) * (peak - floor);
}
int main() {
    Vector parameters{0.5, -0.5};
    AdamW firstStep(2, 0.0);
    firstStep.update(parameters, Vector{1000.0, -0.001}, 0.01);
    assert(std::abs(parameters[0] - 0.49) < 1e-6 && std::abs(parameters[1] + 0.49) < 1e-6); // |step| = η per coordinate, whatever the gradient scale (up to ε).
    Vector gradient{3.0, 4.0};
    const double before = clipGlobalNorm(gradient, 1.0);
    assert(std::abs(before - 5.0) < 1e-12 && std::abs(gradient[0] - 0.6) < 1e-12 && std::abs(gradient[1] - 0.8) < 1e-12); // Direction kept, norm capped at 1.
    assert(std::abs(warmupCosine(0, 2000, 20000, 6e-4, 6e-5) - 6e-4 / 2001) < 1e-15);
    assert(std::abs(warmupCosine(2000, 2000, 20000, 6e-4, 6e-5) - 6e-4) < 1e-15);   // Peak at the end of warmup.
    assert(std::abs(warmupCosine(20000, 2000, 20000, 6e-4, 6e-5) - 6e-5) < 1e-15);  // Floor at the end.
    Vector point{-2.5, 2.5};
    AdamW optimizer(2, 0.0);
    for (int iteration = 0; iteration < 2000; ++iteration) {
        Vector bowlGradient{point[0], 50.0 * point[1]}; // ∇ of ½(x² + 50 y²): condition number 50.
        clipGlobalNorm(bowlGradient, 100.0);
        optimizer.update(point, bowlGradient, warmupCosine(iteration, 50, 2000, 0.1, 0.001));
    }
    const double loss = 0.5 * (point[0] * point[0] + 50.0 * point[1] * point[1]);
    assert(loss < 1e-8);
}
struct AdamW { beta1: f64, beta2: f64, epsilon: f64, weight_decay: f64, first_moment: Vec<f64>, second_moment: Vec<f64>, step: i32 }
impl AdamW {
    fn new(size: usize, weight_decay: f64) -> AdamW {
        AdamW { beta1: 0.9, beta2: 0.999, epsilon: 1e-8, weight_decay, first_moment: vec![0.0; size], second_moment: vec![0.0; size], step: 0 }
    }
    fn update(&mut self, parameters: &mut [f64], gradient: &[f64], scheduled_rate: f64) {
        self.step += 1;
        let correction1 = 1.0 - self.beta1.powi(self.step);
        let correction2 = 1.0 - self.beta2.powi(self.step);
        for index in 0..parameters.len() {
            parameters[index] -= scheduled_rate * self.weight_decay * parameters[index]; // Decoupled decay, applied to θ directly.
            self.first_moment[index] = self.beta1 * self.first_moment[index] + (1.0 - self.beta1) * gradient[index];
            self.second_moment[index] = self.beta2 * self.second_moment[index] + (1.0 - self.beta2) * gradient[index] * gradient[index];
            let corrected_first = self.first_moment[index] / correction1;
            let corrected_second = self.second_moment[index] / correction2;
            parameters[index] -= scheduled_rate * corrected_first / (corrected_second.sqrt() + self.epsilon);
        }
    }
}
// Scale the whole gradient so its global ℓ2 norm is at most max_norm; returns the norm before clipping.
fn clip_global_norm(gradient: &mut [f64], max_norm: f64) -> f64 {
    let norm = gradient.iter().map(|value| value * value).sum::<f64>().sqrt();
    if norm > max_norm { for value in gradient.iter_mut() { *value *= max_norm / norm; } }
    norm
}
// Linear warmup to peak, cosine decay to floor, constant floor afterwards.
fn warmup_cosine(iteration: i32, warmup: i32, total: i32, peak: f64, floor: f64) -> f64 {
    if iteration < warmup { return peak * (iteration + 1) as f64 / (warmup + 1) as f64; }
    if iteration > total { return floor; }
    let ratio = (iteration - warmup) as f64 / (total - warmup) as f64;
    floor + 0.5 * (1.0 + (std::f64::consts::PI * ratio).cos()) * (peak - floor)
}
fn main() {
    let mut parameters = [0.5, -0.5];
    let mut first_step = AdamW::new(2, 0.0);
    first_step.update(&mut parameters, &[1000.0, -0.001], 0.01);
    assert!((parameters[0] - 0.49).abs() < 1e-6 && (parameters[1] + 0.49).abs() < 1e-6); // |step| = η per coordinate, whatever the gradient scale (up to ε).
    let mut gradient = [3.0, 4.0];
    let before = clip_global_norm(&mut gradient, 1.0);
    assert!((before - 5.0).abs() < 1e-12 && (gradient[0] - 0.6).abs() < 1e-12 && (gradient[1] - 0.8).abs() < 1e-12); // Direction kept, norm capped at 1.
    assert!((warmup_cosine(0, 2000, 20000, 6e-4, 6e-5) - 6e-4 / 2001.0).abs() < 1e-15);
    assert!((warmup_cosine(2000, 2000, 20000, 6e-4, 6e-5) - 6e-4).abs() < 1e-15);   // Peak at the end of warmup.
    assert!((warmup_cosine(20000, 2000, 20000, 6e-4, 6e-5) - 6e-5).abs() < 1e-15);  // Floor at the end.
    let mut point = [-2.5, 2.5];
    let mut optimizer = AdamW::new(2, 0.0);
    for iteration in 0..2000 {
        let mut bowl_gradient = [point[0], 50.0 * point[1]]; // ∇ of ½(x² + 50 y²): condition number 50.
        clip_global_norm(&mut bowl_gradient, 100.0);
        optimizer.update(&mut point, &bowl_gradient, warmup_cosine(iteration, 50, 2000, 0.1, 0.001));
    }
    let loss = 0.5 * (point[0] * point[0] + 50.0 * point[1] * point[1]);
    assert!(loss < 1e-8);
}
What's intentionally missing

Parameter groups that exempt biases and normalization gains from weight decay, the fused and multi-tensor kernels that apply the update to millions of parameters at once, mixed-precision master weights with a loss scaler, gradient accumulation across micro-batches, distributed all-reduce of gradients, and checkpointing of the optimizer state. nanoGPT's training script wires all of these around the same arithmetic.[9]

08Where training runs go wrong

A loss that falls and then jumps to NaN is usually a numerics problem, not a bug in the model

Check, in order: the learning rate against the warmup (a peak reached too early), gradient clipping (is it on, and what norms is it seeing), and the precision format (float16 overflows at 65,504; a dynamic scaler that keeps backing off means gradients keep overflowing). Only then suspect the architecture.

Weight decay in AdamW is applied directly to the parameters, so decaying biases and normalization gains pulls them toward zero, which is rarely wanted. nanoGPT puts every parameter with fewer than two dimensions, biases and LayerNorm gains, in a separate group with decay disabled.[9]

Normalization hides initialization problems rather than solving them. A model that trains only because LayerNorm is rescuing every layer has gradients whose scale still depends on the pre-normalization values, and it may train slowly or need an unusually small learning rate; check the activation statistics at initialization before and after the normalization, as the §1 widget does.

A scaling law fit is a description of one family of runs on one dataset with one recipe. Its exponents transfer better than its constants, and its optimum shifts if inference cost is part of the budget, since a smaller model trained on more tokens is cheaper to serve. Use the shape of the curve to reason; measure the level with runs of your own.

09What's next

A run that trains is not yet a model that works. Evaluation and Generalization covers how to tell: held-out splits and what leaks between them, the gap between training and validation loss, metrics for classification and for language models, calibration, and why a benchmark score has an error bar whether or not it is printed.

10Sources

The cited texts define the methods; the framework sources document how shipped trainers implement them. Widget readouts report values computed in the page from seeded simulations and the stated formulas.

  1. Aston Zhang, Zachary C. Lipton, Mu Li, Alexander J. Smola, 2023. Dive into Deep Learning, Section 5.4: Numerical Stability and Initialization, Cambridge University Press. Vanishing and exploding gradients, the sigmoid's role, the product of random matrices, symmetry breaking, and the Xavier variance derivation.
  2. PyTorch contributors. torch.nn.init. Kaiming initialization with std = gain/√fan and the gains √2 for ReLU and 5/3 for tanh, citing He et al. (2015) and Glorot and Bengio (2010).
  3. PyTorch contributors. torch.nn.LayerNorm. Mean and variance over the normalized dimensions of each input, with learned affine parameters.
  4. Meta Llama team. llama/model.py. The RMSNorm reference implementation: x · rsqrt(mean(x²) + ε) · weight, with a class default ε = 10⁻⁶ and the model's norm_eps = 10⁻⁵.
  5. Stanford CS231n course staff. Setting up the data and the model. Batch normalization as normalization using batch statistics, inserted before nonlinearities.
  6. Aston Zhang et al., 2023. Dive into Deep Learning, Section 12.6: Momentum. The velocity recursion, the effective step η/(1 − β), and the stability range ηλ < 2 + 2β.
  7. Aston Zhang et al., 2023. Dive into Deep Learning, Section 12.10: Adam. The moment updates, the defaults β₁ = 0.9 and β₂ = 0.999, the initial bias toward zero, and the bias-correction factors.
  8. PyTorch contributors. torch.optim.AdamW. The algorithm with weight decay applied to the parameters before the moment update, outside the moment averages.
  9. Andrej Karpathy. nanoGPT train.py. Linear warmup then cosine decay to a floor of a tenth of the peak, AdamW with β₂ = 0.95 and weight decay 0.1, gradient clipping at 1.0, and bfloat16 autocast with a float16 GradScaler fallback. Its model.py configure_optimizers exempts biases and LayerNorm gains from weight decay.
  10. Aston Zhang et al., 2023. Dive into Deep Learning, Section 12.11: Learning Rate Scheduling. The cosine schedule attributed to Loshchilov and Hutter (2016) and warmup limiting early divergence, citing Gotmare et al. (2018).
  11. PyTorch contributors. torch.nn.utils.clip_grad_norm_. The norm computed over all parameter gradients as one concatenated vector.
  12. PyTorch contributors. Automatic Mixed Precision package. Autocast with float16 or bfloat16, gradient underflow in float16, loss scaling and unscaling before the optimizer step, and the float16 maximum of 65,504.
  13. PyTorch contributors. torch.amp.GradScaler. The dynamic scale: initial 2¹⁶, step skipped and scale reduced on inf or NaN, scale doubled after 2,000 consecutive clean iterations.
  14. Jordan Hoffmann et al., 2022. Training Compute-Optimal Large Language Models. The parametric loss fit and the finding that parameters and tokens should scale together; constants taken as reproduced in source 15.
  15. Andrej Karpathy. nanoGPT scaling_laws.ipynb. The Approach 3 constants E = 1.69, A = 406.4, B = 410.7, α = 0.34, β = 0.28, the Approach 2 table, the 6ND compute approximation with backward counted as twice the forward, and the note that the fit does not reproduce the paper's tabulated optima exactly.