Chapter 19 · Content revision 1
Accumulate attention without keeping the full grid
Follow a stable running attention summary, reconstruct tiled gradients and compare numerical results and logical storage with the dense reference.
Keep a summary instead of the whole attention grid
The dense reference computes a score and a probability for every query-key coordinate. With twice as many sequence cells, each of those stored grids has four times as many values. Sharing K/V heads reduces the learned and cached K/V lanes; a local window restricts eligible keys. Neither change automatically removes the dense grids from that reference implementation.
An attention output needs the normalized weighted sum of values. It need not retain every weight at once. We can consume keys and values in tiles, carrying a score maximum, a normalization sum and a weighted value vector from one tile to the next. Rescaling the two sums when the maximum changes preserves the relative weight of earlier keys.
Follow the last query through two tiles
The content-only text abcd supplies four constructed feature rows at sequence
cells . There is one query/key feature and two value features per row:
Row of and belongs to the same text cell. The final query cell is , whose query vector is . Dividing its dot products with the four keys by gives scores . All four keys are causally eligible for that query. This raw attention example is separate from the configured RoPE decoder: its key width is and its value width is , whereas the decoder’s rotary head width remains even.
Use query and key tile edges of cells. For the final query, the first key tile contains keys . Subtracting its maximum gives exponential weights and . The state after this tile is
Here is the shifted exponential sum, and the two coordinates of are the corresponding unnormalized weighted value sums. The second tile contains keys , so the maximum rises to . Earlier weights were measured relative to ; they must now be measured relative to . Multiply both previous sums by , then add the new keys. Primes below mark the updated sums:
Only after the final tile do we divide each weighted-sum coordinate by the
normalizer. The result is approximately . Rust reports
tiled output [3.492652734585769,34.9265273458577] and dense output
[3.49265273458577,34.92652734585769] for this row. Across all output
coordinates, the maximum absolute difference is
, within the declared CPU f64 absolute
tolerance .
rust/demos/practical-ch19-online-tiled-attention/src/lib.rs#online-attention-worked-example /// Construct four content-only text feature rows; no decoder training is implied.
pub fn worked_example(execution: &TensorExecution, tiles: AttentionTiles) -> ArchiveResult<Value> {
let text = "abcd";
let tokenizer = BpeTokenizer::from_merge_pairs(&[])?;
let ids = tokenizer.encode_utf8(text);
let q = parameter(&[1, 1, ids.len(), 1], vec![1.; ids.len()])?;
let k = parameter(
&[1, 1, ids.len(), 1],
(0..ids.len()).map(|i| i as f64).collect(),
)?;
let v = parameter(
&[1, 1, ids.len(), 2],
(0..ids.len())
.flat_map(|i| [(i + 1) as f64, 10. * (i + 1) as f64])
.collect(),
)?;
let dense = causal_attention_with_ranges(
AutogradContext::no_grad().with_execution(execution),
&q.reshape(&[1, 4, 1])?,
&k.reshape(&[1, 4, 1])?,
&v.reshape(&[1, 4, 2])?,
&[4],
&[0; 4],
)?;
let tiled = tiled_causal_attention(
AutogradContext::no_grad().with_execution(execution),
&q,
&k,
&v,
AttentionLayout::default(),
tiles,
Some(AttentionTraceRow {
batch: 0,
head: 0,
query: 3,
}),
)?;
let maximum = maximum_error(
tiled.output().value().as_slice(),
dense.output().value().as_slice(),
)?;
Ok(
json!({"text":text,"query_rows":q.value().as_slice(),"key_rows":k.value().as_slice(),"value_rows":v.value().as_slice(),
"key_width":1,"value_width":2,"last_scores":dense.scaled_scores().value().as_slice()[12..16],
"last_reference_probabilities":dense.weights().value().as_slice()[12..16],
"tile_summaries":tiled.trace(),"last_tiled_output":tiled.output().value().as_slice()[6..8],
"last_reference_output":dense.output().value().as_slice()[6..8],
"max_abs_output_error":maximum,"absolute_tolerance":OUTPUT_TOLERANCE,"within_tolerance":maximum<=OUTPUT_TOLERANCE,
"stats":tiled.stats(),"all_outputs_graph_free":!tiled.output().tracks_gradient() && !dense.output().tracks_gradient()}),
)
} Rescale earlier mass before adding a new tile
Fix one active query vector with features. Key vector has the same positive feature count ; is its zero-based sequence-cell index. Their dimensionless score is . Value vector has features, which may differ from in raw attention.
Let be the nonempty set of eligible keys in the current tile and its largest score. Before the tile, is the maximum score seen, is the sum of exponential weights shifted by , and is their weighted value sum. Start with , and the zero vector . A prime marks the updated state. The complete rule is
The factor converts the previous sums to the new maximum. Each is an unnormalized shifted exponential, not a final probability. is dimensionless and positive after a contributing tile; and the final output have value coordinates. The last line uses the completed sums after all eligible tiles. Rust explicitly uses for the first contributing tile.
For any already consumed key, the identity explains why rescaling preserves its relative mass. Applying the same factor to preserves its contribution to every output coordinate. Independently normalizing each tile and averaging its output would lose the relative normalization mass of the tiles.
A tile with no eligible keys leaves all three state values unchanged. It does not compute . Existing valid causal layouts include self for each active query, so its final normalizer is positive. Inactive padded query rows remain zero and undergo no division.
rust/crates/llm-from-scratch-practical/src/attention/online.rs#online-normalizer-update for (local_query, query) in (query_start..query_end).enumerate() {
let row = lane * s.tokens + query;
let lower = layout.starts.map_or(0, |a| a[batch * s.tokens + query]);
let mut tile_max = f64::NEG_INFINITY;
for (local_key, key) in (key_start..key_end).enumerate() {
let index = local_query * columns + local_key;
let score = probabilities.as_slice()[index] * scale;
finite(score, index)?;
probabilities.as_mut_slice()[index] = score;
if key >= lower && key <= query {
tile_max = tile_max.max(score);
}
}
if tile_max == f64::NEG_INFINITY {
probabilities.as_mut_slice()
[local_query * columns..(local_query + 1) * columns]
.fill(0.0);
continue; // An empty tile leaves m, l and u untouched.
}
let maximum = maxima[row].max(tile_max);
let alpha = if normalizers[row] == 0.0 {
0.0
} else {
(maxima[row] - maximum).exp()
};
normalizers[row] *= alpha;
for x in &mut output[row * s.value..(row + 1) * s.value] {
*x *= alpha;
}
let mut eligible = 0;
for (local_key, key) in (key_start..key_end).enumerate() {
let index = local_query * columns + local_key;
let p = if key >= lower && key <= query {
eligible += 1;
(probabilities.as_slice()[index] - maximum).exp()
} else {
0.0
};
probabilities.as_mut_slice()[index] = p;
normalizers[row] += p;
}
maxima[row] = maximum;
stats.eligible_scores += eligible;
if trace_row == Some(AttentionTraceRow { batch, head, query }) {
selected = Some((row, eligible, alpha));
}
}
let weighted_values =
execution.matmul(&probabilities.view(), &value_tile.view())?;
for (local_query, query) in (query_start..query_end).enumerate() {
let row = lane * s.tokens + query;
for feature in 0..s.value {
output[row * s.value + feature] +=
weighted_values.as_slice()[local_query * s.value + feature];
finite(output[row * s.value + feature], row * s.value + feature)?;
}
} In ideal real arithmetic this evaluates the same attention rule as the dense
reference. f64 reductions occur in a different order, so small rounding
differences are expected. Very small exponentials can underflow to zero.
Finite inputs also do not guarantee representable dot products or weighted
sums; the operation refuses nonfinite results rather than returning a partial
attention result.
Reconstruct probabilities when a gradient needs them
For zero-based query-row index , retain its output vector , final maximum and positive normalizer . During backward, let be the upstream gradient with respect to ; it has the same coordinates. Recompute scores from the saved query and key vectors, then reconstruct the eligible probabilities:
is the scaled score of query and key ; is its dimensionless normalized probability. Masked coordinates have probability zero. For a scalar objective , use the prefix to denote a gradient of with respect to the following quantity. Define
is one scalar per query row. measures how the objective changes with that probability; measures how it changes with the score. The softmax correction subtracts the row-wide weighted mean , which can be obtained from the already saved output instead of storing a probability row.
For a query/key tile, and hold its row vectors, holds its value rows, and holds its upstream vectors. Matrices , and have query rows and key columns. Accumulate
The superscript transposes the tile. These gradients have the same shapes as their corresponding , and inputs. Excluded keys contribute zero. When several query heads read one shared K/V head, their key and value gradients add into that same shared head.
rust/crates/llm-from-scratch-practical/src/attention/online.rs#tiled-attention-backward /// Each parent pullback reconstructs only one score tile and the requested VJP.
pub(crate) fn backward(
saved: &OnlineAttentionSaved,
operand: AttentionOperand,
upstream: &Tensor,
execution: &TensorExecution,
) -> Result<Tensor, TensorAutodiffError> {
let execution = execution
.with_matmul_precision(MatmulPrecision::F64)
.map_err(crate::tensor::matmul::MatmulError::Execution)?;
let s = shape(&saved.query, &saved.key, &saved.value)?;
let target = match operand {
AttentionOperand::Query => &saved.query,
AttentionOperand::Key => &saved.key,
AttentionOperand::Value => &saved.value,
};
let mut gradient = Tensor::from_vec(target.shape().to_vec(), zeros(target.len())?)?;
let scale = 1.0 / (s.key as f64).sqrt();
for batch in 0..s.batch {
let length = saved.lengths.as_ref().map_or(s.tokens, |l| l[batch]);
for head in 0..s.heads {
let lane = batch * s.heads + head;
let kv_lane = batch * s.kv_heads
+ kv_head_for_query(head, s.heads, s.kv_heads).expect("validated grouping");
for query_start in (0..length).step_by(saved.tiles.queries) {
let query_end = query_start.saturating_add(saved.tiles.queries).min(length);
let q = rows(&saved.query, lane, query_start, query_end)?;
let g = rows(upstream, lane, query_start, query_end)?;
for key_start in (0..query_end).step_by(saved.tiles.keys) {
let key_end = key_start.saturating_add(saved.tiles.keys).min(query_end);
let k = rows(&saved.key, kv_lane, key_start, key_end)?;
let v = rows(&saved.value, kv_lane, key_start, key_end)?;
let mut p =
execution.matmul_with_transpose(&q.view(), &k.view(), false, true)?;
let columns = key_end - key_start;
for (i, query) in (query_start..query_end).enumerate() {
let row = lane * s.tokens + query;
let lower = saved
.starts
.as_ref()
.map_or(0, |a| a[batch * s.tokens + query]);
for (j, key) in (key_start..key_end).enumerate() {
let index = i * columns + j;
p.as_mut_slice()[index] = if key >= lower && key <= query {
(p.as_slice()[index] * scale - saved.maxima[row]).exp()
/ saved.normalizers[row]
} else {
0.0
};
}
}
if operand == AttentionOperand::Value {
let dv =
execution.matmul_with_transpose(&p.view(), &g.view(), true, false)?;
add_rows(&mut gradient, kv_lane, key_start, &dv);
continue;
}
let mut ds =
execution.matmul_with_transpose(&g.view(), &v.view(), false, true)?;
for (i, query) in (query_start..query_end).enumerate() {
let row = lane * s.tokens + query;
let delta = (0..s.value)
.map(|f| {
g.as_slice()[i * s.value + f]
* saved.output.as_slice()[row * s.value + f]
})
.sum::<f64>();
for j in 0..columns {
let index = i * columns + j;
ds.as_mut_slice()[index] =
p.as_slice()[index] * (ds.as_slice()[index] - delta) * scale;
}
}
match operand {
AttentionOperand::Query => {
let dq = execution.matmul(&ds.view(), &k.view())?;
add_rows(&mut gradient, lane, query_start, &dq);
}
AttentionOperand::Key => {
let dk = execution.matmul_with_transpose(
&ds.view(),
&q.view(),
true,
false,
)?;
add_rows(&mut gradient, kv_lane, key_start, &dk);
}
AttentionOperand::Value => unreachable!("value pullback handled above"),
}
}
}
}
}
Ok(gradient)
} The matrix contains the output rows . The custom operation joins
the existing autodiff tape. Its three parent edges share one immutable
snapshot of , row maxima and normalizers,
layout and tile settings through an owned Rc. No thread-local state is
needed. Each operand edge currently reconstructs tiles separately; this
repeats work in exchange for not saving quadratic probabilities. Ordinary
model execution records no recurrence trace; the worked example explicitly
selects just one query for tracing. Existing stale-parent and released-graph
refusals still apply.
rust/crates/llm-from-scratch-practical/src/attention/online.rs#tiled-attention-autograd /// Q:[B,Hq,T,d], K:[B,Hkv,T,d], V:[B,Hkv,T,dv]. No K/V expansion.
/// Default layout is full causal. Trace is an explicit, single-query diagnostic.
#[allow(clippy::too_many_arguments)]
pub fn tiled_causal_attention(
context: AutogradContext,
query: &TensorValue,
key: &TensorValue,
value: &TensorValue,
layout: AttentionLayout<'_>,
tiles: AttentionTiles,
trace_row: Option<AttentionTraceRow>,
) -> Result<TiledAttentionForward, TensorAutodiffError> {
let execution = context.execution();
f64_execution(&execution)?;
let mut evidence = None;
let output = TensorValue::model_operation_with_context(
context,
TensorOperation::OnlineAttention,
[query, key, value],
|[q, k, v]| {
let result = forward(q, k, v, layout, tiles, trace_row, &execution)?;
let saved = Rc::new(OnlineAttentionSaved {
query: q.clone(),
key: k.clone(),
value: v.clone(),
output: result.output.clone(),
maxima: result.maxima,
normalizers: result.normalizers,
lengths: layout.lengths.map(<[usize]>::to_vec),
starts: layout.starts.map(<[usize]>::to_vec),
tiles,
});
evidence = Some((result.stats, result.trace));
Ok((
result.output,
[
ModelSavedContext::OnlineAttention {
saved: Rc::clone(&saved),
operand: AttentionOperand::Query,
},
ModelSavedContext::OnlineAttention {
saved: Rc::clone(&saved),
operand: AttentionOperand::Key,
},
ModelSavedContext::OnlineAttention {
saved,
operand: AttentionOperand::Value,
},
],
))
},
)?;
let (stats, trace) = evidence.expect("successful operation supplies evidence");
Ok(TiledAttentionForward {
output,
stats,
trace,
})
} Separate running summaries from GPU memory movement
Materialized attention stores a score and a probability for every query-key coordinate, so these intermediate grids grow quadratically with sequence length even though the output is a vector per query. This familiar reference makes every weight inspectable, but retaining those grids is an implementation choice rather than a requirement of the output.
In Online normalizer calculation for softmax, version 1 dated 8 May 2018, Milakov and Gimelshein’s Algorithm 3 updates a running maximum and rescales the previous exponential normalizer when that maximum changes. That paper’s normalizer supplies the scalar part of the recurrence; our attention calculation also carries a weighted value vector.
Rabe and Staats’s attention paper was initially released in 2021; the cited version 3 is dated 10 October 2022. Rabe and Staats describe stable weighted attention summaries, query/key chunking and recomputation of chunk intermediates during differentiation. Saving every intermediate summary for ordinary automatic differentiation would lose the intended storage saving, so their account makes recomputation part of the solution.
In FlashAttention, version 1 dated 27 May 2022, Dao and colleagues combine tiling, recomputation and fused GPU execution to reduce attention reads and writes between high-bandwidth memory and on-chip SRAM. That memory hierarchy and fused kernel are additional implementation choices; the course’s host-backed primitive calls do not reproduce them. The cited Rabe/Staats revision is later than this FlashAttention edition.
Stable running summaries allow attention to consume key/value tiles; reconstructing probabilities during backward avoids retaining their complete grid.
Keep the existing dense attention as an inspectable reference and add output-oriented tiled evaluation through the same projections, layouts, tensor executor and autodiff tape.
Evaluate the same decoder attention with smaller explicit intermediate buffers while keeping logical storage, GPU memory traffic and measured speed as separate claims.
The course implements host-backed tiled attention and reports bounded CPU f64 comparisons and logical payload counts. It does not reproduce a fused FlashAttention kernel or measure GPU traffic, speed or language quality.
Change attention evaluation inside the existing model
The raw operation accepts , and , producing . Here counts batch rows, counts stored sequence cells, counts query heads and counts shared key heads and, separately, value heads. Feature counts are positive, as are the head counts, and must be divisible by . Chapter 18’s adjacent group map selects the same shared head in the dense layer, tiled forward and tiled backward. Compact K/V stay unexpanded.
Common preparation still performs the learned projections, rotates with RoPE, leaves unrotated and intersects the causal range with active lengths, packed-segment boundaries and any local window. The resulting interval determines which entries contribute to each query’s attention sum; the implementation still reads and scores rectangular prefix tiles that can include excluded entries, then sets their weights to zero before the weighted value product. Sequence-row indices remain distinct from RoPE labels. Tiles change neither the direct causal context nor cache capacity or eviction. Head outputs still merge and pass through the existing output projection.
rust/crates/llm-from-scratch-practical/src/attention/multi_head.rs#compact-grouped-attention pub(crate) fn forward_output_with_layout(
&self,
context: AutogradContext,
input: &TensorValue,
position_offset: usize,
lengths: Option<&[usize]>,
starts: Option<&[usize]>,
positions: Option<&[usize]>,
) -> Result<TensorValue, MultiHeadAttentionError> {
let Some(tiles) = context.execution().attention_tiles() else {
return self
.forward_with_layout(context, input, position_offset, lengths, starts, positions)
.map(MultiHeadAttentionForward::into_output);
};
super::online::f64_execution(&context.execution())
.map_err(multi_autodiff(MultiHeadStage::RestoreHeadOutputs))?;
let prepared = self.prepare_heads_with_layout(
context,
input,
position_offset,
lengths,
starts,
positions,
)?;
let shape = input.shape();
// The existing preparation repeats identical metadata for every head.
// Restore one batch copy; K/V tensors themselves remain unexpanded.
let lengths = prepared.lane_lengths.as_ref().map(|values| {
(0..shape[0])
.map(|b| values[b * self.heads])
.collect::<Vec<_>>()
});
let starts = prepared.lane_starts.as_ref().map(|values| {
(0..shape[0])
.flat_map(|b| {
values[b * self.heads * shape[1]..(b * self.heads + 1) * shape[1]]
.iter()
.copied()
})
.collect::<Vec<_>>()
});
let attended = tiled_causal_attention(
context,
&prepared.rotated_query_heads,
&prepared.rotated_key_heads,
&prepared.projected_value_heads,
AttentionLayout {
lengths: lengths.as_deref(),
starts: starts.as_deref(),
},
tiles,
None,
)
.map_err(multi_autodiff(MultiHeadStage::RestoreHeadOutputs))?;
self.project_head_outputs(context, attended.output())
.map(|(_, output)| output)
} TensorExecution::with_tiled_attention selects this output-oriented path.
The older inspectable attention APIs retain their dense reports; compact
decoder reports omit block intermediates instead of inventing dense matrices.
The initial tiled path requires explicit f64 matrix arithmetic and refuses
other precision policies before dispatch. Tile products reuse the existing
CPU/CUDA primitive boundary. All results here are CPU results; CPU loading
and use require no CUDA. Requesting unavailable CUDA is an error, with no
silent CPU substitution.
rust/crates/llm-from-scratch-practical/src/tensor/execution.rs#explicit-attention-execution /// Select compact, tiled full attention for output-oriented decoder paths.
/// Inspectable attention APIs retain their dense reference reports.
pub fn with_tiled_attention(mut self, tiles: AttentionTiles) -> Self {
self.attention_tiles = Some(tiles);
self
}
pub const fn attention_tiles(&self) -> Option<AttentionTiles> {
self.attention_tiles
} Count numerical payload at the executed shapes
The storage report runs , , , , with
query/key tile edges , at three sequence lengths. Each f64 value
occupies bytes. The reference stores score values and the same
number of probability values, one for each batch/head/query/key coordinate;
each column below counts one such grid separately.
| Sequence cells | Dense score values | Dense probability values | Largest score-tile values |
|---|---|---|---|
The maximum score tile has shape : values or bytes. The five simultaneously counted owned forward buffers are the query tile, key tile, value tile, score/weight tile and tile value-product. At these widths each contains values, for values or bytes altogether. That count describes these five buffers only.
The operation also retains linear-size payloads. Its input count combines , and ; its output has shape . Maxima and normalizers are two scalar arrays, each with shape .
| Sequence cells | Q/K/V input values | Output values | Maximum and normalizer values together |
|---|---|---|---|
These are logical numerical payloads, not peak-memory measurements. They exclude allocator, object and index metadata, temporary snapshots and tape alias accounting, primitive-internal or device workspaces, and model-wide activations. Retained-tape diagnostics conservatively count the shared saved snapshot once per operand edge, three times; that is not three physical copies or a process-RAM measurement. The columns above must not be added into a supposedly complete peak total.
Avoiding quadratic intermediate grids does not remove the quadratic number of eligible query-key pairs in full causal attention. No timing, GPU traffic or throughput measurement follows from these payload counts.
Compare derivatives, then the same restored model
The default report uses the nonuniform upstream seed shown in the next excerpt. It compares all input-gradient coordinates: query, key and value coordinates. Their observed maximum absolute errors are , and respectively, each within .
rust/demos/practical-ch19-online-tiled-attention/src/lib.rs#online-attention-gradient-comparison /// Compare all Q/K/V gradients against the existing dense causal computation.
pub fn compare_gradients(
execution: &TensorExecution,
tiles: AttentionTiles,
) -> ArchiveResult<Value> {
let make = || -> ArchiveResult<[TensorValue; 3]> {
Ok([
parameter(&[1, 1, 4, 1], vec![1.; 4])?,
parameter(&[1, 1, 4, 1], vec![0., 1., 2., 3.])?,
parameter(&[1, 1, 4, 2], vec![1., 10., 2., 20., 3., 30., 4., 40.])?,
])
};
let reference = make()?;
let online = make()?;
let context = AutogradContext::recording().with_execution(execution);
let dense = causal_attention_with_ranges(
context,
&reference[0].reshape_with_context(context, &[1, 4, 1])?,
&reference[1].reshape_with_context(context, &[1, 4, 1])?,
&reference[2].reshape_with_context(context, &[1, 4, 2])?,
&[4],
&[0; 4],
)?;
let tiled = tiled_causal_attention(
context,
&online[0],
&online[1],
&online[2],
AttentionLayout::default(),
tiles,
None,
)?;
let seed = vec![1., 0.05, -0.2, 0.15, 0.3, -0.01, 1.2, 0.08];
dense.output().backward_with_seed_and_execution(
&tensor(&[1, 4, 2], seed.clone())?.view(),
GraphRetention::Release,
execution,
)?;
tiled.output().backward_with_seed_and_execution(
&tensor(&[1, 1, 4, 2], seed)?.view(),
GraphRetention::Release,
execution,
)?;
let mut comparisons = Vec::new();
for (i, name) in ["query", "key", "value"].iter().enumerate() {
let actual = online[i]
.gradient_snapshot()
.ok_or("missing tiled gradient")?;
let expected = reference[i]
.gradient_snapshot()
.ok_or("missing reference gradient")?;
let maximum = maximum_error(actual.as_slice(), expected.as_slice())?;
comparisons.push(json!({"operand":name,"compared_coordinates":actual.len(),"tiled_gradient":actual.as_slice(),
"reference_gradient":expected.as_slice(),"max_abs_error":maximum,"absolute_tolerance":GRADIENT_TOLERANCE,
"within_tolerance":maximum<=GRADIENT_TOLERANCE}));
}
Ok(json!(comparisons))
} Separate small-kernel tests finite-difference every coordinate with shared heads, using step and absolute tolerance . They also cover MHA/GQA/MQA, uneven and wider-than-sequence tiles, large and equal scores, fully masked tiles, inactive queries and empty batches, invalid layouts or selectors, and stale/released parents. Nonfinite failure tests check that gradients and results are not partly committed. These are finite fixtures, not universal floating-point equivalence guarantees.
The model comparison reuses Chapter 18’s initialized four-query-head,
two-KV-head, window- model. It saves and restores the existing portable
archive, then compares dense and tiled full-prefix logits for all framed
prefix lengths of abcd. Counting every position at each prefix gives
full-logit comparisons; comparing each final row with the existing
incremental cache gives cached-logit comparisons. Both maximum errors
are , within . No-grad outputs
remain graph-free, and the embedding and text head retain one tied table.
rust/demos/practical-ch19-online-tiled-attention/src/lib.rs#tiled-model-comparison /// Load the same portable model, compare every full-prefix logit, then the
/// existing incremental cache. Tile selection never changes model identity.
pub fn compare_model(execution: &TensorExecution, tiles: AttentionTiles) -> ArchiveResult<Value> {
let config = practical_ch18_grouped_query_attention_context::settings(None)?;
let (model, tokenizer) =
practical_ch18_grouped_query_attention_context::configured_model(&config)?;
let archive = PortableModel::from_text(&model, &tokenizer)?;
let loaded = PortableModel::from_bytes(&archive.to_bytes()?)?;
let (restored, tokenizer) = loaded.restore_text()?;
let tokens = tokenizer.encode_utf8_document("abcd");
let tiled_execution = execution.clone().with_tiled_attention(tiles);
let mut cache = DecoderKvCache::new(&restored)?;
let mut session = cache.bind(&restored)?;
let mut full_error = 0.0_f64;
let mut cached_error = 0.0_f64;
let mut compared_full = 0;
let mut compared_cached = 0;
let mut graph_free = true;
let mut empty_reports = true;
for count in 1..=tokens.len() {
let dense = restored.forward_with_context(
AutogradContext::no_grad().with_execution(execution),
&tokens[..count],
&[1, count],
)?;
let tiled = restored.forward_with_context(
AutogradContext::no_grad().with_execution(&tiled_execution),
&tokens[..count],
&[1, count],
)?;
let cached = if count == 1 {
session.prefill(&tokens[..1])?
} else {
session.decode(tokens[count - 1])?
};
full_error = full_error.max(maximum_error(
tiled.logits().value().as_slice(),
dense.logits().value().as_slice(),
)?);
compared_full += tiled.logits().value().len();
let vocabulary = model.config().vocabulary_size();
cached_error = cached_error.max(maximum_error(
cached.logits().value().as_slice(),
&tiled.logits().value().as_slice()[(count - 1) * vocabulary..count * vocabulary],
)?);
compared_cached += vocabulary;
graph_free &= !tiled.logits().tracks_gradient() && !cached.logits().tracks_gradient();
empty_reports &= tiled.blocks().is_empty();
}
Ok(
json!({"model_id_before":archive.model_id()?,"model_id_after":loaded.model_id()?,"completed_training_updates":0,
"query_heads":model.config().heads(),"kv_heads":model.config().kv_heads(),"window":model.config().context_window(),
"prefix_rows":tokens.len(),"compared_full_logits":compared_full,"compared_cached_logits":compared_cached,
"max_abs_full_logit_error":full_error,"max_abs_cached_logit_error":cached_error,"absolute_tolerance":OUTPUT_TOLERANCE,
"within_tolerance":full_error<=OUTPUT_TOLERANCE && cached_error<=OUTPUT_TOLERANCE,
"all_outputs_graph_free":graph_free,"compact_reports_omit_dense_intermediates":empty_reports,
"one_tied_table":restored.embedding().table().tensor().is_same_node(restored.text_output_head().table())}),
)
} The model ID before and after restoration is
234d9d80f5d63091842fbf7b3cef7ac867aefd20732853a1a463c5b594f3f1f2.
Device and tile choices are execution state, so they do not alter the portable
learned-model identity. The source-independent backbone still accepts
text-derived encoded vectors without requiring a tokenizer; the complete text
model retains its tokenizer and tied head. This default example performs zero
training updates and establishes no language-quality result.
Continue actual training under the same execution choices
The second demo library test uses a separate seeded training fixture and
Chapter 15’s existing training loop through advance_with_execution. It
trains a four-query-head, two-KV-head, window- model with
dropout, two-microbatch accumulation, clipping, schedule, loss scaling and
block recomputation. After update it saves a native checkpoint, restores
it and advances both branches through update . Their state_record
values agree exactly.
rust/demos/practical-ch19-online-tiled-attention/src/tests.rs#tiled-accumulated-resume #[test]
fn existing_accumulated_dropout_training_resumes_with_tiled_recomputation() -> ArchiveResult<()> {
use llm_from_scratch_practical::checkpoint::resume::TrainingCheckpoint;
use practical_ch15_save_load_resume_model::{
advance_with_execution, checkpoint, data_id, executable_envelope, fresh_job, state_record,
};
let execution = TensorExecution::cpu().with_tiled_attention(AttentionTiles::new(2, 3)?);
let mut job = fresh_job()?;
let config = practical_ch18_grouped_query_attention_context::settings(None)?;
job.model = llm_from_scratch_practical::models::decoder::DecoderModel::new(
practical_ch18_grouped_query_attention_context::model_config(
&config,
job.tokenizer.layout().vocabulary_size(),
2,
Some(2),
)?,
&mut llm_from_scratch_practical::nn::init::SplitMix64::from_seed(19),
)?;
job.optimizer = llm_from_scratch_practical::training::adamw::AdamW::new(job.optimizer.config());
advance_with_execution(&mut job, 1, &execution)?;
let mut envelope = executable_envelope()?;
envelope.numerical_execution = "same-binary-scalar-cpu-f64-tiled-q2-k3-serial-v1".into();
let saved = checkpoint(&job, &envelope)?;
let restored = TrainingCheckpoint::from_bytes(&saved.to_bytes()?)?;
let mut different_execution = executable_envelope()?;
different_execution.numerical_execution =
"same-binary-scalar-cpu-f64-tiled-q3-k2-serial-v1".into();
assert!(restored.restore(&different_execution, &data_id()?).is_err());
let mut resumed = restored.restore(&envelope, &data_id()?)?;
assert_eq!(state_record(&job)?, state_record(&resumed)?);
advance_with_execution(&mut job, 2, &execution)?;
advance_with_execution(&mut resumed, 2, &execution)?;
assert_eq!(state_record(&job)?, state_record(&resumed)?);
Ok(())
} This exact continuation binds the same executable and CPU f64 execution
label same-binary-scalar-cpu-f64-tiled-q2-k3-serial-v1. The test refuses
same-binary-scalar-cpu-f64-tiled-q3-k2-serial-v1. That narrow training
envelope is separate from portable inference and from the tolerance-based
dense comparisons above. The default report uses tile edges ; this
continuation test deliberately uses .
Follow one state into the next
The figure keeps the two summaries attached to the same final query. The second summary includes the first tile’s rescaled contributions; it is not an independently normalized answer to average with the first. Divide each coordinate of the final weighted sum by the same final normalizer.
One query, two tiles, one final normalization
For the final query of the four-row example, the first card summarizes keys zero and one. The second card continues that same state: before adding keys two and three, it multiplies the previous normalizer and both weighted value sums by its reported rescaling factor. Dividing the completed weighted value sums by the final normalizer produces the tiled output. The coordinate cards compare that output with the dense reference for the same query.
Continue the final query across two key tiles
Each card records the running state after its key tile has contributed. Key intervals include their start and exclude their end.
After the first key tile
- Key interval: start included, end excluded
- Running score maximum
- Factor applied to the previous state
- Shifted exponential sum
- Unnormalized weighted value sum, coordinate
- Unnormalized weighted value sum, coordinate
After the second key tile
- Key interval: start included, end excluded
- Running score maximum
- Factor applied to the previous state
- Shifted exponential sum
- Unnormalized weighted value sum, coordinate
- Unnormalized weighted value sum, coordinate
The second card carries the first summary forward: rescale both the previous normalizer and weighted value vector, then add the new tile's contributions. The first card has no previous mass, so its rescaling factor is zero.
Divide the final sums and compare the same output coordinates
Each tiled coordinate is its completed weighted value sum divided by the final normalizer. The dense reference uses all eligible keys in one stable row calculation.
Output coordinate
- Tiled output
- Dense reference output
Output coordinate
- Tiled output
- Dense reference output
- Maximum absolute error over all output rows and coordinates
- Declared absolute comparison tolerance
Constructed content-only rows from abcd; one query/key feature and two value features. Values come from the CPU f64 report. These comparisons measure numerical agreement for the fixture, not GPU performance or model quality.
Reproduce the report and inspect its checks
Run from the repository root with Docker and the existing course Docker setup available. All configuration, tokenizer and text fixtures are included; no downloaded model or external corpus is needed. The default CPU command is
COURSE_CORPUS=false ./course run cargo run --quiet --offline --locked -p practical-ch19-online-tiled-attentionIt prints JSON with worked_example, gradient_comparison,
storage_comparisons and model_comparison. It reports Cpu, f64, tile
edges and within_tolerance: true for every comparison. The two
successive summaries are in worked_example.tile_summaries.
From the same working directory and Docker setup, run the two demo tests:
COURSE_CORPUS=false ./course run cargo test --offline --locked -p practical-ch19-online-tiled-attention --libExactly tests pass: the report checks and the actual accumulated-training continuation through update . To run the small attention-kernel checks described above, use the same setup and working directory:
COURSE_CORPUS=false ./course run cargo test --offline --locked -p llm-from-scratch-practical attention::online::tests --libExactly tests pass, including the exhaustive small-coordinate finite differences and masked/inactive-row cases.
Optional inspection and explanation practice
- Use the default command’s
tile_summariesto explain why the second tile rescales both earlier sums. Identify the final division and explain why independently normalized tile outputs cannot simply be averaged. - Inspect
storage_comparisonsfrom that report. Compare each dense grid with the largest score tile, then identify the input, output and row-statistic payloads excluded by a score-tile-only count. - Inspect all three
gradient_comparisonentries and run the kernel-test command above. State the coordinate counts, numerical tolerances and the difference between a dense-gradient comparison and finite differences. - Inspect
model_comparisonand run the two-demo-test command above. Explain the two logit counts, the preserved model identity and the narrower execution condition for exact training continuation.
Checked answers: rescaling, payload counts, derivatives and model continuation
- The maximum changes from to , so converts the previous normalizer and weighted value vector to the new score scale. The completed sums are divided only once. Tile outputs normalized separately omit their different relative masses; an unweighted average is not the full attention result.
- At , each dense grid contains values, while the largest score tile has values, or bytes. Five counted temporary forward buffers contain values, or bytes. Inputs contain values; output and combined row statistics each contain . Each value occupies bytes. These selected payloads exclude overhead and other workspaces, so they establish neither peak process RAM nor speed.
- The report compares query, key and value gradient coordinates with dense attention; all errors are within . This checks two analytic paths under the same upstream seed. The separate finite-difference test perturbs every small coordinate, with step and absolute tolerance . The kernel command selects and passes tests.
- The six framed prefixes contribute full-logit comparisons, including earlier positions again at later prefixes, and last-row cached comparisons. Both maximum errors are within . The portable model ID and one tied table remain unchanged under tile selection. The default is untrained. Of the passing demo tests, the continuation test actually trains and restores through update under the same executable and CPU
f64tile- label; it refuses tile-. That exact-resume restriction does not make portable inference tile-dependent.
Keep the model while changing its intermediate work
The existing text encoder, source-independent backbone, tied head and trainer can now evaluate attention with compact shared heads and reconstructed gradients. Running summaries replace complete intermediate score/probability grids in the output-oriented path while the dense reference remains available for inspection. Eligible keys and learned projections keep their existing meaning.
Planned Chapter 20, Paged KV layout, will study logical-to-physical cache pages and partial-page waste while preserving cached logits. That future storage layout is separate from this chapter’s attention tiles; no page allocator, serving scheduler or multi-tenant cache is introduced here.