Compare commits
2 Commits
2e68942032
...
59e33f60e3
| Author | SHA1 | Date | |
|---|---|---|---|
| 59e33f60e3 | |||
| f3a1188f0e |
@@ -5,3 +5,7 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
xtrain-tensor = { path = "../xtrain-tensor" }
|
||||
|
||||
[dev-dependencies]
|
||||
# Acceptance tests need device selection (set_device) to drive the GPU.
|
||||
xtrain-cuda = { path = "../xtrain-cuda" }
|
||||
|
||||
172
crates/xtrain-autodiff/src/ops.rs
Normal file
172
crates/xtrain-autodiff/src/ops.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
//! Differentiable ops as autograd nodes (Phase T4).
|
||||
//!
|
||||
//! Each function runs the forward [`Tensor`] kernel, then builds a [`Var`] whose
|
||||
//! backward closure computes the analytic gradient (see
|
||||
//! `docs/03-autograd-engine.md` for the math) and pushes it to each parent via
|
||||
//! [`Var::push_grad`] (which SUMs — correct under fan-out). Forward outputs that
|
||||
//! the backward needs (softmax `y`, rms `inv_rms`, cross-entropy `probs`) are
|
||||
//! cached by moving them into the closure.
|
||||
//!
|
||||
//! Attention is NOT a node here: it is composed from `matmul` + `scale` +
|
||||
//! `softmax` in user code, and its backward falls out of theirs.
|
||||
|
||||
#![cfg(not(no_cuda))]
|
||||
|
||||
use crate::tape::Var;
|
||||
use xtrain_tensor::Tensor;
|
||||
|
||||
/// `C = A @ B` (2D). Backward: `dA = dC @ Bᵀ`, `dB = Aᵀ @ dC`.
|
||||
pub fn matmul(a: &Var, b: &Var) -> Var {
|
||||
let out = a.value().matmul(&b.value());
|
||||
Var::from_op(
|
||||
out,
|
||||
vec![a.clone(), b.clone()],
|
||||
Box::new(|dc, parents| {
|
||||
let a = parents[0].value();
|
||||
let b = parents[1].value();
|
||||
let (da, db) = Tensor::matmul_backward(&a, &b, dc);
|
||||
Var::push_grad(&parents[0], da);
|
||||
Var::push_grad(&parents[1], db);
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Elementwise `out = a + b` (same shape). Backward: grad flows unchanged to both.
|
||||
pub fn add(a: &Var, b: &Var) -> Var {
|
||||
let out = a.value().add(&b.value());
|
||||
Var::from_op(
|
||||
out,
|
||||
vec![a.clone(), b.clone()],
|
||||
Box::new(|d, parents| {
|
||||
Var::push_grad(&parents[0], d.clone());
|
||||
Var::push_grad(&parents[1], d.clone());
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Elementwise `out = a * b` (Hadamard). Backward: `da = d∘b`, `db = d∘a`.
|
||||
pub fn mul(a: &Var, b: &Var) -> Var {
|
||||
let out = a.value().mul(&b.value());
|
||||
Var::from_op(
|
||||
out,
|
||||
vec![a.clone(), b.clone()],
|
||||
Box::new(|d, parents| {
|
||||
let a = parents[0].value();
|
||||
let b = parents[1].value();
|
||||
Var::push_grad(&parents[0], d.mul(&b));
|
||||
Var::push_grad(&parents[1], d.mul(&a));
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Broadcast bias add: `out[r,c] = x[r,c] + bias[c]`. Backward: `dx = d`,
|
||||
/// `dbias[c] = sum_r d[r,c]` (sum over the broadcast dim).
|
||||
pub fn add_bias(x: &Var, bias: &Var) -> Var {
|
||||
let out = x.value().add_bias(&bias.value());
|
||||
Var::from_op(
|
||||
out,
|
||||
vec![x.clone(), bias.clone()],
|
||||
Box::new(|d, parents| {
|
||||
Var::push_grad(&parents[0], d.clone());
|
||||
Var::push_grad(&parents[1], d.sum_rows());
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Scale by a constant: `out = x * alpha`. Backward: `dx = d * alpha`.
|
||||
pub fn scale(x: &Var, alpha: f32) -> Var {
|
||||
let out = x.value().scale(alpha);
|
||||
Var::from_op(
|
||||
out,
|
||||
vec![x.clone()],
|
||||
Box::new(move |d, parents| {
|
||||
Var::push_grad(&parents[0], d.scale(alpha));
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// RMSNorm: `y = x * rsqrt(mean(x²)+eps) * gamma`. Caches `inv_rms` for backward.
|
||||
pub fn rms_norm(x: &Var, gamma: &Var, eps: f32) -> Var {
|
||||
let (y, inv_rms) = x.value().rms_norm(&gamma.value(), eps);
|
||||
Var::from_op(
|
||||
y,
|
||||
vec![x.clone(), gamma.clone()],
|
||||
Box::new(move |dy, parents| {
|
||||
let x = parents[0].value();
|
||||
let gamma = parents[1].value();
|
||||
let (dx, dgamma) = Tensor::rms_norm_backward(&x, &gamma, dy, &inv_rms);
|
||||
Var::push_grad(&parents[0], dx);
|
||||
Var::push_grad(&parents[1], dgamma);
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// SiLU: `y = x * sigmoid(x)`. Backward uses the forward `x`.
|
||||
pub fn silu(x: &Var) -> Var {
|
||||
let out = x.value().silu();
|
||||
Var::from_op(
|
||||
out,
|
||||
vec![x.clone()],
|
||||
Box::new(|dy, parents| {
|
||||
let x = parents[0].value();
|
||||
Var::push_grad(&parents[0], Tensor::silu_backward(&x, dy));
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// SwiGLU (SiLU-gated GLU): `out = silu(gate) ∘ up`. Composed from `silu` + `mul`
|
||||
/// so its backward comes from theirs — no dedicated kernel needed.
|
||||
pub fn swiglu(gate: &Var, up: &Var) -> Var {
|
||||
mul(&silu(gate), up)
|
||||
}
|
||||
|
||||
/// RoPE (rotate_half) over `x:[tokens,heads,head_dim]`. Orthogonal map, so the
|
||||
/// backward is the inverse rotation of `dy` — no cached forward values needed.
|
||||
pub fn rope(x: &Var, theta: f32) -> Var {
|
||||
let out = x.value().rope(theta);
|
||||
Var::from_op(
|
||||
out,
|
||||
vec![x.clone()],
|
||||
Box::new(move |dy, parents| {
|
||||
Var::push_grad(&parents[0], Tensor::rope_backward(dy, theta));
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Row-wise softmax. Caches the output `y` for the Jacobian backward.
|
||||
pub fn softmax(x: &Var) -> Var {
|
||||
let y = x.value().softmax();
|
||||
let y_cache = y.clone();
|
||||
Var::from_op(
|
||||
y,
|
||||
vec![x.clone()],
|
||||
Box::new(move |dy, parents| {
|
||||
Var::push_grad(&parents[0], Tensor::softmax_backward(&y_cache, dy));
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Cross-entropy mean loss over logits `x:[rows,cols]` with one I32 target per
|
||||
/// row. Returns a scalar [`Var`]. Backward: `dx = (probs - onehot)/rows`,
|
||||
/// scaled by the upstream scalar grad.
|
||||
pub fn cross_entropy(x: &Var, target: &Tensor) -> Var {
|
||||
let (probs, per_row) = x.value().cross_entropy(target);
|
||||
let rows = x.value().shape()[0];
|
||||
// Mean loss as a host scalar wrapped back into a [1] tensor.
|
||||
let mean = per_row.to_device(xtrain_tensor::Device::Cpu);
|
||||
let mean_val: f32 = mean.as_slice::<f32>().iter().sum::<f32>() / rows as f32;
|
||||
let loss = Tensor::from_slice(&[mean_val], &[1]).to_device(x.value().device());
|
||||
|
||||
let target = target.clone();
|
||||
Var::from_op(
|
||||
loss,
|
||||
vec![x.clone()],
|
||||
Box::new(move |d, parents| {
|
||||
// `d` is the scalar upstream grad (1.0 when this is the loss root).
|
||||
let upstream = d.to_device(xtrain_tensor::Device::Cpu).as_slice::<f32>()[0];
|
||||
let scale = upstream / rows as f32;
|
||||
let dx = Tensor::cross_entropy_backward(&probs, &target, scale);
|
||||
Var::push_grad(&parents[0], dx);
|
||||
}),
|
||||
)
|
||||
}
|
||||
546
crates/xtrain-autodiff/tests/autograd.rs
Normal file
546
crates/xtrain-autodiff/tests/autograd.rs
Normal file
@@ -0,0 +1,546 @@
|
||||
// GPU acceptance tests for the Phase T4 autograd engine + per-op backward.
|
||||
// Pattern (from xtrain-tensor/tests/gemm.rs `run_bwd`): build a scalar loss
|
||||
// L = sum(W ∘ out) with W fixed random ⇒ the upstream grad dOut = W. Run the op
|
||||
// through the tape, call backward(), and grad-check each input's .grad() against
|
||||
// central finite differences of L.
|
||||
//
|
||||
// Gated behind `not(no_cuda)`: compiles out on a GPU-less host, runs on dash5.
|
||||
#![cfg(not(no_cuda))]
|
||||
|
||||
use xtrain_autodiff::ops;
|
||||
use xtrain_autodiff::tape::Var;
|
||||
use xtrain_autodiff::{GradCheckConfig, grad_check};
|
||||
use xtrain_cuda::device;
|
||||
use xtrain_tensor::{Device, Tensor};
|
||||
|
||||
// Deterministic LCG fill in [-0.5, 0.5), same as the gemm tests.
|
||||
fn fill(n: usize, seed: u64) -> Vec<f32> {
|
||||
let mut state = seed
|
||||
.wrapping_mul(2862933555777941757)
|
||||
.wrapping_add(3037000493);
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
state = state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
((state >> 33) as f32 / (1u64 << 31) as f32) - 0.5
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn require_gpu() {
|
||||
assert!(
|
||||
device::device_count().expect("device count") > 0,
|
||||
"no CUDA device"
|
||||
);
|
||||
device::set_device(0).unwrap();
|
||||
}
|
||||
|
||||
fn cuda(data: &[f32], shape: &[usize]) -> Tensor {
|
||||
Tensor::from_slice(data, shape).to_device(Device::Cuda(0))
|
||||
}
|
||||
|
||||
// L = sum(W ∘ out) for fixed weights W over the op output.
|
||||
fn weighted_sum(out: &Tensor, w: &[f32]) -> f32 {
|
||||
out.to_device(Device::Cpu)
|
||||
.as_slice::<f32>()
|
||||
.iter()
|
||||
.zip(w)
|
||||
.map(|(o, w)| o * w)
|
||||
.sum()
|
||||
}
|
||||
|
||||
// Tolerances: ops with elementwise/linear forwards (add, mul, scale, bias, rope)
|
||||
// are exactly linear in each input, so a large eps just sharpens f32 resolution.
|
||||
// Nonlinear ops (rms_norm, silu, softmax, cross_entropy) carry O(eps²) truncation
|
||||
// → smaller eps. atol floors near-zero grads.
|
||||
fn cfg_linear() -> GradCheckConfig {
|
||||
GradCheckConfig {
|
||||
eps: 1e-2,
|
||||
rel_tol: 2e-2,
|
||||
atol: 1e-3,
|
||||
}
|
||||
}
|
||||
fn cfg_nonlinear() -> GradCheckConfig {
|
||||
GradCheckConfig {
|
||||
eps: 1e-3,
|
||||
rel_tol: 3e-2,
|
||||
atol: 1e-3,
|
||||
}
|
||||
}
|
||||
|
||||
fn report(name: &str, res: &xtrain_autodiff::GradCheckResult) {
|
||||
println!(
|
||||
"{name}: max_rel_err = {:.3e} (worst num={:.5} ana={:.5} @ {})",
|
||||
res.max_rel_err, res.worst_numeric, res.worst_analytic, res.worst_index
|
||||
);
|
||||
assert!(res.passed, "{name} grad-check failed: {res:?}");
|
||||
}
|
||||
|
||||
// ---- add ----
|
||||
#[test]
|
||||
fn add_bwd() {
|
||||
require_gpu();
|
||||
let (m, n) = (8, 6);
|
||||
let a_h = fill(m * n, 1);
|
||||
let b_h = fill(m * n, 2);
|
||||
let w = fill(m * n, 3);
|
||||
|
||||
let a = Var::leaf(cuda(&a_h, &[m, n]));
|
||||
let b = Var::leaf(cuda(&b_h, &[m, n]));
|
||||
let out = ops::add(&a, &b);
|
||||
let loss = scalar_loss(&out, &w);
|
||||
loss.backward();
|
||||
|
||||
let da = a.grad().unwrap().to_device(Device::Cpu);
|
||||
let db = b.grad().unwrap().to_device(Device::Cpu);
|
||||
let bf = b_h.clone();
|
||||
let wf = w.clone();
|
||||
let la = move |v: &[f32], s: &[usize]| {
|
||||
let o = cuda(v, s).add(&cuda(&bf, &[m, n]));
|
||||
weighted_sum(&o, &wf)
|
||||
};
|
||||
report(
|
||||
"add dA",
|
||||
&grad_check(&a_h, &[m, n], &la, da.as_slice::<f32>(), cfg_linear()),
|
||||
);
|
||||
let af = a_h.clone();
|
||||
let wf = w.clone();
|
||||
let lb = move |v: &[f32], s: &[usize]| {
|
||||
let o = cuda(&af, &[m, n]).add(&cuda(v, s));
|
||||
weighted_sum(&o, &wf)
|
||||
};
|
||||
report(
|
||||
"add dB",
|
||||
&grad_check(&b_h, &[m, n], &lb, db.as_slice::<f32>(), cfg_linear()),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- mul ----
|
||||
#[test]
|
||||
fn mul_bwd() {
|
||||
require_gpu();
|
||||
let (m, n) = (8, 6);
|
||||
let a_h = fill(m * n, 11);
|
||||
let b_h = fill(m * n, 22);
|
||||
let w = fill(m * n, 33);
|
||||
|
||||
let a = Var::leaf(cuda(&a_h, &[m, n]));
|
||||
let b = Var::leaf(cuda(&b_h, &[m, n]));
|
||||
let out = ops::mul(&a, &b);
|
||||
scalar_loss(&out, &w).backward();
|
||||
|
||||
let da = a.grad().unwrap().to_device(Device::Cpu);
|
||||
let db = b.grad().unwrap().to_device(Device::Cpu);
|
||||
let bf = b_h.clone();
|
||||
let wf = w.clone();
|
||||
let la = move |v: &[f32], s: &[usize]| weighted_sum(&cuda(v, s).mul(&cuda(&bf, &[m, n])), &wf);
|
||||
report(
|
||||
"mul dA",
|
||||
&grad_check(&a_h, &[m, n], &la, da.as_slice::<f32>(), cfg_linear()),
|
||||
);
|
||||
let af = a_h.clone();
|
||||
let wf = w.clone();
|
||||
let lb = move |v: &[f32], s: &[usize]| weighted_sum(&cuda(&af, &[m, n]).mul(&cuda(v, s)), &wf);
|
||||
report(
|
||||
"mul dB",
|
||||
&grad_check(&b_h, &[m, n], &lb, db.as_slice::<f32>(), cfg_linear()),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- add_bias (broadcast) ----
|
||||
#[test]
|
||||
fn add_bias_bwd() {
|
||||
require_gpu();
|
||||
let (m, n) = (10, 7);
|
||||
let x_h = fill(m * n, 5);
|
||||
let b_h = fill(n, 6);
|
||||
let w = fill(m * n, 7);
|
||||
|
||||
let x = Var::leaf(cuda(&x_h, &[m, n]));
|
||||
let bias = Var::leaf(cuda(&b_h, &[n]));
|
||||
let out = ops::add_bias(&x, &bias);
|
||||
scalar_loss(&out, &w).backward();
|
||||
|
||||
let dx = x.grad().unwrap().to_device(Device::Cpu);
|
||||
let dbias = bias.grad().unwrap().to_device(Device::Cpu);
|
||||
let bf = b_h.clone();
|
||||
let wf = w.clone();
|
||||
let lx =
|
||||
move |v: &[f32], s: &[usize]| weighted_sum(&cuda(v, s).add_bias(&cuda(&bf, &[n])), &wf);
|
||||
report(
|
||||
"add_bias dX",
|
||||
&grad_check(&x_h, &[m, n], &lx, dx.as_slice::<f32>(), cfg_linear()),
|
||||
);
|
||||
let xf = x_h.clone();
|
||||
let wf = w.clone();
|
||||
let lb =
|
||||
move |v: &[f32], s: &[usize]| weighted_sum(&cuda(&xf, &[m, n]).add_bias(&cuda(v, s)), &wf);
|
||||
report(
|
||||
"add_bias dBias",
|
||||
&grad_check(&b_h, &[n], &lb, dbias.as_slice::<f32>(), cfg_linear()),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- matmul (sanity through the Var layer; T3 already checks the kernel) ----
|
||||
#[test]
|
||||
fn matmul_bwd() {
|
||||
require_gpu();
|
||||
let (m, k, n) = (6, 5, 4);
|
||||
let a_h = fill(m * k, 41);
|
||||
let b_h = fill(k * n, 42);
|
||||
let w = fill(m * n, 43);
|
||||
|
||||
let a = Var::leaf(cuda(&a_h, &[m, k]));
|
||||
let b = Var::leaf(cuda(&b_h, &[k, n]));
|
||||
let out = ops::matmul(&a, &b);
|
||||
scalar_loss(&out, &w).backward();
|
||||
|
||||
let da = a.grad().unwrap().to_device(Device::Cpu);
|
||||
let db = b.grad().unwrap().to_device(Device::Cpu);
|
||||
let bf = b_h.clone();
|
||||
let wf = w.clone();
|
||||
let la =
|
||||
move |v: &[f32], s: &[usize]| weighted_sum(&cuda(v, s).matmul(&cuda(&bf, &[k, n])), &wf);
|
||||
report(
|
||||
"matmul dA",
|
||||
&grad_check(&a_h, &[m, k], &la, da.as_slice::<f32>(), cfg_linear()),
|
||||
);
|
||||
let af = a_h.clone();
|
||||
let wf = w.clone();
|
||||
let lb =
|
||||
move |v: &[f32], s: &[usize]| weighted_sum(&cuda(&af, &[m, k]).matmul(&cuda(v, s)), &wf);
|
||||
report(
|
||||
"matmul dB",
|
||||
&grad_check(&b_h, &[k, n], &lb, db.as_slice::<f32>(), cfg_linear()),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- rms_norm ----
|
||||
#[test]
|
||||
fn rms_norm_bwd() {
|
||||
require_gpu();
|
||||
let (rows, cols) = (5, 16);
|
||||
let eps = 1e-5;
|
||||
let x_h = fill(rows * cols, 51);
|
||||
let g_h: Vec<f32> = fill(cols, 52).iter().map(|v| v + 1.0).collect(); // gamma ~1
|
||||
let w = fill(rows * cols, 53);
|
||||
|
||||
let x = Var::leaf(cuda(&x_h, &[rows, cols]));
|
||||
let gamma = Var::leaf(cuda(&g_h, &[cols]));
|
||||
let out = ops::rms_norm(&x, &gamma, eps);
|
||||
scalar_loss(&out, &w).backward();
|
||||
|
||||
let dx = x.grad().unwrap().to_device(Device::Cpu);
|
||||
let dg = gamma.grad().unwrap().to_device(Device::Cpu);
|
||||
let gf = g_h.clone();
|
||||
let wf = w.clone();
|
||||
let lx = move |v: &[f32], s: &[usize]| {
|
||||
let (o, _) = cuda(v, s).rms_norm(&cuda(&gf, &[cols]), eps);
|
||||
weighted_sum(&o, &wf)
|
||||
};
|
||||
report(
|
||||
"rms_norm dX",
|
||||
&grad_check(
|
||||
&x_h,
|
||||
&[rows, cols],
|
||||
&lx,
|
||||
dx.as_slice::<f32>(),
|
||||
cfg_nonlinear(),
|
||||
),
|
||||
);
|
||||
let xf = x_h.clone();
|
||||
let wf = w.clone();
|
||||
let lg = move |v: &[f32], s: &[usize]| {
|
||||
let (o, _) = cuda(&xf, &[rows, cols]).rms_norm(&cuda(v, s), eps);
|
||||
weighted_sum(&o, &wf)
|
||||
};
|
||||
report(
|
||||
"rms_norm dGamma",
|
||||
&grad_check(&g_h, &[cols], &lg, dg.as_slice::<f32>(), cfg_nonlinear()),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- silu ----
|
||||
#[test]
|
||||
fn silu_bwd() {
|
||||
require_gpu();
|
||||
let n = 64;
|
||||
let x_h = fill(n, 61);
|
||||
let w = fill(n, 62);
|
||||
|
||||
let x = Var::leaf(cuda(&x_h, &[n]));
|
||||
let out = ops::silu(&x);
|
||||
scalar_loss(&out, &w).backward();
|
||||
|
||||
let dx = x.grad().unwrap().to_device(Device::Cpu);
|
||||
let wf = w.clone();
|
||||
let lx = move |v: &[f32], s: &[usize]| weighted_sum(&cuda(v, s).silu(), &wf);
|
||||
report(
|
||||
"silu dX",
|
||||
&grad_check(&x_h, &[n], &lx, dx.as_slice::<f32>(), cfg_nonlinear()),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- swiglu (composed: silu(gate) ∘ up) ----
|
||||
#[test]
|
||||
fn swiglu_bwd() {
|
||||
require_gpu();
|
||||
let n = 48;
|
||||
let g_h = fill(n, 71);
|
||||
let u_h = fill(n, 72);
|
||||
let w = fill(n, 73);
|
||||
|
||||
let gate = Var::leaf(cuda(&g_h, &[n]));
|
||||
let up = Var::leaf(cuda(&u_h, &[n]));
|
||||
let out = ops::swiglu(&gate, &up);
|
||||
scalar_loss(&out, &w).backward();
|
||||
|
||||
let dg = gate.grad().unwrap().to_device(Device::Cpu);
|
||||
let du = up.grad().unwrap().to_device(Device::Cpu);
|
||||
let uf = u_h.clone();
|
||||
let wf = w.clone();
|
||||
let lg =
|
||||
move |v: &[f32], s: &[usize]| weighted_sum(&cuda(v, s).silu().mul(&cuda(&uf, &[n])), &wf);
|
||||
report(
|
||||
"swiglu dGate",
|
||||
&grad_check(&g_h, &[n], &lg, dg.as_slice::<f32>(), cfg_nonlinear()),
|
||||
);
|
||||
let gf = g_h.clone();
|
||||
let wf = w.clone();
|
||||
let lu =
|
||||
move |v: &[f32], s: &[usize]| weighted_sum(&cuda(&gf, &[n]).silu().mul(&cuda(v, s)), &wf);
|
||||
report(
|
||||
"swiglu dUp",
|
||||
&grad_check(&u_h, &[n], &lu, du.as_slice::<f32>(), cfg_linear()),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- rope ----
|
||||
#[test]
|
||||
fn rope_bwd() {
|
||||
require_gpu();
|
||||
let (tokens, heads, head_dim) = (4, 2, 8);
|
||||
let n = tokens * heads * head_dim;
|
||||
let theta = 10000.0;
|
||||
let x_h = fill(n, 81);
|
||||
let w = fill(n, 82);
|
||||
|
||||
let x = Var::leaf(cuda(&x_h, &[tokens, heads, head_dim]));
|
||||
let out = ops::rope(&x, theta);
|
||||
scalar_loss(&out, &w).backward();
|
||||
|
||||
let dx = x.grad().unwrap().to_device(Device::Cpu);
|
||||
let wf = w.clone();
|
||||
let lx = move |v: &[f32], s: &[usize]| weighted_sum(&cuda(v, s).rope(theta), &wf);
|
||||
report(
|
||||
"rope dX",
|
||||
&grad_check(
|
||||
&x_h,
|
||||
&[tokens, heads, head_dim],
|
||||
&lx,
|
||||
dx.as_slice::<f32>(),
|
||||
cfg_linear(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- softmax ----
|
||||
#[test]
|
||||
fn softmax_bwd() {
|
||||
require_gpu();
|
||||
let (rows, cols) = (4, 10);
|
||||
let x_h = fill(rows * cols, 91);
|
||||
let w = fill(rows * cols, 92);
|
||||
|
||||
let x = Var::leaf(cuda(&x_h, &[rows, cols]));
|
||||
let out = ops::softmax(&x);
|
||||
scalar_loss(&out, &w).backward();
|
||||
|
||||
let dx = x.grad().unwrap().to_device(Device::Cpu);
|
||||
let wf = w.clone();
|
||||
let lx = move |v: &[f32], s: &[usize]| weighted_sum(&cuda(v, s).softmax(), &wf);
|
||||
report(
|
||||
"softmax dX",
|
||||
&grad_check(
|
||||
&x_h,
|
||||
&[rows, cols],
|
||||
&lx,
|
||||
dx.as_slice::<f32>(),
|
||||
cfg_nonlinear(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- cross_entropy (scalar loss; backward = (softmax - onehot)/rows) ----
|
||||
#[test]
|
||||
fn cross_entropy_bwd() {
|
||||
require_gpu();
|
||||
let (rows, cols) = (5, 8);
|
||||
let x_h = fill(rows * cols, 101);
|
||||
let targets: Vec<i32> = (0..rows).map(|r| (r * 3 % cols) as i32).collect();
|
||||
let target = Tensor::from_slice(&targets, &[rows]).to_device(Device::Cuda(0));
|
||||
|
||||
let x = Var::leaf(cuda(&x_h, &[rows, cols]));
|
||||
let loss = ops::cross_entropy(&x, &target);
|
||||
loss.backward();
|
||||
|
||||
let dx = x.grad().unwrap().to_device(Device::Cpu);
|
||||
// Loss is already scalar (mean NLL) — grad-check it directly, no W weighting.
|
||||
let tgt = targets.clone();
|
||||
let lx = move |v: &[f32], s: &[usize]| {
|
||||
let t = Tensor::from_slice(&tgt, &[rows]).to_device(Device::Cuda(0));
|
||||
let (_, per_row) = cuda(v, s).cross_entropy(&t);
|
||||
per_row
|
||||
.to_device(Device::Cpu)
|
||||
.as_slice::<f32>()
|
||||
.iter()
|
||||
.sum::<f32>()
|
||||
/ rows as f32
|
||||
};
|
||||
report(
|
||||
"cross_entropy dX",
|
||||
&grad_check(
|
||||
&x_h,
|
||||
&[rows, cols],
|
||||
&lx,
|
||||
dx.as_slice::<f32>(),
|
||||
cfg_nonlinear(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- FAN-OUT: a tensor feeding two consumers must SUM grads ----
|
||||
// y = x*x + x*x via two separate mul nodes on the same Var x → dL/dx must be the
|
||||
// sum of both branches. With W=1, out=2x², so dOut=W=1 and dx (numeric) = 4x.
|
||||
#[test]
|
||||
fn fanout_grad_accumulation() {
|
||||
require_gpu();
|
||||
let n = 12;
|
||||
let x_h = fill(n, 111);
|
||||
let w = vec![1.0f32; n];
|
||||
|
||||
let x = Var::leaf(cuda(&x_h, &[n]));
|
||||
let sq1 = ops::mul(&x, &x); // x∘x (x consumed twice within one node)
|
||||
let sq2 = ops::mul(&x, &x); // x∘x (x consumed again across nodes)
|
||||
let out = ops::add(&sq1, &sq2); // 2x²
|
||||
scalar_loss(&out, &w).backward();
|
||||
|
||||
let dx = x.grad().unwrap().to_device(Device::Cpu);
|
||||
let wf = w.clone();
|
||||
let lx = move |v: &[f32], s: &[usize]| {
|
||||
let t = cuda(v, s);
|
||||
let o = t.mul(&t).add(&t.mul(&t));
|
||||
weighted_sum(&o, &wf)
|
||||
};
|
||||
// Analytic dx should be 4x; fan-out summed all four uses of x.
|
||||
report(
|
||||
"fanout dX",
|
||||
&grad_check(&x_h, &[n], &lx, dx.as_slice::<f32>(), cfg_linear()),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- COMPOSED ATTENTION: attn = matmul(softmax(matmul(Q,Kᵀ)·scale), V) ----
|
||||
// Single head, single batch. Backward falls out of matmul+scale+softmax nodes.
|
||||
#[test]
|
||||
fn attention_composed_bwd() {
|
||||
require_gpu();
|
||||
let (s, d) = (5, 6); // seq_len, head_dim
|
||||
let scale = 1.0 / (d as f32).sqrt();
|
||||
let q_h = fill(s * d, 121);
|
||||
let k_h = fill(s * d, 122);
|
||||
let v_h = fill(s * d, 123);
|
||||
let w = fill(s * d, 124); // weights over the [s,d] attention output
|
||||
|
||||
let attn = |q: &Var, k: &Var, v: &Var| -> Var {
|
||||
let kt = transpose_var(k); // [d,s] (manual transpose node)
|
||||
let scores = ops::scale(&ops::matmul(q, &kt), scale); // [s,s]
|
||||
let probs = ops::softmax(&scores);
|
||||
ops::matmul(&probs, v) // [s,d]
|
||||
};
|
||||
|
||||
let q = Var::leaf(cuda(&q_h, &[s, d]));
|
||||
let k = Var::leaf(cuda(&k_h, &[s, d]));
|
||||
let v = Var::leaf(cuda(&v_h, &[s, d]));
|
||||
let out = attn(&q, &k, &v);
|
||||
scalar_loss(&out, &w).backward();
|
||||
|
||||
let dq = q.grad().unwrap().to_device(Device::Cpu);
|
||||
let dk = k.grad().unwrap().to_device(Device::Cpu);
|
||||
let dv = v.grad().unwrap().to_device(Device::Cpu);
|
||||
|
||||
// Re-run the same forward inside the loss closures (host-side) per input.
|
||||
let fwd = move |qh: &[f32], kh: &[f32], vh: &[f32]| -> f32 {
|
||||
let qv = cuda(qh, &[s, d]);
|
||||
let kv = cuda(kh, &[s, d]);
|
||||
let vv = cuda(vh, &[s, d]);
|
||||
let scores = qv.matmul(&kv.transpose_2d()).scale(scale);
|
||||
let probs = scores.softmax();
|
||||
weighted_sum(&probs.matmul(&vv), &w)
|
||||
};
|
||||
|
||||
let (kf, vf, ff) = (k_h.clone(), v_h.clone(), fwd.clone());
|
||||
let lq = move |x: &[f32], _s: &[usize]| ff(x, &kf, &vf);
|
||||
report(
|
||||
"attn dQ",
|
||||
&grad_check(&q_h, &[s, d], &lq, dq.as_slice::<f32>(), cfg_nonlinear()),
|
||||
);
|
||||
|
||||
let (qf, vf, ff) = (q_h.clone(), v_h.clone(), fwd.clone());
|
||||
let lk = move |x: &[f32], _s: &[usize]| ff(&qf, x, &vf);
|
||||
report(
|
||||
"attn dK",
|
||||
&grad_check(&k_h, &[s, d], &lk, dk.as_slice::<f32>(), cfg_nonlinear()),
|
||||
);
|
||||
|
||||
let (qf, kf, ff) = (q_h.clone(), k_h.clone(), fwd.clone());
|
||||
let lv = move |x: &[f32], _s: &[usize]| ff(&qf, &kf, x);
|
||||
report(
|
||||
"attn dV",
|
||||
&grad_check(&v_h, &[s, d], &lv, dv.as_slice::<f32>(), cfg_linear()),
|
||||
);
|
||||
}
|
||||
|
||||
// --- test helpers ---
|
||||
|
||||
// Scalar loss node L = sum(W ∘ out): wraps a fixed-weight Var and reduces. We
|
||||
// implement it as: elementwise mul by a constant-W leaf, then sum-to-scalar.
|
||||
fn scalar_loss(out: &Var, w: &[f32]) -> Var {
|
||||
let wt = Var::leaf(cuda(w, out.value().shape()));
|
||||
let prod = ops::mul(out, &wt);
|
||||
sum_all(&prod)
|
||||
}
|
||||
|
||||
// Sum-to-scalar node: out = sum(x). Backward broadcasts the scalar grad to a
|
||||
// ones-shaped tensor over x. Implemented here (test-local) since the engine's
|
||||
// op set doesn't include a generic reduction; cross_entropy is the only loss op.
|
||||
fn sum_all(x: &Var) -> Var {
|
||||
let xv = x.value();
|
||||
let total: f32 = xv.to_device(Device::Cpu).as_slice::<f32>().iter().sum();
|
||||
let scalar = Tensor::from_slice(&[total], &[1]).to_device(xv.device());
|
||||
let shape: Vec<usize> = xv.shape().to_vec();
|
||||
Var::from_op(
|
||||
scalar,
|
||||
vec![x.clone()],
|
||||
Box::new(move |d, parents| {
|
||||
// d is [1]; broadcast d to a same-shape tensor over the input.
|
||||
let dval = d.to_device(Device::Cpu).as_slice::<f32>()[0];
|
||||
let ones = vec![dval; shape.iter().product()];
|
||||
let g = Tensor::from_slice(&ones, &shape).to_device(Device::Cuda(0));
|
||||
Var::push_grad(&parents[0], g);
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Manual transpose node for the composed-attention test (the engine has no
|
||||
// transpose op; xserv does the equivalent host-side reshape around RoPE).
|
||||
fn transpose_var(x: &Var) -> Var {
|
||||
let xt = x.value().transpose_2d();
|
||||
Var::from_op(
|
||||
xt,
|
||||
vec![x.clone()],
|
||||
Box::new(|d, parents| {
|
||||
Var::push_grad(&parents[0], d.transpose_2d());
|
||||
}),
|
||||
)
|
||||
}
|
||||
136
docs/03-autograd-engine.md
Normal file
136
docs/03-autograd-engine.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Phase: Autograd Engine + Op Backward — Design Document
|
||||
|
||||
## Goal
|
||||
|
||||
在 T3 的 `Tensor`(matmul/transpose/finite-diff harness)之上,交付 **tape-based 动态 autograd 引擎** + 一个 tiny 现代 transformer 所需算子的**前向 kernel + 解析 backward**,每个 backward 都用 T3 的有限差分 harness 对拍通过。
|
||||
|
||||
具体三件事:
|
||||
|
||||
1. **autograd 引擎**:define-by-run 的反向自动微分。`Var` 包一个 `Tensor` + 可选 grad;每个 op 在 tape 上记一个节点(父节点 + backward 闭包);`backward()` 按逆拓扑序遍历,把梯度推给父节点。**关键正确性点:梯度累加**——一个张量被多个 op 消费(扇出)时,各路梯度必须**求和**(T3 没有累加路径,在这里实现)。
|
||||
2. **算子节点**:`matmul` / `add` / `mul` / `add_bias`(broadcast) / `scale` / `rms_norm` / `silu` / `swiglu` / `rope` / `softmax` / `cross_entropy`,各带前向 CUDA kernel(需要时)+ 解析 backward。
|
||||
3. **Attention 用组合**:`attn = matmul(softmax(matmul(Q,Kᵀ)·scale), V)`。一旦 matmul/softmax/scale 是 autograd 节点,attention 的 backward 自动成立——**不写 fused attention backward kernel**,只加一个端到端 grad-check 测试。
|
||||
|
||||
**明确不做**(留给 T5/T6):组装 transformer / 训练 loop / 优化器 / embedding / KV-cache / GQA 重复。本 Phase 只到「算子 backward 逐个对拍通过」。
|
||||
|
||||
## Module Layout
|
||||
|
||||
```
|
||||
csrc/ops/nn.cu # 所有 T4 算子的 fwd+bwd kernel + launch_*(含 inlined warp/block reduce)
|
||||
|
||||
crates/xtrain-cuda/
|
||||
├── build.rs # 新增 nn.cu
|
||||
└── src/ffi.rs # 新增 launch_* 声明(no_cuda 门控)
|
||||
|
||||
crates/xtrain-tensor/
|
||||
├── src/dtype.rs # 新增 I32(cross-entropy target 用)
|
||||
└── src/tensor.rs # add/mul/add_bias/sum_rows/rms_norm(+bwd)/silu(+bwd)/
|
||||
# rope(+bwd)/softmax(+bwd)/cross_entropy(+bwd)(no_cuda 门控)
|
||||
|
||||
crates/xtrain-autodiff/ # 引擎落在这里(已含 grad_check harness,自然归宿)
|
||||
├── build.rs # 新增:检测 nvcc → no_cuda cfg(cfg 不跨 crate 传播)
|
||||
├── src/
|
||||
│ ├── lib.rs # 导出 tape::Var + ops(no_cuda 门控)
|
||||
│ ├── finite_diff.rs # T3 既有 harness(不动)
|
||||
│ ├── tape.rs # Var / VarNode / backward / 梯度累加
|
||||
│ └── ops.rs # 各算子的 Var 节点构造器
|
||||
└── tests/autograd.rs # 每算子 grad-check + 扇出累加 + 组合 attention(#![cfg(not(no_cuda))])
|
||||
```
|
||||
|
||||
为什么引擎放 `xtrain-autodiff` 而不是新 crate:该 crate 本就是「自动微分」语义的归宿,且已持有 `grad_check`。前向 kernel/`Tensor` 方法仍按 T2/T3 约定落在 `xtrain-tensor`(与 `scale`/`matmul` 一致),引擎只是在其上叠 tape。
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### Tape 设计:`Rc<RefCell<VarNode>>` + 逆拓扑遍历
|
||||
|
||||
```rust
|
||||
pub struct VarNode {
|
||||
value: Tensor, // 前向输出
|
||||
grad: Option<Tensor>, // 反向累加的梯度
|
||||
parents: Vec<Var>, // 计算来源
|
||||
backward: Option<BackwardFn>, // None=叶子
|
||||
}
|
||||
pub struct Var(Rc<RefCell<VarNode>>);
|
||||
type BackwardFn = Box<dyn Fn(&Tensor, &[Var])>;
|
||||
```
|
||||
|
||||
- `Var` clone 只是 bump `Rc`,**clone 共享同一节点**——这正是「扇出」的识别方式(同一 `Rc::as_ptr` 在多处出现)。
|
||||
- `backward()`:① post-order DFS 建拓扑序(按指针去重);② 把 loss(必须是标量)的 grad 种子设为 1;③ 逆序遍历,每个节点把自己的 grad 传给父节点的 backward 闭包。
|
||||
- 闭包签名 `Fn(&grad, &parents)`:给本节点已累加的 grad 和父节点列表,闭包算出各父的梯度贡献并 `push_grad` 回去。前向需要 cache 的中间量(softmax 的 `y`、rms 的 `inv_rms`、ce 的 `probs`)用 `move` 闭包捕获。
|
||||
|
||||
### 梯度累加(扇出求和)——本 Phase 的正确性核心
|
||||
|
||||
`push_grad(parent, g)` 一律走 `accumulate`:
|
||||
|
||||
```rust
|
||||
fn accumulate(&self, g: Tensor) {
|
||||
match self.grad.take() {
|
||||
None => self.grad = Some(g), // 首次
|
||||
Some(prev) => self.grad = Some(prev.add(&g)),// 扇出:SUM
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
任何节点(叶子或中间)都累加:中间节点需要完整 grad 才能继续链式;叶子的累加结果就是输出。一个张量喂多个消费者时,多路 `push_grad` 自动求和。`mul(&x, &x)` 这类「同一 `Var` 进同一节点两次」也正确:`parents=[x,x]`(同指针),两次 `push_grad` 累加,拓扑去重保证 x 只遍历一次但收齐两路。测试 `fanout_grad_accumulation` 专门验证:`y=x∘x + x∘x`,`dL/dx` 须 = 4x(四处 x 全部求和)。
|
||||
|
||||
### 各算子 backward 数学
|
||||
|
||||
记上游梯度为 `d`(=本节点输出的梯度)。
|
||||
|
||||
| op | forward | backward |
|
||||
|----|---------|----------|
|
||||
| `matmul` | `C=A@B` | `dA=d@Bᵀ`, `dB=Aᵀ@d`(复用 T3 `matmul_backward`)|
|
||||
| `add` | `a+b` | `da=d`, `db=d` |
|
||||
| `mul` | `a∘b` | `da=d∘b`, `db=d∘a` |
|
||||
| `add_bias` | `x[r,c]+bias[c]` | `dx=d`, `dbias[c]=Σ_r d[r,c]`(沿广播维求和)|
|
||||
| `scale` | `x·α` | `dx=d·α` |
|
||||
| `silu` | `x·σ(x)` | `dx=d·(σ + x·σ·(1−σ))`, `σ=σ(x)` |
|
||||
| `swiglu` | `silu(g)∘u` | 由 `silu`+`mul` 组合自动得 |
|
||||
| `rope` | rotate_half 旋转 | RoPE 是正交变换,`dx` = 用**逆(转置)旋转**作用于 `d`(角度 +θ 的转置 ≡ −θ)|
|
||||
| `softmax` | row-wise safe softmax → `y` | Jacobian:`dx[r,c]=y[r,c]·(d[r,c] − Σ_c' d·y)` |
|
||||
| `cross_entropy` | mean NLL(softmax(x), tgt) | `dx = (probs − onehot)/rows`,再乘上游标量 grad |
|
||||
|
||||
**RMSNorm**(`y[r,c]=x[r,c]·ir·γ[c]`, `ir=rsqrt(mean(x²)+eps)`):
|
||||
设 `g[c]=d[r,c]·γ[c]`,`n=cols`,
|
||||
```
|
||||
dx[r,c] = ir·g[c] − x[r,c]·ir³/n·Σ_c'(g[c']·x[r,c'])
|
||||
dγ[c] = Σ_r d[r,c]·x[r,c]·ir
|
||||
```
|
||||
前向 cache 每行 `inv_rms[r]`,backward 直接复用,避免重算 reduce。
|
||||
|
||||
**RoPE 反向推导**:前向是 2×2 旋转矩阵 `R(θ)`,正交 ⇒ `Rᵀ = R(−θ)`。故
|
||||
```
|
||||
dx[i] = d[i]·cos + d[i+h]·sin
|
||||
dx[i+h] = d[i+h]·cos − d[i]·sin
|
||||
```
|
||||
position=0 时旋转是恒等,backward 也恒等(sanity check)。
|
||||
|
||||
**Softmax 反向推导**:`∂y_i/∂x_j = y_i(δ_ij − y_j)`,链式后
|
||||
`dx_i = Σ_j d_j·y_i(δ_ij − y_j) = y_i(d_i − Σ_j d_j y_j)`,即每行减去 `Σ(d∘y)` 后乘 `y`。
|
||||
|
||||
**Cross-entropy 反向推导**:`L=−log softmax(x)[t]`,softmax+NLL 的经典结果 `∂L/∂x_c = softmax_c − [c=t]`;取 batch 平均 ⇒ 除以 rows。kernel 把 `scale=upstream/rows` 折进去。
|
||||
|
||||
### Attention 用组合,不写 fused kernel
|
||||
|
||||
```
|
||||
Kᵀ = transpose(K)
|
||||
scores = scale(matmul(Q, Kᵀ), 1/√d) # [s,s]
|
||||
probs = softmax(scores)
|
||||
out = matmul(probs, V) # [s,d]
|
||||
```
|
||||
|
||||
每一步都是已有 autograd 节点,`backward()` 自动沿 matmul→softmax→scale→matmul 链回传,得到 `dQ/dK/dV`,无需手写 attention backward。测试 `attention_composed_bwd` 单头单 batch 端到端 grad-check Q/K/V 三者。(transpose 在测试里用一个临时 `Var::from_op` 节点包,因为引擎暂未把 transpose 列为 op——T5 若需要再补。)
|
||||
|
||||
### kernel 实现要点
|
||||
|
||||
- `nn.cu` 自带 inlined `warp/block_reduce_sum/max`(不引外部头文件,与现有 csrc/ 单文件风格一致);block-reduce 末尾广播到全 block,便于 softmax/rms 的「标量广播」模式。
|
||||
- 每个 op 各自 `cudaDeviceSynchronize()`(T3 约定,无 stream)。
|
||||
- 全 F32、row-major、contiguous;cross-entropy target 用新增的 `DType::I32`。
|
||||
|
||||
## 验证方法
|
||||
|
||||
模板沿用 T3 `gemm.rs::run_bwd`:标量 loss `L = sum(W∘out)`,`W` 固定随机 ⇒ 上游 `dOut = W`;跑 op 的 `backward()` 拿 `.grad()`,对每个输入用 `grad_check` 与中心差分对拍。
|
||||
|
||||
- **每算子**一个 grad-check(线性/双线性 op 用大 eps=1e-2、rel_tol=2e-2;非线性 op 用 eps=1e-3、rel_tol=3e-2、atol=1e-3 压住近零梯度)。
|
||||
- **扇出累加**:`fanout_grad_accumulation`,验证 `dL/dx=4x`。
|
||||
- **组合 attention**:`attention_composed_bwd`,端到端 grad-check `dQ/dK/dV`。
|
||||
- 全部 `#![cfg(not(no_cuda))]` 门控;本地只 `cargo check`/`fmt`,构建+实跑在 dash5(8× RTX 5090, sm_120),capture 每 op 的 pass + max rel-err。
|
||||
Reference in New Issue
Block a user