← All chapters

Chapter 05 · Content revision 1

Pack sequences without joining their contexts

Use next-fit placement, occurrence segments and reset positions to preserve document-local attention, targets and token-weighted loss.

The problem: shared storage can change context

Right padding preserves unequal examples, but short rows can leave unused storage. Placing several short examples in one row can fill those cells. An ordinary causal mask, however, lets each query use every earlier key in that row. A later example would then receive the earlier example as additional context. Shifting concatenated source documents would also create next-token targets across their boundaries.

We need to preserve the examples while moving their storage. Sequence packing places complete, already formed input-target pairs into shared rows. Each occurrence keeps its own segment identity and local positions, so storage adjacency creates neither an attention edge nor a new target.

Form pairs inside each source range

Use three supplied encoded documents:

ExampleEncoded token IDsInput IDsTarget IDsPrediction pairs
A[0,99,100,1][0,99,100][99,100,1]33
B[0,101,1][0,101][101,1]22
C[0,102,103,104,1][0,102,103,104][102,103,104,1]44

The existing layout uses 0 for BOS and 1 for EOS. These arrays are controlled valid-ID examples in the 266266-entry fixture vocabulary. Their token counts are 44, 33 and 55; their prediction lengths are 33, 22 and 44. A selected range supplies one fewer next-token pair than source tokens.

Form each pair before packing. A’s final input 100 still predicts its own EOS 1; it does not predict B’s BOS. Globally shifting the concatenated source documents would add an EOS-to-BOS pair at each join. Those targets do not belong to the examples we selected. An explicitly selected interior range follows the same rule: retain its existing neighboring pairs without adding BOS or EOS.

Fill only the current row

Capacity 66 means six prediction pairs per storage row. Our next-fit policy reads examples in their supplied order. A fills columns zero through two, B fills columns three and four, and the last cell is padding. C does not fit in that one remaining cell, so it starts a new row and leaves two padding cells. The packer never revisits an earlier row, sorts by length or splits an example.

The two input rows are [0,99,100,0,101,-1] and [0,102,103,104,-1,-1]. Their targets are [99,100,1,101,1,-1] and [102,103,104,1,-1,-1]. The rectangle stores 1212 cells and contains 99 real targets. PAD_CELL is -1, a storage marker rather than a vocabulary ID.

A, B and C receive occurrence segment IDs 0, 1 and 2. The segment rows are [0,0,0,1,1,-1] and [2,2,2,2,-1,-1], with NO_SEGMENT also -1. The position rows are [0,1,2,0,1,0] and [0,1,2,3,0,0]. B therefore begins at local position zero even though its storage column is three. Padding’s stored position zero is unused.

An occurrence is one supplied use of an example. Two uses of the identical source document and range still receive distinct occurrence ordinals and segments. Their source IDs, example IDs and ranges may match; their causal contexts remain separate.

Input validity, target validity and loss eligibility keep the meanings from Chapter 4. Every real target is supervised here, so the three row-zero fields are [true,true,true,true,true,false]; the row-one fields are [true,true,true,true,false,false]. Input validity says an input exists, target validity says its immediate successor exists, and loss eligibility says that target is supervised. Their agreement in this fixture does not make the fields interchangeable.

Permission depends on the example

Mij=vivj𝟏[si=sj∧pj≤pi]M_{ij}=v_i v_j\mathbf{1}[s_i=s_j\land p_j\le p_i]

The relation applies within one row. A query may use a key only when both positions are valid, both belong to the same occurrence segment, and the key’s local position is no later than the query’s. A binary value of one means permitted; it is not an attention probability.

This is block-causal permission: the allowed entries form one causal block per occurrence.

For B’s first query, storage column three, the only permitted key is column three. The earlier keys in columns zero through two belong to A and are excluded. B’s next query permits columns three and four. Merely checking that the key precedes the query in storage would admit A incorrectly.

Both validity checks matter. Padding cells share segment -1 and stored position zero, so segment equality and local causality alone would allow padding-to-padding edges. An inactive query instead skips softmax and produces a zero attention mixture. A later bias or residual may produce a nonzero padded hidden state; validity must prevent that state from influencing real predictions or loss.

What the permission symbols mean

  • MijM_{ij} is binary permission for query column ii to use key column jj within the same storage row.
  • ii is the zero-based query column in that row.
  • jj is the zero-based key column in that row.
  • viv_i is query input validity: 11 for a real input and 00 for padding.
  • vjv_j is key input validity: 11 for a real input and 00 for padding.
  • sis_i identifies the occurrence segment containing the query.
  • sjs_j identifies the occurrence segment containing the key.
  • pip_i is the zero-based prediction position local to the query’s occurrence.
  • pjp_j is the zero-based prediction position local to the key’s occurrence.
  • 𝟏\mathbf{1} is an indicator: 11 when its bracketed condition holds and 00 otherwise.

For a contiguous segment, the allowed keys form an inclusive range from its first storage column through the query column. B’s segment starts at column three, so its two ranges are [3,4) and [3,5) in Rust’s half-open notation. The checked start and valid length represent the permission relation without allocating a separate dense mask for every row and attention head.

Position reset and segment isolation are separate requirements. Assigning B row-global positions [3,4] violates its local-position metadata. It does not necessarily change rotary attention scores: with fixed unrotated queries and keys, a common offset applied to all their positions in an isolated segment preserves ideal relative rotary dot products. We check the local positions directly. Resetting positions alone also cannot exclude A’s keys.

Keep the same nine-target objective

Packing retains the raw loss sum and valid-target count from Chapter 4. The token-weighted loss gives every eligible target equal weight and conditions on its own permitted segment prefix. Specify gold probability 1/21/2 for all three A targets, 1/41/4 for both B targets and 1/21/2 for all four C targets. These are constructed loss-probe probabilities, rather than model predictions.

The negative log likelihood sums are 3ln⁡23\ln2, 4ln⁡24\ln2 and 4ln⁡24\ln2 nats. Adding them and dividing once gives 11ln⁡2/9=(11/9)ln⁡211\ln2/9=(11/9)\ln2 nats per valid target, printed as 0.847180 by the probe. The rows contain five and four targets: averaging their means equally would give (6/5)ln⁡2(6/5)\ln2, printed as 0.831777. Averaging the three document means equally would give (4/3)ln⁡2(4/3)\ln2, printed as 0.924196. Both change the weight assigned to individual targets.

The finite-logit probe uses vocabulary size V=266V=266. For desired gold probability 0<q<10<q<1, give each nongold class logit zero and the gold class logit zy=ln⁡((V−1)q/(1−q))z_y=\ln((V-1)q/(1-q)). Softmax assigns gold probability qq in ideal arithmetic. The Rust calculation uses f64, with rounding and the declared comparison tolerance.

In ideal arithmetic, for eligible target ii and vocabulary class cc, the mean-loss logit derivative is (pic−𝟏[c=yi])/N(p_{ic}-\mathbf{1}[c=y_i])/N, where picp_{ic} is that class’s probability, yiy_i is the gold class and NN is the total eligible-target count. Here ii indexes eligible targets across all rows. Excluded logit cells have zero contribution. The loss adapter skips them before target conversion or arithmetic; multiplying an already computed NaN by zero would not exclude it safely. Zero eligible targets is an error because the mean has no denominator.

Extend the existing storage and decoder

PackConfig declares prediction capacity, maximum rows per model call, cell capacity and metadata budget. pack_examples validates the existing SequenceExample inputs and their shared binding, then assigns whole examples to the current row. An oversized example fails instead of being silently split or truncated. An exact fill creates no extra empty row, and the final model call retains its actual smaller row count.

Place complete prediction-pair examples in input order, filling only the current row. rust/crates/llm-from-scratch-practical/src/training/packer.rs#stable-next-fit-packing
pub fn pack_examples(
    partition: Partition,
    examples: &[SequenceExample<'_>],
    expected: &BatchBinding,
    config: &PackConfig,
) -> Result<PackedBatchEpoch, VariableBatchError> {
    validate_pack_examples(partition, examples, expected, config.storage)?;
    if examples.len() > i32::MAX as usize + 1 {
        return Err(VariableBatchError::Invalid(
            "occurrence segment IDs must fit i32",
        ));
    }
    let planner_bytes = checked_metadata_add(
        checked_metadata_mul(examples.len(), size_of::<BatchPlacement>())?,
        size_of::<Vec<BatchPlacement>>() + ALLOCATION_ALLOWANCE,
    )?;
    enforce_metadata(planner_bytes, config.max_metadata_bytes)?;
    let mut placements = reserve(examples.len())?;
    let mut row = 0usize;
    let mut used = 0usize;
    for (occurrence, example) in examples.iter().enumerate() {
        if example.len() > config.capacity() - used {
            row = row.checked_add(1).ok_or(VariableBatchError::Invalid(
                "packed row count overflows usize",
            ))?;
            used = 0;
        }
        placements.push(BatchPlacement {
            occurrence,
            row,
            start: used,
        });
        used += example.len();
    }
    let row_count = row.checked_add(1).ok_or(VariableBatchError::Invalid(
        "packed row count overflows usize",
    ))?;
    let batch_count = row_count.div_ceil(config.max_rows_per_batch());
    let descriptor_bytes = checked_metadata_add(
        checked_metadata_mul(examples.len(), size_of::<PackedOccurrence>())?,
        checked_metadata_mul(batch_count, ALLOCATION_ALLOWANCE)?,
    )?;
    let mut metadata_peak = checked_metadata_add(size_of::<PackedBatchEpoch>(), planner_bytes)?;
    metadata_peak = checked_metadata_add(metadata_peak, descriptor_bytes)?;
    metadata_peak = checked_metadata_add(
        metadata_peak,
        checked_metadata_mul(batch_count, size_of::<PackedBatch>())?,
    )?;
    metadata_peak = checked_metadata_add(metadata_peak, ALLOCATION_ALLOWANCE)?;
    let mut first = 0usize;
    for batch in 0..batch_count {
        let first_row = checked_metadata_mul(batch, config.max_rows_per_batch())?;
        let rows = (row_count - first_row).min(config.max_rows_per_batch());
        let next_row = first_row + rows;
        let mut end = first;
        while end < placements.len() && placements[end].row < next_row {
            end += 1;
        }
        let cells = checked_metadata_mul(rows, config.capacity())?;
        if cells > config.max_cells() {
            return Err(VariableBatchError::Capacity {
                cells,
                maximum: config.max_cells(),
            });
        }
        // The batch Vec already owns the embedded VariableBatch struct; count its buffers once.
        let storage_bytes =
            VariableBatch::budget_placed([rows, config.capacity()], examples, first..end)?;
        metadata_peak =
            checked_metadata_add(metadata_peak, storage_bytes - size_of::<VariableBatch>())?;
        first = end;
    }
    enforce_metadata(metadata_peak, config.max_metadata_bytes)?;
    let mut batches = reserve(batch_count)?;
    let mut first = 0usize;
    for batch in 0..batch_count {
        let first_row = batch * config.max_rows_per_batch();
        let rows = (row_count - first_row).min(config.max_rows_per_batch());
        let mut end = first;
        while end < placements.len() && placements[end].row < first_row + rows {
            end += 1;
        }
        // Normalize row coordinates in the already budgeted planner, preserving global occurrence IDs.
        for placement in &mut placements[first..end] {
            placement.row -= first_row;
        }
        batches.push(PackedBatch::from_placements(
            partition,
            examples,
            expected,
            [rows, config.capacity()],
            &placements[first..end],
            first..end,
        )?);
        first = end;
    }
    Ok(PackedBatchEpoch::from_parts(
        batches,
        row_count,
        examples.len(),
        metadata_peak,
    ))
}

PackedBatch adds occurrence placement records around the existing VariableBatch. The shared storage builder retains source and example IDs, source ranges, partition, tokenizer identity and split-receipt identity. Its private arrays derive positions, segment starts, validity and targets together. The packed wrapper reuses the same valid-target loss methods. A receipt digest identifies the caller’s accepted evidence; it does not approve that evidence.

Retain each occurrence's ordinal, segment and placed interval around the shared prediction storage. rust/crates/llm-from-scratch-practical/src/training/packed_batch.rs#packed-occurrence-storage
    pub(crate) fn from_placements(
        partition: Partition,
        examples: &[SequenceExample<'_>],
        expected: &BatchBinding,
        shape: [usize; 2],
        placements: &[BatchPlacement],
        span: Range<usize>,
    ) -> Result<Self, VariableBatchError> {
        let storage =
            VariableBatch::build_placed(partition, examples, expected, shape, placements, span)?;
        let mut occurrences = reserve(placements.len())?;
        for placement in placements {
            occurrences.push(PackedOccurrence {
                ordinal: placement.occurrence,
                row: placement.row,
                columns: placement.start..placement.start + examples[placement.occurrence].len(),
                segment: i32::try_from(placement.occurrence).expect("checked occurrence namespace"),
            });
        }
        Ok(Self {
            storage,
            occurrences,
        })
    }

The row-local allowed_key method exposes the full binary rule. The model receives a valid prefix length per row and a segment start per query. The existing causal-softmax loops use those ranges in forward and backward calculation; the same boundary reaches every attention head and layer.

Require valid query and key cells, one occurrence segment and local causality before admitting an edge. rust/crates/llm-from-scratch-practical/src/training/variable_batch.rs#implicit-padding-mask
    /// Eligibility is stored as lengths and validity, without a dense head mask.
    pub fn allowed_key(
        &self,
        row: usize,
        query: usize,
        key: usize,
    ) -> Result<bool, VariableBatchError> {
        if row >= self.shape[0] || query >= self.shape[1] || key >= self.shape[1] {
            return Err(VariableBatchError::Invalid(
                "attention coordinate is outside this batch",
            ));
        }
        let q = row * self.shape[1] + query;
        let k = row * self.shape[1] + key;
        Ok(self.valid_tokens[q]
            && self.valid_tokens[k]
            && self.segments[q] == self.segments[k]
            && self.positions[k] <= self.positions[q])
    }
Pass checked segment starts into the existing attention calculation and range-aware softmax. rust/crates/llm-from-scratch-practical/src/attention/causal_mask.rs#segment-causal-attention
pub fn causal_attention_with_ranges(
    context: AutogradContext,
    query: &TensorValue,
    key: &TensorValue,
    value: &TensorValue,
    lengths: &[usize],
    starts: &[usize],
) -> Result<CausalSelfAttentionForward, CausalMaskingError> {
    causal_attention_with_layout(context, query, key, value, Some(lengths), Some(starts))
}

fn causal_attention_with_layout(
    context: AutogradContext,
    query: &TensorValue,
    key: &TensorValue,
    value: &TensorValue,
    lengths: Option<&[usize]>,
    starts: Option<&[usize]>,
) -> Result<CausalSelfAttentionForward, CausalMaskingError> {
    let prepared = scaled_self_attention_scores_with_context(context, query, key, value)?;
    let tokens = query.shape()[1];
    let additive_mask = causal_additive_mask(tokens)?;
    let weights = match (lengths, starts) {
        (Some(lengths), Some(starts)) => prepared
            .scaled_scores
            .causal_softmax_with_ranges(context, lengths, starts),
        (Some(lengths), None) => prepared
            .scaled_scores
            .causal_softmax_with_lengths(context, lengths),
        (None, None) => prepared.scaled_scores.causal_softmax_with_context(context),
        (None, Some(_)) => {
            return Err(autodiff_error(CausalMaskingStage::MaskedSoftmax)(
                crate::autograd::model_ops::ModelOpError::CausalLengthCount {
                    expected: query.shape()[0],
                    actual: 0,
                }
                .into(),
            ));
        }
    }
    .map_err(autodiff_error(CausalMaskingStage::MaskedSoftmax))?;
    let output = weights
        .matmul_with_context(context, 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,
        range_starts: starts.map(<[usize]>::to_vec),
    })
}

The existing causal-only additive report still has shape [T,T][T,T], where TT is row capacity. It describes future columns but does not describe the segment boundaries. The range-aware softmax enforces those boundaries. Dense score and attention-weight tensors remain in this scalar decoder; an implicit permission rule does not eliminate their rectangular work.

forward_packed delegates to the existing padded decoder entry with the shared storage. The embedding gather excludes PAD_CELL, rotary lookup uses local positions, and the decoder runs its existing blocks and tied vocabulary projection. loss_packed reuses the existing eligible-row gather and indexed NLL. Packing adds no tokenizer token, probability kernel or second decoder.

Reuse the padded decoder and valid-target loss through the packed storage wrapper. rust/crates/llm-from-scratch-practical/src/models/decoder.rs#packed-decoder-entry
    pub fn forward_packed(
        &self,
        batch: &crate::training::packed_batch::PackedBatch,
        expected: &BatchBinding,
    ) -> Result<DecoderModelForward, PaddedDecoderError> {
        self.forward_packed_with_context(AutogradContext::recording(), batch, expected)
    }

    pub fn forward_packed_with_context(
        &self,
        context: AutogradContext,
        batch: &crate::training::packed_batch::PackedBatch,
        expected: &BatchBinding,
    ) -> Result<DecoderModelForward, PaddedDecoderError> {
        self.forward_padded_with_context(context, batch.storage(), expected)
    }

    pub fn loss_packed(
        &self,
        batch: &crate::training::packed_batch::PackedBatch,
        expected: &BatchBinding,
    ) -> Result<MaskedLoss, PaddedDecoderError> {
        self.loss_packed_with_context(AutogradContext::recording(), batch, expected)
    }

    pub fn loss_packed_with_context(
        &self,
        context: AutogradContext,
        batch: &crate::training::packed_batch::PackedBatch,
        expected: &BatchBinding,
    ) -> Result<MaskedLoss, PaddedDecoderError> {
        self.loss_padded_with_context(context, batch.storage(), expected)
    }
Construct finite full-vocabulary logits with the declared gold probabilities for the loss probe. rust/demos/practical-ch05-packed-sequence-masks/src/lib.rs#packed-constructed-loss
fn constructed_logits(batch: &PackedBatch) -> Result<Tensor, Box<dyn Error>> {
    let vocabulary = batch.binding().vocabulary_size();
    let mut values = vec![0.0; batch.inputs().len() * vocabulary];
    for (cell, &valid) in batch.loss_mask().iter().enumerate() {
        if valid {
            let q = if batch.segments()[cell] == 1 {
                0.25
            } else {
                0.5
            };
            values[cell * vocabulary + batch.targets()[cell] as usize] =
                ((vocabulary - 1) as f64 * q / (1.0 - q)).ln();
        }
    }
    Ok(Tensor::from_vec(
        vec![batch.shape()[0], batch.shape()[1], vocabulary],
        values,
    )?)
}

Run the bounded example

With Docker running and the repository’s Rust workspace setup available, run from the repository root. The command uses the bundled Chapter 4 prepared fixture and split receipt; it needs no corpus download:

COURSE_CORPUS=false ./course run cargo run --offline --locked \
  -p practical-ch05-packed-sequence-masks

The JSON trace contains both packed rows and occurrence records, the allowed-key lists, nine valid targets, and the constructed loss sum 7.624619 nats and mean 0.847180 nats per target. It separately compares a decoder with identical untrained parameters across separate, padded and packed execution. Those model loss sums are each printed as 50.551553 nats; the constructed loss probabilities do not describe this decoder’s predictions.

The model has width 88, 22 heads, feed-forward width 1616, 22 layers, maximum prediction length 1616 and 34483448 parameter coordinates, with seed 42. Across the nine targets, 23942394 valid vocabulary logits and all 34483448 named parameter-gradient coordinates are checked for each comparison against separate execution: 47884788 logit comparisons and 68966896 gradient comparisons in total. Both printed maximum absolute differences round to 0.000000000000; the individual comparisons still use the stated f64 tolerance. No optimizer update or trained-model quality result is implied.

Print the packed metadata, constructed loss, decoder comparisons, storage accounting and edge traversals. rust/demos/practical-ch05-packed-sequence-masks/src/main.rs#packed-sequence-example
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let trace = practical_ch05_packed_sequence_masks::demo_trace()?;
    println!("{}", serde_json::to_string_pretty(&trace)?);
    Ok(())
}

Count storage and attention separately

For BB rows of width TT, token storage has BTBT cells, while dense attention has BT2BT^2 query-key score positions per head. A causal segment of prediction length ll permits l(l+1)/2l(l+1)/2 semantic edges. A permission edge says which relationship is legal; it does not say whether the implementation computes other score positions before masking.

Prediction lengths; capacity sixPadded token cellsPacked token cellsPadded dense scores per headPacked dense scores per headPermitted edges per head in either layout
3,2,43,2,412121212484872721919
4,2,4,24,2,4,216161212646472722626

The main example has 75%75\% valid density in either layout. The second rises from 75%75\% to 100%100\%, but its packed rectangle has more dense score positions. These counts establish storage and shape differences. They do not establish a speedup.

History: packing and the cost of moving attention data

Rectangular Transformer training batches allocate padding to accommodate unequal sequence lengths, leaving some stored positions without real tokens. The original 2021 version of Packing: Towards 2x NLP BERT Acceleration examines that problem. The authors study BERT pretraining and compare two deterministic methods based on sequence-length histograms. Using multiple sequences in one sample requires additional attention masking and a change to the masked-language-model loss. Those methods and BERT results do not specify our input-order next-fit policy, causal targets or decoder performance.

Later attention work addressed the cost of moving data during the attention calculation. The 2022 version-two FlashAttention paper describes an exact, IO-aware attention algorithm that uses tiling to reduce transfers between GPU high-bandwidth memory and on-chip SRAM. FlashAttention, version 2

Changing the computation order is a different decision from deciding which examples may attend to one another. Neither an execution tile nor shared storage permits a cross-segment edge.

The runnable Rust contrast counts padded and packed storage, then visits the same permitted edge inventory in query order and in tiles of width two. Sorting copies of the inventories checks that the traversal order retains the same relationships. The tile traversal is an illustration; it implements neither FlashAttention’s numerical kernel nor its GPU memory strategy and provides no timing benchmark.

Visit the same permitted edges in query order and width-two tile order, then compare sorted inventories. rust/demos/practical-ch05-packed-sequence-masks/src/lib.rs#permitted-edge-traversal
    let tile_width = 2;
    let mut tile_edges = Vec::new();
    for row in 0..2 {
        for query_start in (0..6).step_by(tile_width) {
            for key_start in (0..6).step_by(tile_width) {
                for query in query_start..(query_start + tile_width).min(6) {
                    for key in key_start..(key_start + tile_width).min(6) {
                        if batch.allowed_key(row, query, key)? {
                            tile_edges.push([row, query, key]);
                        }
                    }
                }
            }
        }
    }
    let mut sorted_query = query_edges.clone();
    sorted_query.sort_unstable();
    let mut sorted_tile = tile_edges.clone();
    sorted_tile.sort_unstable();
    if sorted_query != sorted_tile {
        return Err("edge traversals disagree".into());
    }

For a causal decoder, packing preserves each example’s prediction and attention boundaries. A later attention execution strategy must preserve each example’s permitted context. The tiny Rust traversal illustrates edge ordering without reproducing FlashAttention or establishing a speedup.

Read the aligned cells and permission matrices

The figure places each row’s input, target, occurrence segment, local position and validity fields beside its permission matrix. A matrix row selects a query column and a matrix column selects a key column. The matrices expose B’s separate causal block even though A and B share storage.

Shared rows, separate causal contexts

Row 0 holds A in columns 0 through 2 and B in columns 3 and 4; row 1 holds C in columns 0 through 3. Each occurrence restarts its local positions at zero and keeps its own next-token pairs, including its EOS target. A and B share storage but cannot attend across their boundary. Matrix rows are queries and columns are keys: one permits an edge and zero prohibits it; neither value is an attention probability. Nine targets enter loss. Three padding cells marked -1 have no segment and inactive queries. The three causal blocks permit nineteen edges per attention head.

Packed row 00

Occurrences in this row
  • Example A · Occurrence segment 0 Encoded source token IDs: [0,99,100,1] Source token range, end excluded: [0,4) · Prediction pairs: 33 Packed columns, end excluded: [0,3)
  • Example B · Occurrence segment 1 Encoded source token IDs: [0,101,1] Source token range, end excluded: [0,3) · Prediction pairs: 22 Packed columns, end excluded: [3,5)
Prediction columns and aligned fields: Packed row 00
Batch field001122334455
Input ID0991000101-1PAD
Target ID9910011011-1PAD
Local position012010
Occurrence segment00011-1No segment
Input existsYesYesYesYesYesNo
Target existsYesYesYesYesYesNo
Target supervisedYesYesYesYesYesNo
Binary attention permission: Packed row 00

Matrix rows select query columns; matrix columns select key columns. One means permitted and zero means prohibited. These are permissions, not attention probabilities.

Query column / Key columnj=0j=0j=1j=1j=2j=2j=3j=3j=4j=4j=5j=5
i=0i=0 100000
i=1i=1 110000
i=2i=2 111000
i=3i=3 000100
i=4i=4 000110
i=5i=5 000000

Packed row 11

Occurrences in this row
  • Example C · Occurrence segment 2 Encoded source token IDs: [0,102,103,104,1] Source token range, end excluded: [0,5) · Prediction pairs: 44 Packed columns, end excluded: [0,4)
Prediction columns and aligned fields: Packed row 11
Batch field001122334455
Input ID0102103104-1PAD-1PAD
Target ID1021031041-1PAD-1PAD
Local position012300
Occurrence segment2222-1No segment-1No segment
Input existsYesYesYesYesNoNo
Target existsYesYesYesYesNoNo
Target supervisedYesYesYesYesNoNo
Binary attention permission: Packed row 11

Matrix rows select query columns; matrix columns select key columns. One means permitted and zero means prohibited. These are permissions, not attention probabilities.

Query column / Key columnj=0j=0j=1j=1j=2j=2j=3j=3j=4j=4j=5j=5
i=0i=0 100000
i=1i=1 110000
i=2i=2 111000
i=3i=3 111100
i=4i=4 000000
i=5i=5 000000

Count the preserved predictions

Stored token cells
1212
Valid targets
99
Padding cells
33
Permitted edges per head
1919

Padding has no context or target

The marker -1 is outside the vocabulary and belongs to no occurrence. Inactive queries skip normalization and produce zero attention mixtures. Later biases or residuals need not leave all padded hidden states zero.

Optional practice: inspect the preserved relationships

  1. Reproduce A, B and C with the example command above. Inspect their input-target pairs, occurrence segments, local positions and allowed-key lists. Explain why row-zero query three excludes key two and why each final content token still predicts its own EOS.
  2. Inspect the example’s constructed raw loss sum and nine-target mean. Explain why an unweighted average of row means or document means changes target weighting.
  3. Compare its storage counts for lengths three, two, four and for lengths four, two, four, two. Explain why fewer token cells need not mean fewer dense attention scores. Inspect the four, four, two, two case to distinguish next-fit from a policy that revisits earlier rows.
  4. Run the seeded model comparisons with the first test command below. Inspect the valid-logit and full parameter-gradient counts, using the same parameters and source examples across separate, padded and packed execution.
  5. Run the two selected activation tests with the second command. Inspect B-only loss and gradient independence when A’s input states change, and the zero derivatives into future or padding states for a selected query.

From the repository root, with the same Docker setup and bundled inputs as the example, run the bounded CPU comparisons:

COURSE_CORPUS=false ./course run cargo test --offline --locked \
  -p llm-from-scratch-practical --test practical_ch05_packed_sequence_masks \
  -- --nocapture

The integration suite runs 1313 tests and prints counts for 100100 fixed seeds in each CPU model profile. The smaller profile has width 44, 22 heads, feed-forward width 44, one layer and maximum prediction length 44. The larger profile has width 88, 22 heads, feed-forward width 1616, two layers and maximum prediction length 1616. Both use the 266266-entry vocabulary. The complete capacity-six example uses the larger profile.

Expect 13 passed and 0 failed. Within each case, the comparisons check every valid vocabulary logit, the raw token-summed loss and every named parameter-gradient coordinate across the three representations. Each finite comparison must satisfy absolute tolerance 10−1010^{-10} plus relative tolerance 10−910^{-9} times the larger absolute value. The displayed maximum differences are rounded to twelve decimal places; a displayed zero does not prove exact equality. The tests use untrained parameters and take no optimizer step.

With the same working directory and setup, run the activation tests:

COURSE_CORPUS=false ./course run cargo test --offline --locked \
  -p llm-from-scratch-practical --lib packing_tests -- --nocapture

Expect 2 passed and 0 failed. The first test perturbs independent A activations while holding parameters, shapes, B inputs and the B-only objective fixed. B’s logits, B-only loss and all 34483448 named parameter-gradient coordinates remain within the same tolerance; derivatives into A and padding are exactly zero. Removing the segment range guard admits A’s influence. The second test checks exact zero derivatives into future and padding activations for B’s first query. Changing A can still change the whole-batch parameter gradient because A’s own loss contributes to it. Shared-parameter learning and an illegal cross-example context dependency are different relationships.

Explanations for segment boundaries, target weighting, storage and independent gradients
  1. Row-zero inputs and targets are [0,99,100,0,101,-1] and [99,100,1,101,1,-1]. Row-one inputs and targets are [0,102,103,104,-1,-1] and [102,103,104,1,-1,-1]. Their segment rows are [0,0,0,1,1,-1] and [2,2,2,2,-1,-1]; positions are [0,1,2,0,1,0] and [0,1,2,3,0,0]. The three validity fields are [true,true,true,true,true,false] and [true,true,true,true,false,false]. Query three in row zero belongs to B, while key two belongs to A, so their edge is prohibited. Its allowed-key list is [3]; query four’s is [3,4]. B’s local positions are [0,1], even though its storage columns are three and four. Packing retains each source-local EOS target and never adds an EOS-to-BOS join. Inactive padding queries have empty allowed-key lists.
  2. Add 3ln⁡23\ln2, 4ln⁡24\ln2 and 4ln⁡24\ln2, then divide by 99, obtaining (11/9)ln⁡2(11/9)\ln2 nats per target. The five-target and four-target rows must carry weights five and four when combining their means. The three documents similarly carry weights three, two and four. Equal row or document weights describe different objectives.
  3. The main example uses twelve token cells either way, with 4848 padded versus 7272 packed dense score positions per head and nineteen permitted edges. The supplementary example uses sixteen padded versus twelve packed token cells, 6464 versus 7272 dense scores and twenty-six permitted edges. Next-fit on lengths four, four, two, two yields [4], [4,2], [2]. Filling the first row later would change the declared policy.
  4. Join outputs by occurrence ordinal and local position before comparison. Multiply each separate mean loss and mean-gradient vector by its valid-target count to recover raw sums. Aggregate the sums and divide once by the total count when comparing the global mean. Comparing every gradient coordinate checks information that equality of gradient norms alone would miss.
  5. The activation fixtures change A’s per-position input states rather than a shared embedding parameter. B-only loss excludes A’s own target losses, so unchanged B gradients test the context boundary. The selected-query test additionally separates B’s current position from its later position and padding, whose input derivatives are exactly zero.

Keep the example contract as decoder depth grows

Packed rows retain the same selected source pairs, occurrence-local causal prefixes and token weighting as separate examples. The decoder consumes their checked local positions and key ranges through its existing block loop. The next chapter addresses deeper configurable decoder blocks. More layers must retain these boundaries at every attention operation; depth does not repair an incorrectly joined context or target.