← Все главы

37 · Версия материала 5

Сохраняйте префикс, проецируйте только новую строку

Разберитесь, как привязанный к слою KV-кэш добавляет по одной строке повёрнутого ключа и значения без поворота и даёт для новой позиции тот же результат внимания, что и расчёт по всему префиксу.

Предскажите результат третьего вызова до запуска примера

В примере размер пакета равен единице, ширина модели D=4D=4, число голов H=2H=2, ширина головы dh=2d_h=2, а ёмкость кэша C=4C=4. Перед первым вызовом кэш пуст. Каждый вызов принимает одну строку формы [1,1,4][1,1,4], поэтому логические формы ключей и значений последовательно становятся [1,2,1,2][1,2,1,2], [1,2,2,2][1,2,2,2] и [1,2,3,2][1,2,3,2].

Первые два выхода с кэшем равны [1,0,1,0][1,0,1,0] и [0.213809009,0.786190991,0.770151153,0.420735492][0.213809009,0.786190991,0.770151153,-0.420735492]. Прежде чем посмотреть третий выход, предскажите три факта:

  1. Старая длина равна 22, поэтому новые QQ и KK используют абсолютную позицию RoPE 22.
  2. Каждая голова сопоставляет новый запрос с 33 ключами, а затем формирует выход из соответствующих 33 значений.
  3. Кэш добавляет одну строку повёрнутых ключей и одну строку значений без поворота, но не сохраняет запрос.

Результат равен [0.629044078,0.945303958,0.374718490,0.583589471][0.629044078,0.945303958,0.374718490,-0.583589471]. Независимый эталонный расчёт по полному префиксу даёт те же показанные значения, а максимальная абсолютная разность без округления не превышает 101210^{-12}.

Добавляйте строки вдоль оси позиций

Для слоя внимания \ell переход кэша имеет вид

K1:t()=[K1:t1();kt()],V1:t()=[V1:t1();vt()]K^{(\ell)}_{1:t}=[K^{(\ell)}_{1:t-1};k^{(\ell)}_t],\quad V^{(\ell)}_{1:t}=[V^{(\ell)}_{1:t-1};v^{(\ell)}_t]

Точка с запятой означает конкатенацию вдоль оси позиций последовательности. К новому ключу kt()k^{(\ell)}_t уже применён RoPE для его абсолютной позиции. Новое значение vt()v^{(\ell)}_t уже спроецировано и разделено на головы, но не повёрнуто. Запрос нужен только для текущего вычисления, поэтому эта реализация не хранит запросы в кэше.

Реализация использует смещение с нумерацией от нуля, равное старой длине кэша. В формуле принята обычная запись префикса с нумерацией от единицы, поэтому математическая позиция tt на единицу больше этого смещения.

Различайте слой, логическую длину и ёмкость

  • K1:t()K^{(\ell)}_{1:t} — префикс повёрнутых ключей слоя \ell после добавления новой строки.
  • V1:t()V^{(\ell)}_{1:t} — префикс значений того же слоя после добавления.
  • \ell обозначает слой внимания блока декодера, которому принадлежит состояние.
  • tt обозначает последнюю позицию в математической записи префикса с нумерацией от единицы.
  • 1:t1:t включает все сохранённые позиции вплоть до последней.
  • K1:t1()K^{(\ell)}_{1:t-1} и V1:t1()V^{(\ell)}_{1:t-1} — неизменившиеся строки предыдущих позиций.
  • kt()k^{(\ell)}_t и vt()v^{(\ell)}_t — пара строк для одной новой позиции: строка ключа и строка значения.
  • [A;B][A;B] означает конкатенацию вдоль оси позиций последовательности, при которой BB добавляется после AA.

Физические буферы имеют форму [B,H,C,dh][B,H,C,d_h], где BB — размер пакета, HH — число голов, CC — фиксированная ёмкость, а dhd_h — ширина головы. Логический снимок имеет форму [B,H,t,dh][B,H,t,d_h]. Сброс переводит tt в 00, не меняя CC и не выделяя новые буферы. При этом также не меняются идентичности узлов параметров и версии их значений, зафиксированные при создании кэша.

От каузального внимания к управлению состоянием LLM при генерации

Каузальное внимание Transformer позволяет каждой новой позиции декодера обращаться к известному префиксу. Однако без сохранённых проекций цикл генерации может на каждом шаге заново вычислять неизменившиеся строки ключей и значений предыдущих позиций.

Attention Is All You Need описывает каузальное вычисление по полному префиксу, которое служит здесь эталоном. Васвани и соавторы задают внимание на основе масштабированного скалярного произведения, описывают авторегрессионный декодер, который выдаёт по одному элементу, и маскируют самовнимание декодера так, чтобы позиция могла использовать только известный префикс; статья не описывает ни KV-кэширование, ни RoPE.

Fast Transformer Decoding: One Write-Head is All You Need явно показывает, какие данные переиспользуются. Инкрементальное многоголовое самовнимание Шейзира принимает предыдущие тензоры ключей и значений, добавляет текущую спроецированную пару и возвращает увеличенные тензоры; прежде чем предложить многозапросное внимание (multi-query attention), автор указывает, что повторная загрузка этих тензоров становится узким местом по пропускной способности памяти.

Efficient Memory Management for Large Language Model Serving with PagedAttention продолжает этот путь в современных системах обслуживания. Квон и соавторы описывают последовательную генерацию в LLM, при которой прежние векторы ключей и значений кэшируются, а вычисляется только последняя пара, после чего организуют динамически растущие KV-кэши как логические блоки, отображаемые на несмежные участки физической памяти.

При инкрементальном декодировании повторное использование на каждом шаге стало явным: тензоры ключей и значений сохраняются отдельно для каждого слоя. Позднее системы обслуживания LLM стали рассматривать растущий KV-кэш как центральный объект управления памятью.

При современной генерации с кэшированием каждый блок декодера хранит собственный совместимый кэш: вектор запроса для новой позиции использует сохранённые ключи и значения префикса, а заново проецируются только ключ и значение этой позиции.

Подход развивался от каузального внимания с маской через явное повторное использование K/V при инкрементальном декодировании к системам обслуживания, построенным вокруг растущих кэшей. Для корректности реализации из этой главы также необходимы фиксированная ёмкость, заданное расположение осей и данных в тензорах, использование старой длины кэша как смещения RoPE, неизменность идентичностей узлов параметров и зафиксированных версий их значений, сброс без повторного выделения памяти и типизированные ошибки.

Измерить число ключей для запроса новой позиции и повторное использование проекций на трёх префиксах rust/demos/ch37-incremental-attention/src/lib.rs#historical-kv-contrast
/// Measures complete-prefix recomputation against one-row incremental projection.
pub fn historical_kv_contrast(
    steps: &[StepEvidence],
) -> Result<HistoricalKvContrast, FixtureError> {
    require(
        !steps.is_empty(),
        "history evidence needs at least one step",
    )?;
    let mut newest_query_key_rows = Vec::new();
    newest_query_key_rows
        .try_reserve_exact(steps.len())
        .map_err(|_| FixtureError::Invariant("cannot allocate history evidence"))?;
    for step in steps {
        let rows = step
            .heads
            .first()
            .ok_or(FixtureError::Invariant(
                "history step has no attention head",
            ))?
            .weights
            .len();
        require(
            step.heads.iter().all(|head| head.weights.len() == rows),
            "attention heads disagree about retained key rows",
        )?;
        require(
            rows == step.cache_after && step.cache_shape.get(2) == Some(&step.cache_after),
            "attention span disagrees with logical cache length",
        )?;
        newest_query_key_rows.push(rows);
    }
    let complete_prefix_rows_per_projection =
        steps.iter().map(|step| step.full_rows_per_projection).sum();
    let incremental_rows_per_projection = steps
        .iter()
        .map(|step| step.incremental_rows_per_projection)
        .sum();
    let reused_rows = steps.iter().map(|step| step.reused_key_value_rows).sum();
    require(
        complete_prefix_rows_per_projection == incremental_rows_per_projection + reused_rows,
        "projection-row contrast is inconsistent",
    )?;
    Ok(HistoricalKvContrast {
        newest_query_key_rows,
        complete_prefix_rows_per_projection,
        incremental_rows_per_projection,
        reused_key_rows: reused_rows,
        reused_value_rows: reused_rows,
    })
}

Привяжите кэш к слою и записывайте строку только после полного расчёта

LayerKvCache::new принимает сам слой внимания. Метод запоминает размер пакета, ширину модели, число голов, ёмкость кэша и ширину головы, владеет фиксированными буферами ключей и значений, а для каждого из четырёх параметров слоя запоминает как идентичность узла, так и текущую версию его значения. Кроме того, кэш сохраняет точную конфигурацию RoPE.

В заново созданном слое параметры находятся в других узлах, поэтому кэш возвращает CacheLayerMismatch. Успешный шаг AdamW записывает новые значения в прежние узлы: их идентичности сохраняются, но версии значений увеличиваются. Старый кэш после этого становится устаревшим и возвращает CacheLayerRevisionMismatch. Эта вторая проверка необходима, потому что сохранённые строки K/V были вычислены со старыми весами. Если добавить к ним строки, полученные с обновлёнными весами, кэш уже не будет соответствовать ни одной определённой версии модели. После любого обновления весов создайте новый кэш. Вызов reset для старого кэша не восстанавливает совместимость: сброс очищает логическую длину, но не обновляет зафиксированные версии и не привязывает кэш к другому слою.

Хранить фиксированный привязанный к слою кэш, добавлять одну проверенную строку и сбрасывать логическую длину rust/crates/llm-from-scratch/src/attention/incremental.rs#layer-kv-cache
/// A rejected cache configuration, append, or logical snapshot.
#[derive(Clone, Debug, PartialEq)]
pub enum LayerKvCacheError {
    ZeroBatchSize,
    ZeroCapacity,
    CapacityExceedsPositions {
        capacity: usize,
        max_positions: usize,
    },
    ElementCountOverflow {
        batch_size: usize,
        heads: usize,
        capacity: usize,
        head_width: usize,
    },
    AllocationFailed {
        elements: usize,
    },
    Full {
        capacity: usize,
    },
    KeyShapeMismatch {
        expected: Vec<usize>,
        actual: Vec<usize>,
    },
    ValueShapeMismatch {
        expected: Vec<usize>,
        actual: Vec<usize>,
    },
    NonFiniteKey {
        index: usize,
        value: f64,
    },
    NonFiniteValue {
        index: usize,
        value: f64,
    },
    Tensor(TensorError),
}

impl fmt::Display for LayerKvCacheError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::ZeroBatchSize => formatter.write_str("KV cache batch size must be nonzero"),
            Self::ZeroCapacity => formatter.write_str("KV cache capacity must be nonzero"),
            Self::CapacityExceedsPositions {
                capacity,
                max_positions,
            } => write!(
                formatter,
                "KV cache capacity {capacity} exceeds RoPE position capacity {max_positions}"
            ),
            Self::ElementCountOverflow {
                batch_size,
                heads,
                capacity,
                head_width,
            } => write!(
                formatter,
                "KV cache element count overflows for batch {batch_size}, {heads} heads, capacity {capacity}, and head width {head_width}"
            ),
            Self::AllocationFailed { elements } => write!(
                formatter,
                "cannot allocate KV cache storage for {elements} f64 values"
            ),
            Self::Full { capacity } => {
                write!(formatter, "KV cache is full at capacity {capacity}")
            }
            Self::KeyShapeMismatch { expected, actual } => write!(
                formatter,
                "appended key must have shape {expected:?}, got {actual:?}"
            ),
            Self::ValueShapeMismatch { expected, actual } => write!(
                formatter,
                "appended value must have shape {expected:?}, got {actual:?}"
            ),
            Self::NonFiniteKey { index, value } => write!(
                formatter,
                "appended key value at flat index {index} must be finite, got {value:?}"
            ),
            Self::NonFiniteValue { index, value } => write!(
                formatter,
                "appended value at flat index {index} must be finite, got {value:?}"
            ),
            Self::Tensor(source) => source.fmt(formatter),
        }
    }
}

impl Error for LayerKvCacheError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            Self::Tensor(source) => Some(source),
            _ => None,
        }
    }
}

impl From<TensorError> for LayerKvCacheError {
    fn from(source: TensorError) -> Self {
        Self::Tensor(source)
    }
}

/// Fixed-capacity rotated-key and value storage for one attention layer.
///
/// Physical storage has layout `[batch, heads, capacity, head_width]`. `len`
/// selects the logical prefix; reset keeps the allocation and moves that prefix
/// back to zero.
#[derive(Clone, Debug)]
pub struct LayerKvCache {
    batch_size: usize,
    model_width: usize,
    heads: usize,
    head_width: usize,
    capacity: usize,
    len: usize,
    keys: Vec<f64>,
    values: Vec<f64>,
    parameter_bindings: [TensorValueBinding; 4],
    rope_feature_width: usize,
    rope_max_positions: usize,
    rope_base_bits: u64,
}

impl PartialEq for LayerKvCache {
    fn eq(&self, other: &Self) -> bool {
        self.batch_size == other.batch_size
            && self.model_width == other.model_width
            && self.heads == other.heads
            && self.head_width == other.head_width
            && self.capacity == other.capacity
            && self.len == other.len
            && self.keys == other.keys
            && self.values == other.values
            && self.rope_feature_width == other.rope_feature_width
            && self.rope_max_positions == other.rope_max_positions
            && self.rope_base_bits == other.rope_base_bits
            && self
                .parameter_bindings
                .iter()
                .zip(&other.parameter_bindings)
                .all(|(left, right)| left.same_binding(right))
    }
}

impl LayerKvCache {
    pub fn new(
        layer: &MultiHeadAttention,
        batch_size: usize,
        capacity: usize,
    ) -> Result<Self, LayerKvCacheError> {
        if batch_size == 0 {
            return Err(LayerKvCacheError::ZeroBatchSize);
        }
        if capacity == 0 {
            return Err(LayerKvCacheError::ZeroCapacity);
        }
        if capacity > layer.rope().max_positions() {
            return Err(LayerKvCacheError::CapacityExceedsPositions {
                capacity,
                max_positions: layer.rope().max_positions(),
            });
        }
        let model_width = layer.model_width();
        let heads = layer.heads();
        let head_width = layer.head_width();
        let elements = batch_size
            .checked_mul(heads)
            .and_then(|count| count.checked_mul(capacity))
            .and_then(|count| count.checked_mul(head_width))
            .ok_or(LayerKvCacheError::ElementCountOverflow {
                batch_size,
                heads,
                capacity,
                head_width,
            })?;
        let keys = zeroed_storage(elements)?;
        let values = zeroed_storage(elements)?;
        let parameters = layer.parameters();
        debug_assert_eq!(parameters.len(), 4);
        Ok(Self {
            batch_size,
            model_width,
            heads,
            head_width,
            capacity,
            len: 0,
            keys,
            values,
            parameter_bindings: [
                TensorValueBinding::capture(parameters[0].tensor()),
                TensorValueBinding::capture(parameters[1].tensor()),
                TensorValueBinding::capture(parameters[2].tensor()),
                TensorValueBinding::capture(parameters[3].tensor()),
            ],
            rope_feature_width: layer.rope().feature_width(),
            rope_max_positions: layer.rope().max_positions(),
            rope_base_bits: layer.rope().base().to_bits(),
        })
    }

    fn validate_append(&self, key: &Tensor, value: &Tensor) -> Result<(), LayerKvCacheError> {
        let expected = [self.batch_size, self.heads, 1, self.head_width];
        if key.shape() != expected.as_slice() {
            return Err(LayerKvCacheError::KeyShapeMismatch {
                expected: expected.to_vec(),
                actual: key.shape().to_vec(),
            });
        }
        if value.shape() != expected.as_slice() {
            return Err(LayerKvCacheError::ValueShapeMismatch {
                expected: expected.to_vec(),
                actual: value.shape().to_vec(),
            });
        }
        if self.is_full() {
            return Err(LayerKvCacheError::Full {
                capacity: self.capacity,
            });
        }
        if let Some((index, value)) = first_nonfinite(key.as_slice()) {
            return Err(LayerKvCacheError::NonFiniteKey { index, value });
        }
        if let Some((index, value)) = first_nonfinite(value.as_slice()) {
            return Err(LayerKvCacheError::NonFiniteValue { index, value });
        }
        Ok(())
    }

    fn append_prevalidated(&mut self, key: &Tensor, value: &Tensor) {
        debug_assert!(self.validate_append(key, value).is_ok());
        for batch in 0..self.batch_size {
            for head in 0..self.heads {
                let source_start = (batch * self.heads + head) * self.head_width;
                let destination_start =
                    ((batch * self.heads + head) * self.capacity + self.len) * self.head_width;
                self.keys[destination_start..destination_start + self.head_width]
                    .copy_from_slice(&key.as_slice()[source_start..source_start + self.head_width]);
                self.values[destination_start..destination_start + self.head_width]
                    .copy_from_slice(
                        &value.as_slice()[source_start..source_start + self.head_width],
                    );
            }
        }
        self.len += 1;
    }

    /// Returns the logical rotated-key prefix as `[batch, heads, len, head_width]`.
    pub fn keys(&self) -> Result<Tensor, LayerKvCacheError> {
        self.logical_tensor(&self.keys)
    }

    /// Returns the logical value prefix as `[batch, heads, len, head_width]`.
    pub fn values(&self) -> Result<Tensor, LayerKvCacheError> {
        self.logical_tensor(&self.values)
    }

    fn logical_tensor(&self, storage: &[f64]) -> Result<Tensor, LayerKvCacheError> {
        let elements = self
            .batch_size
            .checked_mul(self.heads)
            .and_then(|count| count.checked_mul(self.len))
            .and_then(|count| count.checked_mul(self.head_width))
            .ok_or(LayerKvCacheError::ElementCountOverflow {
                batch_size: self.batch_size,
                heads: self.heads,
                capacity: self.len,
                head_width: self.head_width,
            })?;
        let mut logical = Vec::new();
        logical
            .try_reserve_exact(elements)
            .map_err(|_| LayerKvCacheError::AllocationFailed { elements })?;
        for batch in 0..self.batch_size {
            for head in 0..self.heads {
                let start = (batch * self.heads + head) * self.capacity * self.head_width;
                let end = start + self.len * self.head_width;
                logical.extend_from_slice(&storage[start..end]);
            }
        }
        Tensor::from_vec(
            vec![self.batch_size, self.heads, self.len, self.head_width],
            logical,
        )
        .map_err(Into::into)
    }

    /// Empties the logical prefix without reallocating either backing buffer.
    pub fn reset(&mut self) {
        self.len = 0;
    }

    pub const fn batch_size(&self) -> usize {
        self.batch_size
    }

    pub const fn model_width(&self) -> usize {
        self.model_width
    }

    pub const fn heads(&self) -> usize {
        self.heads
    }

    pub const fn head_width(&self) -> usize {
        self.head_width
    }

    pub const fn capacity(&self) -> usize {
        self.capacity
    }

    pub const fn len(&self) -> usize {
        self.len
    }

    pub const fn is_empty(&self) -> bool {
        self.len == 0
    }

    pub const fn is_full(&self) -> bool {
        self.len == self.capacity
    }

    /// Exposes allocated key storage for deterministic state audits.
    pub fn key_storage(&self) -> &[f64] {
        &self.keys
    }

    /// Exposes allocated value storage for deterministic state audits.
    pub fn value_storage(&self) -> &[f64] {
        &self.values
    }
}

fn zeroed_storage(elements: usize) -> Result<Vec<f64>, LayerKvCacheError> {
    let mut values = Vec::new();
    values
        .try_reserve_exact(elements)
        .map_err(|_| LayerKvCacheError::AllocationFailed { elements })?;
    values.resize(elements, 0.0);
    Ok(values)
}

fn first_nonfinite(values: &[f64]) -> Option<(usize, f64)> {
    values
        .iter()
        .copied()
        .enumerate()
        .find(|(_, value)| !value.is_finite())
}

forward_incremental — самостоятельный публичный вызов, который выполняет полный набор проверок. Вызывающий код передаёт слой внимания, одну входную строку и кэш напрямую. До вычисления внимания метод последовательно проверяет ранг и форму входа из одного токена, размер пакета и ширину входа, ширину модели и геометрию голов кэша, идентичности узлов параметров и версии их значений, точную конфигурацию RoPE и наличие свободного места. Эти проверки нужны, потому что самостоятельный вызов может получить вход и кэш, не относящиеся к данному слою.

После проверок функция prepare_incremental_bound, доступная только коду того же крейта, выполняет общую реализацию самого вычисления внимания. Внутри no_grad она не строит граф вычислений: проецирует новую строку, разделяет её на головы, поворачивает QQ и KK для позиции, равной старой длине кэша, вычисляет численно устойчивые веса формы [B,H,1,t+1][B,H,1,t+1], смешивает сохранённые и добавляемые значения, объединяет головы и применяет существующую выходную проекцию. Функция возвращает полный выход вместе с двумя подготовленными строками: повёрнутым ключом и значением без поворота. Только последующая запись переносит эти строки в следующую ячейку кэша и увеличивает логическую длину.

Внутренняя функция не является ни вторым алгоритмом внимания, ни публичным способом обойти проверки. Код, вызывающий эту внутреннюю функцию, обязан заранее подтвердить все перечисленные условия и гарантировать, что до записи или отбрасывания подготовленных строк используются те же слой и кэш. В главе 38 совместимость модели, параметров, RoPE и кэшей будет подтверждаться при создании сеанса для всей модели.

Рассчитать внимание по сохранённому префиксу и новой паре до записи в кэш rust/crates/llm-from-scratch/src/attention/incremental.rs#incremental-attention
/// A fallible incremental-attention buffer or tensor stage.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IncrementalAttentionStage {
    Scores,
    HeadOutputs,
    HeadOutputLeaf,
}

impl fmt::Display for IncrementalAttentionStage {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::Scores => "attention scores",
            Self::HeadOutputs => "weighted head outputs",
            Self::HeadOutputLeaf => "head-output tensor",
        })
    }
}

/// A rejected single-token input, cache pairing, or incremental forward stage.
#[derive(Clone, Debug, PartialEq)]
pub enum IncrementalAttentionError {
    InputRank {
        rank: usize,
    },
    SingleTokenRequired {
        tokens: usize,
    },
    InputBatchMismatch {
        cache: usize,
        input: usize,
    },
    InputWidthMismatch {
        expected: usize,
        actual: usize,
    },
    CacheModelWidthMismatch {
        layer: usize,
        cache: usize,
    },
    CacheHeadCountMismatch {
        layer: usize,
        cache: usize,
    },
    CacheHeadWidthMismatch {
        layer: usize,
        cache: usize,
    },
    CacheLayerMismatch,
    CacheLayerRevisionMismatch {
        parameter: usize,
        cache: u64,
        layer: u64,
    },
    CacheRopeMismatch {
        cache_feature_width: usize,
        layer_feature_width: usize,
        cache_max_positions: usize,
        layer_max_positions: usize,
        cache_base: f64,
        layer_base: f64,
    },
    BatchHeadOverflow {
        batch: usize,
        heads: usize,
    },
    BufferSizeOverflow {
        stage: IncrementalAttentionStage,
    },
    BufferAllocationFailed {
        stage: IncrementalAttentionStage,
        elements: usize,
    },
    Cache(LayerKvCacheError),
    QkvProjection(QkvError),
    HeadLayout {
        input: MultiHeadInput,
        source: HeadLayoutError,
    },
    Rotary {
        input: MultiHeadInput,
        source: RopeError,
    },
    Probability(ProbabilityError),
    Tensor {
        stage: IncrementalAttentionStage,
        source: TensorError,
    },
    Autodiff {
        stage: IncrementalAttentionStage,
        source: TensorAutodiffError,
    },
    MergeLayout(HeadLayoutError),
    OutputProjection(LinearError),
}

impl fmt::Display for IncrementalAttentionError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::InputRank { rank } => write!(
                formatter,
                "incremental attention input must have rank three [batch, 1, model_width], got rank {rank}"
            ),
            Self::SingleTokenRequired { tokens } => write!(
                formatter,
                "incremental attention needs exactly one new token, got {tokens}"
            ),
            Self::InputBatchMismatch { cache, input } => write!(
                formatter,
                "incremental attention input batch {input} must match cache batch {cache}"
            ),
            Self::InputWidthMismatch { expected, actual } => write!(
                formatter,
                "incremental attention input width must equal model width {expected}, got {actual}"
            ),
            Self::CacheModelWidthMismatch { layer, cache } => write!(
                formatter,
                "KV cache model width {cache} must match attention layer width {layer}"
            ),
            Self::CacheHeadCountMismatch { layer, cache } => write!(
                formatter,
                "KV cache head count {cache} must match attention layer head count {layer}"
            ),
            Self::CacheHeadWidthMismatch { layer, cache } => write!(
                formatter,
                "KV cache head width {cache} must match attention layer head width {layer}"
            ),
            Self::CacheLayerMismatch => formatter
                .write_str("KV cache parameter identity does not match this attention layer"),
            Self::CacheLayerRevisionMismatch {
                parameter,
                cache,
                layer,
            } => write!(
                formatter,
                "KV cache parameter revision {cache} at stable index {parameter} does not match layer revision {layer}"
            ),
            Self::CacheRopeMismatch {
                cache_feature_width,
                layer_feature_width,
                cache_max_positions,
                layer_max_positions,
                cache_base,
                layer_base,
            } => write!(
                formatter,
                "KV cache RoPE configuration ({cache_feature_width} features, {cache_max_positions} positions, base {cache_base:?}) does not match layer configuration ({layer_feature_width} features, {layer_max_positions} positions, base {layer_base:?})"
            ),
            Self::BatchHeadOverflow { batch, heads } => write!(
                formatter,
                "incremental attention lane count overflows for batch {batch} and {heads} heads"
            ),
            Self::BufferSizeOverflow { stage } => {
                write!(formatter, "incremental {stage} element count overflows")
            }
            Self::BufferAllocationFailed { stage, elements } => write!(
                formatter,
                "cannot allocate incremental {stage} buffer for {elements} f64 values"
            ),
            Self::Cache(source) => source.fmt(formatter),
            Self::QkvProjection(source) => {
                write!(formatter, "incremental Q/K/V projection: {source}")
            }
            Self::HeadLayout { input, source } => {
                write!(formatter, "incremental {input} head layout: {source}")
            }
            Self::Rotary { input, source } => {
                write!(formatter, "incremental {input} RoPE: {source}")
            }
            Self::Probability(source) => write!(formatter, "incremental softmax: {source}"),
            Self::Tensor { stage, source } => {
                write!(formatter, "incremental {stage}: {source}")
            }
            Self::Autodiff { stage, source } => {
                write!(formatter, "incremental {stage}: {source}")
            }
            Self::MergeLayout(source) => {
                write!(formatter, "incremental head output merge: {source}")
            }
            Self::OutputProjection(source) => {
                write!(formatter, "incremental output projection: {source}")
            }
        }
    }
}

impl Error for IncrementalAttentionError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            Self::Cache(source) => Some(source),
            Self::QkvProjection(source) => Some(source),
            Self::HeadLayout { source, .. } => Some(source),
            Self::Rotary { source, .. } => Some(source),
            Self::Probability(source) => Some(source),
            Self::Tensor { source, .. } => Some(source),
            Self::Autodiff { source, .. } => Some(source),
            Self::MergeLayout(source) => Some(source),
            Self::OutputProjection(source) => Some(source),
            _ => None,
        }
    }
}

impl From<LayerKvCacheError> for IncrementalAttentionError {
    fn from(source: LayerKvCacheError) -> Self {
        Self::Cache(source)
    }
}

/// Exact row counts for comparing full-prefix and cached projections.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct IncrementalAttentionWork {
    position: usize,
    full_prefix_rows_per_projection: usize,
    incremental_rows_per_projection: usize,
    reused_key_value_rows: usize,
}

impl IncrementalAttentionWork {
    pub const fn position(&self) -> usize {
        self.position
    }

    pub const fn full_prefix_rows_per_projection(&self) -> usize {
        self.full_prefix_rows_per_projection
    }

    pub const fn incremental_rows_per_projection(&self) -> usize {
        self.incremental_rows_per_projection
    }

    pub const fn reused_key_value_rows(&self) -> usize {
        self.reused_key_value_rows
    }
}

/// Inspectable graph-free evidence from one committed cache append.
#[derive(Clone, Debug)]
pub struct IncrementalAttentionForward {
    projected_query_heads: TensorValue,
    projected_key_heads: TensorValue,
    projected_value_heads: TensorValue,
    rotated_query_heads: TensorValue,
    rotated_key_heads: TensorValue,
    attention_weights: Tensor,
    head_outputs: TensorValue,
    merged: TensorValue,
    output: TensorValue,
    work: IncrementalAttentionWork,
    cache_len: usize,
}

impl IncrementalAttentionForward {
    pub fn projected_query_heads(&self) -> &TensorValue {
        &self.projected_query_heads
    }

    pub fn projected_key_heads(&self) -> &TensorValue {
        &self.projected_key_heads
    }

    pub fn projected_value_heads(&self) -> &TensorValue {
        &self.projected_value_heads
    }

    pub fn rotated_query_heads(&self) -> &TensorValue {
        &self.rotated_query_heads
    }

    pub fn rotated_key_heads(&self) -> &TensorValue {
        &self.rotated_key_heads
    }

    /// Returns `[batch, heads, 1, cache_len]` probabilities for the new query.
    pub fn attention_weights(&self) -> &Tensor {
        &self.attention_weights
    }

    pub fn head_outputs(&self) -> &TensorValue {
        &self.head_outputs
    }

    pub fn merged(&self) -> &TensorValue {
        &self.merged
    }

    pub fn output(&self) -> &TensorValue {
        &self.output
    }

    pub const fn work(&self) -> IncrementalAttentionWork {
        self.work
    }

    pub const fn cache_len(&self) -> usize {
        self.cache_len
    }

    pub fn into_output(self) -> TensorValue {
        self.output
    }
}

/// A crate-sealed incremental result whose candidate K/V row is not committed.
///
/// Chapter 38 prepares one ticket per decoder block, completes the later blocks
/// and tied vocabulary head, verifies every ticket still targets its original
/// cache, and only then commits the complete stack.
pub(crate) struct PreparedIncrementalAttention {
    forward: IncrementalAttentionForward,
    candidate_key: Tensor,
    candidate_value: Tensor,
    expected_len: usize,
    key_storage: *const f64,
    value_storage: *const f64,
}

impl PreparedIncrementalAttention {
    pub(crate) fn output(&self) -> &TensorValue {
        self.forward.output()
    }

    pub(crate) const fn cache_len(&self) -> usize {
        self.forward.cache_len()
    }

    pub(crate) fn attention_score_values(&self) -> usize {
        self.forward.attention_weights().len()
    }

    pub(crate) fn matches_cache(&self, cache: &LayerKvCache) -> bool {
        self.expected_len == cache.len()
            && std::ptr::eq(self.key_storage, cache.key_storage().as_ptr())
            && std::ptr::eq(self.value_storage, cache.value_storage().as_ptr())
    }

    pub(crate) fn commit(self, cache: &mut LayerKvCache) -> IncrementalAttentionForward {
        debug_assert!(self.matches_cache(cache));
        cache.append_prevalidated(&self.candidate_key, &self.candidate_value);
        self.forward
    }
}

impl MultiHeadAttention {
    /// Projects one new row, attends over retained K/V rows, and commits one append.
    ///
    /// The cache is changed only after projection, RoPE, stable softmax, value
    /// mixing, merge, and output projection all succeed.
    pub fn forward_incremental(
        &self,
        input: &TensorValue,
        cache: &mut LayerKvCache,
    ) -> Result<IncrementalAttentionForward, IncrementalAttentionError> {
        let prepared = self.prepare_incremental(input, cache)?;
        Ok(prepared.commit(cache))
    }

    /// Computes one incremental row without changing the layer cache.
    pub(crate) fn prepare_incremental(
        &self,
        input: &TensorValue,
        cache: &LayerKvCache,
    ) -> Result<PreparedIncrementalAttention, IncrementalAttentionError> {
        self.validate_incremental_request(input, cache)?;
        self.prepare_incremental_bound(input, cache)
    }

    fn validate_incremental_request(
        &self,
        input: &TensorValue,
        cache: &LayerKvCache,
    ) -> Result<(), IncrementalAttentionError> {
        let shape = input.shape();
        if shape.len() != 3 {
            return Err(IncrementalAttentionError::InputRank { rank: shape.len() });
        }
        if shape[1] != 1 {
            return Err(IncrementalAttentionError::SingleTokenRequired { tokens: shape[1] });
        }
        if shape[0] != cache.batch_size() {
            return Err(IncrementalAttentionError::InputBatchMismatch {
                cache: cache.batch_size(),
                input: shape[0],
            });
        }
        if shape[2] != self.model_width() {
            return Err(IncrementalAttentionError::InputWidthMismatch {
                expected: self.model_width(),
                actual: shape[2],
            });
        }
        self.validate_incremental_cache_binding(cache)?;
        if cache.is_full() {
            return Err(LayerKvCacheError::Full {
                capacity: cache.capacity(),
            }
            .into());
        }
        Ok(())
    }

    /// Checks the persistent relationship between one layer and one cache.
    ///
    /// A model-wide session calls this once while binding its complete cache.
    /// The standalone entry calls it for every arbitrary layer/cache pairing.
    pub(crate) fn validate_incremental_cache_binding(
        &self,
        cache: &LayerKvCache,
    ) -> Result<(), IncrementalAttentionError> {
        if cache.model_width() != self.model_width() {
            return Err(IncrementalAttentionError::CacheModelWidthMismatch {
                layer: self.model_width(),
                cache: cache.model_width(),
            });
        }
        if cache.heads() != self.heads() {
            return Err(IncrementalAttentionError::CacheHeadCountMismatch {
                layer: self.heads(),
                cache: cache.heads(),
            });
        }
        if cache.head_width() != self.head_width() {
            return Err(IncrementalAttentionError::CacheHeadWidthMismatch {
                layer: self.head_width(),
                cache: cache.head_width(),
            });
        }
        if !self
            .parameters()
            .iter()
            .zip(&cache.parameter_bindings)
            .all(|(parameter, cached)| cached.node_matches(parameter.tensor()))
        {
            return Err(IncrementalAttentionError::CacheLayerMismatch);
        }
        if let Some((parameter, (cached, layer))) = cache
            .parameter_bindings
            .iter()
            .zip(self.parameters())
            .enumerate()
            .find(|(_, (cached, parameter))| !cached.revision_matches(parameter.tensor()))
        {
            return Err(IncrementalAttentionError::CacheLayerRevisionMismatch {
                parameter,
                cache: cached.revision(),
                layer: layer.tensor().value_revision(),
            });
        }
        if cache.rope_feature_width != self.rope().feature_width()
            || cache.rope_max_positions != self.rope().max_positions()
            || cache.rope_base_bits != self.rope().base().to_bits()
        {
            return Err(IncrementalAttentionError::CacheRopeMismatch {
                cache_feature_width: cache.rope_feature_width,
                layer_feature_width: self.rope().feature_width(),
                cache_max_positions: cache.rope_max_positions,
                layer_max_positions: self.rope().max_positions(),
                cache_base: f64::from_bits(cache.rope_base_bits),
                layer_base: self.rope().base(),
            });
        }
        Ok(())
    }

    /// Prepares one row after its crate-private caller establishes every precondition.
    ///
    /// A model-wide bind establishes the persistent layer/cache relationship.
    /// The current session operation separately guarantees the one-row input
    /// shape and remaining capacity. The caller must preserve that exact
    /// layer/cache pairing until every prepared row either commits or is
    /// discarded. Keeping this entry crate-private lets Chapter 38 reuse the one
    /// attention implementation without creating an unchecked public path.
    pub(crate) fn prepare_incremental_bound(
        &self,
        input: &TensorValue,
        cache: &LayerKvCache,
    ) -> Result<PreparedIncrementalAttention, IncrementalAttentionError> {
        no_grad(|| {
            let position = cache.len();
            let projected = self
                .qkv()
                .forward(input)
                .map_err(IncrementalAttentionError::QkvProjection)?;
            let projected_query_heads =
                split_heads(projected.query(), self.heads()).map_err(|source| {
                    IncrementalAttentionError::HeadLayout {
                        input: MultiHeadInput::Query,
                        source,
                    }
                })?;
            let projected_key_heads =
                split_heads(projected.key(), self.heads()).map_err(|source| {
                    IncrementalAttentionError::HeadLayout {
                        input: MultiHeadInput::Key,
                        source,
                    }
                })?;
            let projected_value_heads =
                split_heads(projected.value(), self.heads()).map_err(|source| {
                    IncrementalAttentionError::HeadLayout {
                        input: MultiHeadInput::Value,
                        source,
                    }
                })?;
            let rotated_query_heads = self
                .rope()
                .rotate(&projected_query_heads, position)
                .map_err(|source| IncrementalAttentionError::Rotary {
                    input: MultiHeadInput::Query,
                    source,
                })?;
            let rotated_key_heads =
                self.rope()
                    .rotate(&projected_key_heads, position)
                    .map_err(|source| IncrementalAttentionError::Rotary {
                        input: MultiHeadInput::Key,
                        source,
                    })?;
            let candidate_key = rotated_key_heads.value_snapshot();
            let candidate_value = projected_value_heads.value_snapshot();
            let (attention_weights, head_output_tensor) = incremental_mixture(
                &rotated_query_heads.value(),
                &candidate_key,
                &candidate_value,
                cache,
            )?;
            let head_outputs = TensorValue::constant(head_output_tensor).map_err(|source| {
                IncrementalAttentionError::Autodiff {
                    stage: IncrementalAttentionStage::HeadOutputLeaf,
                    source,
                }
            })?;
            let merged =
                merge_heads(&head_outputs).map_err(IncrementalAttentionError::MergeLayout)?;
            let output = self
                .output_projection()
                .forward(&merged)
                .map_err(IncrementalAttentionError::OutputProjection)?;
            let cache_len = position + 1;
            let result = IncrementalAttentionForward {
                projected_query_heads,
                projected_key_heads,
                projected_value_heads,
                rotated_query_heads,
                rotated_key_heads,
                attention_weights,
                head_outputs,
                merged,
                output,
                work: IncrementalAttentionWork {
                    position,
                    full_prefix_rows_per_projection: cache_len,
                    incremental_rows_per_projection: 1,
                    reused_key_value_rows: position,
                },
                cache_len,
            };
            cache.validate_append(&candidate_key, &candidate_value)?;
            Ok(PreparedIncrementalAttention {
                forward: result,
                candidate_key,
                candidate_value,
                expected_len: position,
                key_storage: cache.key_storage().as_ptr(),
                value_storage: cache.value_storage().as_ptr(),
            })
        })
    }
}

fn incremental_mixture(
    query: &Tensor,
    candidate_key: &Tensor,
    candidate_value: &Tensor,
    cache: &LayerKvCache,
) -> Result<(Tensor, Tensor), IncrementalAttentionError> {
    let lanes = cache.batch_size().checked_mul(cache.heads()).ok_or(
        IncrementalAttentionError::BatchHeadOverflow {
            batch: cache.batch_size(),
            heads: cache.heads(),
        },
    )?;
    let prefix = cache.len() + 1;
    let score_elements =
        lanes
            .checked_mul(prefix)
            .ok_or(IncrementalAttentionError::BufferSizeOverflow {
                stage: IncrementalAttentionStage::Scores,
            })?;
    let mut scores = reserved_buffer(score_elements, IncrementalAttentionStage::Scores)?;
    let scale = 1.0 / (cache.head_width() as f64).sqrt();

    for batch in 0..cache.batch_size() {
        for head in 0..cache.heads() {
            let lane = batch * cache.heads() + head;
            let query_start = lane * cache.head_width();
            for position in 0..prefix {
                let key_start = if position == cache.len() {
                    lane * cache.head_width()
                } else {
                    (lane * cache.capacity() + position) * cache.head_width()
                };
                let key_values = if position == cache.len() {
                    candidate_key.as_slice()
                } else {
                    cache.key_storage()
                };
                let mut dot = 0.0;
                for feature in 0..cache.head_width() {
                    dot +=
                        query.as_slice()[query_start + feature] * key_values[key_start + feature];
                }
                scores[lane * prefix + position] = dot * scale;
            }
        }
    }

    let score_tensor = Tensor::from_vec(vec![cache.batch_size(), cache.heads(), 1, prefix], scores)
        .map_err(|source| IncrementalAttentionError::Tensor {
            stage: IncrementalAttentionStage::Scores,
            source,
        })?;
    let weights =
        softmax(&score_tensor.view(), 3).map_err(IncrementalAttentionError::Probability)?;
    let output_elements = lanes.checked_mul(cache.head_width()).ok_or(
        IncrementalAttentionError::BufferSizeOverflow {
            stage: IncrementalAttentionStage::HeadOutputs,
        },
    )?;
    let mut outputs = reserved_buffer(output_elements, IncrementalAttentionStage::HeadOutputs)?;
    for batch in 0..cache.batch_size() {
        for head in 0..cache.heads() {
            let lane = batch * cache.heads() + head;
            for feature in 0..cache.head_width() {
                let mut mixture = 0.0;
                for position in 0..prefix {
                    let value_start = if position == cache.len() {
                        lane * cache.head_width()
                    } else {
                        (lane * cache.capacity() + position) * cache.head_width()
                    };
                    let value_values = if position == cache.len() {
                        candidate_value.as_slice()
                    } else {
                        cache.value_storage()
                    };
                    mixture += weights.as_slice()[lane * prefix + position]
                        * value_values[value_start + feature];
                }
                outputs[lane * cache.head_width() + feature] = mixture;
            }
        }
    }
    let outputs = Tensor::from_vec(
        vec![cache.batch_size(), cache.heads(), 1, cache.head_width()],
        outputs,
    )
    .map_err(|source| IncrementalAttentionError::Tensor {
        stage: IncrementalAttentionStage::HeadOutputs,
        source,
    })?;
    Ok((weights, outputs))
}

fn reserved_buffer(
    elements: usize,
    stage: IncrementalAttentionStage,
) -> Result<Vec<f64>, IncrementalAttentionError> {
    let mut values = Vec::new();
    values
        .try_reserve_exact(elements)
        .map_err(|_| IncrementalAttentionError::BufferAllocationFailed { stage, elements })?;
    values.resize(elements, 0.0);
    Ok(values)
}

Для префиксов длины 11, 22 и 33 программа из этой главы сравнивает два подхода и показывает, что запросы для новых позиций сопоставляются соответственно с [1,2,3][1,2,3] строками ключей. Вызовы по полному префиксу обрабатывают 1+2+3=61+2+3=6 входных строк в каждой проекции QQ, KK или VV. Инкрементальный путь обрабатывает в каждой проекции 1+1+1=31+1+1=3 строки. Значит, он повторно использует 33 прежние строки ключей и 33 прежние строки значений. Однако запрос для новой позиции по-прежнему читает весь получившийся префикс из tt позиций: t1t-1 сохранённых позиций и текущую добавляемую позицию. Поэтому эти подсчёты не означают, что время расчёта внимания стало постоянным, и сами по себе не доказывают ускорение.

Для обоих вариантов вызова обновление кэша остаётся атомарным: при ошибке ни одна его часть не изменяется. Ошибки проекции, неконечных значений, выделения памяти, раскладки тензоров по головам и последующих операций с тензорами возникают до записи в кэш. Демонстрационная программа главы 37 проверяет вход из двух токенов, заполненный кэш, несовпадение ширины модели или числа голов, заново созданные веса той же формы, другое основание RoPE, то же основание с другой ёмкостью позиций и конечный вход, при проекции которого получается неконечное значение. В каждом из этих случаев вызов завершается до изменения сохранённых значений или логической длины. Отдельная проверка версии в начале forward_incremental отклоняет слой, параметры которого AdamW обновил на месте, ещё до проекции или добавления строки. Несовпадение версий остаётся ошибкой и после reset, потому что сброс не меняет привязку кэша. Функция, которая непосредственно записывает ключи и значения в кэш, недоступна внешнему коду. Поэтому внешний код не может записать строки, минуя самостоятельный публичный вызов и его проверки.

Отклонить недопустимые вызовы и проверить, что состояние кэша не изменилось rust/demos/ch37-incremental-attention/src/lib.rs#cache-errors
fn error_evidence(layer: &MultiHeadAttention) -> Result<ErrorEvidence, FixtureError> {
    let single = constant(&[1, 1, MODEL_WIDTH], &INPUT_VALUES[..MODEL_WIDTH])?;

    let mut two_token_cache = LayerKvCache::new(layer, 1, 2)?;
    let two_tokens_rejected = unchanged_after_error(
        layer,
        &constant(&[1, 2, MODEL_WIDTH], &INPUT_VALUES[..2 * MODEL_WIDTH])?,
        &mut two_token_cache,
    );

    let mut full_cache = LayerKvCache::new(layer, 1, 1)?;
    layer.forward_incremental(&single, &mut full_cache)?;
    let full_cache_rejected = unchanged_after_error(layer, &single, &mut full_cache);

    let mut rng = SplitMix64::from_seed(37);
    let wider = MultiHeadAttention::new("wider", 8, 2, MAX_POSITIONS, ROPE_BASE, &mut rng)?;
    let mut model_cache = LayerKvCache::new(&wider, 1, CAPACITY)?;
    let model_mismatch_rejected = unchanged_after_error(layer, &single, &mut model_cache);

    let one_head = MultiHeadAttention::new(
        "one_head",
        MODEL_WIDTH,
        1,
        MAX_POSITIONS,
        ROPE_BASE,
        &mut rng,
    )?;
    let mut head_cache = LayerKvCache::new(&one_head, 1, CAPACITY)?;
    let head_mismatch_rejected = unchanged_after_error(layer, &single, &mut head_cache);

    let other_layer = fixture_layer()?;
    let mut other_cache = LayerKvCache::new(&other_layer, 1, CAPACITY)?;
    let layer_mismatch_rejected = unchanged_after_error(layer, &single, &mut other_cache);

    let different_rope = MultiHeadAttention::from_parameters(
        layer.parameters()[0].clone(),
        layer.parameters()[1].clone(),
        layer.parameters()[2].clone(),
        layer.parameters()[3].clone(),
        HEADS,
        MAX_POSITIONS,
        ROPE_BASE * 2.0,
    )?;
    let mut rope_cache = LayerKvCache::new(layer, 1, CAPACITY)?;
    let rope_mismatch_rejected = unchanged_after_error(&different_rope, &single, &mut rope_cache);

    let different_positions = MultiHeadAttention::from_parameters(
        layer.parameters()[0].clone(),
        layer.parameters()[1].clone(),
        layer.parameters()[2].clone(),
        layer.parameters()[3].clone(),
        HEADS,
        MAX_POSITIONS + 1,
        ROPE_BASE,
    )?;
    let mut position_cache = LayerKvCache::new(layer, 1, CAPACITY)?;
    let rope_positions_mismatch_rejected =
        unchanged_after_error(&different_positions, &single, &mut position_cache);

    let mut nonfinite_cache = LayerKvCache::new(layer, 1, CAPACITY)?;
    let nonfinite_projection_rejected = unchanged_after_error(
        layer,
        &constant(&[1, 1, MODEL_WIDTH], &[f64::MAX; MODEL_WIDTH])?,
        &mut nonfinite_cache,
    );

    let every_cache_unchanged = two_tokens_rejected
        && full_cache_rejected
        && model_mismatch_rejected
        && head_mismatch_rejected
        && layer_mismatch_rejected
        && rope_mismatch_rejected
        && rope_positions_mismatch_rejected
        && nonfinite_projection_rejected;
    Ok(ErrorEvidence {
        two_tokens_rejected,
        full_cache_rejected,
        model_mismatch_rejected,
        head_mismatch_rejected,
        layer_mismatch_rejected,
        rope_mismatch_rejected,
        rope_positions_mismatch_rejected,
        nonfinite_projection_rejected,
        every_cache_unchanged,
    })
}

Пример выполняет три вызова с кэшем и три независимых эталонных расчёта. Он сравнивает не только выходы, но и полные логические префиксы ключей и значений, сбрасывает длину в 00 без замены хранилища, затем снова обрабатывает те же три входные строки и получает те же выходы и префиксы кэша. Это подтверждает повторное использование выделенной памяти. Однако reset не делает старый кэш совместимым с моделью после обучения.

Собрать результаты сравнений с кэшем, число строк, повторную проверку после сброса и результаты проверок ошибок rust/demos/ch37-incremental-attention/src/lib.rs#cache-step
/// Runs three single-row appends, full-prefix references, reset, replay, and errors.
pub fn learner_evidence() -> Result<LearnerEvidence, FixtureError> {
    let layer = fixture_layer()?;
    let mut cache = LayerKvCache::new(&layer, 1, CAPACITY)?;
    let steps = collect_steps(&layer, &mut cache)?;
    let history = historical_kv_contrast(&steps)?;
    let work = WorkEvidence {
        full_rows_per_projection: steps.iter().map(|step| step.full_rows_per_projection).sum(),
        incremental_rows_per_projection: steps
            .iter()
            .map(|step| step.incremental_rows_per_projection)
            .sum(),
        reused_rows_per_key_value_projection: steps
            .iter()
            .map(|step| step.reused_key_value_rows)
            .sum(),
        avoided_rows_across_key_and_value: 2 * steps
            .iter()
            .map(|step| step.reused_key_value_rows)
            .sum::<usize>(),
    };

    let before_reset = cache.len();
    let key_pointer = cache.key_storage().as_ptr();
    let value_pointer = cache.value_storage().as_ptr();
    let key_storage = cache.key_storage().to_vec();
    let value_storage = cache.value_storage().to_vec();
    cache.reset();
    let reset_after = cache.len();
    let allocation_reused = cache.key_storage().as_ptr() == key_pointer
        && cache.value_storage().as_ptr() == value_pointer;
    let storage_unchanged =
        cache.key_storage() == key_storage && cache.value_storage() == value_storage;
    let replay = collect_steps(&layer, &mut cache)?;
    let replay_identical = replay == steps;
    let reset = ResetEvidence {
        before: before_reset,
        after: reset_after,
        allocation_reused,
        storage_unchanged,
        replay_identical,
    };
    require(reset.after == 0, "cache reset did not return to zero")?;
    require(
        reset.allocation_reused && reset.storage_unchanged && reset.replay_identical,
        "cache reset or replay evidence failed",
    )?;

    let errors = error_evidence(&layer)?;
    require(
        errors.every_cache_unchanged,
        "one rejected operation changed cache state",
    )?;
    Ok(LearnerEvidence {
        steps,
        work,
        reset,
        errors,
        history,
    })
}

Выполните cargo run --quiet --locked -p ch37-incremental-attention. Основные результаты проверки печатает сама программа:

step=position:0 cache:0->1 shape:[1,2,1,2] max_abs_diff:0.000000000000 output:[1.000000000,0.000000000,1.000000000,0.000000000]
step=position:1 cache:1->2 shape:[1,2,2,2] max_abs_diff:0.000000000000 output:[0.213809009,0.786190991,0.770151153,-0.420735492]
step=position:2 cache:2->3 shape:[1,2,3,2] max_abs_diff:0.000000000000 output:[0.629044078,0.945303958,0.374718490,-0.583589471]
work=full_rows_per_projection:6 incremental_rows_per_projection:3 reused_rows_per_kv_projection:3 avoided_rows_across_kv:6
reset=before:3 after:0 allocation_reused:true storage_unchanged:true replay_identical:true
errors=two_tokens:true full_cache:true model_mismatch:true head_mismatch:true layer_mismatch:true rope_mismatch:true rope_positions_mismatch:true nonfinite_projection:true unchanged:true
Напечатать точный отчёт главы 37 об инкрементальном внимании rust/demos/ch37-incremental-attention/src/main.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
    print!("{}", ch37_incremental_attention::learner_report()?);
    Ok(())
}

Проследите путь сохранённых строк к запросу новой позиции

Схема показывает точные значения, полученные при трёх вызовах программы на Rust. Читайте её от абсолютной позиции 00 до позиции 22. Прямоугольники со сплошной рамкой обозначают сохранённые строки, а прямоугольник с двойной рамкой — новую пару ключа и значения. Для каждой пары указан вес внимания: он вычисляется по соответствующему ключу и используется для взвешивания значения. Каждый шаг также показывает выход с кэшем, эталонный выход по полному префиксу и максимальную абсолютную разность между ними.

Карточки с оценкой объёма вычислений сопоставляют число строк проекций, а не прошедшее время. Последние карточки показывают, что при сбросе используется та же выделенная память, а отклонённые вызовы сохраняют состояние. Сплошная рамка обозначает сохранённую строку, двойная — добавленную пару.

Сохраняйте предыдущие строки ключей и значений и добавляйте ровно одну новую пару

Точная трассировка программы на Rust охватывает три абсолютные позиции, показывает кэши обеих голов и веса внимания, сопоставляет выход новой позиции на каждом шаге с эталонным расчётом по полному префиксу и подтверждает сохранность хранилища после сброса и отклонённых вызовов.

  • сохранённая строка — сплошная рамка
  • добавленная строка — двойная рамка
  • выходы для новой позиции совпадают в пределах допуска

Добавляйте в кэш слоя по одной позиции

Старая логическая длина равна позиции RoPE при нумерации с нуля. Каждая голова сохраняет прежние строки, добавляет одну пару ключа и значения, а запрос для новой позиции использует весь получившийся префикс.

Абсолютная позиция p=0p=0

Длина кэша 010\to1

Логическая форма кэша: [1,2,1,2][1,2,1,2]

Голова внимания h=0h=0
  1. добавленная строка — двойная рамка Повёрнутые ключи: K0,0=1.000000000K_{0,0}=1.000000000K0,1=0.000000000K_{0,1}=0.000000000 Значения без поворота: V0,0=1.000000000V_{0,0}=1.000000000V0,1=0.000000000V_{0,1}=0.000000000 Веса запроса для новой позиции: a0=1.000000000000a_{0}=1.000000000000
Голова внимания h=1h=1
  1. добавленная строка — двойная рамка Повёрнутые ключи: K0,0=1.000000000K_{0,0}=1.000000000K0,1=0.000000000K_{0,1}=0.000000000 Значения без поворота: V0,0=1.000000000V_{0,0}=1.000000000V0,1=0.000000000V_{0,1}=0.000000000 Веса запроса для новой позиции: a0=1.000000000000a_{0}=1.000000000000

Выход с кэшем: y0=1.000000000y_{0}=1.000000000y1=0.000000000y_{1}=0.000000000y2=1.000000000y_{2}=1.000000000y3=0.000000000y_{3}=0.000000000

Эталонный расчёт по полному префиксу: y0=1.000000000y_{0}=1.000000000y1=0.000000000y_{1}=0.000000000y2=1.000000000y_{2}=1.000000000y3=0.000000000y_{3}=0.000000000

выходы для новой позиции совпадают в пределах допуска Максимальная абсолютная разность: Δmax=0.000000000000\Delta_{\mathrm{max}}=0.000000000000

Абсолютная позиция p=1p=1

Длина кэша 121\to2

Логическая форма кэша: [1,2,2,2][1,2,2,2]

Голова внимания h=0h=0
  1. сохранённая строка — сплошная рамка Повёрнутые ключи: K0,0=1.000000000K_{0,0}=1.000000000K0,1=0.000000000K_{0,1}=0.000000000 Значения без поворота: V0,0=1.000000000V_{0,0}=1.000000000V0,1=0.000000000V_{0,1}=0.000000000 Веса запроса для новой позиции: a0=0.500000000000a_{0}=0.500000000000
  2. добавленная строка — двойная рамка Повёрнутые ключи: K1,0=1.000000000K_{1,0}=1.000000000K1,1=0.000000000K_{1,1}=0.000000000 Значения без поворота: V1,0=0.540302306V_{1,0}=0.540302306V1,1=0.841470985V_{1,1}=-0.841470985 Веса запроса для новой позиции: a1=0.500000000000a_{1}=0.500000000000
Голова внимания h=1h=1
  1. сохранённая строка — сплошная рамка Повёрнутые ключи: K0,0=1.000000000K_{0,0}=1.000000000K0,1=0.000000000K_{0,1}=0.000000000 Значения без поворота: V0,0=1.000000000V_{0,0}=1.000000000V0,1=0.000000000V_{0,1}=0.000000000 Веса запроса для новой позиции: a0=0.213809008676a_{0}=0.213809008676
  2. добавленная строка — двойная рамка Повёрнутые ключи: K1,0=0.841470985K_{1,0}=-0.841470985K1,1=0.540302306K_{1,1}=0.540302306 Значения без поворота: V1,0=0.000000000V_{1,0}=0.000000000V1,1=1.000000000V_{1,1}=1.000000000 Веса запроса для новой позиции: a1=0.786190991324a_{1}=0.786190991324

Выход с кэшем: y0=0.213809009y_{0}=0.213809009y1=0.786190991y_{1}=0.786190991y2=0.770151153y_{2}=0.770151153y3=0.420735492y_{3}=-0.420735492

Эталонный расчёт по полному префиксу: y0=0.213809009y_{0}=0.213809009y1=0.786190991y_{1}=0.786190991y2=0.770151153y_{2}=0.770151153y3=0.420735492y_{3}=-0.420735492

выходы для новой позиции совпадают в пределах допуска Максимальная абсолютная разность: Δmax=0.000000000000\Delta_{\mathrm{max}}=0.000000000000

Абсолютная позиция p=2p=2

Длина кэша 232\to3

Логическая форма кэша: [1,2,3,2][1,2,3,2]

Голова внимания h=0h=0
  1. сохранённая строка — сплошная рамка Повёрнутые ключи: K0,0=1.000000000K_{0,0}=1.000000000K0,1=0.000000000K_{0,1}=0.000000000 Значения без поворота: V0,0=1.000000000V_{0,0}=1.000000000V0,1=0.000000000V_{0,1}=0.000000000 Веса запроса для новой позиции: a0=0.333333333333a_{0}=0.333333333333
  2. сохранённая строка — сплошная рамка Повёрнутые ключи: K1,0=1.000000000K_{1,0}=1.000000000K1,1=0.000000000K_{1,1}=0.000000000 Значения без поворота: V1,0=0.540302306V_{1,0}=0.540302306V1,1=0.841470985V_{1,1}=-0.841470985 Веса запроса для новой позиции: a1=0.333333333333a_{1}=0.333333333333
  3. добавленная строка — двойная рамка Повёрнутые ключи: K2,0=1.000000000K_{2,0}=1.000000000K2,1=0.000000000K_{2,1}=0.000000000 Значения без поворота: V2,0=0.416146837V_{2,0}=-0.416146837V2,1=0.909297427V_{2,1}=-0.909297427 Веса запроса для новой позиции: a2=0.333333333333a_{2}=0.333333333333
Голова внимания h=1h=1
  1. сохранённая строка — сплошная рамка Повёрнутые ключи: K0,0=1.000000000K_{0,0}=1.000000000K0,1=0.000000000K_{0,1}=0.000000000 Значения без поворота: V0,0=1.000000000V_{0,0}=1.000000000V0,1=0.000000000V_{0,1}=0.000000000 Веса запроса для новой позиции: a0=0.054696042457a_{0}=0.054696042457
  2. сохранённая строка — сплошная рамка Повёрнутые ключи: K1,0=0.841470985K_{1,0}=-0.841470985K1,1=0.540302306K_{1,1}=0.540302306 Значения без поворота: V1,0=0.000000000V_{1,0}=0.000000000V1,1=1.000000000V_{1,1}=1.000000000 Веса запроса для новой позиции: a1=0.370955922197a_{1}=0.370955922197
  3. добавленная строка — двойная рамка Повёрнутые ключи: K2,0=1.325444263K_{2,0}=-1.325444263K2,1=0.493150590K_{2,1}=0.493150590 Значения без поворота: V2,0=1.000000000V_{2,0}=1.000000000V2,1=1.000000000V_{2,1}=1.000000000 Веса запроса для новой позиции: a2=0.574348035346a_{2}=0.574348035346

Выход с кэшем: y0=0.629044078y_{0}=0.629044078y1=0.945303958y_{1}=0.945303958y2=0.374718490y_{2}=0.374718490y3=0.583589471y_{3}=-0.583589471

Эталонный расчёт по полному префиксу: y0=0.629044078y_{0}=0.629044078y1=0.945303958y_{1}=0.945303958y2=0.374718490y_{2}=0.374718490y3=0.583589471y_{3}=-0.583589471

выходы для новой позиции совпадают в пределах допуска Максимальная абсолютная разность: Δmax=0.000000000000\Delta_{\mathrm{max}}=0.000000000000

Число строк проекций, а не время работы

Для префиксов длины от одного до трёх эталонный путь проецирует по шесть строк в каждой ветви. Путь с кэшем проецирует три новые строки и повторно использует три прежние строки ключей и три строки значений.

Эталонный расчёт по полному префиксу
1+2+3=61+2+3=6

строк полного префикса в каждой проекции запроса, ключа или значения

Выход с кэшем
1+1+1=31+1+1=3

новых строк в каждой инкрементальной проекции запроса, ключа или значения

сохранённая строка — сплошная рамка
2×3=62\times3=6

прежних строк без повторной проекции в ветвях ключей и значений

Сброс или отказ без повреждения состояния

При логическом сбросе выделенная память остаётся прежней. Вызовы при заполненном кэше, несовместимости или недопустимом входе не изменяют ни длину, ни хранимые значения.

сброс сохраняет выделенную память и хранимые значения

3o03 o0

allocation_reused=true storage_unchanged=true
повторная обработка тех же строк даёт тот же результат
replay_identical=true

выходы для новой позиции совпадают в пределах допуска

отклонённый вызов ничего не добавляет
two_tokens=true full_cache=true model_mismatch=true head_mismatch=true layer_mismatch=true rope_mismatch=true rope_positions_mismatch=true nonfinite_projection=true unchanged=true

Сначала предскажите, затем сверьтесь с трассировкой

  1. Какое смещение RoPE используется при логической длине кэша 22?
  2. Сохраняет ли кэш слоя запрос для новой позиции?
  3. Сколько ключей может прочитать запрос для новой позиции в каждой голове при третьем вызове?
  4. Сколько строк обрабатывает одна проекция ключей по полному префиксу для длин 11, 22 и 33 вместе?
  5. Сколько строк обрабатывает инкрементальная проекция ключей?
  6. Что изменяет сброс и обновляет ли он привязку к параметрам?
  7. Можно ли повторно использовать кэш той же формы с заново созданными весами?
  8. Можно ли повторно использовать старый кэш после того, как AdamW обновил значения в тех же узлах параметров?
  9. Что происходит с состоянием кэша, если его ёмкость уже исчерпана?
Проверьте девять ответов
  1. Смещение равно 22 — в точности старой логической длине.
  2. Нет. Сохраняются только повёрнутые строки KK и строки VV без поворота.
  3. Каждый запрос читает 33 ключа и смешивает 33 значения.
  4. Эталон обрабатывает 1+2+3=61+2+3=6 строк проекции ключей.
  5. Инкрементальный путь обрабатывает 1+1+1=31+1+1=3 строки проекции ключей.
  6. Сброс переводит логическую длину в 00, сохраняя выделенную память и хранимые значения. Он не обновляет зафиксированные идентичности узлов и версии значений параметров и не меняет привязку кэша.
  7. Нет. В заново созданном слое не совпадут идентичности узлов параметров, даже если формы и числовые значения весов те же.
  8. Нет. Обновление AdamW на месте сохраняет узлы параметров, но увеличивает версии их значений. Старый кэш вернёт CacheLayerRevisionMismatch; для обновлённого слоя нужно создать новый кэш.
  9. Вызов возвращает типизированную ошибку до изменения хранимых значений или логической длины.

Следующий шаг — отдельный кэш для каждого блока декодера

Теперь один слой внимания может хранить между шагами декодирования повёрнутые ключи и значения без поворота вне графа вычислений и получать для новой позиции тот же результат внимания, что и при расчёте по полному префиксу. Одна и та же реализация вычисления внимания используется двумя способами: самостоятельный публичный вызов сам подтверждает совместимость входа, слоя и кэша, а внутренний путь требует, чтобы вызывающий код уже подтвердил те же условия. Сброс очищает логическое состояние без нового выделения памяти и без изменения привязки.

Пока декодер курса по-прежнему вычисляет весь префикс. В главе 38 отдельный кэш каждого блока будет привязан к сеансу всей модели и использован сначала при обработке промпта (prefill), а затем при декодировании по одному токену. Полная генерация с кэшем будет сопоставлена с эталоном без кэша из главы 36.