← Все главы

09 · Версия материала 6

Представления общего хранилища и явное копирование тензоров

Проследите путь от признаков слов в фиксированном контексте к тензорам Q/K/V и разделению внимания на головы, а затем сравните представления общего хранилища с явным копированием в реализации курса.

Сначала предскажите два тензора формы [3, 2]

Возьмём тот же плоский буфер исходного тензора, который используется в примере:

shape   = [2, 3]
strides = [3, 1]
storage = [10, 11, 12, 20, 21, 22]

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

  1. view.reshape(&[3,2])
  2. view.transpose(0,1)

Ни один из запросов не должен перемещать или копировать значения f64. Сначала зафиксируйте оба порядка обхода.

Ответ: одинаковые формы, разный логический порядок

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

reshape values = [[10, 11], [12, 20], [21, 22]]
offsets        = [0, 1, 2, 3, 4, 5]

Теперь транспонируем оси 0 и 1. Бывшая ось столбцов становится новой осью строк. Форма [2,3] превращается в [3,2], а соответствующие шаги [3,1] — в [1,3]:

transpose values = [[10, 20], [11, 21], [12, 22]]
offsets          = [0, 3, 1, 4, 2, 5]

Обе формы одинаковы, и в целом в них находятся те же шесть скалярных значений, но значения занимают разные координаты. Исходное хранилище не меняется. Например, координата [2,1] транспонированного представления и исходная координата [1,2] указывают на смещение 5, а значит, выбирают одно и то же хранящееся значение 22.0.

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

Разделите совместимость формы и перестановку осей

Два компактных правила отделяют условие изменения формы от преобразования метаданных при перестановке:

knk=jnj,nk=nπ(k),sk=sπ(k)\prod_k n_k=\prod_j n'_j, \qquad n'_k=n_{\pi(k)}, \quad s'_k=s_{\pi(k)}

Первое равенство задаёт требование к числу элементов при изменении формы. Проверяемые произведения размеров исходных осей nkn_k и размеров запрошенной формы njn'_j должны совпадать. В нашем примере 23=32=62\cdot3=3\cdot2=6, поэтому форму [2,3] можно заменить на [3,2]. Для формы [4,2] потребовалось бы восемь значений, и запрос отклоняется до создания представления.

Одного равенства произведений в этой реализации недостаточно. Исходное представление также должно быть непрерывным при построчном обходе. Благодаря этому reshape не выделяет память неявно после транспонирования или среза по внутренней оси. Если намеренно нужен новый непрерывный владелец, вызовите materialize.

Последние два равенства описывают перестановку. Выходная ось kk получает размер и шаг исходной оси π(k)\pi(k). При перестановке [1,0] выходная ось 00 получает исходную ось 11, а выходная ось 11 — исходную ось 00. Поэтому форма становится [3,2], а шаги — [1,3].

Учтите каждый размер, индекс оси и шаг

СимволПрактический смысл
nkn_kРазмер исходной оси kk. В примере n0=2n_0=2 и n1=3n_1=3.
njn'_jРазмер оси jj в запрошенной форме. Для [3,2] получаем n0=3n'_0=3 и n1=2n'_1=2.
kkИндекс исходной оси или оси результата перестановки, отсчитываемый от нуля; его роль определяется выражением.
jjИндекс оси в запрошенной форме, отсчитываемый от нуля. Число таких осей не обязано совпадать с числом осей, индексируемых через kk.
nkn'_kРазмер выходной оси kk после перестановки.
nπ(k)n_{\pi(k)}Размер исходной оси, перенесённый в выходную позицию kk.
sks'_kШаг в элементах по выходной оси kk после перестановки.
π(k)\pi(k)Исходная ось, помещённая на выходную ось kk. Допустимая перестановка называет каждую исходную ось ровно один раз.
sπ(k)s_{\pi(k)}Шаг исходной оси π(k)\pi(k), перенесённый вместе с ней в выходную позицию kk.

Изменение формы и перестановка используют разные части формулы. Для непрерывного представления изменение формы выводит новые построчные шаги из запрошенной формы. Перестановка вместе меняет порядок существующих размеров и соответствующих им шагов. Срез с единичным шагом не выполняет ни то ни другое: он сохраняет список шагов, изменяет размер одной оси и прибавляет startstride[axis]\operatorname{start}\cdot\operatorname{stride}[\operatorname{axis}] к базовому смещению.

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

От фиксированного контекста к разделению и объединению голов внимания

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

Более ранняя модель описана в работе Bengio et al., A Neural Probabilistic Language Model. Более поздние источники — Vaswani et al., Attention Is All You Need и официальный файл model.py модели GPT-2 от OpenAI.

Васвани и соавторы задают внимание через матрицы запросов, ключей и значений, вычисляют масштабированные произведения с транспонированными ключами и параллельно применяют обучаемые проекции нескольких голов, выходы которых затем конкатенируются. В официальном файле model.py модели GPT-2 один тензор с осями пакета, последовательности и признаков проецируется в упакованные группы запросов, ключей и значений. Затем группы разделяются и транспонируются с выделением оси голов, выполняется умножение на тензор ключей с переставленными двумя последними осями, после чего головы транспонируются обратно и объединяются.

Вектор фиксированного контекста у Бенжио отводит признакам каждого выбранного слова заранее определённое место. Во внимании Transformer у QQ, KK и VV сохраняется отдельная ось позиций. В масштабированное скалярное произведение входит QK𝖳QK^{\mathsf T}, поэтому на границе умножения оси последовательности и признаков у ключа меняются ролями. Несколько обучаемых голов добавляют ещё одну ось, а затем их выходы конкатенируются.

Код GPT-2 показывает эти преобразования явно. Проекция внимания сначала создаёт три упакованные группы признаков и разделяет их на QQ, KK и VV. В каждой группе ширина признаков разбивается на головы и размерность признаков одной головы, после чего [пакет,последовательность,голова,ширина][\text{пакет},\text{последовательность},\text{голова},\text{ширина}] транспонируется в [пакет,голова,последовательность,ширина][\text{пакет},\text{голова},\text{последовательность},\text{ширина}]. Затем QQ умножается на KK, у которого переставлены последние две оси. Результат по отдельным головам транспонируется обратно перед объединением осей голов и признаков.

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

Изменение формы, перестановка осей и транспонирование позволяют выразить логические преобразования разделения на головы, транспонирования ключей и обратного объединения голов, используемые во внимании декодера. Можно ли при объединении изменить форму без копирования, зависит от непрерывности полученного представления. Заимствованный TensorView и явная материализация — решения этой реализации; статьи и код GPT-2 на TensorFlow не предписывают такое устройство хранилища.

Будем считать зафиксированный пример формы [2,3] очень маленьким тензором KK одной головы с двумя позициями ключей и тремя признаками; оси пакета и голов в нём опущены. Функция copying_transpose с циклом копирования сразу создаёт собственный непрерывный транспонированный тензор KK со значениями [10,20,11,21,12,22]. Заимствованное транспонированное представление сохраняет исходного владельца и читает тот же логический порядок через форму [3,2], шаги [1,3] и смещения [0,3,1,4,2,5]. Здесь подготавливается лишь один транспонированный операнд: QQ, матричное умножение, масштабирование, маскирование, softmax и полное вычисление внимания появятся позже.

Создайте копию транспонированного тензора ключей, чтобы сравнить её с заимствованным логическим транспонированием rust/demos/ch09-tensor-views/src/lib.rs#eager-copying-transpose
/// Builds a transposed tensor by explicitly allocating and copying every value.
pub fn copying_transpose(source: &Tensor) -> Result<Tensor, TensorError> {
    let [rows, columns] = source.shape() else {
        return Err(TensorError::RankMismatch {
            expected: 2,
            actual: source.rank(),
        });
    };
    if source.is_empty() {
        return Tensor::from_vec(vec![*columns, *rows], Vec::new());
    }

    let mut copied = Vec::with_capacity(source.len());
    for column in 0..*columns {
        for row in 0..*rows {
            copied.push(*source.get(&[row, column])?);
        }
    }
    Tensor::from_vec(vec![*columns, *rows], copied)
}

Заимствуйте данные владельца, преобразуйте метаданные и копируйте только явно

Все значения по-прежнему принадлежат Tensor. Метод Tensor::view хранит неизменяемую ссылку на владельца, поэтому время жизни представления и правила совместного доступа зафиксированы в самом типе Rust. Форма, шаги, базовое смещение и длина копируются как метаданные; скалярные значения не копируются.

Свяжите метаданные представления и проверяемый доступ с одним заимствованным владельцем Tensor rust/crates/llm-from-scratch/src/tensor/view.rs#borrowed-tensor-view
/// An immutable n-dimensional interpretation of storage owned by a [`Tensor`].
///
/// The view copies only shape and stride metadata. Rust keeps the source tensor
/// borrowed for the view's lifetime, so safe code cannot mutate the owner while
/// a subsequently used view still exists:
///
/// ```compile_fail
/// use llm_from_scratch::tensor::storage::Tensor;
///
/// let mut tensor = Tensor::from_vec(vec![2], vec![10.0, 20.0]).unwrap();
/// let view = tensor.view();
/// tensor.as_mut_slice()[0] = 99.0;
/// assert_eq!(*view.get(&[0]).unwrap(), 10.0);
/// ```
#[derive(Clone, Debug, PartialEq)]
pub struct TensorView<'a> {
    source: &'a Tensor,
    shape: Vec<usize>,
    strides: Vec<usize>,
    base_offset: usize,
    len: usize,
}

impl Tensor {
    /// Borrows this tensor as a row-major-contiguous view without copying values.
    pub fn view(&self) -> TensorView<'_> {
        TensorView {
            source: self,
            shape: self.shape().to_vec(),
            strides: self.strides().to_vec(),
            base_offset: 0,
            len: self.len(),
        }
    }
}

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

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

    /// Returns the source-storage movement for each logical axis.
    pub fn strides(&self) -> &[usize] {
        &self.strides
    }

    /// Returns the source-storage offset of the view's logical origin.
    pub fn base_offset(&self) -> usize {
        self.base_offset
    }

    /// Returns the number of logical values in the view.
    pub fn len(&self) -> usize {
        self.len
    }

    /// Reports whether the view has no logical values.
    pub fn is_empty(&self) -> bool {
        self.len == 0
    }

    /// Reports whether logical row-major iteration visits one dense storage span.
    ///
    /// Singleton axes may carry any stride because they never advance. Scalars
    /// and empty views are contiguous by this chapter's explicit convention.
    pub fn is_contiguous(&self) -> bool {
        if self.is_empty() {
            return true;
        }

        let mut expected_stride = 1_usize;
        for (&dimension, &stride) in self.shape.iter().zip(&self.strides).rev() {
            if dimension > 1 && stride != expected_stride {
                return false;
            }
            expected_stride = expected_stride
                .checked_mul(dimension)
                .expect("a valid tensor view retains a checked shape");
        }
        true
    }

    /// Maps one checked logical coordinate to the owner's flat storage offset.
    pub fn storage_offset(&self, coordinate: &[usize]) -> Result<usize, TensorViewError> {
        checked_offset(&self.shape, &self.strides, self.base_offset, coordinate).map_err(Into::into)
    }

    /// Borrows the source value selected by one checked logical coordinate.
    pub fn get(&self, coordinate: &[usize]) -> Result<&'a f64, TensorViewError> {
        let offset = self.storage_offset(coordinate)?;
        let source: &'a [f64] = self.source.as_slice();
        Ok(&source[offset])
    }
}

Если код создаёт представление, изменяет его владельца, а затем снова использует представление, Rust отклоняет конфликтующее заимствование. Несколько неизменяемых представлений могут существовать одновременно. materialize создаёт независимый тензор, который можно изменять отдельно; исходный владелец снова становится доступен для изменения после последнего использования заимствованного представления.

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

Для отдельного непрерывного тензора QQ, KK или VV эти операции для тензоров произвольного ранга позволяют выразить переход от [пакет,последовательность,признаки][\text{пакет},\text{последовательность},\text{признаки}] к [пакет,головы,последовательность,признаки одной головы][\text{пакет},\text{головы},\text{последовательность},\text{признаки одной головы}]. Если к представлению, полученному разделением исходного непрерывного тензора, применить обратную перестановку, непрерывность может восстановиться. Но новый непрерывный результат внимания в порядке [пакет,головы,последовательность,признаки одной головы][\text{пакет},\text{головы},\text{последовательность},\text{признаки одной головы}] обычно перестаёт быть непрерывным при построчном обходе после перестановки в [пакет,последовательность,головы,признаки одной головы][\text{пакет},\text{последовательность},\text{головы},\text{признаки одной головы}]. Поэтому перед объединением осей голов и признаков через reshape эта реализация материализует результат. Возможность использовать общее хранилище определяется непрерывностью, а не названием операции. Небольшой пример ранга 2 показывает только транспонирование; полное внимание будет реализовано позже.

Есть ещё одна намеренная граница непрерывности. Если представить упакованную проекцию GPT-2 формой [пакет,последовательность,3признаки][\text{пакет},\text{последовательность},3\cdot\text{признаки}], то при пакетпоследовательность>1\text{пакет}\cdot\text{последовательность}>1 срез одного блока запросов, ключей или значений по последней оси сохранит промежутки между внешними строками. Перед reshape такой многопозиционный срез придётся явно материализовать. Исходный код GPT-2 на TensorFlow задаёт логические операции, но не определяет, должна ли другая реализация заимствовать данные или копировать их.

Изменяйте форму непрерывного логического порядка или переставляйте полный набор метаданных осей rust/crates/llm-from-scratch/src/tensor/view.rs#view-axis-transforms
    /// Reinterprets a row-major-contiguous view with a compatible shape.
    pub fn reshape(&self, shape: &[usize]) -> Result<Self, TensorViewError> {
        let (strides, requested) = checked_row_major_layout(shape)?;
        if requested != self.len {
            return Err(TensorViewError::ReshapeElementCountMismatch {
                current: self.len,
                requested,
            });
        }
        if !self.is_contiguous() {
            return Err(TensorViewError::NonContiguousReshape);
        }

        Ok(Self {
            source: self.source,
            shape: shape.to_vec(),
            strides,
            base_offset: self.base_offset,
            len: self.len,
        })
    }

    /// Swaps two logical axes without moving source values.
    pub fn transpose(&self, first: usize, second: usize) -> Result<Self, TensorViewError> {
        self.check_axis(first)?;
        self.check_axis(second)?;

        let mut axes = (0..self.rank()).collect::<Vec<_>>();
        axes.swap(first, second);
        self.permute(&axes)
    }

    /// Reorders axes so output axis `k` uses source axis `axes[k]`.
    pub fn permute(&self, axes: &[usize]) -> Result<Self, TensorViewError> {
        if axes.len() != self.rank() {
            return Err(TensorViewError::PermutationLengthMismatch {
                expected: self.rank(),
                actual: axes.len(),
            });
        }

        let mut seen = vec![false; self.rank()];
        for &axis in axes {
            self.check_axis(axis)?;
            if seen[axis] {
                return Err(TensorViewError::DuplicateAxis { axis });
            }
            seen[axis] = true;
        }

        Ok(Self {
            source: self.source,
            shape: axes.iter().map(|&axis| self.shape[axis]).collect(),
            strides: axes.iter().map(|&axis| self.strides[axis]).collect(),
            base_offset: self.base_offset,
            len: self.len,
        })
    }

Общедоступные методы и внутренний обход получают данные с разными гарантиями корректности. Методы storage_offset и get получают одну координату от вызывающего кода. При каждом вызове они проверяют, совпадает ли число компонентов координаты с рангом тензора и находится ли каждый индекс в границах своей оси, а затем вычисляют смещение в хранилище. materialize, напротив, обходит все логические позиции TensorView, который уже был создан безопасным конструктором или проверенным преобразованием.

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

Сначала курсор выдаёт базовое смещение, а затем продвигает последнюю логическую ось. Когда позиция достигает размера оси, курсор сбрасывает её в ноль и переносит шаг на предыдущую ось. Поэтому для зафиксированного среза он выдаёт [1,2,4,5]; для скаляра — базовое смещение ровно один раз; для пустого представления — ни одного смещения. Нулевой эффективный шаг намеренно повторяет смещения: это понадобится для правил расширения в главе 10. Ось размера один не продвигается, даже если в её метаданных хранится большой шаг.

Реализация курсора использует только безопасный Rust, не содержит ссылки на тензор и один раз выделяет память для состояний осей. Метод next не создаёт новый вектор координат и не повторяет для каждого значения проверки ранга и границ осей, обязательные для общедоступных методов. Чтение по выданному смещению по-прежнему выполняется обычным индексированием среза в Rust с проверкой границ.

Один раз проверьте внутреннюю схему расположения и обходите её безопасным курсором логических смещений rust/crates/llm-from-scratch/src/tensor/view.rs#validated-strided-offset-iteration
/// Logical row-major traversal over a layout whose metadata was already checked.
///
/// The cursor owns one `O(rank)` axis-state vector and updates it in place. It
/// keeps no tensor borrow, so later kernels can use the same plumbing for source
/// reads or destination writes. It remains crate-private so arbitrary external
/// coordinates still enter through [`TensorView::storage_offset`] or
/// [`TensorView::get`].
#[derive(Clone, Copy, Debug)]
struct OffsetAxis {
    extent: usize,
    stride: usize,
    position: usize,
    rewind: usize,
}

#[derive(Debug)]
pub(crate) struct StridedOffsets {
    axes: Vec<OffsetAxis>,
    next_offset: usize,
    remaining: usize,
}

impl StridedOffsets {
    /// Checks one internal traversal plan and then owns its reusable axis state.
    pub(crate) fn checked(
        shape: &[usize],
        strides: &[usize],
        base_offset: usize,
        logical_len: usize,
        backing_len: usize,
    ) -> Option<Self> {
        if shape.len() != strides.len() {
            return None;
        }

        let expected_len = if shape.contains(&0) {
            0
        } else {
            shape
                .iter()
                .try_fold(1_usize, |count, &extent| count.checked_mul(extent))?
        };
        if logical_len != expected_len {
            return None;
        }

        if logical_len == 0 {
            return Some(Self {
                axes: shape
                    .iter()
                    .zip(strides)
                    .map(|(&extent, &stride)| OffsetAxis {
                        extent,
                        stride,
                        position: 0,
                        rewind: 0,
                    })
                    .collect(),
                next_offset: base_offset,
                remaining: 0,
            });
        }

        let mut maximum_offset = base_offset;
        let mut axes = Vec::with_capacity(shape.len());
        for (&extent, &stride) in shape.iter().zip(strides) {
            let rewind = (extent - 1).checked_mul(stride)?;
            maximum_offset = maximum_offset.checked_add(rewind)?;
            axes.push(OffsetAxis {
                extent,
                stride,
                position: 0,
                rewind,
            });
        }
        if maximum_offset >= backing_len {
            return None;
        }

        Some(Self {
            axes,
            next_offset: base_offset,
            remaining: logical_len,
        })
    }

    fn advance(&mut self) {
        for axis in self.axes.iter_mut().rev() {
            let next_position = axis.position + 1;
            if next_position < axis.extent {
                axis.position = next_position;
                self.next_offset = self
                    .next_offset
                    .checked_add(axis.stride)
                    .expect("a checked traversal cannot overflow while advancing");
                return;
            }

            axis.position = 0;
            self.next_offset = self
                .next_offset
                .checked_sub(axis.rewind)
                .expect("a checked traversal cannot underflow while carrying");
        }

        unreachable!("a checked nonempty traversal advances within its logical shape");
    }
}

impl Iterator for StridedOffsets {
    type Item = usize;

    fn next(&mut self) -> Option<Self::Item> {
        if self.remaining == 0 {
            return None;
        }

        let offset = self.next_offset;
        self.remaining -= 1;
        if self.remaining != 0 {
            self.advance();
        }
        Some(offset)
    }

    fn size_hint(&self) -> (usize, Option<usize>) {
        (self.remaining, Some(self.remaining))
    }
}

impl ExactSizeIterator for StridedOffsets {}
impl FusedIterator for StridedOffsets {}

impl<'a> TensorView<'a> {
    /// Traverses this already validated view in logical row-major order.
    pub(crate) fn logical_offsets(&self) -> StridedOffsets {
        self.projected_offsets(&self.shape, &self.strides, self.len)
            .expect("a TensorView retains checked traversal metadata")
    }

    /// Checks effective strides for another logical traversal of this source.
    pub(crate) fn projected_offsets(
        &self,
        iteration_shape: &[usize],
        effective_strides: &[usize],
        logical_len: usize,
    ) -> Option<StridedOffsets> {
        StridedOffsets::checked(
            iteration_shape,
            effective_strides,
            self.base_offset,
            logical_len,
            self.source.len(),
        )
    }

    /// Copies one source scalar selected by an offset from a checked plan.
    pub(crate) fn value_at_storage_offset(&self, offset: usize) -> f64 {
        self.source.as_slice()[offset]
    }
}

Зафиксированный срез 1..3 по внутренней оси начинается с базового смещения 1. Он сохраняет исходные шаги [3,1], поэтому две логические строки читают смещения [1,2] и [4,5]. Смещение 3 пропускается, следовательно, это представление формы [2,2] не является непрерывным при построчном обходе. При материализации курсор обходит эти логические смещения, а новое хранилище получает значения [11,12,21,22] и шаги [2,1]. Повторное использование курсора не устраняет копирование: materialize по-прежнему выделяет новое хранилище и читает каждое логическое значение.

Сохраните шаги среза и сделайте последующее копирование явным rust/crates/llm-from-scratch/src/tensor/view.rs#view-slice-materialize
    /// Selects a half-open, unit-step range on one axis without copying values.
    pub fn slice(&self, axis: usize, range: Range<usize>) -> Result<Self, TensorViewError> {
        self.check_axis(axis)?;
        if range.start > range.end {
            return Err(TensorViewError::SliceStartAfterEnd {
                axis,
                start: range.start,
                end: range.end,
            });
        }

        let dimension = self.shape[axis];
        if range.end > dimension {
            return Err(TensorViewError::SliceEndOutOfBounds {
                axis,
                end: range.end,
                dimension,
            });
        }

        let start_offset = range
            .start
            .checked_mul(self.strides[axis])
            .ok_or(TensorViewError::Tensor(TensorError::ShapeOverflow))?;
        let base_offset = self
            .base_offset
            .checked_add(start_offset)
            .ok_or(TensorViewError::Tensor(TensorError::ShapeOverflow))?;
        let mut shape = self.shape.clone();
        shape[axis] = range.end - range.start;
        let (_, len) = checked_row_major_layout(&shape)?;

        Ok(Self {
            source: self.source,
            shape,
            strides: self.strides.clone(),
            base_offset,
            len,
        })
    }

    /// Copies logical row-major values into a new owned, contiguous tensor.
    pub fn materialize(&self) -> Result<Tensor, TensorViewError> {
        let mut values = Vec::with_capacity(self.len);
        for storage_offset in self.logical_offsets() {
            values.push(self.value_at_storage_offset(storage_offset));
        }
        Tensor::from_vec(self.shape.clone(), values).map_err(Into::into)
    }

Запускаемый учебный пример применяет те же общедоступные методы ко всем успешным и ошибочным случаям, поэтому каждый результат можно связать с конкретным проверяемым инвариантом.

Получите данные об изменении формы, транспонировании, срезе, материализации и граничных случаях rust/demos/ch09-tensor-views/src/main.rs#learner-view-output
    let tensor = frozen_tensor_fixture()?;
    let copied = copying_transpose(&tensor)?;
    let view = tensor.view();
    let reshaped = view.reshape(&[3, 2])?;
    let transposed = view.transpose(0, 1)?;
    let slice = view.slice(1, 1..3)?;
    let materialized = slice.materialize()?;

    let reshape_error = view.reshape(&[4, 2]).unwrap_err();
    let contiguity_error = transposed.reshape(&[2, 3]).unwrap_err();
    let slice_error = view.slice(1, 1..4).unwrap_err();

    let scalar = Tensor::from_vec(vec![], vec![7.0])?;
    let empty = Tensor::from_vec(vec![2, 0, 3], vec![])?;

Отличайте общее хранилище от скопированного

Диаграмма сопоставляет пять записей трассировки в одной таблице. Строки исходного представления, изменения формы, транспонирования и среза называют владельца base; пустой ромб и видимое имя владельца показывают общую связь без опоры на цвет. Строка материализованного представления называет нового владельца и отмечена заполненным ромбом. Явные метки «Да» и «Нет» указывают непрерывность, а каждый отклонённый запрос отмечен знаком умножения и содержит название нарушенного условия.

Изменение формы и транспонирование находятся в соседних строках, потому что одинаковая форма [3,2] легко вводит в заблуждение. Сначала сравните столбцы шагов и смещений, а затем значения. В строке материализованного представления указаны смещения [0,1,2,3] в новом хранилище; таблица соответствия показывает, что им отвечают исходные смещения [1,2,4,5]. Поэтому место явного копирования видно непосредственно.

Один владелец: четыре представления и одна явная копия

Сравните представления общего хранилища, одну явную копию и три отклонённых запроса.

Пять интерпретаций тензора

◇ — исходное хранилище; ◆ — отдельная копия; «Нет» — порядок не непрерывен при построчном обходе.

Операция Хранилище Форма Шаги в элементах Базовое смещение Непрерывность Смещения в хранилище Логические значения
Исходное представление То же исходное хранилище: base [2,3] [3,1] 0 Да [0,1,2,3,4,5] [10.0,11.0,12.0,20.0,21.0,22.0]
Изменение формы [3,2] То же исходное хранилище: base [3,2] [2,1] 0 Да [0,1,2,3,4,5] [10.0,11.0,12.0,20.0,21.0,22.0]
Транспонирование [0,1] То же исходное хранилище: base [3,2] [1,3] 0 Нет [0,3,1,4,2,5] [10.0,20.0,11.0,21.0,12.0,22.0]
Срез 1: 1..3 То же исходное хранилище: base [2,2] [3,1] 1 Нет [1,2,4,5] [11.0,12.0,21.0,22.0]
Материализованный срез Новое хранилище после материализации: materialized [2,2] [2,1] 0 Да [0,1,2,3] [11.0,12.0,21.0,22.0]

Копирование в новое хранилище

Строка связывает исходное смещение и значение с их местом в новой непрерывной копии.

Смещение в источнике Логические значения Смещение в копии
1 11.0 0
2 12.0 1
4 21.0 2
5 22.0 3

Три отклонённых запроса

Отклонённый запрос не меняет хранилище и указывает нарушенный инвариант.

Операция Запрос Данные проверки Причина отклонения
Операция отклонена: Изменение формы [4,2] Исходных элементов: 6
Нужно элементов: 8
В запрошенной форме другое число элементов.
Операция отклонена: Изменение формы [2,3] Непрерывность: Нет Транспонированное представление не непрерывно при построчном обходе. Перед изменением формы нужна материализация.
Операция отклонена: Срез 1: 1..4 Размер оси: 3 Конец полуоткрытого среза выходит за размер выбранной оси.

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

Сначала предскажите метаданные и владельца, затем запустите Rust

Если в вопросе не указана другая форма, используйте зафиксированный исходный тензор.

  1. Измените форму [2,3] на [3,2]. Запишите форму, шаги, логические смещения и значения.
  2. Транспонируйте оси 0 и 1. Запишите те же четыре характеристики. Почему результат отличается от изменения формы?
  3. Для формы [2,3,4] и шагов [12,4,1] предскажите форму и шаги после permute([2,0,1]).
  4. Для slice(1, 1..3) запишите новое базовое смещение, форму, шаги, смещения и значения. Непрерывно ли это представление при построчном обходе?
  5. Можно ли напрямую изменить форму этого среза на [4]? Назовите безопасную последовательность из двух действий.
  6. Запишите владельца, форму, шаги, смещения и значения материализованного среза. Почему materialize может повторно использовать один закрытый курсор смещений, тогда как общедоступный get проверяет каждую координату, переданную вызывающим кодом?
  7. Объясните, почему Rust отклоняет изменение tensor.as_mut_slice()[0], пока существует заимствованное представление, которое будет использовано позже.
Проверить семь ответов о форме, шагах, порядке и владении
  1. Изменение формы даёт форму [3,2], шаги [2,1], смещения [0,1,2,3,4,5] и значения [10,11,12,20,21,22]. Используется тот же владелец base.
  2. Транспонирование даёт форму [3,2], шаги [1,3], смещения [0,3,1,4,2,5] и значения [10,20,11,21,12,22]. При транспонировании каждый размер перемещается вместе со своим шагом; изменение формы сохраняет непрерывный линейный порядок.
  3. Перестановка [2,0,1] помещает исходные оси 2, 0 и 1 в три выходные позиции. Форма становится [4,2,3], а шаги — [1,12,4].
  4. Срез начинается с 0+11=10+1\cdot1=1. Его форма равна [2,2], шаги — [3,1], смещения — [1,2,4,5], а значения — [11,12,21,22]. Из-за промежутка между смещениями 2 и 4 логический построчный порядок не является непрерывным.
  5. Нет. В этом интерфейсе одинакового числа элементов недостаточно, если представление не является непрерывным. Сначала материализуйте срез, затем измените форму нового непрерывного владельца на [4].
  6. При материализации создаётся новый владелец с формой [2,2], шагами [2,1], смещениями [0,1,2,3] и хранилищем [11,12,21,22]. Значения совпадают с логическими значениями среза, но адреса и владелец отличаются. Метаданные представления были проверены при его создании, поэтому один закрытый курсор может обходить эту неизменную схему расположения. Общедоступный get при каждом вызове получает от вызывающего кода новую координату и обязан заново проверить её ранг и границы осей.
  7. TensorView содержит неизменяемое заимствование владельца. Rust не разрешит конфликтующее изменяемое заимствование, пока неизменяемое ещё понадобится. Это гарантия правил совместного доступа на этапе компиляции, а не копирование во время выполнения.

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

Перенесите явные оси в правила расширения и редукции

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

Это различие будет важно во всём декодере. Состояния токенов в пакетах, головы внимания и операнды матричного умножения будут неоднократно менять интерпретацию осей. Проверяемое представление позволяет выразить такие преобразования без копирования скалярных значений; явная материализация делает каждое выделение памяти намеренным действием. После того как численная операция проверит всю схему расположения, закрытый курсор смещений сможет обойти её, не создавая новый вектор координат для каждого скаляра. Общедоступные методы по-прежнему проверяют координату при каждом вызове, потому что вызывающий код каждый раз может передать новую недопустимую координату.

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