← All chapters

Chapter 14 · Content revision 1

Schedule AdamW updates and clip global gradients

Build a warmup and cosine schedule around existing AdamW, inspect a shared clipping factor, and keep successful updates separate from refused or skipped attempts.

Control gradient magnitude and the rate of each update

A backward pass can produce a gradient dominated by a few large coordinates. Feeding that gradient directly into the optimizer lets its magnitude determine the optimizer’s new moment values. Independently, a fixed learning rate gives the same multiplier to the first and last updates. A training experiment may instead call for a gradual increase at the start and smaller rates near the end. These are two separate choices: control the gradient entering AdamW, and choose the rate used by each successful update.

The existing trainer already performs global gradient-norm clipping and AdamW. We will inspect that clipping operation and extend its existing learning-rate schedule with linear warmup followed by one cosine decay. The optimizer, model and update owner stay the same.

One factor for gradients in two tensors

Start with a constructed scalar diagnostic on the text model. Its derivative has values 33 and 44 in the first two coordinates of token_embedding.weight, and 1212 in the first coordinate of blocks.0.attention_norm.gain. Every other parameter-gradient coordinate is zero. This is an inspection objective chosen to make its gradient easy to check; it is not a next-token likelihood.

The complete gradient has Euclidean norm 32+42+122=169=13\sqrt{3^2+4^2+12^2}=\sqrt{169}=13. Set the global norm limit to 6.56.5. Multiplying every coordinate by the same factor 6.5/13=0.56.5/13=0.5 gives 1.51.5, 22 and 66, whose combined norm is 6.56.5. The nonzero vector keeps its direction. Clipping each tensor independently would change the relationship between the two tensors’ contributions.

The Rust diagnostic observes exactly those three effective gradient values, applies one existing AdamW update at rate 0.0010.001, and clears gradients. It uses its own fresh model and optimizer; the text-training experiment below starts separately.

Six successful updates, with two warmup updates

For the text experiment, plan six successful optimizer updates. Choose a peak rate of 0.0020.002, a final decay rate of 0.00020.0002, and two warmup updates. Linear warmup uses half the peak, 0.0010.001, on update 11 and reaches 0.0020.002 on update 22. The four following updates move along one cosine decay to the final rate.

The recorded rates for updates 33, 44 and 55 are 0.00173639610306789280.0017363961030678928, 0.00110.0011 and 0.00046360389693210730.0004636038969321073. Update 66 uses 0.00020.0002. These are the reported f64 values, rather than a claim that decimal arithmetic or every backend gives identical bits. A successful update owns one rate; an attempted call that is refused or skipped does not consume a rate.

Define one global clip and one warmup–cosine schedule

Let jj enumerate scalar coordinates across all distinct named parameter tensors, counting a tied parameter once. Let gjg_j be a gradient coordinate after any loss unscaling and before clipping. The complete gradient’s Euclidean norm is rr, its finite positive limit is cc, and aa is the one common nonamplifying factor. AdamW receives effective coordinate qjq_j.

For the schedule, tt is the one-based successful optimizer-update index and TT is the total planned number of those updates. WW counts warmup updates; PP is the positive peak rate; FF is the positive final decay rate; and ηt\eta_t is the rate assigned to update tt. The constant π\pi is the usual circle constant, and the cosine angles use radians. The ideal rules are

r=∑jgj2,a={1,r=0,min⁡(1,c/r),r>0,qj=a gj,ηt={P t/W,W>0, 1≤t≤W,F+P−F2 ⁣[1+cos⁡ ⁣(πt−WT−W)],W>0, W<t≤T,F+P−F2 ⁣[1+cos⁡ ⁣(πt−1T−1)],W=0, 1≤t≤T.\begin{aligned} r&=\sqrt{\sum_j g_j^2},& a&=\begin{cases}1,&r=0,\\ \min(1,c/r),&r>0,\end{cases}& q_j&=a\,g_j,\\ \eta_t&=\begin{cases} P\,t/W,&W>0,\ 1\le t\le W,\\ F+\dfrac{P-F}{2}\!\left[1+\cos\!\left(\pi\dfrac{t-W}{T-W}\right)\right],&W>0,\ W<t\le T,\\ F+\dfrac{P-F}{2}\!\left[1+\cos\!\left(\pi\dfrac{t-1}{T-1}\right)\right],&W=0,\ 1\le t\le T. \end{cases} \end{aligned}

The zero-norm branch avoids division by zero and leaves the gradient zero. For a nonzero gradient, the factor is 11 when the norm already fits and c/rc/r when it exceeds the limit. Thus in exact arithmetic the effective gradient has norm at most cc, and a nonzero vector retains its direction.

With positive warmup, t/Wt/W reaches 11 on update WW. The subsequent cosine argument moves from 00 toward π\pi as updates approach TT, moving the rate from PP to FF. With zero warmup, update 11 must already use PP; the cosine progress therefore starts from t−1t-1 over T−1T-1. There is one decay and no restart. FF is the decay endpoint, not a lower bound on rates earlier in warmup.

The warmup–cosine constructor requires 2≤T≤2532\le T\le2^{53}, 0≤W<T0\le W<T and finite rates 0<F≤P0<F\le P. The integer bound lets every index convert exactly to f64; leaving W<TW<T reserves a decay update and a nonzero denominator. Allocation must also succeed. The Rust code assigns peak and final endpoints explicitly and passes every generated rate through the existing positive-rate validator. Even positive ideal warmup values can underflow to stored zero and be refused.

The existing norm calculation rescales its sum of squares to avoid avoidable overflow. Its gradient transform supports both uniform multiplication and a normalized representation for numerical range. The equations describe the ideal transformation; the implementation still performs represented f64 operations. When clipping occurs, the trainer’s after-norm field records the chosen limit. That field is not an independent remeasurement of every effective coordinate. The three-coordinate diagnostic above supplies a separate hand calculation of its resulting norm.

Keep clipping, Adam moments and weight decay separate

Clipping limits the gradient supplied to AdamW’s moments. It does not directly limit the final parameter displacement, because AdamW also uses coordinatewise moment normalization and a separate weight-decay term.

For one coordinate, write its effective gradient at update tt as qtq_t. Let mtm_t and vtv_t be its first and second moments, with initial m0=v0=0m_0=v_0=0. Their coefficients β1\beta_1 and β2\beta_2 lie in [0,1)[0,1); the bias-corrected moments are m^t\widehat m_t and v^t\widehat v_t. Let ϵ>0\epsilon>0 be the denominator offset, θt−1\theta_{t-1} the old weight, and λ\lambda its decay coefficient. For a coordinate selected for decay, the existing AdamW rule is, in exact arithmetic,

mt=β1mt−1+(1−β1)qt,vt=β2vt−1+(1−β2)qt2,m^t=mt1−β1t,v^t=vt1−β2t,θt=θt−1−ηtm^tv^t+ϵ−ηtλθt−1.\begin{aligned} m_t&=\beta_1m_{t-1}+(1-\beta_1)q_t,\\ v_t&=\beta_2v_{t-1}+(1-\beta_2)q_t^2,\\ \widehat m_t&=\frac{m_t}{1-\beta_1^t},& \widehat v_t&=\frac{v_t}{1-\beta_2^t},\\ \theta_t&=\theta_{t-1} -\eta_t\frac{\widehat m_t}{\sqrt{\widehat v_t}+\epsilon} -\eta_t\lambda\theta_{t-1}. \end{aligned}

Schedule decay lowers the learning rate ηt\eta_t across updates. Weight decay subtracts ηtλθt−1\eta_t\lambda\theta_{t-1} during an update, outside the loss-gradient and moment calculation. Do not add that decay term to the gradient before clipping. Both subtractions use the scheduled rate, but they perform different operations.

The diagnostic and six-update text run use β1=0.9\beta_1=0.9, β2=0.95\beta_2=0.95, ϵ=10−8\epsilon=10^{-8} and λ=0.01\lambda=0.01. In the diagnostic’s first coordinate, the effective gradient is 1.51.5. The stored first and second moments are 0.149999999999999970.14999999999999997 and 0.11250000000000010.1125000000000001; the corrected moments are 1.51.5 and 2.252.25. At rate 0.0010.001, the adaptive subtraction is 0.00099999999333333350.0009999999933333335 and the separate decay subtraction is 9.270782062966737×10−79.270782062966737\times10^{-7}. Starting from weight 0.092707820629667370.09270782062966737, the stored result is 0.091706893558127740.09170689355812774. These represented values expose the two branches; the cap on the input gradient is not a cap on their combined parameter change.

From recurrent-gradient control to scheduled transformer training

Exploding gradients made recurrent-network optimization difficult; controlling their norm addresses gradient size rather than defining a complete training schedule. Pascanu, Mikolov and Bengio propose gradient norm clipping for exploding gradients in recurrent neural networks. Their paper, On the difficulty of training Recurrent Neural Networks, was first submitted in 2012; the cited revision is from 2013. This supports the norm-control idea, not a guarantee about vanishing gradients or AdamW convergence.

Loshchilov and Hutter separate weight decay from the loss-gradient update in adaptive optimization. Decoupled Weight Decay Regularization was first submitted in 2017; the cited revision is from 2019. That distinction is why this lesson leaves decay outside the clipping and moment branch and reuses the course’s existing AdamW implementation.

LLaMA combines AdamW with gradient clipping and a learning-rate schedule for transformer language-model training. Touvron and colleagues’ 2023 LLaMA report uses AdamW, a cosine schedule ending at one tenth of the maximum rate, two thousand warmup steps and gradient clipping with threshold one. See its optimizer section. Our six-update experiment and two warmup updates are a small teaching choice. Its text-run norm limit 0.50.5 and decay coefficient 0.010.01 also belong to this experiment; this run does not reproduce LLaMA training.

In this decoder, clipping controls the gradient entering AdamW, while the schedule chooses the rate associated with each successful optimizer update. Reuse the existing global clipping and AdamW update, adding linear warmup followed by one cosine decay over successful optimizer updates. The Rust example makes norm control, scheduled rates and preserved state inspectable on tiny text-model fixtures; it does not reproduce the papers’ training runs or establish comparative language-model quality.

Extend rate construction and reuse the update owner

LearningRateSchedule::linear_warmup_cosine_decay returns the same schedule type as the explicit-rate constructor. Its vector contains one rate per planned successful update, with no mutable cursor. Changing a rate does not recreate AdamW or reset its moments.

Construct one warmup and cosine decay, validating represented rates and assigning peak and final endpoints explicitly. rust/crates/llm-from-scratch-practical/src/training/trainer.rs#warmup-cosine-schedule
impl LearningRateSchedule {
    /// Builds one linear warmup and one cosine decay over successful updates.
    ///
    /// Updates are numbered `1..=total_steps`. Positive warmup reaches the peak
    /// at `warmup_steps`; zero warmup starts at the peak on update one. The last
    /// update always uses `final_rate`, which is a decay endpoint rather than a
    /// lower bound on the warmup rates. Both endpoints must be positive under
    /// the existing AdamW policy. There are no restarts or mutable schedule
    /// counters: callers select the next successful optimizer-update index.
    pub fn linear_warmup_cosine_decay(
        total_steps: usize,
        warmup_steps: usize,
        peak_rate: f64,
        final_rate: f64,
    ) -> Result<Self, TrainerError> {
        let invalid = |reason| TrainerError::InvalidWarmupCosineSchedule { reason };
        if total_steps < 2 || total_steps as u128 > (1_u128 << 53) {
            return Err(invalid("total steps must be between 2 and 2^53"));
        }
        if warmup_steps >= total_steps {
            return Err(invalid("warmup must leave at least one decay update"));
        }
        if !peak_rate.is_finite() || peak_rate <= 0.0 {
            return Err(invalid("the peak rate must be finite and positive"));
        }
        if !final_rate.is_finite() || final_rate <= 0.0 || final_rate > peak_rate {
            return Err(invalid(
                "the final rate must be finite, positive and no greater than the peak",
            ));
        }
        let mut rates = Vec::new();
        rates
            .try_reserve_exact(total_steps)
            .map_err(|_| invalid("the schedule cannot be allocated"))?;
        for step in 1..=total_steps {
            let rate = if step == total_steps {
                final_rate
            } else if step == warmup_steps || (warmup_steps == 0 && step == 1) {
                peak_rate
            } else if step < warmup_steps {
                peak_rate * (step as f64 / warmup_steps as f64)
            } else {
                let (elapsed, duration) = if warmup_steps == 0 {
                    (step - 1, total_steps - 1)
                } else {
                    (step - warmup_steps, total_steps - warmup_steps)
                };
                let progress = elapsed as f64 / duration as f64;
                let weight = 0.5 * (1.0 + (std::f64::consts::PI * progress).cos());
                final_rate + (peak_rate - final_rate) * weight
            };
            rates.push(rate);
        }
        // Preserve the existing positive-rate validation, including a warmup
        // value that rounds to zero even though its mathematical value is positive.
        Self::new(rates)
    }
}

The diagnostic constructs its scalar objective from existing parameter leaves. Coefficients make the two selected tensors contribute the explained gradient coordinates; the existing reverse engine calculates their derivatives.

Construct an inspection objective with gradient coordinates three and four in one model tensor and twelve in another. rust/demos/practical-ch14-optimizer-schedules-clipping/src/lib.rs#constructed-global-gradient
/// A scalar diagnostic on two existing model tensors gives gradients 3,4 and12.
/// This is an inspection objective, separate from the text likelihood below.
fn constructed_gradient_loss(
    model: &DecoderModel,
    context: AutogradContext<'_>,
) -> Result<TensorValue, TrainerError> {
    let mut terms = Vec::new();
    for (parameter, nonzero) in model
        .parameters()
        .iter()
        .zip([&[3.0, 4.0][..], &[12.0][..]])
    {
        let count = parameter.tensor().value().as_slice().len();
        let mut coefficients = vec![0.0; count];
        coefficients[..nonzero.len()].copy_from_slice(nonzero);
        let coefficients = TensorValue::constant(Tensor::from_vec(vec![count], coefficients)?)?;
        terms.push(
            parameter
                .tensor()
                .reshape_with_context(context, &[count])?
                .mul_with_context(context, &coefficients)?
                .sum_axis_with_context(context, 0, false)?,
        );
    }
    Ok(terms[0].add_with_context(context, &terms[1])?)
}

The probe passes that scalar through decoder_update_with_forward. It observes the existing trainer’s common gradient transform and AdamW’s effective gradient, moment and decay trace, then checks the cleared gradients. The transform supplies effective moment inputs without overwriting the raw gradient buffers; the trainer clears those buffers after the update commits. In scaled paths, unscaling and clipping can be combined in that transform rather than performed as separate in-place changes.

Observe the existing global clip and AdamW trace for the two-tensor diagnostic, then verify cleared gradients. rust/demos/practical-ch14-optimizer-schedules-clipping/src/lib.rs#global-clipping-probe
/// Observe the existing trainer's common transform across two named tensors.
pub fn clipping_probe() -> DemoResult<Value> {
    let model = model_fixture()?;
    let mut optimizer = optimizer()?;
    let plan = TrainerConfig::new(LearningRateSchedule::new(vec![0.001])?, vec![0, 1], 6.5)?;
    let execution = TensorExecution::cpu();
    let mut observer = Observe::default();
    let update = decoder_update_with_forward(
        &model,
        &mut optimizer,
        &plan,
        1,
        || {
            constructed_gradient_loss(
                &model,
                AutogradContext::recording().with_execution(&execution),
            )
        },
        &mut observer,
    )?;
    let trace = update.optimizer_trace.ok_or("missing clipping trace")?;
    let first = &trace.updates()[0];
    let second = &trace.updates()[1];
    let second_offset = model.parameters()[0].tensor().value().as_slice().len();
    let raw = [0, 1, second_offset].map(|index| f64::from_bits(observer.raw_gradient_bits[index]));
    let effective = [
        first.gradient()[0],
        first.gradient()[1],
        second.gradient()[0],
    ];
    if raw != [3.0, 4.0, 12.0] || effective != [1.5, 2.0, 6.0] {
        return Err("constructed gradients or shared clipping changed".into());
    }
    Ok(json!({
        "objective":"constructed scalar diagnostic, not text likelihood",
        "parameter_names":[first.name(),second.name()],
        "raw_gradients":raw,
        "global_norm_before":update.step.gradient_norm_before(),
        "clip_threshold":6.5,
        "global_norm_after":update.step.gradient_norm_after(),
        "clipped":update.step.clipped(),
        "transform":transform_record(observer.transform.ok_or("missing transform")?),
        "effective_gradients":effective,
        "learning_rate":trace.learning_rate(),
        "optimizer_step":trace.step(),
        "gradients_cleared":gradients(&model).iter().all(|&bits|bits==0),
        "first_coordinate":{
            "first_moment":first.first_moment()[0],
            "second_moment":first.second_moment()[0],
            "corrected_first_moment":first.corrected_first_moment()[0],
            "corrected_second_moment":first.corrected_second_moment()[0],
            "weight_before":first.before()[0],
            "adaptive_delta":first.adaptive_delta()[0],
            "decay_delta":first.decay_delta()[0],
            "weight_after":first.after()[0],
            "weight_decay":first.effective_weight_decay()
        }
    }))
}

Advance the clock only when AdamW commits

The requested update must equal the optimizer’s committed count plus one. This check runs before the forward callback. Looking up a rate does not consume it.

Require the requested learning-rate entry to match the next optimizer update before running forward. rust/crates/llm-from-scratch-practical/src/training/trainer.rs#scheduled-update-clock
fn validate_update_step(
    optimizer: &AdamW,
    config: &TrainerConfig,
    step: usize,
) -> Result<(f64, u64), TrainerError> {
    let learning_rate = config
        .schedule
        .learning_rate(step)
        .ok_or(TrainerError::InvalidUpdateStep { step })?;
    let expected = u64::try_from(step).map_err(|_| TrainerError::InvalidUpdateStep { step })?;
    if optimizer.step_count().checked_add(1) != Some(expected) {
        return Err(TrainerError::OptimizerStepMismatch {
            expected,
            actual: optimizer.step_count(),
        });
    }
    Ok((learning_rate, expected))
}

The text run reuses Chapter 11’s seed-3939 model fixture: vocabulary 258258, residual width 44, one attention head, feed-forward width 88, two decoder blocks and capacity for 88 positions. It has 2020 named parameter tensors and 13721372 learned scalars. Input IDs [0,99,100] have shape [1,3][1,3], meaning one row and three positions; targets [99,100,1] provide three next-token targets for the mean negative log likelihood. The same fixed text is reused at every update.

Within this run, one live model and one AdamW instance continue through all six generated rates. Computation is CPU f64, dropout is disabled and the loss scale is 11. The unscaled entry and the scaled/accumulated entries share the same finish owner. After contributions combine, its logical operations are unscaling when applicable, checking the finite global gradient, clipping once, applying scheduled AdamW once and clearing gradients. The gradient transform can fuse unscaling and clipping before the moment calculation. Microbatches and recomputed forwards do not create extra schedule steps.

After committing update 11, the run deliberately requests update 33. It receives OptimizerStepMismatch with "optimizer committed step 1, expected 3", and forward_called is false. The error’s expected index comes from the requested 33; the next eligible update is still 1+1=21+1=2. Weight and gradient bits, parameter revisions, populated moments, beta powers and optimizer step remain unchanged. The default schedule still supplies 0.0020.002 for update 22. The run then actually executes update 22 and continues through 66. Executing the next valid update is not retrying the refused request for 33.

Reuse one live text model and optimizer across six rates; refuse an out-of-order request before forward, then apply the next valid update. rust/demos/practical-ch14-optimizer-schedules-clipping/src/lib.rs#scheduled-text-updates
/// Reuse one live model and optimizer across all six generated learning rates.
pub fn text_training_probe(warmup_steps: usize) -> DemoResult<Value> {
    let rates = schedule(warmup_steps)?;
    let plan = TrainerConfig::new(rates.clone(), vec![0, TOTAL_STEPS], 0.5)?;
    let model = model_fixture()?;
    let mut optimizer = optimizer()?;
    let execution = TensorExecution::cpu();
    let mut updates = Vec::new();
    let mut refusal = None;
    for step in 1..=TOTAL_STEPS {
        if step == 2 {
            let before = DecoderModelState::snapshot(&model).bit_pattern();
            let before_gradients = gradients(&model);
            let before_revisions = revisions(&model);
            let before_optimizer = optimizer.persistence_state();
            let next_rate_before = rates.learning_rate(2);
            let mut forward_called = false;
            let rejected = decoder_update_with_forward(
                &model,
                &mut optimizer,
                &plan,
                3,
                || {
                    forward_called = true;
                    Err(TrainerError::ObserverRejected(
                        "out-of-order forward was called",
                    ))
                },
                &mut NoDecoderUpdateObserver,
            );
            let error = match rejected {
                Err(
                    error @ TrainerError::OptimizerStepMismatch {
                        expected: 3,
                        actual: 1,
                    },
                ) => error,
                _ => return Err("out-of-order update was not refused before forward".into()),
            };
            let preserved = before == DecoderModelState::snapshot(&model).bit_pattern()
                && before_gradients == gradients(&model)
                && before_revisions == revisions(&model)
                && same_optimizer_bits(&before_optimizer, &optimizer.persistence_state());
            if forward_called || !preserved || next_rate_before != rates.learning_rate(2) {
                return Err("refused update changed live training state or schedule lookup".into());
            }
            refusal = Some(json!({
                "attempted_step":3,"optimizer_step_before":1,"optimizer_step_after":optimizer.step_count(),
                "error":error.to_string(),"forward_called":forward_called,
                "weights_gradients_revisions_moments_powers_step_preserved":preserved,
                "next_valid_step":2,"next_valid_learning_rate":next_rate_before
            }));
        }
        let previous = optimizer.persistence_state();
        let first_name = model.parameters()[0].name();
        let previous_first_moment = previous
            .state(first_name)
            .map_or(0.0, |m| m.first_moment()[0]);
        let mut observer = Observe::default();
        let update = decoder_update_with_forward(
            &model,
            &mut optimizer,
            &plan,
            step,
            || {
                Ok(model.loss_with_context(
                    AutogradContext::recording().with_execution(&execution),
                    &[0, 99, 100],
                    &[1, 3],
                    &[99, 100, 1],
                )?)
            },
            &mut observer,
        )?;
        let trace = update.optimizer_trace.ok_or("missing text-update trace")?;
        let first = &trace.updates()[0];
        updates.push(json!({
            "step":trace.step(),"learning_rate":trace.learning_rate(),
            "train_loss":update.step.train_loss(),
            "gradient_norm_before":update.step.gradient_norm_before(),
            "gradient_norm_after":update.step.gradient_norm_after(),
            "clipped":update.step.clipped(),
            "gradients_cleared":gradients(&model).iter().all(|&bits|bits==0),
            "first_coordinate":{
                "parameter":first.name(),"raw_gradient":f64::from_bits(observer.raw_gradient_bits[0]),
                "effective_gradient":first.gradient()[0],"previous_first_moment":previous_first_moment,
                "first_moment":first.first_moment()[0],"second_moment":first.second_moment()[0],
                "weight_before":first.before()[0],"adaptive_delta":first.adaptive_delta()[0],
                "decay_delta":first.decay_delta()[0],"weight_after":first.after()[0]
            }
        }));
    }
    Ok(json!({
        "input_ids":[0,99,100],"target_ids":[99,100,1],"input_shape":[1,3],
        "valid_targets_per_update":3,"updates":updates,
        "refused_update":refusal,"final_optimizer_step":optimizer.step_count(),
        "optimizer_base_rate_unchanged":optimizer.config().learning_rate()==PEAK_RATE,
        "beta1":optimizer.config().beta1(),"beta2":optimizer.config().beta2(),
        "epsilon":optimizer.config().epsilon(),"weight_decay":optimizer.config().weight_decay(),
        "clip_threshold":0.5,"named_parameter_tensors":model.parameters().len(),
        "scalar_parameters":model.parameter_count(),"dropout":"disabled","loss_scale":1.0
    }))
}

Every default text update is marked clipped at threshold 0.50.5 and clears its gradients. The first measured before-norm is 1.26302530791436381.2630253079143638; the trainer reports after-norm 0.50.5 as the chosen clipping cap. Its first recorded forward mean loss is 5.6683988424315715.668398842431571, before update 11. The sixth recorded forward mean loss is 5.6109842832722835.610984283272283, before update 66. There is no loss measurement after the sixth commit and no held-out or comparative model-quality result.

The first coordinate of token_embedding.weight makes moment continuity visible. Update 11 receives effective gradient 0.074712105538285790.07471210553828579 and stores first moment 0.00747121055382857740.0074712105538285774. Update 22 reads that same previous moment, receives effective gradient 0.0622608670533834340.062260867053383434 and stores 0.0129501762037840620.012950176203784062, using rate 0.0020.002. AdamW reaches step 66, while its base configuration rate remains 0.0020.002; the per-update override changes the rate used for that step without resetting optimizer state.

Inspect actual same-step retries in a separate fixed-rate fixture

The demonstration also calls Chapter 12’s existing public overflow_fixture. That experiment creates another model and optimizer, retains the fixed rates [0.001,0.001], and uses the existing CPU FP16/F32 matrix policy and its original settings, including β2=0.999\beta_2=0.999. These rates are separate from the generated cosine schedule.

After successful update 11 populates moments, scheduled update 22 overflows and is skipped eight times. The first skip lowers the loss scale from 1677721616777216 to 83886088388608 and preserves weight and gradient bits, parameter revisions, AdamW moments, beta powers and optimizer step 11. The fixture actually retries that same scheduled update 22; it succeeds at scale 6553665536, commits optimizer step 22 and clears gradients.

A numerical skip performs no AdamW update or decoupled weight decay and consumes no learning-rate step. Its loss-scale run state can change. This state boundary does not promise rollback of arbitrary caller effects, a data cursor or live random-number state. Applying AdamW to zero gradients would still be an optimizer step and could decay weights; an overflow skip does neither.

Report the generated schedule, separate scalar diagnostic and text run, and the original fixed-rate Chapter 12 overflow retries. rust/demos/practical-ch14-optimizer-schedules-clipping/src/lib.rs#chapter14-evidence-composition
pub fn demonstrate(warmup_steps: usize) -> DemoResult<Value> {
    let rates = schedule(warmup_steps)?;
    Ok(json!({
        "chapter":"14-optimizer-schedules-clipping","execution":"cpu-f64-reference",
        "schedule":{"steps":TOTAL_STEPS,"warmup_steps":warmup_steps,
            "peak_rate":PEAK_RATE,"final_rate":FINAL_RATE,"rates":rates.rates(),
            "clock":"successful optimizer updates, numbered from one"},
        "clipping":clipping_probe()?,"text_training":text_training_probe(warmup_steps)?,
        "reused_fixed_rate_overflow":{
            "source":"Chapter12 overflow_fixture; separate CPU FP16/F32 experiment",
            "scheduled_rates":[0.001,0.001],"observation":overflow_fixture()?
        }
    }))
}

Follow rates, one global factor and the next valid update

One global clip; rates indexed by successful updates

The default text run assigns six rates to six successful updates, reaching the peak after two warmup updates and the final rate at update six. A separate scalar diagnostic joins coordinates from two named tensors under one global norm and one common clipping factor. After the text run commits update one, an out-of-order request for three is refused before forward, preserving model and AdamW state; update two remains next and is then applied.

Default text run: warmup, then one cosine decay

Learning rate for each successful update; displayed rates are rounded to seven significant digits.
Successful update Learning rate
11 0.0010.001
22 0.0020.002
33 0.0017363960.001736396
44 0.00110.0011
55 0.00046360390.0004636039
66 0.00020.0002

The JSON record retains the full reported f64 values. These six updates use one continuing text model and optimizer.

Separate diagnostic: one factor across two tensors

Only the listed coordinates are nonzero. Join both tensors when measuring the gradient; multiply every coordinate by the same factor before AdamW.

  1. Named parameter tensor: token_embedding.weight

    Raw gradient coordinates: 3,43,4

    Effective gradient coordinates: 1.5,21.5,2

  2. Named parameter tensor: blocks.0.attention_norm.gain

    Raw gradient coordinates: 1212

    Effective gradient coordinates: 66

Combined raw gradient norm: 1313

Global norm limit: 6.56.5

Common clipping factor: 0.50.5

Hand-checked effective gradient norm: 6.56.5

Text-run refusal preserves the next valid update

  1. Committed optimizer step before refusal: 11

  2. Requested update refused before forward: 33

    Weight and gradient bits, parameter revisions, populated moments, beta powers and optimizer step are preserved.

    Committed optimizer step after refusal: 11

  3. Next valid update, then actually applied: 22

    Rate for that next valid update: 0.0020.002

The scalar diagnostic is not text likelihood. Global clipping bounds the gradient entering AdamW in ideal arithmetic; moment normalization and separate weight decay determine the final parameter change. The actual same-step overflow retries are a separate Chapter 12 CPU precision experiment with its original fixed rates.

Reproduce the rates and inspect the update boundaries

The existing clap CLI accepts --warmup-steps from 00 through 55 and defaults to 22. Each command below starts its own demonstration and prints one JSON record; separate invocations do not continue each other’s optimizer.

Select zero through five warmup updates with existing clap, default to two, and print the bounded demonstration JSON. rust/demos/practical-ch14-optimizer-schedules-clipping/src/main.rs
use clap::Parser;

#[derive(Parser)]
#[command(about = "Inspect six scheduled text updates and global gradient clipping")]
struct Args {
    /// Number of warmup updates in the six-update example.
    #[arg(long, default_value_t = 2, value_parser = clap::value_parser!(u8).range(0..=5))]
    warmup_steps: u8,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args = Args::parse();
    println!(
        "{}",
        practical_ch14_optimizer_schedules_clipping::demonstrate(usize::from(args.warmup_steps))?
    );
    Ok(())
}

From the repository root, with the existing course Docker setup available, run the default CPU example. It needs no GPU, external corpus or downloaded model:

COURSE_CORPUS=false ./course run cargo run --quiet --offline --locked -p practical-ch14-optimizer-schedules-clipping

The record has schedule, clipping, text_training and reused_fixed_rate_overflow objects. The default schedule.rates starts with [0.001,0.002] and ends with 0.0002. text_training.updates contains six committed steps, each with gradients_cleared: true. text_training.refused_update.forward_called is false. The reused overflow observation reports eight skips and a successful scheduled-step-two retry.

Keep the same repository-root working directory and existing Docker setup to run both boundary warmup choices and the focused tests:

COURSE_CORPUS=false ./course run cargo run --quiet --offline --locked -p practical-ch14-optimizer-schedules-clipping -- --warmup-steps 0
COURSE_CORPUS=false ./course run cargo run --quiet --offline --locked -p practical-ch14-optimizer-schedules-clipping -- --warmup-steps 5
COURSE_CORPUS=false ./course run cargo test --offline --locked -p practical-ch14-optimizer-schedules-clipping
COURSE_CORPUS=false ./course run cargo test --offline --locked -p llm-from-scratch-practical warmup_cosine

Both variants print six successful text updates and finish at rate 0.00020.0002. The zero-warmup variant peaks on update 11; the five-update warmup variant peaks on update 55. The demo test command runs three library tests for the shared clip, continuing optimizer and supported warmup boundaries. The warmup_cosine selector runs three core constructor tests, covering rates, endpoint conventions, invalid inputs and warmup underflow refusal.

The following practice is optional; the explanations above supply the rules and observed results.

  1. Explain the diagnostic’s shared clipping factor for gradient coordinates 33, 44 and 1212 across two tensors, using global limit 6.56.5. Identify the effective coordinates and explain why this scalar diagnostic is separate from text likelihood.
  2. Use the zero- and five-warmup commands and setup above. Inspect schedule.rates and text_training.final_optimizer_step; identify each peak update and the common final update and rate.
  3. Use the default command and setup above. Inspect the first two text_training.updates records for first-moment continuity, then inspect clipping.first_coordinate for the adaptive and weight-decay subtractions. Explain which quantity global clipping bounds.
  4. Use the default command and setup above. Compare text_training.refused_update with reused_fixed_rate_overflow.observation. Identify the next valid update after refusal and the actual same-step retry after numerical skips, retaining each experiment’s rates.
Checked explanations for the global factor, schedule endpoints, AdamW state and distinct refusal/retry experiments
  1. For the diagnostic’s nonzero gradient coordinates 3,4,123,4,12 across two tensors, the full norm is 32+42+122=13\sqrt{3^2+4^2+12^2}=13. The global norm limit is 6.56.5, so the common factor is 6.5/13=0.56.5/13=0.5. Apply it to all coordinates to obtain 1.5,2,61.5,2,6, with norm 6.56.5. The two tensors share one factor; per-tensor clipping would be a different transformation. These derivatives come from a constructed scalar inspection objective, not a next-token likelihood.
  2. With zero warmup, update 11 uses the peak rate 0.0020.002. With five warmup updates, update 55 reaches 0.0020.002. Both experiments commit six updates and use 0.00020.0002 on update 66. Each command starts another model and optimizer; these are separate six-update runs.
  3. In the default text run, the first coordinate of token_embedding.weight has first moment 0.00747121055382857740.0074712105538285774 after update 11. That is the previous moment read by update 22. Its next first moment is 0.0129501762037840620.012950176203784062; the optimizer was not reset. In the separate scalar diagnostic, the adaptive subtraction is 0.00099999999333333350.0009999999933333335 and the decay subtraction is 9.270782062966737×10−79.270782062966737\times10^{-7}, taking weight 0.092707820629667370.09270782062966737 to 0.091706893558127740.09170689355812774. Global clipping bounds the effective input-gradient norm in ideal arithmetic; it does not directly cap AdamW’s combined parameter displacement.
  4. The generated-rate text run commits 11, refuses requested 33 without calling forward, preserves its model and populated optimizer state, and then applies next valid update 22 at 0.0020.002. That is not a retry of requested 33. The separate Chapter 12 CPU FP16/F32 fixture keeps fixed rates [0.001,0.001]; it skips scheduled 22 eight times, first backing off scale 1677721616777216 to 83886088388608, then actually commits the same scheduled 22 at scale 6553665536 and clears gradients. Skips consume no optimizer step or weight decay; loss-scale state can change.

Preserve the update clock before preserving model state

The text encoder, source-independent transformer, tied head and existing AdamW now share generated rates indexed by committed logical updates. Global clipping still transforms the complete gradient before moments; refused and overflow-skipped attempts consume no optimizer step. The backbone continues to consume vectors, positions and attention layout without requiring a tokenizer or vocabulary. Text remains the worked input and output.

Chapter 15 will address portable model saving and loading in a fresh process, followed by the additional state needed for training continuation. Saving the learned composition, weights, settings and required assets for inference is a different responsibility from retaining optimizer, random-generator and data position state for exact continuation.

Full-corpus training, physical-GPU measurements and accelerated mixed-precision execution still need their own evidence. The reused CPU FP16/F32 fixture is a precision experiment, not a physical-GPU result.