28 · Content revision 2
Block future keys with a causal mask
Learn how an inclusive lower-triangular causal mask blocks future Transformer keys, assigns them exactly zero attention probability, and preserves earlier outputs.
Predict the visible triangle
Extend Chapter 27 with a third position:
There is one batch, three token positions, a query/key width of two, and a value width of two. Thus
The unmasked raw score rows are , , and . Before running the fixture, mark the cells each query may use. Query keeps key ; query keeps keys and ; query keeps all three keys. That is six allowed cells and three blocked cells, including all three diagonal cells among the allowed six.
Mask before softmax
For query row and key column , use the additive mask
The diagonal is deliberately allowed. During decoder training, target inputs are shifted by one position. The input representation on the diagonal therefore contains an earlier known token; the prediction does not read its own target. The shift and mask work together to preserve autoregressive conditioning.
Let the scaled score tensor be
Add the visibility rule before normalization, then mix values:
For this example,
Every blocked probability is exactly zero. Each allowed prefix keeps the full unit mass:
The resulting rows are
Zeroing future probabilities after an ordinary all-key softmax is not equivalent: the remaining values would retain a denominator that included the blocked keys. A large finite negative sentinel can approximate the ideal mask, but it is not mathematically identical to .
Keep visibility separate from position
- is the scaled query-key score tensor before masking.
- is the additive visibility mask.
- indexes a query position and indexes a key position.
- contains attention probabilities after the future keys are excluded.
- , , and retain their Chapter 27 query, key, and value roles.
- contains one visible-prefix mixture per query position.
- is the batch size, the token count, the query/key width, and the value width.
The complete shape rule remains
For an allowed cell, the row normalization is
and for a blocked cell,
This rule supplies visibility, not position. Rows still need a separate absolute or relative position signal to distinguish order. Padding masks, variable lengths, multiple heads, output projection, and key/value caching are also separate concerns.
From recurrent prefix state to an explicit decoder mask
Graves, Generating Sequences With Recurrent Neural Networks trains next-element prediction one sequence element at a time. During generation, each sampled element becomes the next recurrent input, so future elements do not yet exist. This prefix boundary comes from sequential recurrence, whose state must advance one step at a time. The claim concerns the paper’s text-generation path, not its separately conditioned handwriting-synthesis model.
Vaswani et al., Attention Is All You Need pack queries into matrices and mask decoder self-attention so a row cannot attend to subsequent positions. Illegal pre-softmax scores are set to ; together with output embeddings shifted by one position, row depends only on known outputs before .
This lets the masked attention rows for known target positions be evaluated together during training, while ordinary autoregressive decoding still appends one token at a time. A decoder-only Transformer applies this causal boundary in each self-attention layer. The mask controls visibility; it does not encode position.
Keep the mask inspectable and recorded values finite
causal_additive_mask constructs a plain tensor. Allowed cells contain
and future cells contain :
rust/crates/llm-from-scratch/src/attention/causal_mask.rs#causal-mask-construction /// Builds an additive square mask with zero for `key <= query` and negative
/// infinity for future keys.
pub fn causal_additive_mask(tokens: usize) -> Result<Tensor, CausalMaskingError> {
let elements = tokens
.checked_mul(tokens)
.ok_or(CausalMaskingError::MaskTensor(TensorError::ShapeOverflow))?;
let mut values = Vec::new();
values
.try_reserve_exact(elements)
.map_err(|_| CausalMaskingError::MaskAllocationFailed { elements })?;
for query in 0..tokens {
for key in 0..tokens {
values.push(if key <= query { 0.0 } else { f64::NEG_INFINITY });
}
}
Tensor::from_vec(vec![tokens, tokens], values).map_err(CausalMaskingError::MaskTensor)
} A differentiable TensorValue rejects nonfinite leaf data. Its
causal_softmax operation implements the same mathematical boundary without
storing : it reads only , subtracts the maximum of that
allowed prefix, normalizes the allowed cells, and writes the exact
floating-point value to every blocked cell.
rust/crates/llm-from-scratch/src/autograd/model_ops.rs#causal-softmax-forward fn causal_softmax_forward(input: &Tensor) -> Result<Tensor, TensorAutodiffError> {
if input.rank() < 2 {
return Err(ModelOpError::CausalSoftmaxRank { rank: input.rank() }.into());
}
let queries = input.shape()[input.rank() - 2];
let keys = input.shape()[input.rank() - 1];
if queries != keys {
return Err(ModelOpError::CausalSoftmaxNonSquare { queries, keys }.into());
}
if queries == 0 {
return Err(ModelOpError::CausalSoftmaxEmptyTokens.into());
}
let mut probabilities = zeros(input.shape())?;
let grids = input.len() / (queries * keys);
for grid in 0..grids {
for query in 0..queries {
let row_start = (grid * queries + query) * keys;
let allowed = &input.as_slice()[row_start..=row_start + query];
let maximum = allowed.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let mut exponential_tail = 0.0;
let mut skipped_one_maximum = false;
for &score in allowed {
let shifted = score - maximum;
if shifted == 0.0 && !skipped_one_maximum {
skipped_one_maximum = true;
} else {
exponential_tail += shifted.exp();
}
}
debug_assert!(skipped_one_maximum);
let denominator = 1.0 + exponential_tail;
for (key, &score) in allowed.iter().enumerate() {
let probability = (score - maximum).exp() / denominator;
probabilities.as_mut_slice()[row_start + key] =
if probability == 0.0 { 0.0 } else { probability };
}
}
}
Ok(probabilities)
} The operation accepts square score tensors of rank two or greater. This lets a future score shape such as preserve its independent leading axes. It rejects a rank below two, unequal final axes, an empty token axis, or a released operand.
causal_scaled_dot_product_self_attention reuses the Chapter 27 score
construction, applies causal_softmax, and multiplies by :
rust/crates/llm-from-scratch/src/attention/causal_mask.rs#causal-self-attention-forward /// Inspectable evidence from one causally masked attention head.
#[derive(Clone, Debug)]
pub struct CausalSelfAttentionForward {
raw_scores: TensorValue,
scaled_scores: TensorValue,
additive_mask: Tensor,
weights: TensorValue,
output: TensorValue,
scale: f64,
key_width: usize,
value_width: usize,
}
impl CausalSelfAttentionForward {
pub fn raw_scores(&self) -> &TensorValue {
&self.raw_scores
}
pub fn dot_products(&self) -> &TensorValue {
&self.raw_scores
}
pub fn scaled_scores(&self) -> &TensorValue {
&self.scaled_scores
}
/// The plain additive mask. It is intentionally not a tape value because
/// its blocked cells contain negative infinity.
pub const fn additive_mask(&self) -> &Tensor {
&self.additive_mask
}
pub fn weights(&self) -> &TensorValue {
&self.weights
}
pub fn probabilities(&self) -> &TensorValue {
&self.weights
}
pub fn output(&self) -> &TensorValue {
&self.output
}
pub const fn scale(&self) -> f64 {
self.scale
}
pub const fn key_width(&self) -> usize {
self.key_width
}
pub const fn value_width(&self) -> usize {
self.value_width
}
pub fn into_output(self) -> TensorValue {
self.output
}
}
/// Computes one scaled self-attention head with an inclusive-prefix mask.
pub fn causal_scaled_dot_product_self_attention(
query: &TensorValue,
key: &TensorValue,
value: &TensorValue,
) -> Result<CausalSelfAttentionForward, CausalMaskingError> {
let prepared = scaled_self_attention_scores(query, key, value)?;
let tokens = query.shape()[1];
let additive_mask = causal_additive_mask(tokens)?;
let weights = prepared
.scaled_scores
.causal_softmax()
.map_err(autodiff_error(CausalMaskingStage::MaskedSoftmax))?;
let output = weights
.matmul(value)
.map_err(autodiff_error(CausalMaskingStage::ValueMixture))?;
Ok(CausalSelfAttentionForward {
raw_scores: prepared.raw_scores,
scaled_scores: prepared.scaled_scores,
additive_mask,
weights,
output,
scale: prepared.scale,
key_width: prepared.key_width,
value_width: prepared.value_width,
})
} For an allowed score, reverse mode follows
while a blocked score receives
All six coordinates of each of , , and agree with central differences using step and tolerance . The checked boundary also includes a single token, empty batches, rank-two and rank-four score grids, independent leading axes, extreme blocked scores, typed failures, released tape values, and bitwise replay.
The executable prints the checked mask, probabilities, outputs, prefix-invariance result, gradients, and boundary cases:
rust/demos/ch28-causal-masking/src/main.rs fn main() -> Result<(), Box<dyn std::error::Error>> {
let evidence = ch28_causal_masking::learner_evidence()?;
print!("{}", ch28_causal_masking::render_report(&evidence));
Ok(())
} Run cargo run --quiet --locked -p ch28-causal-masking to inspect the complete
worked result.
Follow the lower triangle through attention
See the causal boundary in every attention row
Follow the query, key, and value rows through the lower-triangular visibility rule, then compare the original and suffix-replaced outputs and inspect the gradients.
- Allowed: solid border
- Blocked: dashed border
- Inclusive diagonal: double border
- Bitwise unchanged
- Changed
Trace the lower triangle through one attention calculation
Each row keeps its diagonal and all earlier key columns; borders and text carry every distinction without color.
Start with query, key, and value rows
-
Query rows: which prefix may this position retrieve?
Shape:
-
Key rows: which positions are available to match?
Shape:
-
Value rows: what content can visible positions contribute?
Shape:
Mark the inclusive lower-triangular mask
| Diagonal | Blocked | Blocked | |
| Allowed | Diagonal | Blocked | |
| Allowed | Allowed | Diagonal |
Exclude future scores before normalization
| Diagonal | Blocked | Blocked | |
| Allowed | Diagonal | Blocked | |
| Allowed | Allowed | Diagonal |
Normalize each available prefix
| Diagonal | Blocked | Blocked | |
| Allowed | Diagonal | Blocked | |
| Allowed | Allowed | Diagonal |
- :
- :
- :
Mix only visible value rows
| Already-weighted value terms | Output row | |
|---|---|---|
Change the suffix and test earlier outputs
Only the final key and value change; the first two output rows remain bitwise identical.
Replace the final key and value
Before: After:
Before: After:
| Original output | After suffix replacement | Prefix result | |
|---|---|---|---|
| Bitwise unchanged | |||
| Bitwise unchanged | |||
| Changed |
Inspect reverse-mode and boundary evidence
Full-output and prefix-only reverse seeds show where gradients can and cannot flow.
Prefix-only reverse seed
The changed suffix receives zero gradient from a prefix-only loss.
VerifiedOne-token boundary
Empty-batch shape preservation
→
VerifiedChecked causal properties
Recorded autodiff values remain finite: Verified
Future-key probabilities: Exactly zero
Earlier outputs after the suffix change: Bitwise unchanged
Rejected boundaries
- Attention with no token positions
empty-tokensRejected - Score tensor below rank two
causal-softmax-rank: rank=1Rejected - Non-square final score axes
causal-softmax-non-square: queries=2|keys=3Rejected - Query tensor with the wrong rank
score-input-rank: input=query|rank=2Rejected - Query, key, and value token counts disagree
score-token-mismatch: query=3|key=2|value=3Rejected - Released score tape operand
released-operand: operation=causal-softmax|operand=0Rejected
Follow prefix visibility toward Transformer decoders
Sequential recurrence provides a prefix-only state implicitly; Transformer decoder self-attention makes the boundary explicit.
-
Recurrent prefix availability
During recurrent generation, only the already generated prefix exists, and the recurrent state advances one position at a time.
-
Explicit Transformer decoder mask
During training, shifted decoder inputs and a causal mask let known target positions be evaluated together without exposing later targets; generation remains sequential.
The triangular tables show which scores survive before normalization. Solid, dashed, and double borders distinguish allowed, blocked, and diagonal cells without relying on color.
Changing only
leaves and bitwise unchanged, while
Predict before opening the answers
- Write the allowed key-index set for query rows , , and .
- Predict whether changing only and can change or .
- Explain why all three diagonal cells are allowed without letting a prediction read its own target.
- Predict the query and key gradients for a one-token causal self-attention head.
- Explain why setting future probabilities to zero after an ordinary softmax does not preserve a unit row sum.
- Decide whether the mask makes known-target training, autoregressive generation, both, or neither parallel across token positions.
- Identify which separate mechanism Chapter 29 must add without changing the lower-triangular boundary.
Check the predictions
- The sets are , , and .
- Neither earlier output can change; only can use the final key and value.
- Decoder inputs are shifted by one target position, so the diagonal carries an earlier known token rather than the target being predicted.
- The only probability is the constant , so both gradients are exactly zero.
- Post-softmax zeroing removes probability mass but does not recompute the denominator over the allowed prefix.
- Packed known-target rows can be evaluated together during training; generation still appends one token at a time.
- Relative position information must identify order while leaving the same causal visibility rule intact.
Preserve the prefix boundary as the decoder grows
The cumulative decoder now produces an output at position using only keys and values through position . If a loss uses only the first two positions, the future suffix receives exact zero gradient:
That is the information boundary required by an autoregressive decoder. Chapter 29 adds relative position information without widening it.