15 · Версия материала 9
Обратный проход по тензорному графу с локальными VJP
Постройте на Rust ленту автоматического дифференцирования тензоров. Реализуйте для каждого ребра локальное произведение вектора на якобиан (VJP), корректно проводите обратный проход через преобразования формы, согласование форм и редукции и проверяйте градиенты, необходимые для обучения LLM.
Предскажите граф тензоров с изменениями формы
Начните с двух листовых узлов-параметров:
x shape [2,3] bias shape [3]
[[1, 2, 3], [1, -1, 0]
[4, 5, 6]]
Измените форму x на [3,2], затем транспонируйте оси 0 и 1. Изменение
формы сохраняет логический порядок элементов по строкам, а транспонирование
меняет координаты, из которых читаются значения:
reshape [3,2] transpose [2,3]
[[1, 2], [[1, 3, 5],
[3, 4], [2, 4, 6]]
[5, 6]]
Явно выполните согласование форм (broadcasting) для bias по ведущей оси,
прибавьте его, возведите результат
в квадрат, использовав один результат сложения как оба операнда умножения, и
возьмите среднее по оси 1:
broadcast bias add multiply mean axis 1
[[ 1,-1,0], [[2,2,5], [[4,4,25], [11,18]
[ 1,-1,0]] [3,3,6]] [9,9,36]]
Выход не является скаляром. Выберите скалярную цель ; её выходная
сопряжённая величина равна . VJP среднего делит каждую
компоненту начальной величины на сохранённый размер оси, равный трём, а затем
повторяет частное во всех трёх элементах соответствующей строки входа. Поэтому
[3,6] превращается в
у узла умножения. Оба операнда
умножения ссылаются на один узел. Каждое ребро даёт произведение входящей
сопряжённой величины на другой операнд, а сумма двух вкладов равна:
VJP сложения копирует эту полную сопряжённую величину отдельно во вход с
транспонированным x и во вход с согласованной формой bias; величина не
делится между двумя входами. При обращении согласования форм две строки
складываются по соответствующим координатам:
. Поэтому
. Транспонирование снова меняет местами те же
оси, а изменение формы возвращает исходную форму , откуда
.
rust/demos/ch15-tensor-autodiff-core/src/lib.rs#shared-tensor-vjp-fixture /// Computes the worked graph with bounded arrays and a handwritten backward pass.
///
/// This is intentionally a contrast, not a second autodiff implementation: every
/// loop bound and shape is fixed to the eight-node Chapter 15 fixture.
pub fn handwritten_fixed_shape_baseline() -> HandwrittenBaseline {
let mut reshaped = [0.0; 6];
reshaped.copy_from_slice(&X_VALUES);
let mut transposed = [0.0; 6];
for row in 0..RESHAPED_SHAPE[0] {
for column in 0..RESHAPED_SHAPE[1] {
transposed[column * X_SHAPE[1] + row] = reshaped[row * RESHAPED_SHAPE[1] + column];
}
}
let mut broadcast = [0.0; 6];
let mut added = [0.0; 6];
let mut squared = [0.0; 6];
for row in 0..X_SHAPE[0] {
for (column, &bias_value) in BIAS_VALUES.iter().enumerate() {
let offset = row * X_SHAPE[1] + column;
broadcast[offset] = bias_value;
added[offset] = transposed[offset] + broadcast[offset];
squared[offset] = added[offset] * added[offset];
}
}
let mut output = [0.0; 2];
for row in 0..X_SHAPE[0] {
output[row] = squared[row * X_SHAPE[1]..(row + 1) * X_SHAPE[1]]
.iter()
.sum::<f64>()
/ X_SHAPE[1] as f64;
}
let mut added_adjoint = [0.0; 6];
for (row, &seed) in SEED_VALUES.iter().enumerate() {
let mean_adjoint = seed / X_SHAPE[1] as f64;
for column in 0..X_SHAPE[1] {
let offset = row * X_SHAPE[1] + column;
// The repeated multiply has two ordered operand contributions.
added_adjoint[offset] += mean_adjoint * added[offset];
added_adjoint[offset] += mean_adjoint * added[offset];
}
}
let mut bias_gradient = [0.0; 3];
for row in 0..X_SHAPE[0] {
for column in 0..X_SHAPE[1] {
bias_gradient[column] += added_adjoint[row * X_SHAPE[1] + column];
}
}
let mut reshaped_adjoint = [0.0; 6];
for row in 0..RESHAPED_SHAPE[0] {
for column in 0..RESHAPED_SHAPE[1] {
reshaped_adjoint[row * RESHAPED_SHAPE[1] + column] =
added_adjoint[column * X_SHAPE[1] + row];
}
}
let x_gradient = reshaped_adjoint;
HandwrittenBaseline {
output,
x_gradient,
bias_gradient,
}
}
fn frozen_x() -> Tensor {
tensor(&X_SHAPE, &X_VALUES)
}
fn frozen_bias() -> Tensor {
tensor(&BIAS_SHAPE, &BIAS_VALUES)
}
fn frozen_seed() -> Tensor {
tensor(&OUTPUT_SHAPE, &SEED_VALUES)
}
/// The accumulated gradients stored by the two parameter leaves.
#[derive(Clone, Debug, PartialEq)]
pub struct ParameterGradients {
pub x: Tensor,
pub bias: Tensor,
}
/// Forward nodes, reverse evidence, and all retained/released parameter states.
#[derive(Clone, Debug)]
pub struct FrozenTensorExample {
pub nodes: Vec<TensorValue>,
pub seed: Tensor,
pub baseline: HandwrittenBaseline,
pub first_pass: TensorBackwardPass,
pub first: ParameterGradients,
pub repeated: ParameterGradients,
pub zeroed: ParameterGradients,
pub after_zero_and_release: ParameterGradients,
pub released_error: TensorAutodiffError,
pub released_gradients_unchanged: bool,
}
fn parameter_snapshot(x: &TensorValue, bias: &TensorValue) -> ParameterGradients {
ParameterGradients {
x: x.gradient_snapshot().expect("x is a parameter"),
bias: bias.gradient_snapshot().expect("bias is a parameter"),
}
}
fn tensor_bits(value: &Tensor) -> Vec<u64> {
value.as_slice().iter().map(|item| item.to_bits()).collect()
}
fn optional_tensor_bits(value: Option<Ref<'_, Tensor>>) -> Option<Vec<u64>> {
value.as_ref().map(|value| tensor_bits(value))
}
/// Builds the same expression through reusable operation-level VJPs and checks
/// its result against `handwritten_fixed_shape_baseline` before returning it.
pub fn frozen_tensor_example() -> Result<FrozenTensorExample, TensorAutodiffError> {
let x = TensorValue::parameter(frozen_x())?;
let reshaped = x.reshape(&RESHAPED_SHAPE)?;
let transposed = reshaped.transpose(0, 1)?;
let bias = TensorValue::parameter(frozen_bias())?;
let broadcast = bias.broadcast_to(&X_SHAPE)?;
let added = transposed.add(&broadcast)?;
let multiplied = added.mul(&added)?;
let output = multiplied.mean_axis(1, false)?;
let seed = frozen_seed();
let baseline = handwritten_fixed_shape_baseline();
assert_eq!(output.value().as_slice(), baseline.output);
let first_pass = output.backward_with_seed_and_trace(&seed.view(), GraphRetention::Retain)?;
let first = parameter_snapshot(&x, &bias);
assert_eq!(first.x.as_slice(), baseline.x_gradient);
assert_eq!(first.bias.as_slice(), baseline.bias_gradient);
output.backward_with_seed(&seed.view(), GraphRetention::Retain)?;
let repeated = parameter_snapshot(&x, &bias);
x.zero_grad()?;
bias.zero_grad()?;
let zeroed = parameter_snapshot(&x, &bias);
output.backward_with_seed(&seed.view(), GraphRetention::Release)?;
let after_zero_and_release = parameter_snapshot(&x, &bias);
let released_before = (
optional_tensor_bits(x.gradient()),
optional_tensor_bits(bias.gradient()),
);
let released_error = output
.backward_with_seed(&seed.view(), GraphRetention::Retain)
.expect_err("a released operation result must reject another pass");
let released_gradients_unchanged = released_before
== (
optional_tensor_bits(x.gradient()),
optional_tensor_bits(bias.gradient()),
);
Ok(FrozenTensorExample {
nodes: vec![
x, reshaped, transposed, bias, broadcast, added, multiplied, output,
],
seed,
baseline,
first_pass,
first,
repeated,
zeroed,
after_zero_and_release,
released_error,
released_gradients_unchanged,
})
} Применяйте VJP для каждого ребра вместо построения якобиана
Центральное правило обратного прохода:
Каждое достижимое ребро соответствует одному использованию родительского тензора на конкретном входе операции. Результат этой операции обозначен . Локальное отображение дифференцирует по значению на этом входе, считая остальные входы операции фиксированными. В обратном проходе уже известна величина — чувствительность выбранной цели к каждой координате . Действие возвращает эту информацию к точной форме .
VJP применяет это отображение напрямую и не создаёт матрицу с одной строкой для каждой координаты и одним столбцом для каждой координаты . Операция необходима, потому что к одному родителю может прийти другая ветвь или другой вход той же операции. Поэтому даёт два отдельных вклада, хотя оба ребра ссылаются на один узел.
Назовите тензоры, отображение и сопряжённые величины
| Символ | Смысл в вычислении |
|---|---|
| Достижимое множество записанных рёбер использования операндов. | |
| Одно использование родительского тензора на конкретном входе операции. | |
| Родительский тензор, переданный на этот вход по ребру . | |
| Результат операции, в которую тензор передан по ребру . | |
| Концептуальный якобиан результата по значению на этом входе при фиксированных остальных входах операции. | |
| Входящая сопряжённая величина текущего прохода точной формы . | |
| Аккумулятор сопряжённой величины родителя для текущего прохода точной формы . | |
| Добавить вклад ребра, не перезаписывая вклады других путей. |
Верхний индекс транспонирования относится к концептуальному якобиану одного
входа операции. Операция transpose(0,1) в прямом проходе — отдельное тензорное
преобразование; её VJP снова меняет местами сохранённые оси.
От явных уравнений для модели следующего слова к переиспользуемым тензорным VJP
Нейросетевая языковая модель Bengio и соавторов содержит миллионы параметров и выполняет явный прямой проход, за которым следуют специальные для этой сети уравнения обратного прохода и обновления. Эти уравнения позволяют проследить распространение градиента от функции потерь для следующего слова назад к параметрам модели. Но глубокая модель состоит из множества повторяющихся блоков, внутри которых меняется форма тензоров. В такой модели отдельный узел графа для каждого скалярного значения — как и вручную выписанный обратный расчёт для каждого тензорного выражения — быстро становится слишком громоздким.
Одним из ранних этапов развития нейросетевых языковых моделей стала работа Bengio et al., A Neural Probabilistic Language Model. Bengio и соавторы описывают нейросетевую модель следующего слова с миллионами параметров и публикуют явный прямой проход, а затем уравнения обратного прохода и обновления для выхода, скрытого слоя и обучаемых признаков слов.
Расчёт для фиксированной формы в рассматриваемом примере делает ограничение наглядным. Он даёт правильные градиенты ровно для одного известного выражения, но такой обратный расчёт нельзя переиспользовать после добавления новой операции, меняющей форму. Это иллюстративное сопоставление, а не код из работы Bengio и соавторов.
Abadi и соавторы представляют вычисление как граф, вершины которого являются операциями, а рёбра несут тензоры. Они описывают автоматическое дифференцирование, которое находит все обратные пути от функции потерь к параметрам и суммирует вклады этих путей в градиенты параметров. Затем Vaswani и соавторы обучают повторяющиеся тензорные блоки Transformer с вниманием и сетями прямого распространения, которые отдельно и одинаково применяются к каждой позиции, а Radford и соавторы масштабируют авторегрессионные языковые модели Transformer в глубину и ширину.
Переход к графам операций описан в работе Abadi et al., TensorFlow: A System for Large-Scale Machine Learning. Abadi и соавторы определяют вершины графа как операции, а значения рёбер — как тензоры, и описывают библиотеку дифференцирования, которая автоматически строит обратный проход для композиций слоёв и функций потерь: находит обратные пути к параметрам и суммирует вклад каждого пути в градиент параметра.
Обучение Transformer представлено работой Vaswani et al., Attention Is All You Need. Vaswani и соавторы строят Transformer из повторяющихся подслоёв внимания и сетей прямого распространения, которые отдельно и одинаково применяются к каждой позиции, и обучают базовые модели в течение 100 000 шагов, а большие — в течение 300 000 шагов, используя Adam.
Масштабирование авторегрессионной языковой модели показано в работе Radford et al., Language Models are Unsupervised Multitask Learners. Radford и соавторы используют авторегрессионные языковые модели на основе Transformer и сообщают о четырёх размерах: от 12 до 48 слоёв и от 117 миллионов до 1,542 миллиарда параметров.
В этой главе каждому использованию тензора на входе операции сопоставляется локальное произведение вектора на якобиан (VJP). Правила для изменения формы, транспонирования, согласования форм, суммы и среднего возвращают каждый вклад к точной форме родителя, после чего эти правила проверяются численно до добавления производных операций, специфичных для модели. При обычном инференсе обратный проход по ленте не запускается; во время обучения он переносит чувствительность функции потерь назад через повторяющиеся тензорные блоки.
Работа Abadi и соавторов подтверждает утверждения о графе операций над тензорами и суммировании путей, но не предписывает используемую здесь ленту с немедленным выполнением, в которой каждый узел владеет своим тензором. Работа Vaswani и соавторов подтверждает, что при обучении Transformer повторяются блоки внимания и сетей прямого распространения, а отчёт Radford и соавторов — что авторегрессионные модели Transformer стали глубже и шире. Ни один из этих источников не задаёт используемые здесь правила VJP и жизненного цикла.
Храните значения тензоров из прямого прохода и сохраняйте локальный контекст
TensorValue::parameter и TensorValue::constant принимают тензоры, все
значения которых конечны, а данные размещены непрерывно. Каждый узел ленты владеет своим тензором
прямого прохода — независимо от того, представляет ли узел параметр, константу
или результат операции. Метод value() предоставляет временный доступ только
для чтения к этому тензору внутри узла и не копирует его. Метод gradient()
аналогично предоставляет доступ только для чтения, если в листовом
узле-параметре хранится накопленный градиент. Константы и узлы операций не
хранят градиенты параметров.
Методы value() и gradient() возвращают объект Ref, который временно
удерживает доступ только для чтения к данным узла. Перед изменением тех же данных
этот Ref нужно освободить — например, явным вызовом drop или завершением
области видимости. Если Ref к накопленному градиенту всё ещё существует, zero_grad
или обратный проход возвращает GradientBorrowed вместо паники. Запрос
отклоняется атомарно: ни накопленные градиенты, ни состояние графа не меняются.
Когда данные должны остаться у вызывающего кода после освобождения Ref,
value_snapshot() создаёт отдельную копию тензора прямого прохода, а
gradient_snapshot() — отдельную копию накопленного градиента, если он
существует.
Такая отдельная копия содержит только данные и сама не является узлом ленты.
Клонирование TensorValue также не создаёт новый узел: исходный объект и его
клон обращаются к одному и тому же узлу. Метод detach() выполняет другую
операцию: он копирует тензор прямого прохода и создаёт из этой копии новый
неотслеживаемый лист TensorValue без ребра операнда. Поэтому последующие
изменения данных этих двух узлов не влияют друг на друга.
В каждом узле хранится также внутренний номер версии его тензора прямого прохода. На последующем шаге обучения новое значение параметра можно записать в тот же узел: идентичность узла сохранится, а номер версии увеличится. Поэтому одной идентичности узла недостаточно, чтобы считать старый сохранённый граф соответствующим текущим значениям параметров.
Номера версий — служебные данные времени выполнения, нужные только для проверки актуальности ленты: по ним обратный проход определяет, относится ли сохранённый контекст к текущим значениям параметров. Они не являются номерами шага оптимизатора и не записываются в контрольные точки модели.
rust/crates/llm-from-scratch/src/autograd/tensor_core.rs#tensor-tape-values #[derive(Clone)]
struct ParentEdge {
parent: TensorValue,
parent_value_revision: u64,
saved: TensorSavedContext,
}
impl ParentEdge {
fn capture(parent: &TensorValue, saved: TensorSavedContext) -> Self {
Self {
parent: parent.clone(),
parent_value_revision: parent.value_revision(),
saved,
}
}
}
struct NodeState {
parents: Vec<ParentEdge>,
parameter_gradient: Option<Tensor>,
released: bool,
}
struct Node {
value: RefCell<Tensor>,
value_revision: Cell<u64>,
operation: TensorOperation,
tracked: bool,
state: RefCell<NodeState>,
}
/// One owned tensor value and its operation-level reverse-mode tape.
#[derive(Clone)]
pub struct TensorValue {
node: Rc<Node>,
}
impl fmt::Debug for TensorValue {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("TensorValue")
.field("shape", &self.shape())
.field("operation", &self.operation())
.field("tracks_gradient", &self.tracks_gradient())
.field("gradient", &self.gradient())
.field("released", &self.is_released())
.finish()
}
}
/// One node in the deterministic parent-first topology of a backward pass.
#[derive(Clone, Debug, PartialEq)]
pub struct TensorBackwardNode {
pub topology_index: usize,
pub operation: TensorOperation,
pub shape: Vec<usize>,
pub tracked: bool,
pub parameter: bool,
pub pass_adjoint: Option<Tensor>,
pub accumulated_gradient: Option<Tensor>,
}
/// One ordered operand edge visited during tensor reverse traversal.
#[derive(Clone, Debug, PartialEq)]
pub struct TensorBackwardEdge {
pub reverse_index: usize,
pub child: usize,
pub parent: usize,
pub operand: usize,
pub saved: TensorSavedContext,
pub upstream: Tensor,
pub contribution: Tensor,
pub parent_tracked: bool,
pub parent_adjoint_before: Option<Tensor>,
pub parent_adjoint_after: Option<Tensor>,
}
/// The trace returned by one explicitly observed, successfully committed pass.
#[derive(Clone, Debug, PartialEq)]
pub struct TensorBackwardPass {
pub seed: Tensor,
pub retention: GraphRetention,
pub nodes: Vec<TensorBackwardNode>,
pub edges: Vec<TensorBackwardEdge>,
}
impl TensorValue {
/// Creates a finite leaf parameter initialized with an exact-shape zero gradient.
pub fn parameter(value: Tensor) -> Result<Self, TensorAutodiffError> {
check_finite_leaf(&value, TensorOperation::Parameter)?;
let gradient = zeros(value.shape())?;
Ok(Self::new_node(
value,
TensorOperation::Parameter,
Vec::new(),
true,
Some(gradient),
))
}
/// Creates a finite untracked tensor leaf.
pub fn constant(value: Tensor) -> Result<Self, TensorAutodiffError> {
check_finite_leaf(&value, TensorOperation::Constant)?;
Ok(Self::new_node(
value,
TensorOperation::Constant,
Vec::new(),
false,
None,
))
}
fn new_node(
value: Tensor,
operation: TensorOperation,
parents: Vec<ParentEdge>,
tracked: bool,
parameter_gradient: Option<Tensor>,
) -> Self {
Self {
node: Rc::new(Node {
value: RefCell::new(value),
value_revision: Cell::new(0),
operation,
tracked,
state: RefCell::new(NodeState {
parents,
parameter_gradient,
released: false,
}),
}),
}
}
fn operation_node(
value: Tensor,
operation: TensorOperation,
mut parents: Vec<ParentEdge>,
) -> Result<Self, TensorAutodiffError> {
check_finite_forward(&value, operation)?;
let tracked = !no_grad_active() && parents.iter().any(|edge| edge.parent.tracks_gradient());
if no_grad_active() {
parents.clear();
}
Ok(Self::new_node(value, operation, parents, tracked, None))
}
/// Builds one checked model-operation node without exposing tape internals.
pub(crate) fn model_operation<const N: usize>(
operation: TensorOperation,
operands: [&Self; N],
forward: impl FnOnce(
[&Tensor; N],
) -> Result<(Tensor, [ModelSavedContext; N]), TensorAutodiffError>,
) -> Result<Self, TensorAutodiffError> {
ensure_operands_available(operation, &operands)?;
let primals: [Ref<'_, Tensor>; N] = std::array::from_fn(|index| operands[index].value());
let primal_refs: [&Tensor; N] = std::array::from_fn(|index| &*primals[index]);
let (value, contexts) = forward(primal_refs)?;
let parents = operands
.into_iter()
.zip(contexts)
.map(|(parent, context)| {
ParentEdge::capture(parent, TensorSavedContext::Model(context))
})
.collect();
Self::operation_node(value, operation, parents)
}
/// Borrows the node-owned primal tensor.
pub fn value(&self) -> Ref<'_, Tensor> {
self.node.value.borrow()
}
/// Copies the node-owned primal into an independent tensor snapshot.
pub fn value_snapshot(&self) -> Tensor {
self.node.value.borrow().clone()
}
/// Copies the primal shape.
pub fn shape(&self) -> Vec<usize> {
self.node.value.borrow().shape().to_vec()
}
pub fn operation(&self) -> TensorOperation {
self.node.operation
}
pub fn tracks_gradient(&self) -> bool {
self.node.tracked
}
pub fn is_parameter(&self) -> bool {
self.operation() == TensorOperation::Parameter
}
pub fn is_released(&self) -> bool {
self.node.state.borrow().released
}
/// Borrows the accumulated gradient stored only by a parameter leaf.
pub fn gradient(&self) -> Option<Ref<'_, Tensor>> {
Ref::filter_map(self.node.state.borrow(), |state| {
state.parameter_gradient.as_ref()
})
.ok()
}
/// Copies the accumulated parameter gradient into an independent snapshot.
pub fn gradient_snapshot(&self) -> Option<Tensor> {
self.gradient().as_deref().cloned()
}
/// Returns whether two handles refer to the same tape node.
pub fn is_same_node(&self, other: &Self) -> bool {
Rc::ptr_eq(&self.node, &other.node)
}
/// Copies the primal into a new untracked leaf and severs all parent edges.
pub fn detach(&self) -> Self {
Self::new_node(
self.value_snapshot(),
TensorOperation::Detached,
Vec::new(),
false,
None,
)
}
} Операции прямого прохода создают тензоры, все значения которых конечны, а данные размещены непрерывно. Для сложения сохраняются формы двух операндов. Каждое ребро умножения сохраняет форму своего родителя и тензор прямого прохода другого операнда, потому что он нужен локальной производной. Изменение формы сохраняет входную форму, транспонирование — две оси, согласование форм — исходную форму, а редукции — ось, признак сохранения размерности и размер редуцируемой оси. Значения операций принадлежат ленте и не являются представлениями операндов: для каждого результата хранится собственный тензор.
При создании каждого ребра использования операнда лента записывает текущий номер версии тензора прямого прохода родителя. Этот номер связывает ребро и сохранённый контекст VJP именно с тем значением родителя, которое использовалось в данном прямом вычислении. Если позднее обновление параметра запишет новое значение в тот же узел, номер версии увеличится. Старое ребро по-прежнему будет вести к этому узлу, однако его сохранённый контекст относится к предыдущему значению.
rust/crates/llm-from-scratch/src/autograd/tensor_core.rs#tensor-forward-operations impl TensorValue {
/// Adds two tensors using trailing-axis broadcasting.
pub fn add(&self, other: &Self) -> Result<Self, TensorAutodiffError> {
ensure_operands_available(TensorOperation::Add, &[self, other])?;
let left = self.value();
let right = other.value();
let value = map_binary(&left.view(), &right.view(), |a, b| a + b)?;
let output_shape = value.shape().to_vec();
let parents = vec![
ParentEdge::capture(self, broadcast_context(left.shape(), &output_shape)),
ParentEdge::capture(other, broadcast_context(right.shape(), &output_shape)),
];
Self::operation_node(value, TensorOperation::Add, parents)
}
/// Multiplies two tensors and records one ordered edge per operand use.
pub fn mul(&self, other: &Self) -> Result<Self, TensorAutodiffError> {
ensure_operands_available(TensorOperation::Multiply, &[self, other])?;
let left = self.value();
let right = other.value();
let value = map_binary(&left.view(), &right.view(), |a, b| a * b)?;
let output_shape = value.shape().to_vec();
let parents = vec![
ParentEdge::capture(
self,
multiply_context(left.shape(), &output_shape, Tensor::clone(&right)),
),
ParentEdge::capture(
other,
multiply_context(right.shape(), &output_shape, Tensor::clone(&left)),
),
];
Self::operation_node(value, TensorOperation::Multiply, parents)
}
/// Changes shape without changing row-major element order.
pub fn reshape(&self, shape: &[usize]) -> Result<Self, TensorAutodiffError> {
ensure_operands_available(TensorOperation::Reshape, &[self])?;
let input = self.value();
let value = input.view().reshape(shape)?.materialize()?;
let saved = TensorSavedContext::Reshape {
input_shape: input.shape().to_vec(),
output_shape: value.shape().to_vec(),
};
Self::operation_node(
value,
TensorOperation::Reshape,
vec![ParentEdge::capture(self, saved)],
)
}
/// Swaps two axes and materializes the logical result as owned storage.
pub fn transpose(
&self,
first_axis: usize,
second_axis: usize,
) -> Result<Self, TensorAutodiffError> {
ensure_operands_available(TensorOperation::Transpose, &[self])?;
let input = self.value();
let value = input
.view()
.transpose(first_axis, second_axis)?
.materialize()?;
let saved = TensorSavedContext::Transpose {
first_axis,
second_axis,
input_shape: input.shape().to_vec(),
output_shape: value.shape().to_vec(),
};
Self::operation_node(
value,
TensorOperation::Transpose,
vec![ParentEdge::capture(self, saved)],
)
}
/// Broadcasts exactly to `shape`; the requested shape may only expand axes.
pub fn broadcast_to(&self, shape: &[usize]) -> Result<Self, TensorAutodiffError> {
ensure_operands_available(TensorOperation::Broadcast, &[self])?;
let input = self.value();
let inferred = broadcast_shape(input.shape(), shape)?;
if inferred != shape {
return Err(TensorAutodiffError::BroadcastTargetMismatch {
input: input.shape().to_vec(),
requested: shape.to_vec(),
inferred,
});
}
let blank = zeros(shape)?;
let value = map_binary(&input.view(), &blank.view(), |value, _| value)?;
Self::operation_node(
value,
TensorOperation::Broadcast,
vec![ParentEdge::capture(
self,
broadcast_context(input.shape(), shape),
)],
)
}
/// Sums one axis and records how to expand its exact-shape VJP.
pub fn sum_axis(&self, axis: usize, keep_dim: bool) -> Result<Self, TensorAutodiffError> {
self.reduce_axis(axis, keep_dim, false)
}
/// Averages one nonempty axis and records the divisor for its VJP.
pub fn mean_axis(&self, axis: usize, keep_dim: bool) -> Result<Self, TensorAutodiffError> {
self.reduce_axis(axis, keep_dim, true)
}
fn reduce_axis(
&self,
axis: usize,
keep_dim: bool,
mean: bool,
) -> Result<Self, TensorAutodiffError> {
let operation = if mean {
TensorOperation::Mean
} else {
TensorOperation::Sum
};
ensure_operands_available(operation, &[self])?;
let input = self.value();
let value = if mean {
tensor_mean_axis(&input.view(), axis, keep_dim)?
} else {
tensor_sum_axis(&input.view(), axis, keep_dim)?
};
let divisor = if mean { input.shape()[axis] } else { 1 };
let saved = TensorSavedContext::Reduction {
axis,
keep_dim,
divisor,
input_shape: input.shape().to_vec(),
output_shape: value.shape().to_vec(),
};
Self::operation_node(value, operation, vec![ParentEdge::capture(self, saved)])
}
} Каждое локальное правило возвращает вклад в форме своего родителя. Сложение и
умножение сначала вычисляют отдельный вклад для каждого ребра операнда, а затем
суммируют значения по осям согласования форм до сохранённой формы этого
операнда. Изменение формы помещает логически плоские значения в сохранённую
форму. Транспонирование снова меняет местами те же оси. Явное согласование форм
суммирует значения по отсутствующим ведущим и единичным осям родителя. VJP
суммы повторяет входящую сопряжённую величину вдоль редуцированной оси; VJP
среднего делает то же самое и делит каждое скопированное значение на сохранённый
ненулевой размер оси. При keep_dim=false редуцированной оси нет во входящей
форме, поэтому она вставляется с нулевым эффективным шагом. При
keep_dim=true эта ось сохраняется во входящей форме с размером один, но её
эффективный шаг всё равно равен нулю: все координаты исходного входа вдоль этой
оси читают одно и то же входящее значение.
Для обратного прохода через согласование форм и для расширения сопряжённой величины редукции реализация строит план эффективных шагов. Проекция сопоставляет каждой оси формы логического обхода эффективный шаг в хранилище, из которого VJP читает либо в которое записывает. Эффективный шаг — это величина, на которую изменяется смещение в хранилище при увеличении координаты одной оси на единицу, если остальные координаты остаются фиксированными. VJP вычисляет эти шаги из метаданных операции, уже проверенных в прямом проходе. Нулевой шаг получают оси, на которых переиспользуется одна координата, а также отсутствующие и восстановленные оси. Остальные выровненные оси используют соответствующие шаги непрерывной построчной раскладки.
Перед началом обхода курсор смещений проверяет, что число эффективных шагов совпадает с числом осей формы, заново вычисляет логическое число элементов и сверяет его с длиной обхода, отклоняет переполнение арифметики смещений и — для непустого обхода — проверяет, что наибольшее достижимое смещение находится внутри соответствующего хранилища. Пустой обход не выдаёт ни одного смещения и ничего не читает. После этой единственной проверки курсор идёт в порядке строк и хранит по одной записи состояния на ось, поэтому объём его состояния равен . Он не создаёт заново вектор координат и не преобразует координаты в смещение для каждого скалярного значения.
При обращении редукции эффективные шаги выбирают значения из входящей
сопряжённой величины. В рассматриваемом примере вход среднего — результат
умножения формы [2,3], а входящая сопряжённая величина имеет форму [2].
Эффективные шаги источника равны [1,0]: шаг 1 выбирает следующий элемент
величины [3,6] при переходе к следующей строке входа среднего, а шаг 0
повторяет выбранный элемент внутри строки. Поэтому курсор выдаёт смещения
источника [0,0,0,1,1,1]: первые три элемента градиента входа среднего читают
элемент начальной сопряжённой величины по смещению 0, а следующие три — по
смещению 1. Данные нового градиента входа среднего размещены непрерывно в
построчном порядке и записываются по смещениям [0,1,2,3,4,5]; каждое
прочитанное значение делится на три. Для тех же формы, оси и значения
keep_dim VJP суммы использует те же смещения и делитель один. В общем случае
каждая нередуцированная ось использует соответствующий шаг входящей
сопряжённой величины в построчной раскладке.
При обращении согласования форм курсор использует эффективные шаги, чтобы для
каждого входящего значения выбрать элемент градиента назначения. Выход операции
согласования форм — а значит, и входящая сопряжённая величина её VJP — имеет
форму [2,3], тогда как новый градиент bias имеет форму [3]. Эффективные
шаги назначения равны [0,1]: отсутствующей ведущей оси соответствует нулевой
шаг, а последняя ось сохраняет шаг градиента bias. Цикл читает входящие
значения [4,4,10,12,12,24] в плоском порядке строк, а курсор сопоставляет им
смещения назначения [0,1,2,0,1,2]. Цикл сохраняет порядок входящих значений:
сначала в аккумуляторы добавляются [4,4,10], затем [12,12,24]. Поэтому
сложения выполняются как и дают [16,16,34]. Каждая
отсутствующая ведущая ось и каждая выровненная ось родителя размером 1
получают нулевой эффективный шаг; каждая выровненная ось другого размера
использует соответствующий шаг родителя в построчной раскладке.
rust/crates/llm-from-scratch/src/autograd/tensor_core.rs#tensor-structural-vjps fn broadcast_context(input_shape: &[usize], output_shape: &[usize]) -> TensorSavedContext {
TensorSavedContext::Broadcast {
input_shape: input_shape.to_vec(),
output_shape: output_shape.to_vec(),
reduced_axes: broadcast_reduced_axes(input_shape, output_shape),
}
}
fn multiply_context(
input_shape: &[usize],
output_shape: &[usize],
other: Tensor,
) -> TensorSavedContext {
TensorSavedContext::Multiply {
other,
input_shape: input_shape.to_vec(),
output_shape: output_shape.to_vec(),
reduced_axes: broadcast_reduced_axes(input_shape, output_shape),
}
}
fn broadcast_reduced_axes(input_shape: &[usize], output_shape: &[usize]) -> Vec<usize> {
let padding = output_shape.len() - input_shape.len();
(0..output_shape.len())
.filter(|&axis| {
axis < padding || (input_shape[axis - padding] == 1 && output_shape[axis] != 1)
})
.collect()
}
fn apply_vjp(upstream: &Tensor, saved: &TensorSavedContext) -> Result<Tensor, TensorAutodiffError> {
match saved {
TensorSavedContext::Broadcast {
input_shape,
output_shape,
..
} => {
debug_assert_eq!(upstream.shape(), output_shape);
unbroadcast(upstream, input_shape)
}
TensorSavedContext::Multiply {
other,
input_shape,
output_shape,
..
} => {
debug_assert_eq!(upstream.shape(), output_shape);
let product = map_binary(&upstream.view(), &other.view(), |a, b| a * b)?;
unbroadcast(&product, input_shape)
}
TensorSavedContext::Reshape {
input_shape,
output_shape,
} => {
debug_assert_eq!(upstream.shape(), output_shape);
Ok(upstream.view().reshape(input_shape)?.materialize()?)
}
TensorSavedContext::Transpose {
first_axis,
second_axis,
output_shape,
..
} => {
debug_assert_eq!(upstream.shape(), output_shape);
Ok(upstream
.view()
.transpose(*first_axis, *second_axis)?
.materialize()?)
}
TensorSavedContext::Reduction {
axis,
keep_dim,
divisor,
input_shape,
output_shape,
} => {
debug_assert_eq!(upstream.shape(), output_shape);
expand_reduction(upstream, input_shape, *axis, *keep_dim, *divisor)
}
TensorSavedContext::Model(saved) => apply_model_vjp(upstream, saved),
}
}
fn unbroadcast(upstream: &Tensor, input_shape: &[usize]) -> Result<Tensor, TensorAutodiffError> {
let mut result = zeros(input_shape)?;
accumulate_unbroadcast(upstream, &mut result);
Ok(result)
}
pub(super) fn accumulate_unbroadcast(upstream: &Tensor, result: &mut Tensor) {
let output_shape = upstream.shape();
let input_shape = result.shape();
debug_assert!(output_shape.len() >= input_shape.len());
let padding = output_shape.len() - input_shape.len();
let destination_strides = output_shape
.iter()
.enumerate()
.map(|(output_axis, _)| {
if output_axis < padding || input_shape[output_axis - padding] == 1 {
0
} else {
result.strides()[output_axis - padding]
}
})
.collect::<Vec<_>>();
let destination_offsets = result
.view()
.projected_offsets(output_shape, &destination_strides, upstream.len())
.expect("a checked broadcast VJP retains a valid destination traversal plan");
for (&value, destination_offset) in upstream.as_slice().iter().zip(destination_offsets) {
result.as_mut_slice()[destination_offset] += value;
}
}
fn expand_reduction(
upstream: &Tensor,
input_shape: &[usize],
axis: usize,
keep_dim: bool,
divisor: usize,
) -> Result<Tensor, TensorAutodiffError> {
debug_assert!(divisor > 0);
let mut result = zeros(input_shape)?;
let mut upstream_axis = 0;
let source_strides = input_shape
.iter()
.enumerate()
.map(|(input_axis, _)| {
if input_axis == axis {
if keep_dim {
upstream_axis += 1;
}
0
} else {
let stride = upstream.strides()[upstream_axis];
upstream_axis += 1;
stride
}
})
.collect::<Vec<_>>();
debug_assert_eq!(upstream_axis, upstream.rank());
let source_offsets = upstream
.view()
.projected_offsets(input_shape, &source_strides, result.len())
.expect("a checked reduction VJP retains a valid source traversal plan");
for (destination, source_offset) in result.as_mut_slice().iter_mut().zip(source_offsets) {
*destination = upstream.as_slice()[source_offset] / divisor as f64;
}
Ok(result)
} backward() — обычный вызов для отслеживаемого выхода нулевого ранга; этот метод
подставляет скалярную начальную сопряжённую величину, равную единице. Для
нескалярного выхода используется backward_with_seed с тензором точно такой же
формы, все значения которого конечны. Оба метода строят порядок узлов, хранят сопряжённые величины
только на время текущего прохода и читают сохранённый контекст, необходимый
локальным VJP. Они записывают градиенты параметров, но не создают отчёт по узлам
и рёбрам.
Если такой отчёт нужен для разбора вычисления, вызывается
backward_with_trace или backward_with_seed_and_trace. Обратный расчёт
выполняется тем же способом; дополнительно эти методы возвращают
TensorBackwardPass. Для каждого ребра
трассировка записывает входящую сопряжённую величину, локальный вклад и значение
аккумулятора родителя для текущего прохода непосредственно до и после добавления
этого вклада. Запись каждого узла содержит его сопряжённую величину для текущего
прохода, а для листа-параметра — также проверенное значение, которое будет
сохранено как градиент после успешного прохода. Производные при этом не
вычисляются повторно.
backward() и backward_with_trace() всегда сохраняют граф (Retain). Методы
с seed принимают явный вариант GraphRetention::Retain или
GraphRetention::Release независимо от того, запрашивается ли трассировка.
Чтобы освободить граф со скалярным выходом, вызовите один из методов с seed,
передав начальную сопряжённую величину нулевого ранга, равную единице, и
GraphRetention::Release. Обратный обход посещает каждый узел один раз, но
обрабатывает все упорядоченные рёбра, включая оба ребра умножения. Градиенты
параметров не меняются, пока не пройдут проверку все VJP, аккумуляторы текущего
прохода и суммы уже накопленных градиентов параметров с новыми вкладами этого
прохода.
Сначала обратный проход проверяет, что контекст выбранного выхода ещё существует
и что для этого выхода отслеживаются градиенты. Затем он строит топологический
порядок всех достижимых узлов; если контекст хотя бы одной достижимой операции
уже освобождён, запрос отклоняется. После этого обратный проход проверяет, что
форма начальной сопряжённой величины точно совпадает с формой выхода и что все её
значения конечны. Только затем для каждого достижимого ребра операнда он
сравнивает номер версии, записанный в ребре, с текущим номером версии родителя.
Узлы проверяются в детерминированном обратном топологическом порядке, а рёбра
каждого узла — в порядке операндов. Вся проверка версий завершается до
вычисления любого VJP, передачи сведений наблюдателю трассировки, если она
запрошена, получения доступа для записи градиента или освобождения контекста
графа. При
первом несовпадении возвращается StaleOperandValue: ошибка указывает
узел-потребитель, родительский узел, позицию входа операции, записанную и текущую версии.
Отклонённый вызов не меняет накопленные градиенты и не освобождает контекст ни
одной операции. Для обновлённых значений параметров необходимо выполнить новый
прямой проход, который создаст новые рёбра и сохранит новый контекст.
rust/crates/llm-from-scratch/src/autograd/tensor_core.rs#tensor-reverse-pass /// Reverses a rank-zero output with an implicit scalar seed of one.
pub fn backward(&self) -> Result<(), TensorAutodiffError> {
self.backward_scalar(NoTensorBackwardTrace)
}
/// Reverses a rank-zero output and records its node and edge evidence.
pub fn backward_with_trace(&self) -> Result<TensorBackwardPass, TensorAutodiffError> {
self.backward_scalar(RecordTensorBackwardTrace::default())
}
fn backward_scalar<Observer: TensorBackwardObserver>(
&self,
observer: Observer,
) -> Result<Observer::Output, TensorAutodiffError> {
if self.is_released() {
return Err(TensorAutodiffError::GraphReleased {
operation: self.operation(),
});
}
if !self.tracks_gradient() {
return Err(TensorAutodiffError::UntrackedOutput {
operation: self.operation(),
});
}
if self.shape() != Vec::<usize>::new() {
return Err(TensorAutodiffError::SeedShapeMismatch {
expected: self.shape(),
actual: Vec::new(),
});
}
let seed = Tensor::from_vec(Vec::new(), vec![1.0])?;
self.backward_with_observer(&seed.view(), GraphRetention::Retain, observer)
}
/// Runs a fresh exact-shape reverse pass without creating a trace record.
///
/// Stored parameter gradients and graph edges remain bit-identical unless
/// every VJP, pass accumulation, and prospective stored gradient is finite.
pub fn backward_with_seed(
&self,
seed: &TensorView<'_>,
retention: GraphRetention,
) -> Result<(), TensorAutodiffError> {
self.backward_with_observer(seed, retention, NoTensorBackwardTrace)
}
/// Runs a fresh exact-shape reverse pass and records its trace.
pub fn backward_with_seed_and_trace(
&self,
seed: &TensorView<'_>,
retention: GraphRetention,
) -> Result<TensorBackwardPass, TensorAutodiffError> {
self.backward_with_observer(seed, retention, RecordTensorBackwardTrace::default())
}
fn backward_with_observer<Observer: TensorBackwardObserver>(
&self,
seed: &TensorView<'_>,
retention: GraphRetention,
mut observer: Observer,
) -> Result<Observer::Output, TensorAutodiffError> {
if self.is_released() {
return Err(TensorAutodiffError::GraphReleased {
operation: self.operation(),
});
}
if !self.tracks_gradient() {
return Err(TensorAutodiffError::UntrackedOutput {
operation: self.operation(),
});
}
let topology = self.topology()?;
let expected = self.shape();
if seed.shape() != expected {
return Err(TensorAutodiffError::SeedShapeMismatch {
expected,
actual: seed.shape().to_vec(),
});
}
let seed = seed.materialize()?;
if let Some((index, value)) = first_nonfinite(&seed) {
return Err(TensorAutodiffError::NonFiniteSeed { index, value });
}
let indices = topology
.iter()
.enumerate()
.map(|(index, value)| (value.key(), index))
.collect::<HashMap<_, _>>();
for (child, value) in topology.iter().enumerate().rev() {
let state = value.node.state.borrow();
for (operand, edge) in state.parents.iter().enumerate() {
let current_revision = edge.parent.value_revision();
if edge.parent_value_revision != current_revision {
return Err(TensorAutodiffError::StaleOperandValue {
child,
parent: indices[&edge.parent.key()],
operand,
recorded_revision: edge.parent_value_revision,
current_revision,
});
}
}
}
let mut pass_adjoints = vec![None; topology.len()];
pass_adjoints[topology.len() - 1] = Some(seed);
for child in (0..topology.len()).rev() {
let Some(upstream) = pass_adjoints[child].clone() else {
continue;
};
let state = topology[child].node.state.borrow();
for (operand, edge) in state.parents.iter().enumerate() {
let parent = indices[&edge.parent.key()];
let contribution = apply_vjp(&upstream, &edge.saved)?;
if let Some((index, value)) = first_nonfinite(&contribution) {
return Err(TensorAutodiffError::NonFiniteVjp {
child,
parent,
operand,
index,
value,
});
}
let parent_tracked = edge.parent.tracks_gradient();
if parent_tracked {
let previous = pass_adjoints[parent]
.clone()
.unwrap_or(zeros(edge.parent.shape().as_slice())?);
let next =
add_checked(&previous, &contribution, |index, previous, contribution| {
TensorAutodiffError::NonFinitePassAdjoint {
node: parent,
index,
previous,
contribution,
}
})?;
observer.observe_edge(
child,
parent,
operand,
&edge.saved,
&upstream,
&contribution,
true,
Some(&previous),
Some(&next),
);
pass_adjoints[parent] = Some(next);
} else {
observer.observe_edge(
child,
parent,
operand,
&edge.saved,
&upstream,
&contribution,
false,
None,
None,
);
}
}
}
let mut prospective = vec![None; topology.len()];
for (index, value) in topology.iter().enumerate() {
let Some(stored) = value.gradient() else {
continue;
};
let pass = pass_adjoints[index]
.clone()
.unwrap_or(zeros(value.shape().as_slice())?);
prospective[index] = Some(add_checked(
&stored,
&pass,
|element, stored, pass_adjoint| TensorAutodiffError::NonFiniteAccumulatedGradient {
node: index,
index: element,
stored,
pass_adjoint,
},
)?);
}
let observation = observer.finish(retention, &topology, &pass_adjoints, &prospective);
let mut commits: Vec<(RefMut<'_, NodeState>, Tensor)> = Vec::new();
for (value, gradient) in topology.iter().zip(prospective) {
if let Some(gradient) = gradient {
let state = value
.node
.state
.try_borrow_mut()
.map_err(|_| TensorAutodiffError::GradientBorrowed)?;
commits.push((state, gradient));
}
}
for (mut state, gradient) in commits {
state.parameter_gradient = Some(gradient);
}
if retention == GraphRetention::Release {
for value in &topology {
if !value.operation().is_leaf() {
let mut state = value.node.state.borrow_mut();
state.parents.clear();
state.released = true;
}
}
}
Ok(observation)
}
/// Clears this parameter's accumulated gradient without changing its tape.
pub fn zero_grad(&self) -> Result<(), TensorAutodiffError> {
if !self.is_parameter() {
return Err(TensorAutodiffError::NotAParameter {
operation: self.operation(),
});
}
let shape = self.shape();
self.node
.state
.try_borrow_mut()
.map_err(|_| TensorAutodiffError::GradientBorrowed)?
.parameter_gradient = Some(zeros(&shape)?);
Ok(())
} В первом проходе рассматриваемого примера явно запрашивается трассировка: именно
из неё берутся показанные ниже сведения о каждом ребре. Во втором проходе нужны
только записанные градиенты, поэтому используется обычный вызов. Между двумя
вызовами значения параметров не меняются, поэтому все записанные номера версий
по-прежнему совпадают с текущими. Второй проход с сохранением графа операций заново
вычисляет сопряжённые величины и вклады текущего прохода и удваивает два
накопленных градиента параметров. Сохранение графа оставляет контекст доступным,
но не обновляет его: после изменения параметра на месте этот контекст уже не
соответствует новым значениям. zero_grad записывает положительный ноль через
сохранённые дескрипторы TensorValue для узлов-параметров. После обнуления
успешный освобождающий проход заново вычисляет сопряжённые величины текущего прохода и записывает
градиенты, численно равные градиентам первого прохода, а затем удаляет
родительские рёбра и сохранённый контекст достижимых узлов операций. Прямые
значения, накопленные градиенты параметров и ранее полученная явная трассировка
остаются доступными, но повторный обратный проход или дифференцируемое
переиспользование освобождённой операции завершается ошибкой без изменения
состояния.
Короткий пример задаёт параметр и перед суммированием складывает
с . В прямом проходе две ветви дают
соответственно и , а их общая сумма равна . У отсоединённой ветви
нет ребра к , поэтому градиент параметра равен [4,6]. В том
же примере каждый поддерживаемый VJP сопоставляется с центральными разностями
из главы 13 в выбранных координатах:
rust/demos/ch15-tensor-autodiff-core/src/lib.rs#tensor-autodiff-lifecycle-gradcheck /// Keeps one live branch and stops the equal-valued detached branch.
pub fn detach_sum_example() -> Result<DetachSumExample, TensorAutodiffError> {
let p = TensorValue::parameter(tensor(&[2], &[2.0, 3.0]))?;
let squared = p.mul(&p)?;
let detached = p.detach();
let ten = TensorValue::constant(tensor(&[], &[10.0]))?;
let stopped = detached.mul(&ten)?;
let elements = squared.add(&stopped)?;
let output = elements.sum_axis(0, false)?;
output.backward()?;
Ok(DetachSumExample {
value: output.value().as_slice()[0],
p_gradient: p.gradient_snapshot().expect("p is a parameter"),
detached_gradient: detached.gradient_snapshot(),
detached_is_new_node: !p.is_same_node(&detached),
detached_tracks_gradient: detached.tracks_gradient(),
})
}
/// Checks add, multiply, reshape, transpose, broadcast, sum, and mean VJPs.
pub fn vjp_gradcheck_example() -> Result<VjpGradcheckExample, Box<dyn Error>> {
let matrix_seed = tensor(&X_SHAPE, &[1.0, -2.0, 0.5, 3.0, -1.5, 2.0]);
let bias = frozen_bias();
let add = sampled_vjp_check(
frozen_x(),
&matrix_seed,
|parameter| {
let bias = TensorValue::constant(bias.clone())?;
parameter.add(&bias)
},
|candidate| {
let values = candidate
.as_slice()
.iter()
.enumerate()
.map(|(offset, value)| value + BIAS_VALUES[offset % X_SHAPE[1]])
.collect::<Vec<_>>();
weighted_sum(&values, matrix_seed.as_slice())
},
)?;
let multiply = sampled_vjp_check(
frozen_x(),
&matrix_seed,
|parameter| {
let bias = TensorValue::constant(bias.clone())?;
parameter.mul(&bias)
},
|candidate| {
let values = candidate
.as_slice()
.iter()
.enumerate()
.map(|(offset, value)| value * BIAS_VALUES[offset % X_SHAPE[1]])
.collect::<Vec<_>>();
weighted_sum(&values, matrix_seed.as_slice())
},
)?;
let reshape_seed = tensor(&RESHAPED_SHAPE, &[0.5, 1.0, -1.0, 2.0, 3.0, -0.25]);
let reshape = sampled_vjp_check(
frozen_x(),
&reshape_seed,
|parameter| parameter.reshape(&RESHAPED_SHAPE),
|candidate| weighted_sum(candidate.as_slice(), reshape_seed.as_slice()),
)?;
let transpose_seed = tensor(&[3, 2], &[1.0, -2.0, 3.0, -4.0, 5.0, -6.0]);
let transpose = sampled_vjp_check(
frozen_x(),
&transpose_seed,
|parameter| parameter.transpose(0, 1),
|candidate| {
let mut result = 0.0;
for row in 0..X_SHAPE[0] {
for column in 0..X_SHAPE[1] {
let input = row * X_SHAPE[1] + column;
let output = column * X_SHAPE[0] + row;
result += candidate.as_slice()[input] * transpose_seed.as_slice()[output];
}
}
result
},
)?;
let broadcast = sampled_vjp_check(
frozen_bias(),
&matrix_seed,
|parameter| parameter.broadcast_to(&X_SHAPE),
|candidate| {
let mut result = 0.0;
for row in 0..X_SHAPE[0] {
for column in 0..X_SHAPE[1] {
result += candidate.as_slice()[column]
* matrix_seed.as_slice()[row * X_SHAPE[1] + column];
}
}
result
},
)?;
let reduction_seed = tensor(&OUTPUT_SHAPE, &[2.0, -3.0]);
let sum = sampled_vjp_check(
frozen_x(),
&reduction_seed,
|parameter| parameter.sum_axis(1, false),
|candidate| {
(0..X_SHAPE[0])
.map(|row| {
candidate.as_slice()[row * X_SHAPE[1]..(row + 1) * X_SHAPE[1]]
.iter()
.sum::<f64>()
* reduction_seed.as_slice()[row]
})
.sum()
},
)?;
let mean = sampled_vjp_check(
frozen_x(),
&reduction_seed,
|parameter| parameter.mean_axis(1, false),
|candidate| {
(0..X_SHAPE[0])
.map(|row| {
candidate.as_slice()[row * X_SHAPE[1]..(row + 1) * X_SHAPE[1]]
.iter()
.sum::<f64>()
/ X_SHAPE[1] as f64
* reduction_seed.as_slice()[row]
})
.sum()
},
)?;
let passed = [
&add, &multiply, &reshape, &transpose, &broadcast, &sum, &mean,
]
.into_iter()
.all(|check| check.passed);
Ok(VjpGradcheckExample {
add,
multiply,
reshape,
transpose,
broadcast,
sum,
mean,
passed,
})
}
/// Exercises typed seed, release, and transactional accumulation failures.
pub fn typed_error_example() -> Result<TypedErrorExample, TensorAutodiffError> {
let shape_parameter = TensorValue::parameter(tensor(&[2], &[2.0, 3.0]))?;
let shape_output = shape_parameter.mul(&shape_parameter)?;
let shape_before = optional_tensor_bits(shape_parameter.gradient());
let wrong_shape = tensor(&[1], &[1.0]);
let seed_shape_error = shape_output
.backward_with_seed(&wrong_shape.view(), GraphRetention::Release)
.expect_err("a wrong-shape seed must fail");
let seed_shape_unchanged = optional_tensor_bits(shape_parameter.gradient()) == shape_before;
let valid_shape_seed = tensor(&[2], &[1.0, 1.0]);
let seed_shape_graph_unchanged = !shape_output.is_released()
&& shape_output
.backward_with_seed(&valid_shape_seed.view(), GraphRetention::Retain)
.is_ok();
let finite_parameter = TensorValue::parameter(tensor(&[2], &[2.0, 3.0]))?;
let finite_output = finite_parameter.mul(&finite_parameter)?;
let finite_before = optional_tensor_bits(finite_parameter.gradient());
let nonfinite = tensor(&[2], &[1.0, f64::NAN]);
let nonfinite_seed_error = finite_output
.backward_with_seed(&nonfinite.view(), GraphRetention::Release)
.expect_err("a non-finite seed must fail");
let nonfinite_seed_unchanged =
optional_tensor_bits(finite_parameter.gradient()) == finite_before;
let nonfinite_graph_unchanged = !finite_output.is_released()
&& finite_output
.backward_with_seed(&valid_shape_seed.view(), GraphRetention::Retain)
.is_ok();
let released_parameter = TensorValue::parameter(tensor(&[2], &[2.0, 3.0]))?;
let released_square = released_parameter.mul(&released_parameter)?;
let released_output = released_square.mean_axis(0, false)?;
released_output.backward_with_seed(&tensor(&[], &[1.0]).view(), GraphRetention::Release)?;
let released_before = optional_tensor_bits(released_parameter.gradient());
let graph_released_error = released_output
.backward()
.expect_err("a released mean must reject another pass");
let graph_released_unchanged =
optional_tensor_bits(released_parameter.gradient()) == released_before;
let graph_still_released = released_output.is_released();
let accumulated = TensorValue::parameter(tensor(&[1], &[1.0]))?;
let maximum = tensor(&[1], &[f64::MAX]);
accumulated.backward_with_seed(&maximum.view(), GraphRetention::Retain)?;
let accumulated_before = optional_tensor_bits(accumulated.gradient());
let nonfinite_accumulation_error = accumulated
.backward_with_seed(&maximum.view(), GraphRetention::Release)
.expect_err("overflowing a stored parameter gradient must fail");
let nonfinite_accumulation_unchanged =
optional_tensor_bits(accumulated.gradient()) == accumulated_before;
let cancellation = tensor(&[1], &[-f64::MAX]);
let accumulation_graph_unchanged = !accumulated.is_released()
&& accumulated
.backward_with_seed(&cancellation.view(), GraphRetention::Retain)
.is_ok();
let seed_shape = AtomicTensorError {
error: seed_shape_error,
gradients_unchanged: seed_shape_unchanged,
graph_unchanged: seed_shape_graph_unchanged,
};
let nonfinite_seed = AtomicTensorError {
error: nonfinite_seed_error,
gradients_unchanged: nonfinite_seed_unchanged,
graph_unchanged: nonfinite_graph_unchanged,
};
let graph_released = AtomicTensorError {
error: graph_released_error,
gradients_unchanged: graph_released_unchanged,
graph_unchanged: graph_still_released,
};
let nonfinite_accumulation = AtomicTensorError {
error: nonfinite_accumulation_error,
gradients_unchanged: nonfinite_accumulation_unchanged,
graph_unchanged: accumulation_graph_unchanged,
};
let all_unchanged = [
&seed_shape,
&nonfinite_seed,
&graph_released,
&nonfinite_accumulation,
]
.into_iter()
.all(|failure| failure.gradients_unchanged && failure.graph_unchanged);
Ok(TypedErrorExample {
seed_shape,
nonfinite_seed,
graph_released,
nonfinite_accumulation,
all_unchanged,
})
} Типизированные ошибки различают неконечные значения в листовых узлах и результатах, недопустимые операции, неверную форму или значение начальной сопряжённой величины, неконечные величины обратного прохода, неотслеживаемые выходы, освобождённые графы, несовпадение записанной в старом графе версии значения родителя с текущей и попытку изменить накопленный градиент, к которому всё ещё открыт доступ только для чтения. При ошибке биты накопленных градиентов и состояние жизненного цикла не меняются.
rust/crates/llm-from-scratch/src/autograd/tensor_core.rs#tensor-autodiff-errors /// A deterministic rejection from tensor tape construction or reversal.
#[derive(Clone, Debug, PartialEq)]
pub enum TensorAutodiffError {
Tensor(TensorError),
View(TensorViewError),
Operation(TensorOpError),
Matmul(MatmulError),
Probability(ProbabilityError),
Model(ModelOpError),
BroadcastTargetMismatch {
input: Vec<usize>,
requested: Vec<usize>,
inferred: Vec<usize>,
},
NonFiniteLeaf {
operation: TensorOperation,
index: usize,
value: f64,
},
NonFiniteForward {
operation: TensorOperation,
index: usize,
value: f64,
},
UntrackedOutput {
operation: TensorOperation,
},
GraphReleased {
operation: TensorOperation,
},
ReleasedOperand {
operation: TensorOperation,
operand: usize,
},
SeedShapeMismatch {
expected: Vec<usize>,
actual: Vec<usize>,
},
NonFiniteSeed {
index: usize,
value: f64,
},
StaleOperandValue {
child: usize,
parent: usize,
operand: usize,
recorded_revision: u64,
current_revision: u64,
},
NonFiniteVjp {
child: usize,
parent: usize,
operand: usize,
index: usize,
value: f64,
},
NonFinitePassAdjoint {
node: usize,
index: usize,
previous: f64,
contribution: f64,
},
NonFiniteAccumulatedGradient {
node: usize,
index: usize,
stored: f64,
pass_adjoint: f64,
},
GradientBorrowed,
NotAParameter {
operation: TensorOperation,
},
}
impl fmt::Display for TensorAutodiffError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Tensor(error) => error.fmt(formatter),
Self::View(error) => error.fmt(formatter),
Self::Operation(error) => error.fmt(formatter),
Self::Matmul(error) => error.fmt(formatter),
Self::Probability(error) => error.fmt(formatter),
Self::Model(error) => error.fmt(formatter),
Self::BroadcastTargetMismatch {
input,
requested,
inferred,
} => write!(
formatter,
"cannot broadcast shape {input:?} exactly to {requested:?}; broadcasting infers {inferred:?}"
),
Self::NonFiniteLeaf {
operation,
index,
value,
} => write!(
formatter,
"{operation} tensor value at flat index {index} must be finite, got {value:?}"
),
Self::NonFiniteForward {
operation,
index,
value,
} => write!(
formatter,
"{operation} produced non-finite value {value:?} at flat index {index}"
),
Self::UntrackedOutput { operation } => write!(
formatter,
"cannot backpropagate from untracked {operation} output"
),
Self::GraphReleased { operation } => {
write!(
formatter,
"the {operation} operation tape has been released"
)
}
Self::ReleasedOperand { operation, operand } => write!(
formatter,
"cannot build {operation}: operand {operand} reaches a released operation tape"
),
Self::SeedShapeMismatch { expected, actual } => write!(
formatter,
"backward seed shape {actual:?} does not match output shape {expected:?}"
),
Self::NonFiniteSeed { index, value } => write!(
formatter,
"backward seed at flat index {index} must be finite, got {value:?}"
),
Self::StaleOperandValue {
child,
parent,
operand,
recorded_revision,
current_revision,
} => write!(
formatter,
"cannot backpropagate through operand {operand} from topology node {child} to {parent}: the forward pass recorded parent value revision {recorded_revision}, but its current revision is {current_revision}; run a new forward pass"
),
Self::NonFiniteVjp {
child,
parent,
operand,
index,
value,
} => write!(
formatter,
"edge {operand} from topology node {child} to {parent} produced non-finite VJP value {value:?} at flat index {index}"
),
Self::NonFinitePassAdjoint {
node,
index,
previous,
contribution,
} => write!(
formatter,
"topology node {node} cannot accumulate pass-adjoint value {previous:?} plus {contribution:?} at flat index {index}"
),
Self::NonFiniteAccumulatedGradient {
node,
index,
stored,
pass_adjoint,
} => write!(
formatter,
"topology node {node} cannot accumulate stored gradient {stored:?} plus pass adjoint {pass_adjoint:?} at flat index {index}"
),
Self::GradientBorrowed => formatter.write_str(
"cannot mutate a parameter gradient while a read-only gradient borrow is active",
),
Self::NotAParameter { operation } => {
write!(
formatter,
"cannot clear a gradient on {operation}; only parameters store gradients"
)
}
}
}
}
impl Error for TensorAutodiffError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Tensor(error) => Some(error),
Self::View(error) => Some(error),
Self::Operation(error) => Some(error),
Self::Matmul(error) => Some(error),
Self::Probability(error) => Some(error),
Self::Model(error) => Some(error),
_ => None,
}
}
}
impl From<TensorError> for TensorAutodiffError {
fn from(error: TensorError) -> Self {
Self::Tensor(error)
}
}
impl From<TensorViewError> for TensorAutodiffError {
fn from(error: TensorViewError) -> Self {
Self::View(error)
}
}
impl From<TensorOpError> for TensorAutodiffError {
fn from(error: TensorOpError) -> Self {
Self::Operation(error)
}
}
impl From<MatmulError> for TensorAutodiffError {
fn from(error: MatmulError) -> Self {
Self::Matmul(error)
}
}
impl From<ProbabilityError> for TensorAutodiffError {
fn from(error: ProbabilityError) -> Self {
Self::Probability(error)
}
}
impl From<ModelOpError> for TensorAutodiffError {
fn from(error: ModelOpError) -> Self {
Self::Model(error)
}
} Команда для запуска примера печатает граф, сведения о жизненном цикле, короткие проверки и ошибки в стабильном порядке:
rust/demos/ch15-tensor-autodiff-core/src/main.rs#learner-tensor-autodiff-output let example = frozen_tensor_example()?;
let detached = detach_sum_example()?;
let gradchecks = vjp_gradcheck_example()?;
let errors = typed_error_example()?; ./course run cargo run --quiet --locked -p ch15-tensor-autodiff-core
parameter x: shape=[2, 3] values=[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
parameter bias: shape=[3] values=[1.0, -1.0, 0.0]
reshape: shape=[3, 2] values=[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
transpose: shape=[2, 3] values=[1.0, 3.0, 5.0, 2.0, 4.0, 6.0]
broadcast: shape=[2, 3] values=[1.0, -1.0, 0.0, 1.0, -1.0, 0.0]
add: shape=[2, 3] values=[2.0, 2.0, 5.0, 3.0, 3.0, 6.0]
multiply reused: shape=[2, 3] values=[4.0, 4.0, 25.0, 9.0, 9.0, 36.0]
mean axis=1 keep_dim=false: shape=[2] values=[11.0, 18.0]
non-scalar seed: shape=[2] values=[3.0, 6.0]
one backward: x_grad=[4.0, 12.0, 4.0, 12.0, 10.0, 24.0] bias_grad=[16.0, 16.0, 34.0]
repeated backward: x_grad=[8.0, 24.0, 8.0, 24.0, 20.0, 48.0] bias_grad=[32.0, 32.0, 68.0]
zero_grad: x_grad=[0.0, 0.0, 0.0, 0.0, 0.0, 0.0] bias_grad=[0.0, 0.0, 0.0]
after zero and release: x_grad=[4.0, 12.0, 4.0, 12.0, 10.0, 24.0] bias_grad=[16.0, 16.0, 34.0]
released graph: operation=mean gradients unchanged=true
detach and sum: value=63.0 p_grad=[4.0, 6.0] detached_grad=none
gradcheck: add | multiply | reshape | transpose | broadcast | sum | mean; pass=true
typed errors: seed-shape | non-finite-seed | graph-released | non-finite-accumulated-gradient; gradients unchanged=true
chapter 16 handoff: add model-critical tensor VJPs
Проследите восстановление формы на трёх отдельных схемах
На первой схеме тензорный граф показан в топологическом порядке. Восемь записей узлов представляют два листовых узла-параметра, три структурные операции, две поэлементные операции и итоговую редукцию, а также формы и значения прямого прохода, класс каждой операции и сопряжённую величину текущего прохода. Все восемь записей об использовании операндов остаются рядом с операциями, которым переданы эти операнды, включая оба операнда умножения.
На второй схеме начальная сопряжённая величина [3,6] проходит по таблице
обратного прохода, где каждой строке соответствует одно ребро. Восемь строк
сохраняют порядок от 0 до 7 для среднего, умножения, сложения, согласования
форм, транспонирования и изменения формы. В каждой строке остаются входящая
сопряжённая величина, сохранённый контекст, оси редукции, точная форма родителя,
локальное правило и получившийся вклад в родителя.
Третья схема отделяет сопряжённые величины текущего прохода от накопленных градиентов параметров. Она сохраняет градиенты, записанные после первого прохода, удвоенный результат второго прохода с сохранением графа, состояние после записи положительных нулей и освобождающий проход, который записывает значения первого прохода. Рядом остаются сумма, detach, выборочные проверки и все четыре запроса, отклонённые с типизированными ошибками. Все три схемы показывают связи, записанные в трассе; реализация на Rust вычисляет все формы, VJP, накопленные градиенты и численные сравнения, а также выполняет переходы жизненного цикла и отклоняет недопустимые запросы.
Постройте тензорный граф, не потеряв ни одного использования операнда
Проследите восемь узлов прямого прохода в топологическом порядке: для каждого указаны операция, форма, значения и сопряжённая величина текущего прохода, а также сохранены все восемь упорядоченных рёбер использования операндов.
- Выходной тензор
[2] [11.000000000000, 18.000000000000]- Начальная сопряжённая величина
[2] [3.000000000000, 6.000000000000]- Уникальные узлы графа
- 8
- Рёбра использования операндов
- 8
Постройте один граф с изменениями формы
Каждая операция владеет тензором прямого прохода с непрерывно размещёнными данными. Идентичность узла исключает повторные посещения в топологическом обходе, но сохраняет оба упорядоченных ребра от умножения к повторно использованному результату сложения.
-
x- Порядок прямого прохода
- 0
- Операция
- листовой узел-параметр
- Форма
[2, 3]- Значения
[1.000000000000, 2.000000000000, 3.000000000000, 4.000000000000, 5.000000000000, 6.000000000000]- Сопряжённая величина текущего прохода
[4.000000000000, 12.000000000000, 4.000000000000, 12.000000000000, 10.000000000000, 24.000000000000]
-
r- Порядок прямого прохода
- 1
- Операция
- изменение формы
- Форма
[3, 2]- Значения
[1.000000000000, 2.000000000000, 3.000000000000, 4.000000000000, 5.000000000000, 6.000000000000]- Сопряжённая величина текущего прохода
[4.000000000000, 12.000000000000, 4.000000000000, 12.000000000000, 10.000000000000, 24.000000000000]
- Вход операции 0
x
-
t- Порядок прямого прохода
- 2
- Операция
- транспонирование
- Форма
[2, 3]- Значения
[1.000000000000, 3.000000000000, 5.000000000000, 2.000000000000, 4.000000000000, 6.000000000000]- Сопряжённая величина текущего прохода
[4.000000000000, 4.000000000000, 10.000000000000, 12.000000000000, 12.000000000000, 24.000000000000]
- Вход операции 0
r
-
bias- Порядок прямого прохода
- 3
- Операция
- листовой узел-параметр
- Форма
[3]- Значения
[1.000000000000, -1.000000000000, 0.000000000000]- Сопряжённая величина текущего прохода
[16.000000000000, 16.000000000000, 34.000000000000]
-
bb- Порядок прямого прохода
- 4
- Операция
- явное согласование форм
- Форма
[2, 3]- Значения
[1.000000000000, -1.000000000000, 0.000000000000, 1.000000000000, -1.000000000000, 0.000000000000]- Сопряжённая величина текущего прохода
[4.000000000000, 4.000000000000, 10.000000000000, 12.000000000000, 12.000000000000, 24.000000000000]
- Вход операции 0
bias
-
z- Порядок прямого прохода
- 5
- Операция
- поэлементное сложение
- Форма
[2, 3]- Значения
[2.000000000000, 2.000000000000, 5.000000000000, 3.000000000000, 3.000000000000, 6.000000000000]- Сопряжённая величина текущего прохода
[4.000000000000, 4.000000000000, 10.000000000000, 12.000000000000, 12.000000000000, 24.000000000000]
- Вход операции 0
t - Вход операции 1
bb
-
q- Порядок прямого прохода
- 6
- Операция
- поэлементное умножение
- Форма
[2, 3]- Значения
[4.000000000000, 4.000000000000, 25.000000000000, 9.000000000000, 9.000000000000, 36.000000000000]- Сопряжённая величина текущего прохода
[1.000000000000, 1.000000000000, 1.000000000000, 2.000000000000, 2.000000000000, 2.000000000000]
- Вход операции 0
z - Вход операции 1
z
-
y- Порядок прямого прохода
- 7
- Операция
- среднее по оси
- Форма
[2]- Значения
[11.000000000000, 18.000000000000]- Сопряжённая величина текущего прохода
[3.000000000000, 6.000000000000]
- Вход операции 0
q
Распространите нескалярную начальную сопряжённую величину назад по всем восьми рёбрам
Проследите начальную сопряжённую величину [3, 6] в порядке обратного прохода от 0 до 7: для каждого ребра сопоставьте входящую сопряжённую величину, локальный VJP, оси редукции, сохранённый контекст, точную форму родителя и вклад в родителя.
Распространите начальную сопряжённую величину назад по каждому ребру
Среднее делит каждую компоненту нескалярной начальной сопряжённой величины на сохранённый размер оси, равный трём; каждое частное повторяется в соответствующей строке входа среднего. Умножение отправляет отдельный вклад по каждому из двух входных рёбер, после чего сложение копирует полную сопряжённую величину обоим родителям.
Локальный VJP
- среднее по оси
- вставить ось, согласовать форму, разделить на сохранённый размер редуцируемой оси
- поэлементное умножение
- умножить входящую сопряжённую величину на другой операнд для каждого упорядоченного ребра
- поэлементное сложение
- передать входящую величину каждому операнду, затем просуммировать по расширенным осям до формы операнда
- явное согласование форм
- суммировать отсутствующие ведущие и расширенные единичные оси
- транспонирование
- снова поменять местами сохранённые оси
- изменение формы
- восстановить сохранённую входную форму
| Порядок обратного прохода | Результат операции-потребителя | Вход операции | Родительский тензор | Входящая сопряжённая величина | Локальный VJP | Оси редукции | Сохранённый контекст | Вклад в родителя |
|---|---|---|---|---|---|---|---|---|
| 0 | y [2] | 0 | q [2, 3] | [3.000000000000, 6.000000000000] | среднее по оси | 1 | Ось=1; Сохранять размерность=нет; Делитель=3 | [1.000000000000, 1.000000000000, 1.000000000000, 2.000000000000, 2.000000000000, 2.000000000000] |
| 1 | q [2, 3] | 0 | z [2, 3] | [1.000000000000, 1.000000000000, 1.000000000000, 2.000000000000, 2.000000000000, 2.000000000000] | поэлементное умножение | нет | Другой операнд [2, 3]=[2.000000000000, 2.000000000000, 5.000000000000, 3.000000000000, 3.000000000000, 6.000000000000]; Входная форма=2x3; Выходная форма=2x3 | [2.000000000000, 2.000000000000, 5.000000000000, 6.000000000000, 6.000000000000, 12.000000000000] |
| 2 | q [2, 3] | 1 | z [2, 3] | [1.000000000000, 1.000000000000, 1.000000000000, 2.000000000000, 2.000000000000, 2.000000000000] | поэлементное умножение | нет | Другой операнд [2, 3]=[2.000000000000, 2.000000000000, 5.000000000000, 3.000000000000, 3.000000000000, 6.000000000000]; Входная форма=2x3; Выходная форма=2x3 | [2.000000000000, 2.000000000000, 5.000000000000, 6.000000000000, 6.000000000000, 12.000000000000] |
| 3 | z [2, 3] | 0 | t [2, 3] | [4.000000000000, 4.000000000000, 10.000000000000, 12.000000000000, 12.000000000000, 24.000000000000] | поэлементное сложение | нет | нет | [4.000000000000, 4.000000000000, 10.000000000000, 12.000000000000, 12.000000000000, 24.000000000000] |
| 4 | z [2, 3] | 1 | bb [2, 3] | [4.000000000000, 4.000000000000, 10.000000000000, 12.000000000000, 12.000000000000, 24.000000000000] | поэлементное сложение | нет | нет | [4.000000000000, 4.000000000000, 10.000000000000, 12.000000000000, 12.000000000000, 24.000000000000] |
| 5 | bb [2, 3] | 0 | bias [3] | [4.000000000000, 4.000000000000, 10.000000000000, 12.000000000000, 12.000000000000, 24.000000000000] | явное согласование форм | 0 | нет | [16.000000000000, 16.000000000000, 34.000000000000] |
| 6 | t [2, 3] | 0 | r [3, 2] | [4.000000000000, 4.000000000000, 10.000000000000, 12.000000000000, 12.000000000000, 24.000000000000] | транспонирование | нет | Оси=0,1 | [4.000000000000, 12.000000000000, 4.000000000000, 12.000000000000, 10.000000000000, 24.000000000000] |
| 7 | r [3, 2] | 0 | x [2, 3] | [4.000000000000, 12.000000000000, 4.000000000000, 12.000000000000, 10.000000000000, 24.000000000000] | изменение формы | нет | Входная форма=2x3; Выходная форма=3x2 | [4.000000000000, 12.000000000000, 4.000000000000, 12.000000000000, 10.000000000000, 24.000000000000] |
Сопоставьте накопленные градиенты, состояния графа, проверки и отклонённые запросы
Сравните первый и второй проходы с сохранением графа, обнуление, освобождающий проход, detach, выборочные проверки градиентов и четыре отклонённых запроса, не смешивая сопряжённые величины текущего прохода с накопленными градиентами параметров.
Восстановите точные формы параметров
При обратном проходе через согласование форм повторно использованные строки суммируются, транспонирование снова меняет местами сохранённые оси, а изменение формы восстанавливает исходную форму. Каждый вклад точно совпадает с формой родителя.
первый проход с сохранением графа: градиенты записаны
- Узел
x- Форма
[2, 3]- Накопленный градиент
[4.000000000000, 12.000000000000, 4.000000000000, 12.000000000000, 10.000000000000, 24.000000000000]
первый проход с сохранением графа: градиенты записаны
- Узел
bias- Форма
[3]- Накопленный градиент
[16.000000000000, 16.000000000000, 34.000000000000]
Сохраняйте, накапливайте, обнуляйте, освобождайте
При сохранённом графе операций каждый вызов заново вычисляет сопряжённые величины текущего прохода, а накапливаются только градиенты параметров. Успешное освобождение графа сохраняет значения и записанные градиенты, но удаляет контекст операций.
второй проход с сохранением графа: градиенты накоплены
x[8.000000000000, 24.000000000000, 8.000000000000, 24.000000000000, 20.000000000000, 48.000000000000]bias[32.000000000000, 32.000000000000, 68.000000000000]
градиенты параметров обнулены
x[0.000000000000, 0.000000000000, 0.000000000000, 0.000000000000, 0.000000000000, 0.000000000000]bias[0.000000000000, 0.000000000000, 0.000000000000]
освобождающий проход заново вычислил и записал градиенты одного прохода
x[4.000000000000, 12.000000000000, 4.000000000000, 12.000000000000, 10.000000000000, 24.000000000000]bias[16.000000000000, 16.000000000000, 34.000000000000]
граф операций освобождён
- Операция
mean- Граф освобождён
- да
- Градиенты не изменились
- да
Проверьте сумму, detach и каждый VJP
Короткий граф с суммой и detach позволяет проверить эти правила отдельно. Центральные разности из главы 13 проверяют каждый поддерживаемый VJP независимо от обратной ленты.
путь градиента отсоединённой ветви прерван
- Операция
sum(p*p+detach(p)*ten)- Значения
- 63.000000000000
- Накопленный градиент
[4.000000000000, 6.000000000000]
выборочные проверки градиентов пройдены
- Операция
add, multiply, reshape, transpose, broadcast, sum, meanx: Выбранные плоские координаты0, 1, 3, 5bias: Выбранные плоские координаты0, 1, 2- Состояние
- выборочные проверки градиентов пройдены
Отклоняйте недопустимые запросы без изменения состояния
До изменения состояния реализация проверяет каждый VJP, аккумулятор текущего прохода, сумму прежнего градиента параметра и нового вклада, а также возможность освобождения. Поэтому ошибочный запрос не меняет ни градиенты, ни граф.
форма начальной сопряжённой величины не совпадает с формой выхода
Ожидаемая форма=2; Фактическая форма=1 начальная сопряжённая величина содержит неконечное значение
Плоский индекс=1; Значение=nan граф операций уже освобождён
Операция=mean сумма накопленного градиента параметра и нового вклада не является конечной
Узел=0; Плоский индекс=0 Сначала предскажите, затем запускайте Rust
- Предскажите форму и значения каждого узла от
xиbiasдоy. - Сосчитайте уникальные узлы и рёбра использования операндов. Почему оба числа равны восьми, хотя умножение использует результат сложения дважды?
- Вычислите VJP среднего для начальной сопряжённой величины
[3,6]. Укажите эффективные шаги источника, последовательность смещений источника, значения после расширения и вклады обоих рёбер умножения. - Для ветви с согласованной формой
biasукажите эффективные шаги назначения и последовательность смещений назначения перед суммированием в градиентbias; затем поменяйте местами оси транспонирования и восстановите формуx. - Предскажите оба градиента параметров после двух проходов с сохранением графа операций, обнуления и одного освобождающего прохода. Почему второй проход допустим в этом примере и что потребовалось бы после обновления параметра на месте между двумя проходами?
- Вход формы
[2,3]суммируется по оси0сkeep_dim=false, поэтому выход имеет форму[3]. Для начальной сопряжённой величины[10,20,30]укажите эффективные шаги источника, последовательность смещений источника и значения, которые VJP запишет в каждую из двух строк градиента входа. - Объясните различия между временным доступом только для чтения через
value()иgradient(), отдельными копиями, которые создаютvalue_snapshot()иgradient_snapshot(), а такжеdetach,zero_grad,RetainиRelease: какие данные или состояние каждый вариант читает, создаёт или изменяет? - Проверка понимания: создаёт ли согласование форм независимые копии параметра, градиенты которых могут сохранить выходную форму или выбрать одно вхождение?
Сверьте ответы на восемь заданий об автоматическом дифференцировании тензоров
- Изменение формы даёт
[3,2], не меняя плоские значения. После транспонирования значения равны[1,3,5,2,4,6]; после согласования форм —[1,-1,0,1,-1,0]; после сложения —[2,2,5,3,3,6]; после умножения —[4,4,25,9,9,36]; после среднего —[11,18]. - Узлы:
x, изменение формы, транспонирование,bias, согласование форм, сложение, умножение и среднее. Рёбра: одно к изменению формы, одно к транспонированию, одно к согласованию форм, два к сложению, два к умножению и одно к среднему. Обход каждого уникального узла один раз не удаляет второе ребро умножения. - Вход среднего — результат умножения формы
[2,3], а входящая сопряжённая величина среднего имеет форму[2]. Эффективные шаги источника[1,0]дают смещения источника[0,0,0,1,1,1]. Чтение начальной сопряжённой величины[3,6]по этим смещениям и деление на три дают у узла умножения[1,1,1,2,2,2]. Каждое ребро умножения умножает входящую величину на результат сложения и даёт[2,2,5,6,6,12]; сумма двух вкладов равна[4,4,10,12,12,24]. bias[3]соответствует последней оси входящего вклада формы[2,3]. Эффективные шаги назначения[0,1]дают смещения[0,1,2,0,1,2], поэтому сложение в порядке строк приводит к . После обмена осей форма равна[3,2], а значения —[4,12,4,12,10,24]; изменение формы возвращает[2,3]с тем же плоским порядком.- После первого прохода и . Значения параметров и их номера версий не меняются, поэтому второй проход с сохранением графа операций допустим и удваивает оба градиента. Обнуление записывает положительный ноль. Освобождающий проход заново вычисляет и записывает градиенты, равные градиентам первого прохода, а затем удаляет контекст операций. При обновлении параметра на месте текущий номер версии узла увеличился бы, а в старом ребре остался бы прежний номер: обратный проход по старому графу был бы отклонён до вычисления VJP, записи градиента или освобождения, а перед следующим обратным проходом пришлось бы заново выполнить прямой проход.
- Исходный вход суммы имеет форму
[2,3]. Входящая сопряжённая величина имеет форму[3], поэтому эффективные шаги источника[0,1]дают смещения[0,1,2,0,1,2]. Чтение начальной сопряжённой величины по этим смещениям записывает[10,20,30]в первую строку градиента входа и те же[10,20,30]во вторую. Для суммы делитель равен одному, в отличие от среднего. value()иgradient()временно предоставляют доступ только для чтения к данным узла, не копируя их;gradient()возвращает такой доступ только для параметра с накопленным градиентом.value_snapshot()иgradient_snapshot()создают отдельные копии тензорных данных, но не создают узел ленты. Прежде чем изменять соответствующие данные, временный доступ необходимо завершить; если доступ к накопленному градиенту ещё действует, операция возвращаетGradientBorrowedбез частичной записи.detachиспользует отдельную копию тензора прямого прохода, чтобы создать новый неотслеживаемый лист.zero_gradочищает накопленный градиент одного параметра;Retainсохраняет контекст операций после успешной записи;Releaseудаляет этот контекст после успешной записи, но сохраняет значения прямого прохода и накопленные градиенты параметров.- Нет. При согласовании форм несколько выходных координат обращаются к одной координате источника. VJP суммирует их вклады в градиент исходной формы параметра. Для
bias[3]было бы неверно и сохранить форму[2,3], и выбрать только одну строку.
После предсказаний запустите приведённую выше команду и сопоставьте напечатанные значения прямого прохода и градиенты со своими расчётами.
Подготовьте градиенты ключевых операций модели
Теперь совокупная реализация умеет проводить обратный проход через поэлементные операции, сохраняющие форму, и структурные тензорные преобразования, возвращать каждый вклад к точной форме родителя, накапливать градиенты только в листовых узлах-параметрах и безопасно освобождать сохранённый контекст операций. Проверенный обход по эффективным шагам можно переиспользовать в VJP, которые сопоставляют одну логическую форму с другой. В главе 16 появятся необходимые для модели VJP матричного умножения, выбора строк эмбеддингов по индексам с повторяющимися ID, нелинейностей, log-softmax и усреднённой функции потерь для заданных индексов целевых токенов; одной главы 15 всё ещё недостаточно для обучения декодера.
Каждый следующий блок декодера переиспользует общий контракт графа: каждую операцию нужно посетить один раз, каждое ребро использования операнда — сохранить и применить к нему один локальный VJP, а вклады, пришедшие к родителю по разным ветвям, — сложить в его сопряжённой величине. Градиенты параметров нужно записать атомарно, а сохранённый контекст — освободить только после успеха. Операции, которым нужно обратить согласование форм или редукцию, также переиспользуют структурный план эффективных шагов. В главе 16 VJP матричного умножения возвращает согласованные вклады к формам родителей, а VJP функции потерь по индексам токенов использует проверенный курсор для обхода начальных смещений групп. Остальные VJP операций строят собственные планы обхода по проверенным формам.