← Все главы

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

От координат тензора к одному плоскому буферу

Разместите матрицы языковой модели и тензоры внимания в одном плоском векторе на Rust, вычисляя построчные шаги с проверкой переполнения и однозначно сопоставляя координатам смещения.

Сначала предскажите смещение, затем перейдите к плоскому буферу

Тензор позволяет интерпретировать набор скалярных значений как nn-мерный объект. Начнём с формы:

[2, 2, 3]

У неё три оси, поэтому ранг тензора равен 3. Размеры осей означают, что вдоль оси 0 расположены два среза, в каждом срезе вдоль оси 1 есть две строки, а в каждой строке вдоль оси 2 — три позиции. Логически значения расположены так:

срез 0: [[10, 11, 12], [20, 21, 22]]
срез 1: [[30, 31, 32], [40, 41, 42]]

Эта реализация тензора не хранит вложенные объекты строк. Все значения находятся в одном плоском Vec<f64>:

[10, 11, 12, 20, 21, 22, 30, 31, 32, 40, 41, 42]

Прежде чем читать дальше, сделайте два предположения:

  1. В какую позицию плоского буфера, отсчитываемую от нуля, попадёт координата [1,0,2] и какое значение там хранится?
  2. Может ли координата [1,2,0] выбрать значение? Если нет, какая ось первой не пройдёт проверку?

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

[6, 3, 1]

Теперь разложим вычисление для первой координаты:

i0s0=16=6,i1s1=03=0,i2s2=21=2,offset=6+0+2=8,data[8]=32.\begin{aligned} i_0s_0 &= 1\cdot 6 = 6, \\ i_1s_1 &= 0\cdot 3 = 0, \\ i_2s_2 &= 2\cdot 1 = 2, \\ \operatorname{offset} &= 6+0+2 = 8, \\ \operatorname{data}[8] &= 32. \end{aligned}

Получились две величины разного рода. Смещение 8 — целочисленная позиция в плоском буфере. Значение 32 — число f64, которое хранится в этой позиции. Если назвать обе величины «индексом», потеряется операция, связывающая координаты с хранилищем.

Второе предположение отклоняется до любого обращения к буферу. Размер оси 1 равен 2, поэтому допустимы только индексы 0 и 1. Координата [1,2,0] содержит на этой оси индекс 2. Ошибка сообщает ось 1, индекс 2 и размер 2, не позволяя арифметике незаметно перейти в другую логическую строку.

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

Для тензора ранга dd координата преобразуется в смещение по правилу:

offset(i0,,id1)=k=0d1iksk\operatorname{offset}(i_0,\ldots,i_{d-1})=\sum_{k=0}^{d-1} i_k s_k

Координата содержит один индекс iki_k, отсчитываемый от нуля, для каждой оси kk. Шаг sks_k показывает, на сколько позиций изменится смещение в плоском буфере, если увеличить этот индекс на единицу, оставив остальные индексы неизменными. Построчные шаги выводятся справа налево. Последний шаг равен единице, а каждый предыдущий — произведению размеров всех осей справа от него с проверкой переполнения.

Для формы [2,2,3] справа от оси 00 находится 23=62\cdot3=6 элементов, справа от оси 1133, а после оси 22 ничего нет. Поэтому получаются шаги [6,3,1]. Они измеряются в элементах f64, а не в байтах.

Формула применима только к допустимой координате. Реализация сначала требует ровно dd индексов, а затем обходит оси слева направо и перед добавлением очередного слагаемого проверяет 0ik<shape[k]0 \le i_k < \operatorname{shape}[k]. Для [1,0,2] слагаемые равны 6, 0 и 2, поэтому смещение равно 8.

Две крайние формы уточняют соглашение о произведении. Форма [] имеет нулевой ранг и пустой список шагов, но представляет одно скалярное значение: пустая координата соответствует смещению 0. У формы [2,0,3] шаги [0,3,1] и нет значений. Нулевой размер оси делает тензор пустым, однако конструктор всё равно выводит и проверяет всю цепочку произведений для шагов.

Свяжите каждый символ с устройством тензора

СимволОперационный смысл
offset\operatorname{offset}Позиция в плоском Vec<f64>, отсчитываемая от нуля и выбранная одной допустимой координатой; это не хранящееся значение f64.
ddРанг тензора. Он равен shape.len() и требуемой длине координаты.
iki_kИндекс координаты на оси kk. Он должен быть меньше shape[k].
kkНомер оси, отсчитываемый от нуля: от 00 до d1d-1. Этот номер указывается в ошибке выхода за границы.
sks_kПострочный шаг по оси kk, измеряемый в элементах: произведение размеров всех осей справа от неё с проверкой переполнения.

Элементы формы и шаги отвечают на разные вопросы. shape[k] показывает, сколько позиций существует на оси kk, а sks_k — на сколько элементов плоского буфера передвигает один шаг по этой оси. В нашем примере размер оси 0 равен 2, а шаг — 6; размер оси 1 тоже равен 2, но её шаг равен 3.

При d=0d=0 в формуле нет слагаемых, поэтому сумма равна нулю. Так пустая координата скаляра получает смещение 0, а отдельный инвариант конструктора задаёт для скаляра одно хранящееся значение. Если размер какой-либо оси равен нулю, допустимого iki_k для неё не существует. Поэтому у пустого тензора нет полной допустимой координаты, хотя его шаги определены однозначно.

От биграммных счётчиков к обучаемым матрицам и тензорам внимания

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

Две ключевые работы — Bengio et al., A Neural Probabilistic Language Model и Vaswani et al., Attention Is All You Need.

Бенжио и соавторы описывают n-граммные модели как таблицы условных вероятностей с коротким контекстом, которые не используют сходство слов, а затем вводят нейронную языковую модель с матрицей CC размера «словарь × ширина признакового представления», содержащей обучаемые признаки слов, и матрицами параметров для предсказания следующего слова. Позднее Васвани и соавторы объединяют одновременно обрабатываемые запросы, ключи и значения в матрицы QQ, KK и VV и с помощью обучаемых проекций параллельно вычисляют несколько голов внимания, после чего конкатенируют их выходы.

Модель Бенжио всё ещё предсказывает по фиксированному окну предшествующих слов. В небольшом примере этой главы матрица CC имеет форму [V,m]=[5,3][|V|,m]=[5,3] и хранит по три обучаемых признака для каждого из пяти элементов словаря. Для контекста из двух слов скрытое преобразование HH имеет форму [h,2m]=[4,6][h,2m]=[4,6], а матрица UU формы [V,h]=[5,4][|V|,h]=[5,4] преобразует четыре скрытые активации в пять оценок следующего слова. Эти небольшие формы лишь показывают роль матриц и не воспроизводят размеры экспериментов или полный набор параметров из статьи.

В примере Transformer каждый из тензоров QQ, KK и VV для одной головы имеет форму [токены,ширина головы]=[2,3][\text{токены},\text{ширина головы}]=[2,3]. Локальный стек тензоров QQ добавляет отдельную ось голов и имеет форму [головы,токены,ширина головы]=[2,2,3][\text{головы},\text{токены},\text{ширина головы}]=[2,2,3]. Статья обосновывает матричные вычисления и параллельные головы, но не требует именно такого порядка осей.

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

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

Объедините проверку формы и координат в одной реализации

Tensor::from_vec принимает форму во владение как Vec<usize>, а буфер данных — как Vec<f64>. Он выводит построчные шаги справа налево, проверяя каждое умножение, а затем требует, чтобы длина буфера точно совпадала с вычисленным числом элементов. Для пустой формы расчёт начинается с единицы — нейтрального элемента умножения, — поэтому скаляру требуется ровно одно значение. Любой нулевой размер оси даёт нулевое число элементов.

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

Выведите построчные шаги с проверкой и потребуйте точную длину плоского буфера rust/crates/llm-from-scratch/src/tensor/storage.rs#tensor-storage-invariants
pub(crate) fn checked_row_major_layout(
    shape: &[usize],
) -> Result<(Vec<usize>, usize), TensorError> {
    if shape.is_empty() {
        return Ok((Vec::new(), 1));
    }

    let mut strides = vec![1; shape.len()];
    for axis in (0..shape.len() - 1).rev() {
        strides[axis] = shape[axis + 1]
            .checked_mul(strides[axis + 1])
            .ok_or(TensorError::ShapeOverflow)?;
    }

    let element_count = shape[0]
        .checked_mul(strides[0])
        .ok_or(TensorError::ShapeOverflow)?;
    Ok((strides, element_count))
}

pub(crate) fn checked_offset(
    shape: &[usize],
    strides: &[usize],
    base_offset: usize,
    coordinate: &[usize],
) -> Result<usize, TensorError> {
    debug_assert_eq!(shape.len(), strides.len());

    if coordinate.len() != shape.len() {
        return Err(TensorError::RankMismatch {
            expected: shape.len(),
            actual: coordinate.len(),
        });
    }

    let mut offset = base_offset;
    for (axis, ((&index, &dimension), &stride)) in
        coordinate.iter().zip(shape).zip(strides).enumerate()
    {
        if index >= dimension {
            return Err(TensorError::IndexOutOfBounds {
                axis,
                index,
                dimension,
            });
        }
        let contribution = index
            .checked_mul(stride)
            .ok_or(TensorError::ShapeOverflow)?;
        offset = offset
            .checked_add(contribution)
            .ok_or(TensorError::ShapeOverflow)?;
    }
    Ok(offset)
}

impl Tensor {
    /// Builds a tensor after checking its row-major layout and buffer length.
    pub fn from_vec(shape: Vec<usize>, data: Vec<f64>) -> Result<Self, TensorError> {
        let (strides, expected) = checked_row_major_layout(&shape)?;
        let actual = data.len();
        if actual != expected {
            return Err(TensorError::DataLengthMismatch { expected, actual });
        }

        Ok(Self {
            data,
            shape,
            strides,
        })
    }

    /// Returns the number of logical axes.
    pub fn rank(&self) -> usize {
        self.shape.len()
    }

    /// Returns the extent of every axis.
    pub fn shape(&self) -> &[usize] {
        &self.shape
    }

    /// Returns the row-major suffix-product stride for every axis.
    pub fn strides(&self) -> &[usize] {
        &self.strides
    }

    /// Returns the number of stored values.
    pub fn len(&self) -> usize {
        self.data.len()
    }

    /// Reports whether the flat buffer stores no values.
    pub fn is_empty(&self) -> bool {
        self.data.is_empty()
    }

    /// Borrows the contiguous value buffer.
    pub fn as_slice(&self) -> &[f64] {
        &self.data
    }

    /// Mutably borrows the contiguous value buffer without changing its length.
    pub fn as_mut_slice(&mut self) -> &mut [f64] {
        &mut self.data
    }

    /// Consumes the tensor and returns its contiguous value buffer.
    pub fn into_vec(self) -> Vec<f64> {
        self.data
    }

Второй фрагмент отвечает за проверку координаты. Метод offset сначала сравнивает длину координаты с рангом. Затем он обходит оси слева направо и сообщает первый индекс, который не меньше размера своей оси. Методы get и get_mut вызывают ту же операцию, поэтому чтение и изменение не могут использовать разные правила индексации.

Проверьте ранг и границы, затем используйте единое преобразование координаты в смещение rust/crates/llm-from-scratch/src/tensor/storage.rs#row-major-indexing
    /// Maps one in-bounds coordinate to its row-major flat-buffer offset.
    pub fn offset(&self, coordinate: &[usize]) -> Result<usize, TensorError> {
        checked_offset(&self.shape, &self.strides, 0, coordinate)
    }

    /// Borrows the value at one checked coordinate.
    pub fn get(&self, coordinate: &[usize]) -> Result<&f64, TensorError> {
        let offset = self.offset(coordinate)?;
        Ok(&self.data[offset])
    }

    /// Mutably borrows the value at one checked coordinate.
    pub fn get_mut(&mut self, coordinate: &[usize]) -> Result<&mut f64, TensorError> {
        let offset = self.offset(coordinate)?;
        Ok(&mut self.data[offset])
    }

Публичные методы позволяют наблюдать инварианты, не давая форме и шагам разойтись. rank, shape, strides, len, is_empty и as_slice читают состояние тензора. as_mut_slice разрешает менять значения, но не размер буфера; into_vec забирает тензор во владение и возвращает принадлежавшие ему значения.

TensorError различает четыре вида ошибок. ShapeOverflow означает, что необходимое произведение не представимо в usize. DataLengthMismatch { expected, actual } означает, что произведение допустимо, но длина переданного буфера неверна. RankMismatch { expected, actual } проверяется раньше осей, а IndexOutOfBounds { axis, index, dimension } указывает первую недопустимую ось. Методы возвращают эти ошибки через Result; обычный неверный ввод не вызывает panic.

Первый фрагмент библиотеки показывает, как тензоры разных этапов развития языковых моделей представляются одним конкретным типом. Он создаёт небольшие тензоры параметров CC, HH и UU, тензоры активаций QQ, KK и VV для одной головы и локальный стек тензоров QQ по оси голов. Все значения равны нулю, потому что здесь важны только форма, шаги и число элементов; сами операции моделей появятся в последующих главах.

Представьте матрицы параметров Бенжио и формы тензоров внимания Transformer одним типом Tensor rust/demos/ch08-tensor-storage/src/lib.rs#llm-shape-history
/// Builds tiny shape-only stand-ins for parameters and activations from the LLM history.
pub fn llm_shape_history_fixture() -> Result<Vec<(&'static str, Tensor)>, TensorError> {
    Ok(vec![
        ("toy Bengio C", Tensor::from_vec(vec![5, 3], vec![0.0; 15])?),
        ("toy Bengio H", Tensor::from_vec(vec![4, 6], vec![0.0; 24])?),
        ("toy Bengio U", Tensor::from_vec(vec![5, 4], vec![0.0; 20])?),
        (
            "toy Transformer Q (one head)",
            Tensor::from_vec(vec![2, 3], vec![0.0; 6])?,
        ),
        (
            "toy Transformer K (one head)",
            Tensor::from_vec(vec![2, 3], vec![0.0; 6])?,
        ),
        (
            "toy Transformer V (one head)",
            Tensor::from_vec(vec![2, 3], vec![0.0; 6])?,
        ),
        (
            "toy Transformer Q head stack",
            Tensor::from_vec(vec![2, 2, 3], vec![0.0; 12])?,
        ),
    ])
}

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

Создайте тензор формы [2,2,3], используемый во всей главе 8 rust/demos/ch08-tensor-storage/src/lib.rs#frozen-tensor-fixture
/// Reconstructs the immutable, contiguous tensor used throughout the chapter.
pub fn frozen_tensor_fixture() -> Result<Tensor, TensorError> {
    Tensor::from_vec(FROZEN_SHAPE.to_vec(), FROZEN_VALUES.to_vec())
}

Следующий фрагмент main.rs сначала создаёт тензоры из исторического сравнения. Затем он вычисляет проверяемый доступ по координате [1,0,2], меняет значение по координате [0,1,1] на 99, создаёт скалярный и пустой тензоры и получает ошибки ранга, границ и переполнения. Код сразу после показанного фрагмента выводит эти значения. Последняя строка обозначает границу следующей главы: глава 9 добавит новую интерпретацию формы, шагов и базового смещения, сохранив тот же буфер.

Создайте формы из истории моделей, затем вычислите допустимый доступ, крайние формы и воспроизводимые ошибки rust/demos/ch08-tensor-storage/src/main.rs#learner-output
    let llm_shapes = llm_shape_history_fixture()?;

    let mut tensor = frozen_tensor_fixture()?;
    let selected_offset = tensor.offset(&SELECTED_COORDINATE)?;
    let selected_value = *tensor.get(&SELECTED_COORDINATE)?;
    *tensor.get_mut(&[0, 1, 1])? = 99.0;

    let scalar = Tensor::from_vec(vec![], vec![7.0])?;
    let scalar_offset = scalar.offset(&[])?;
    let scalar_value = scalar.get(&[])?;
    let empty = Tensor::from_vec(vec![2, 0, 3], vec![])?;

    let rank_error = tensor.offset(&[1, 0]).unwrap_err();
    let bounds_error = tensor.offset(&INVALID_COORDINATE).unwrap_err();
    let overflow_error = Tensor::from_vec(vec![usize::MAX, 2], vec![]).unwrap_err();

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

Запишите тензор, три слагаемых смещения, проверенное чтение и отклонённую координату rust/demos/ch08-tensor-storage/src/diagram_trace.rs#tensor-storage-trace
    let tensor = frozen_tensor_fixture()?;
    let offset = tensor.offset(&SELECTED_COORDINATE)?;
    let value = tensor.get(&SELECTED_COORDINATE)?;
    let bounds_error = match tensor.offset(&INVALID_COORDINATE) {
        Err(error) => error,
        Ok(_) => {
            return Err(TensorError::IndexOutOfBounds {
                axis: 1,
                index: 2,
                dimension: 2,
            });
        }
    };
    let shape = usize_csv(tensor.shape());
    let strides = usize_csv(tensor.strides());
    let buffer = value_csv(tensor.as_slice());
    let coordinate = usize_csv(&SELECTED_COORDINATE);
    let slice0 = format!(
        "SLICE axis0=0 row0={} row1={}",
        row_csv(&tensor, 0, 0)?,
        row_csv(&tensor, 0, 1)?
    );
    let slice1 = format!(
        "SLICE axis0=1 row0={} row1={}",
        row_csv(&tensor, 1, 0)?,
        row_csv(&tensor, 1, 1)?
    );
    let terms = SELECTED_COORDINATE
        .iter()
        .zip(tensor.strides())
        .enumerate()
        .map(|(axis, (&index, &stride))| {
            format!(
                "TERM axis={axis} index={index} stride={stride} contribution={}",
                index * stride
            )
        })
        .collect::<Vec<_>>();

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

Проследите путь координаты через срезы, арифметику и хранилище

Изучите схему в четыре прохода:

  1. Найдите срез 1, строку 0 и третью позицию в этой строке.
  2. Сопоставьте координате [1,0,2] шаги [6,3,1] и проследите все три записанных вклада.
  3. Найдите смещение 8 в плоском буфере и отделите его как позицию от значения 32.0.
  4. Сравните допустимый индекс 0 на оси 1 с отклонённым индексом 2 в координате [1,2,0].

Одна координата — одно смещение при построчном хранении

Два среза — в каждом две строки по три столбца — и один плоский буфер получены из одного примера на Rust. Проследите три вклада координаты [1, 0, 2] в смещение, затем сравните с проверкой координаты, выходящей за границы.

Форма
[2, 2, 3]
Построчные шаги
[6, 3, 1]
Число значений
12

Два среза одного тензора ранга 3

Первая координата выбирает срез, следующие две — строку и столбец внутри него.

  1. Срез i0=0i_0=0
    Строка i2=0i_2=0i2=1i_2=1i2=2i_2=2
    i1=0i_1=0 10.0 11.0 12.0
    i1=1i_1=1 20.0 21.0 22.0
  2. Срез i0=1i_0=1
    Строка i2=0i_2=0i2=1i_2=1i2=2i_2=2
    i1=0i_1=0 30.0 31.0 Выбранная координата и соответствующий элемент буфера: 32.0
    i1=1i_1=1 40.0 41.0 42.0

Как [1, 0, 2] даёт смещение 8

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

Координата: [1, 0, 2]

  1. Вклад: i0s0=16=6i_{0}s_{0}=1\cdot6=6
  2. Вклад: i1s1=03=0i_{1}s_{1}=0\cdot3=0
  3. Вклад: i2s2=21=2i_{2}s_{2}=2\cdot1=2
Смещение
8
Значение
32.0

Найдите смещение 8 в плоском буфере

Двойная рамка и ромб отмечают смещение 8 независимо от цвета.

  1. Смещение 0 10.0
  2. Смещение 1 11.0
  3. Смещение 2 12.0
  4. Смещение 3 20.0
  5. Смещение 4 21.0
  6. Смещение 5 22.0
  7. Смещение 6 30.0
  8. Смещение 7 31.0
  9. Смещение 8 Выбранная координата и соответствующий элемент буфера: 32.0
  10. Смещение 9 40.0
  11. Смещение 10 41.0
  12. Смещение 11 42.0

Проверка недопустимой координаты до чтения буфера

Размер оси 1 равен 2, поэтому индекс 2 отклоняется до обращения к буферу.

Координата
[1, 2, 0]
Ось
1
Индекс
2
Размер оси
2

Обе таблицы срезов и плоский буфер содержат одни и те же двенадцать значений из трассировки Rust. Это разные представления только в объяснительном смысле: в главе 8 ещё нет API TensorView. Расчёт шагов показывает, почему переход по внешней оси пропускает шесть элементов, а по последней — один.

Ромб и двойная рамка отмечают смещение 8 независимо от цвета. Сначала сопоставьте этот маркер координате [1,0,2], а затем — значению 32.0 в плоском буфере.

Панель проверки границ — часть того же рассуждения. Она показывает ось 1, индекс 2 и размер 2: тензор отклоняет координату до вычисления смещения и чтения значения. Даже если для недопустимых индексов арифметика дала бы правдоподобный результат, доступ не стал бы допустимым.

Предскажите каждый результат работы с формой

Шаги выводятся справа налево, но при проверке доступа сначала проверяется ранг, а затем оси обходятся слева направо.

  1. Выведите построчные шаги в элементах для формы [3,4,5].
  2. Для формы [2,2,3], шагов [6,3,1] и зафиксированного буфера вычислите вклад каждой оси координаты [1,0,2]. Отдельно укажите смещение и значение.
  3. Предскажите ошибку для координаты [1,2,0]. Какие ось, индекс и размер должны быть в ней указаны?
  4. Для скалярной формы [] укажите ранг, шаги, число элементов, единственную допустимую координату и её смещение.
  5. Для формы [2,0,3] выведите шаги и число элементов. Почему полная допустимая координата не существует?
  6. Сравните два неудачных вызова конструктора: форма [2,2] с тремя значениями и форма [usize::MAX,2] с пустым вектором данных. Какая ошибка относится к каждому случаю?
  7. Предскажите, какое смещение изменится, когда get_mut(&[0, 1, 1]) присвоит значение 99, и запишите получившийся плоский буфер.
Проверить семь результатов для шагов, доступа и инвариантов
  1. На последней оси шаг равен 11. Шаг по оси 11 равен 51=55\cdot1=5, а по оси 0045=204\cdot5=20. Поэтому построчные шаги равны [20,5,1].
  2. Вклады равны 16=61\cdot6=6, 03=00\cdot3=0 и 21=22\cdot1=2. Их сумма даёт смещение 8. В data[8] хранится значение 32.0. Смещение 8 — позиция типа usize, а значение 32.0 имеет тип f64.
  3. Ранг совпадает, поэтому начинается проверка осей. Ось 0 принимает индекс 1. Размер оси 1 равен 2, и она отклоняет индекс 2, возвращая IndexOutOfBounds { axis: 1, index: 2, dimension: 2 }. Ось 2 и буфер уже не проверяются.
  4. Форма [] имеет ранг 0, шаги [] и один элемент. Координата [] имеет требуемый ранг, а пустая сумма соответствует смещению 0. Любая непустая координата вызывает ошибку ранга.
  5. При выводе справа налево получаются шаги [0,3,1]: последний шаг равен 1, следующий — 3, а нулевой размер оси обращает предыдущий шаг и общее число элементов в 0. Полной допустимой координаты нет, потому что на оси 1 не существует индекса, меньшего нулевого размера.
  6. Для формы [2,2] число элементов 4 представимо, поэтому три значения дают DataLengthMismatch { expected: 4, actual: 3 }. При форме [usize::MAX,2] раньше переполняется необходимое произведение, поэтому результатом будет ShapeOverflow, а не ошибка длины данных и не попытка выделить память.
  7. Координата [0,1,1] даёт 06+13+11=40\cdot6 + 1\cdot3 + 1\cdot1 = 4. Меняется только data[4]. Буфер становится равен [10.0, 11.0, 12.0, 20.0, 99.0, 22.0, 30.0, 31.0, 32.0, 40.0, 41.0, 42.0]; форма и шаги остаются равны [2,2,3] и [6,3,1].

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

Используйте один проверяемый числовой контейнер во всей модели

Теперь в курсе есть Tensor, которому принадлежит один непрерывный Vec<f64>. Форма задаёт его nn-мерную интерпретацию, построчные шаги однозначно сопоставляют допустимым координатам смещения, а проверяемый доступ не даёт ошибкам ранга или границ превратиться в чтение несвязанных элементов буфера. В следующих главах на этом же инварианте хранения будут построены эмбеддинги, активации, веса, промежуточные результаты внимания, логиты, функции потерь и градиенты.

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