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 and in the first two coordinates of
token_embedding.weight, and 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 . Set the global norm limit to . Multiplying every coordinate by the same factor gives , and , whose combined norm is . 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 , 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 , a final decay rate of , and two warmup updates. Linear warmup uses half the peak, , on update and reaches on update . The four following updates move along one cosine decay to the final rate.
The recorded rates for updates , and are
, and .
Update uses . 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 enumerate scalar coordinates across all distinct named parameter tensors, counting a tied parameter once. Let be a gradient coordinate after any loss unscaling and before clipping. The complete gradient’s Euclidean norm is , its finite positive limit is , and is the one common nonamplifying factor. AdamW receives effective coordinate .
For the schedule, is the one-based successful optimizer-update index and is the total planned number of those updates. counts warmup updates; is the positive peak rate; is the positive final decay rate; and is the rate assigned to update . The constant is the usual circle constant, and the cosine angles use radians. The ideal rules are
The zero-norm branch avoids division by zero and leaves the gradient zero. For a nonzero gradient, the factor is when the norm already fits and when it exceeds the limit. Thus in exact arithmetic the effective gradient has norm at most , and a nonzero vector retains its direction.
With positive warmup, reaches on update . The subsequent cosine argument moves from toward as updates approach , moving the rate from to . With zero warmup, update must already use ; the cosine progress therefore starts from over . There is one decay and no restart. is the decay endpoint, not a lower bound on rates earlier in warmup.
The warmup–cosine constructor requires , and finite rates
. The integer bound lets every index convert exactly to f64;
leaving 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 as . Let and be its first and second moments, with initial . Their coefficients and lie in ; the bias-corrected moments are and . Let be the denominator offset, the old weight, and its decay coefficient. For a coordinate selected for decay, the existing AdamW rule is, in exact arithmetic,
Schedule decay lowers the learning rate across updates. Weight decay subtracts 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 , , and . In the diagnostic’s first coordinate, the effective gradient is . The stored first and second moments are and ; the corrected moments are and . At rate , the adaptive subtraction is and the separate decay subtraction is . Starting from weight , the stored result is . 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 and decay coefficient 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.
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.
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.
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.
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- model fixture: vocabulary ,
residual width , one attention head, feed-forward width , two decoder
blocks and capacity for positions. It has named parameter tensors
and learned scalars. Input IDs [0,99,100] have shape , 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 . 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 , the run deliberately requests update .
It receives OptimizerStepMismatch with
"optimizer committed step 1, expected 3", and forward_called is false.
The error’s expected index comes from the requested ; the next eligible
update is still . Weight and gradient bits, parameter revisions,
populated moments, beta powers and optimizer step remain unchanged.
The default schedule still supplies for update .
The run then actually executes update and continues through .
Executing the next valid update is not retrying the refused request for .
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 and clears its gradients. The first measured before-norm is ; the trainer reports after-norm as the chosen clipping cap. Its first recorded forward mean loss is , before update . The sixth recorded forward mean loss is , before update . 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 receives effective gradient
and stores first moment . Update reads that
same previous moment, receives effective gradient
and stores , using rate . AdamW reaches
step , while its base configuration rate remains ; 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 . These rates are separate from
the generated cosine schedule.
After successful update populates moments, scheduled update overflows and is skipped eight times. The first skip lowers the loss scale from to and preserves weight and gradient bits, parameter revisions, AdamW moments, beta powers and optimizer step . The fixture actually retries that same scheduled update ; it succeeds at scale , commits optimizer step 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.
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
| Successful update | Learning rate |
|---|---|
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.
-
Named parameter tensor:
token_embedding.weightRaw gradient coordinates:
Effective gradient coordinates:
-
Named parameter tensor:
blocks.0.attention_norm.gainRaw gradient coordinates:
Effective gradient coordinates:
Combined raw gradient norm:
Global norm limit:
Common clipping factor:
Hand-checked effective gradient norm:
Text-run refusal preserves the next valid update
-
Committed optimizer step before refusal:
-
Requested update refused before forward:
Weight and gradient bits, parameter revisions, populated moments, beta powers and optimizer step are preserved.
Committed optimizer step after refusal:
-
Next valid update, then actually applied:
Rate for that next valid update:
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 through and
defaults to . Each command below starts its own demonstration and prints
one JSON record; separate invocations do not continue each other’s optimizer.
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-clippingThe 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_cosineBoth variants print six successful text updates and finish at rate .
The zero-warmup variant peaks on update ; the five-update warmup variant
peaks on update . 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.
- Explain the diagnostic’s shared clipping factor for gradient coordinates , and across two tensors, using global limit . Identify the effective coordinates and explain why this scalar diagnostic is separate from text likelihood.
- Use the zero- and five-warmup commands and setup above. Inspect
schedule.ratesandtext_training.final_optimizer_step; identify each peak update and the common final update and rate. - Use the default command and setup above. Inspect the first two
text_training.updatesrecords for first-moment continuity, then inspectclipping.first_coordinatefor the adaptive and weight-decay subtractions. Explain which quantity global clipping bounds. - Use the default command and setup above. Compare
text_training.refused_updatewithreused_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
- For the diagnostic’s nonzero gradient coordinates across two tensors, the full norm is . The global norm limit is , so the common factor is . Apply it to all coordinates to obtain , with norm . 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.
- With zero warmup, update uses the peak rate . With five warmup updates, update reaches . Both experiments commit six updates and use on update . Each command starts another model and optimizer; these are separate six-update runs.
- In the default text run, the first coordinate of
token_embedding.weighthas first moment after update . That is the previous moment read by update . Its next first moment is ; the optimizer was not reset. In the separate scalar diagnostic, the adaptive subtraction is and the decay subtraction is , taking weight to . Global clipping bounds the effective input-gradient norm in ideal arithmetic; it does not directly cap AdamW’s combined parameter displacement. - The generated-rate text run commits , refuses requested without calling forward, preserves its model and populated optimizer state, and then applies next valid update at . That is not a retry of requested . The separate Chapter 12 CPU FP16/F32 fixture keeps fixed rates
[0.001,0.001]; it skips scheduled eight times, first backing off scale to , then actually commits the same scheduled at scale 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.