← All chapters

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:

Q=[032111],K=[301221],V=[331324].Q=\begin{bmatrix}0&3\\2&-1\\1&1\end{bmatrix},\quad K=\begin{bmatrix}3&0\\-1&2\\2&1\end{bmatrix},\quad V=\begin{bmatrix}3&-3\\1&3\\-2&4\end{bmatrix}.

There is one batch, three token positions, a query/key width of two, and a value width of two. Thus

Q,K,V1×3×2.Q,K,V\in\mathbb{R}^{1\times3\times2}.

The unmasked raw score rows are [0,6,3][0,6,3], [6,4,3][6,-4,3], and [3,1,3][3,1,3]. Before running the fixture, mark the cells each query may use. Query 00 keeps key 00; query 11 keeps keys 00 and 11; query 22 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 ii and key column jj, use the additive mask

Mij={0jij>i,A=softmax(S+M)M_{ij}=\begin{cases}0&j\le i\\-\infty&j>i\end{cases},\quad A=\operatorname{softmax}(S+M)

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

S=QKdk.S=\frac{QK^\top}{\sqrt{d_k}}.

Add the visibility rule before normalization, then mix values:

A=softmax(S+M),O=AV.A=\operatorname{softmax}(S+M),\qquad O=AV.

For this example,

A[1000.9991510.00084900.4458080.1083830.445808].A\approx \begin{bmatrix} 1&0&0\\ 0.999151&0.000849&0\\ 0.445808&0.108383&0.445808 \end{bmatrix}.

Every blocked probability is exactly zero. Each allowed prefix keeps the full unit mass:

j=0iAbij=1.\sum_{j=0}^{i}A_{bij}=1.

The resulting rows are

O[332.9983032.9949080.5541920.770959].O\approx \begin{bmatrix} 3&-3\\ 2.998303&-2.994908\\ 0.554192&0.770959 \end{bmatrix}.

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 -\infty.

Keep visibility separate from position

  • SS is the scaled query-key score tensor before masking.
  • MM is the additive visibility mask.
  • ii indexes a query position and jj indexes a key position.
  • AA contains attention probabilities after the future keys are excluded.
  • QQ, KK, and VV retain their Chapter 27 query, key, and value roles.
  • OO contains one visible-prefix mixture per query position.
  • BB is the batch size, TT the token count, dkd_k the query/key width, and dvd_v the value width.

The complete shape rule remains

Q,KB×T×dk,VB×T×dv,M({})T×T,S,AB×T×T,OB×T×dv.Q,K\in\mathbb{R}^{B\times T\times d_k},\quad V\in\mathbb{R}^{B\times T\times d_v},\quad M\in\left(\mathbb{R}\cup\{-\infty\}\right)^{T\times T},\quad S,A\in\mathbb{R}^{B\times T\times T},\quad O\in\mathbb{R}^{B\times T\times d_v}.

For an allowed cell, the row normalization is

Abij=exp(Sbij)r=0iexp(Sbir),ji,A_{bij}= \frac{\exp(S_{bij})}{\sum_{r=0}^{i}\exp(S_{bir})}, \qquad j\le i,

and for a blocked cell,

Abij=0,j>i.A_{bij}=0,\qquad j>i.

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 -\infty; together with output embeddings shifted by one position, row ii depends only on known outputs before ii.

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 [T,T][T,T] tensor. Allowed cells contain 00 and future cells contain -\infty:

Construct the inspectable inclusive lower-triangular mask 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 -\infty: it reads only jij\le i, subtracts the maximum of that allowed prefix, normalizes the allowed cells, and writes the exact floating-point value +0.0+0.0 to every blocked cell.

Normalize allowed prefixes while keeping recorded autodiff values finite 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 [B,H,T,T][B,H,T,T] 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 VV:

Compose checked causal scaled dot-product self-attention 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

Sˉbij=Abij(Aˉbijr=0iAˉbirAbir),ji,\bar S_{bij}=A_{bij} \left(\bar A_{bij}-\sum_{r=0}^{i}\bar A_{bir}A_{bir}\right) ,\qquad j\le i,

while a blocked score receives

Sˉbij=0,j>i.\bar S_{bij}=0,\qquad j>i.

All six coordinates of each of QQ, KK, and VV agree with central differences using step 10610^{-6} and tolerance 4×1064\times10^{-6}. 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:

Print the causal-masking example and its checked 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
  1. Query rows: which prefix may this position retrieve?

    QQ

    q0q_0 0.0000000.0000003.0000003.000000
    q1q_1 2.0000002.0000001.000000-1.000000
    q2q_2 1.0000001.0000001.0000001.000000

    Shape: [1,3,2][1,3,2]

  2. Key rows: which positions are available to match?

    KK

    k0k_0 3.0000003.0000000.0000000.000000
    k1k_1 1.000000-1.0000002.0000002.000000
    k2k_2 2.0000002.0000001.0000001.000000

    Shape: [1,3,2][1,3,2]

  3. Value rows: what content can visible positions contribute?

    VV

    v0v_0 3.0000003.0000003.000000-3.000000
    v1v_1 1.0000001.0000003.0000003.000000
    v2v_2 2.000000-2.0000004.0000004.000000

    Shape: [1,3,2][1,3,2]

Mij={0jij>iM_{ij}=\begin{cases}0&j\le i\\-\infty&j>i\end{cases}

Mark the inclusive lower-triangular mask

MM

Query rows by key columns · Additive mask value
qi\kjq_i\backslash k_j k0k_0k1k_1k2k_2
q0q_0 0.0000000.000000 Diagonal -\infty Blocked -\infty Blocked
q1q_1 0.0000000.000000 Allowed 0.0000000.000000 Diagonal -\infty Blocked
q2q_2 0.0000000.000000 Allowed 0.0000000.000000 Allowed 0.0000000.000000 Diagonal
Exclude future scores before normalization

S+MS+M

Allowed-prefix visibility
qi\kjq_i\backslash k_j k0k_0k1k_1k2k_2
q0q_0 0.0000000.000000 Diagonal -\infty Blocked -\infty Blocked
q1q_1 4.2426414.242641 Allowed 2.828427-2.828427 Diagonal -\infty Blocked
q2q_2 2.1213202.121320 Allowed 0.7071070.707107 Allowed 2.1213202.121320 Diagonal
Normalize each available prefix

A=softmax(S+M)A=\operatorname{softmax}(S+M)

Allowed-prefix row sum
qi\kjq_i\backslash k_j k0k_0k1k_1k2k_2
q0q_0 1.0000001.000000 Diagonal 0.0000000.000000 Blocked 0.0000000.000000 Blocked
q1q_1 0.9991510.999151 Allowed 0.0008490.000849 Diagonal 0.0000000.000000 Blocked
q2q_2 0.4458080.445808 Allowed 0.1083830.108383 Allowed 0.4458080.445808 Diagonal
  • q0q_0: 1.0000001.000000
  • q1q_1: 1.0000001.000000
  • q2q_2: 1.0000001.000000
Mix only visible value rows
qiq_i Already-weighted value terms Output row
q0q_0 [3.000000,3.000000][3.000000,-3.000000][0.000000,0.000000][0.000000,0.000000][0.000000,0.000000][0.000000,0.000000] [3.000000,3.000000][3.000000,-3.000000]
q1q_1 [2.997454,2.997454][2.997454,-2.997454][0.000849,0.002546][0.000849,0.002546][0.000000,0.000000][0.000000,0.000000] [2.998303,2.994908][2.998303,-2.994908]
q2q_2 [1.337425,1.337425][1.337425,-1.337425][0.108383,0.325150][0.108383,0.325150][0.891617,1.783233][-0.891617,1.783233] [0.554192,0.770959][0.554192,0.770959]

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

k2k_2

Before: [2.000000,1.000000][2.000000,1.000000] After: [2.000000,4.000000][-2.000000,4.000000]

v2v_2

Before: [2.000000,4.000000][-2.000000,4.000000] After: [5.000000,1.000000][5.000000,-1.000000]

ii Original output After suffix replacement Prefix result
00 [3.000000,3.000000][3.000000,-3.000000] [3.000000,3.000000][3.000000,-3.000000] Bitwise unchanged
11 [2.998303,2.994908][2.998303,-2.994908] [2.998303,2.994908][2.998303,-2.994908] Bitwise unchanged
22 [0.554192,0.770959][0.554192,0.770959] [3.287932,1.591834][3.287932,-1.591834] Changed

Inspect reverse-mode and boundary evidence

Full-output and prefix-only reverse seeds show where gradients can and cannot flow.

Full-output reverse seed: [1.0000000.5000000.2500002.0000001.0000000.750000]\begin{bmatrix}1.000000&-0.500000\\0.250000&2.000000\\-1.000000&0.750000\end{bmatrix}
Qˉ\bar Q [0.0000000.0000000.0275790.0137901.9444241.756510]\begin{bmatrix}0.000000&0.000000\\-0.027579&0.013790\\-1.944424&1.756510\end{bmatrix}
Kˉ\bar K [1.6763431.6556580.1077460.0870621.5685961.568596]\begin{bmatrix}-1.676343&-1.655658\\0.107746&0.087062\\1.568596&1.568596\end{bmatrix}
Vˉ\bar V [0.8039801.8326590.1081710.0829850.4458080.334356]\begin{bmatrix}0.803980&1.832659\\-0.108171&0.082985\\-0.445808&0.334356\end{bmatrix}
Prefix-only reverse seed: [1.0000001.0000000.5000002.0000000.0000000.000000]\begin{bmatrix}1.000000&-1.000000\\0.500000&2.000000\\0.000000&0.000000\end{bmatrix}
Qˉ\bar Q [0.0000000.0000000.0263800.0131900.0000000.000000]\begin{bmatrix}0.000000&0.000000\\-0.026380&0.013190\\0.000000&0.000000\end{bmatrix}
Kˉ\bar K [0.0131900.0065950.0131900.0065950.0000000.000000]\begin{bmatrix}-0.013190&0.006595\\0.013190&-0.006595\\0.000000&0.000000\end{bmatrix}
Vˉ\bar V [1.4995760.9983030.0004240.0016970.0000000.000000]\begin{bmatrix}1.499576&0.998303\\0.000424&0.001697\\0.000000&0.000000\end{bmatrix}
Prefix-only reverse seed

The changed suffix receives zero gradient from a prefix-only loss.

Verified
One-token boundary

A=[1.000000]A=[1.000000]

O=[5.000000,2.000000]O=[5.000000,-2.000000]

Empty-batch shape preservation

[0,3,3][0,3,3][0,3,2][0,3,2]

Verified
Checked 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-tokens Rejected
  • Score tensor below rank two causal-softmax-rank: rank=1 Rejected
  • Non-square final score axes causal-softmax-non-square: queries=2|keys=3 Rejected
  • Query tensor with the wrong rank score-input-rank: input=query|rank=2 Rejected
  • Query, key, and value token counts disagree score-token-mismatch: query=3|key=2|value=3 Rejected
  • Released score tape operand released-operand: operation=causal-softmax|operand=0 Rejected

Follow prefix visibility toward Transformer decoders

Sequential recurrence provides a prefix-only state implicitly; Transformer decoder self-attention makes the boundary explicit.

  1. Recurrent prefix availability

    During recurrent generation, only the already generated prefix exists, and the recurrent state advances one position at a time.

  2. 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

k2:[2,1][2,4],v2:[2,4][5,1]k_2:[2,1]\to[-2,4],\qquad v_2:[-2,4]\to[5,-1]

leaves o0o_0 and o1o_1 bitwise unchanged, while

o2[3.287932,1.591834].o'_2\approx[3.287932,-1.591834].

Predict before opening the answers

  1. Write the allowed key-index set for query rows 00, 11, and 22.
  2. Predict whether changing only k2k_2 and v2v_2 can change o0o_0 or o1o_1.
  3. Explain why all three diagonal cells are allowed without letting a prediction read its own target.
  4. Predict the query and key gradients for a one-token causal self-attention head.
  5. Explain why setting future probabilities to zero after an ordinary softmax does not preserve a unit row sum.
  6. Decide whether the mask makes known-target training, autoregressive generation, both, or neither parallel across token positions.
  7. Identify which separate mechanism Chapter 29 must add without changing the lower-triangular boundary.
Check the predictions
  1. The sets are {0}\{0\}, {0,1}\{0,1\}, and {0,1,2}\{0,1,2\}.
  2. Neither earlier output can change; only o2o_2 can use the final key and value.
  3. Decoder inputs are shifted by one target position, so the diagonal carries an earlier known token rather than the target being predicted.
  4. The only probability is the constant 11, so both gradients are exactly zero.
  5. Post-softmax zeroing removes probability mass but does not recompute the denominator over the allowed prefix.
  6. Packed known-target rows can be evaluated together during training; generation still appends one token at a time.
  7. 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 ii using only keys and values through position ii. If a loss uses only the first two positions, the future suffix receives exact zero gradient:

L1q2=L1k2=L1v2=0.\frac{\partial L_{\le1}}{\partial q_2} =\frac{\partial L_{\le1}}{\partial k_2} =\frac{\partial L_{\le1}}{\partial v_2}=0.

That is the information boundary required by an autoregressive decoder. Chapter 29 adds relative position information without widening it.