use fuel::Device;
use fuel::lazy::Tensor;
use fuel_dispatch::pipelined::StorageCache;
use fuel_graph::{Graph, Node, NodeId, Op, SharedGraph};
use fuel_ir::{DType, Error, Result, Shape};
use fuel_memory::Storage;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
#[derive(Clone)]
pub struct Parameter {
pub name: String,
pub shape: Shape,
pub dtype: DType,
pub initial_data: Arc<[f32]>,
}
impl Parameter {
pub fn new_f32(
name: impl Into<String>,
shape: impl Into<Shape>,
data: impl Into<Arc<[f32]>>,
) -> Self {
let shape = shape.into();
let data = data.into();
assert_eq!(
data.len(),
shape.elem_count(),
"Parameter::new_f32: data length must match shape elem_count",
);
Self {
name: name.into(),
shape,
dtype: DType::F32,
initial_data: data,
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum OptimizerConfig {
Sgd { lr: f32 },
AdamW {
lr: f32,
beta1: f32,
beta2: f32,
eps: f32,
weight_decay: f32,
},
}
impl OptimizerConfig {
pub fn sgd(lr: f32) -> Self {
Self::Sgd { lr }
}
pub fn adam_w(lr: f32) -> Self {
Self::AdamW {
lr,
beta1: 0.9,
beta2: 0.999,
eps: 1e-8,
weight_decay: 0.01,
}
}
pub fn lr(&self) -> f32 {
match *self {
Self::Sgd { lr } => lr,
Self::AdamW { lr, .. } => lr,
}
}
pub fn with_lr(&self, new_lr: f32) -> Self {
match *self {
Self::Sgd { .. } => Self::Sgd { lr: new_lr },
Self::AdamW {
beta1,
beta2,
eps,
weight_decay,
..
} => Self::AdamW {
lr: new_lr,
beta1,
beta2,
eps,
weight_decay,
},
}
}
}
pub trait LrSchedule {
fn lr_at(&self, step: u64) -> f32;
}
pub struct ConstLr(pub f32);
impl LrSchedule for ConstLr {
fn lr_at(&self, _step: u64) -> f32 {
self.0
}
}
pub struct WarmupCosine {
pub warmup: u64,
pub total: u64,
pub peak: f32,
pub final_lr: f32,
}
impl LrSchedule for WarmupCosine {
fn lr_at(&self, step: u64) -> f32 {
if step < self.warmup {
if self.warmup == 0 {
return self.peak;
}
self.peak * (step as f32 / self.warmup as f32)
} else if step >= self.total {
self.final_lr
} else {
let progress = (step - self.warmup) as f32 / (self.total - self.warmup).max(1) as f32;
let cos_scale = 0.5 * (1.0 + (std::f32::consts::PI * progress).cos());
self.final_lr + (self.peak - self.final_lr) * cos_scale
}
}
}
pub struct WarmupLinear {
pub warmup: u64,
pub total: u64,
pub peak: f32,
pub final_lr: f32,
}
impl LrSchedule for WarmupLinear {
fn lr_at(&self, step: u64) -> f32 {
if step < self.warmup {
if self.warmup == 0 {
return self.peak;
}
self.peak * (step as f32 / self.warmup as f32)
} else if step >= self.total {
self.final_lr
} else {
let progress = (step - self.warmup) as f32 / (self.total - self.warmup).max(1) as f32;
self.peak + (self.final_lr - self.peak) * progress
}
}
}
pub(crate) enum OptState {
Sgd,
AdamW {
m: Arc<RwLock<Storage>>,
v: Arc<RwLock<Storage>>,
},
}
#[derive(Debug, Clone, Copy)]
pub enum GradClip {
GlobalNorm(f32),
}
pub struct TrainState {
params: HashMap<String, (Arc<RwLock<Storage>>, Shape)>,
opt_state: HashMap<String, OptState>,
param_order: Vec<String>,
config: OptimizerConfig,
step_count: u64,
grad_clip: Option<GradClip>,
device: Device,
}
impl TrainState {
pub fn new(parameters: &[Parameter], device: &Device, config: OptimizerConfig) -> Result<Self> {
let mut params = HashMap::new();
let mut opt_state = HashMap::new();
let mut param_order = Vec::with_capacity(parameters.len());
for p in parameters {
if p.dtype != DType::F32 {
return Err(Error::Msg(format!(
"TrainState::new: parameter '{}' has dtype {:?}; \
only F32 parameters are supported today",
p.name, p.dtype,
))
.bt());
}
let storage = Arc::new(RwLock::new(fuel_memory::from_slice_cpu::<f32>(
&p.initial_data,
)));
params.insert(p.name.clone(), (storage, p.shape.clone()));
param_order.push(p.name.clone());
let st = match config {
OptimizerConfig::Sgd { .. } => OptState::Sgd,
OptimizerConfig::AdamW { .. } => {
let n = p.shape.elem_count();
let m = Arc::new(RwLock::new(fuel_memory::alloc_cpu_zeroed(DType::F32, n)?));
let v = Arc::new(RwLock::new(fuel_memory::alloc_cpu_zeroed(DType::F32, n)?));
OptState::AdamW { m, v }
}
};
opt_state.insert(p.name.clone(), st);
}
Ok(Self {
params,
opt_state,
param_order,
config,
step_count: 0,
grad_clip: None,
device: device.clone(),
})
}
pub fn device(&self) -> &Device {
&self.device
}
pub fn with_grad_clip(mut self, clip: Option<GradClip>) -> Self {
self.grad_clip = clip;
self
}
pub fn set_grad_clip(&mut self, clip: Option<GradClip>) {
self.grad_clip = clip;
}
pub fn step_count(&self) -> u64 {
self.step_count
}
pub fn set_lr(&mut self, lr: f32) {
self.config = self.config.with_lr(lr);
}
pub fn step_with_schedule<S, F>(&mut self, schedule: &S, build_loss: F) -> Result<f32>
where
S: LrSchedule,
F: FnOnce(&SharedGraph, &HashMap<String, Tensor>) -> Result<Tensor>,
{
self.set_lr(schedule.lr_at(self.step_count));
self.step(build_loss)
}
pub fn optimizer_config(&self) -> OptimizerConfig {
self.config
}
pub fn param_to_host(&self, name: &str) -> Result<Vec<f32>> {
let (storage, shape) = self
.params
.get(name)
.ok_or_else(|| Error::Msg(format!("unknown parameter '{name}'")).bt())?;
let graph = Arc::new(RwLock::new(Graph::new()));
let id = {
let mut g = graph
.write()
.map_err(|_| Error::Msg("param_to_host: graph lock poisoned".into()).bt())?;
g.push(Node {
op: Op::Const,
inputs: vec![],
shape: shape.clone(),
dtype: DType::F32,
})
};
let mut cache = StorageCache::new();
cache.insert(id, Arc::clone(storage));
fuel::pipelined_bridge::realize_one_as_with_initial::<f32>(&graph, id, &self.device, cache)
}
pub fn step<F>(&mut self, build_loss: F) -> Result<f32>
where
F: FnOnce(&SharedGraph, &HashMap<String, Tensor>) -> Result<Tensor>,
{
let seed = Tensor::from_f32(vec![0.0f32], Shape::from_dims(&[1]), &Device::cpu())?;
let graph = seed.graph_tensor().graph().clone();
let mut cache = StorageCache::new();
let mut param_tensors: HashMap<String, Tensor> = HashMap::new();
for name in &self.param_order {
let (storage, shape) = &self.params[name];
let t = seed.const_placeholder_like(shape.clone(), DType::F32);
cache.insert(t.graph_tensor().id(), Arc::clone(storage));
param_tensors.insert(name.clone(), t);
}
let loss = build_loss(&graph, ¶m_tensors)?;
let grad_map = loss.graph_tensor().backward();
let mut raw_grads: HashMap<String, Tensor> = HashMap::new();
for name in &self.param_order {
let param = ¶m_tensors[name];
let grad = grad_map.get(param.graph_tensor()).ok_or_else(|| {
fuel_ir::Error::Msg(format!("parameter '{name}' did not appear in loss graph"))
})?;
raw_grads.insert(name.clone(), Tensor::from_graph_tensor(grad));
}
let clip_scale: Option<Tensor> = match self.grad_clip {
None => None,
Some(GradClip::GlobalNorm(max_norm)) => {
let mut total_sq: Option<Tensor> = None;
for name in &self.param_order {
let g = &raw_grads[name];
let g_sq_sum = g.sqr().sum_all();
total_sq = Some(match total_sq {
None => g_sq_sum,
Some(acc) => acc.add(&g_sq_sum)?,
});
}
let total_sq = total_sq.ok_or_else(|| {
Error::Msg("gradient clipping requires at least one parameter".into()).bt()
})?;
let norm = total_sq.sqrt();
let norm_safe = norm.add_scalar(1e-12);
let ratio = norm_safe.mul_scalar(1.0 / max_norm as f64);
let inv_ratio = ratio
.const_f32_like(vec![1.0f32], Shape::from_dims(&[]))?
.div(&ratio)?;
let scale = inv_ratio.clamp(0.0, 1.0);
Some(scale)
}
};
let mut new_param_tensors: Vec<Tensor> = Vec::with_capacity(self.param_order.len());
let mut new_opt_tensors: Vec<(String, Tensor, Tensor)> = Vec::new();
for name in &self.param_order {
let param = ¶m_tensors[name];
let raw_grad = &raw_grads[name];
let grad_lt = match &clip_scale {
None => raw_grad.clone(),
Some(scale) => {
let grad_shape = raw_grad.graph_tensor().shape();
let scale_bcast = scale.broadcast_to(grad_shape)?;
raw_grad.mul(&scale_bcast)?
}
};
match self.config {
OptimizerConfig::Sgd { lr } => {
let scaled = grad_lt.mul_scalar(lr as f64);
let new_param = param.sub(&scaled)?;
new_param_tensors.push(new_param);
}
OptimizerConfig::AdamW {
lr,
beta1,
beta2,
eps,
weight_decay,
} => {
let param_shape = param.graph_tensor().shape();
let m_placeholder =
seed.const_placeholder_like(param_shape.clone(), DType::F32);
let v_placeholder = seed.const_placeholder_like(param_shape, DType::F32);
let (m_arc, v_arc) = match self.opt_state.get(name.as_str()) {
Some(OptState::AdamW { m, v }) => (Arc::clone(m), Arc::clone(v)),
_ => {
return Err(Error::Msg(format!(
"TrainState::step: optimizer state for \
'{name}' is not AdamW — internal bug",
))
.bt());
}
};
cache.insert(m_placeholder.graph_tensor().id(), m_arc);
cache.insert(v_placeholder.graph_tensor().id(), v_arc);
let m_decayed = m_placeholder.mul_scalar(beta1 as f64);
let g_part = grad_lt.mul_scalar((1.0 - beta1) as f64);
let new_m = m_decayed.add(&g_part)?;
let v_decayed = v_placeholder.mul_scalar(beta2 as f64);
let g_sq = grad_lt.sqr();
let g_sq_part = g_sq.mul_scalar((1.0 - beta2) as f64);
let new_v = v_decayed.add(&g_sq_part)?;
let t = (self.step_count + 1) as f64;
let bc1 = 1.0 - (beta1 as f64).powf(t);
let bc2 = 1.0 - (beta2 as f64).powf(t);
let m_hat = new_m.mul_scalar(1.0 / bc1);
let v_hat = new_v.mul_scalar(1.0 / bc2);
let denom = v_hat.sqrt().add_scalar(eps as f64);
let update = m_hat.div(&denom)?;
let wd_term = param.mul_scalar(weight_decay as f64);
let step_total = update.add(&wd_term)?.mul_scalar(lr as f64);
let new_param = param.sub(&step_total)?;
new_param_tensors.push(new_param);
new_opt_tensors.push((name.clone(), new_m, new_v));
}
}
}
let mut roots: Vec<NodeId> =
Vec::with_capacity(1 + new_param_tensors.len() + 2 * new_opt_tensors.len());
roots.push(loss.graph_tensor().id());
for np in &new_param_tensors {
roots.push(np.graph_tensor().id());
}
for (_, nm, nv) in &new_opt_tensors {
roots.push(nm.graph_tensor().id());
roots.push(nv.graph_tensor().id());
}
let (host, resident) = fuel::pipelined_bridge::realize_split_as_with_initial::<f32>(
&graph,
&roots,
1,
&self.device,
cache,
)?;
let loss_vec = host.into_iter().next().ok_or_else(|| {
Error::Msg(
"TrainState::step: realize returned no host result for the \
loss root — internal bug"
.into(),
)
.bt()
})?;
let loss_scalar = if loss_vec.len() == 1 {
loss_vec[0]
} else {
loss_vec.iter().sum()
};
let mut iter = resident.into_iter();
for name in &self.param_order {
let (new_storage, new_layout) = iter.next().ok_or_else(|| {
Error::Msg(format!(
"TrainState::step: missing resident result for \
parameter '{name}' — internal bug",
))
.bt()
})?;
self.params
.insert(name.clone(), (new_storage, new_layout.shape().clone()));
}
for (name, _, _) in &new_opt_tensors {
let (new_m, _) = iter.next().ok_or_else(|| {
Error::Msg(format!(
"TrainState::step: missing resident result for '{name}' \
first moment — internal bug",
))
.bt()
})?;
let (new_v, _) = iter.next().ok_or_else(|| {
Error::Msg(format!(
"TrainState::step: missing resident result for '{name}' \
second moment — internal bug",
))
.bt()
})?;
self.opt_state
.insert(name.clone(), OptState::AdamW { m: new_m, v: new_v });
}
self.step_count += 1;
Ok(loss_scalar)
}
}
pub mod loss {
use fuel::lazy::Tensor;
use fuel_ir::{Error, Result, Shape};
pub fn mse(pred: &Tensor, target: &Tensor) -> Result<Tensor> {
let n = pred.graph_tensor().shape().elem_count();
let diff = pred.sub(target)?;
let sq = diff.sqr();
Ok(sq.sum_all().mul_scalar(1.0 / n as f64))
}
pub fn cross_entropy_with_logits(logits: &Tensor, target_one_hot: &Tensor) -> Result<Tensor> {
let dims = logits.graph_tensor().shape().dims().to_vec();
let rank = dims.len();
if rank < 1 {
return Err(Error::Msg(format!(
"cross_entropy_with_logits: logits must have rank >= 1, got {dims:?}"
)));
}
let n_outer: usize = dims[..rank - 1].iter().product::<usize>().max(1);
let max_r = logits.max_dim(rank - 1)?;
let mut keepdim = dims.clone();
keepdim[rank - 1] = 1;
let max_kd = max_r.reshape(Shape::from_dims(&keepdim))?;
let max_bcast = max_kd.broadcast_to(Shape::from_dims(&dims))?;
let shifted = logits.sub(&max_bcast)?;
let expd = shifted.exp();
let sum_exp = expd.sum_dim(rank - 1)?;
let log_sum = sum_exp.log();
let log_sum_kd = log_sum.reshape(Shape::from_dims(&keepdim))?;
let log_sum_bcast = log_sum_kd.broadcast_to(Shape::from_dims(&dims))?;
let log_softmax = shifted.sub(&log_sum_bcast)?;
let per_elem = target_one_hot.mul(&log_softmax)?;
let neg_per_sample = per_elem.sum_dim(rank - 1)?.mul_scalar(-1.0);
let total = neg_per_sample.sum_all();
Ok(total.mul_scalar(1.0 / n_outer as f64))
}
pub fn fused_softmax_cross_entropy(
logits: &Tensor,
targets: &Tensor,
reduction: fuel_graph::registry::Reduction,
ignore_index: i64,
) -> Tensor {
logits.fused_softmax_cross_entropy(targets, reduction, ignore_index)
}
}
#[cfg(test)]
mod tests {
use super::*;
use fuel::lazy::Tensor;
use fuel_graph::registry::Reduction;
use fuel_ir::DType;
fn lt_const_i64_like(host: &Tensor, data: Vec<i64>, shape: Shape) -> Tensor {
Tensor::from_graph_tensor(host.graph_tensor().const_i64_like(data, shape).unwrap())
}
#[test]
fn fused_softmax_cross_entropy_matches_primitive_composition() {
let device = fuel::Device::cpu();
let logits_data: Vec<f32> = vec![
1.0, 2.0, 3.0, 4.0, 0.5, 0.0, -0.5, 1.0, -1.0, -2.0, -3.0, 4.0,
];
let targets_i64: Vec<i64> = vec![3, 0, 3];
let mut targets_onehot = vec![0.0f32; 3 * 4];
for (row, &t) in targets_i64.iter().enumerate() {
targets_onehot[row * 4 + t as usize] = 1.0;
}
let logits_fused =
Tensor::from_f32(logits_data.clone(), Shape::from_dims(&[3, 4]), &device).unwrap();
let targets_fused =
lt_const_i64_like(&logits_fused, targets_i64.clone(), Shape::from_dims(&[3]));
let fused_loss =
loss::fused_softmax_cross_entropy(&logits_fused, &targets_fused, Reduction::Mean, -100)
.realize_f32()[0];
let logits_prim =
Tensor::from_f32(logits_data, Shape::from_dims(&[3, 4]), &device).unwrap();
let targets_prim = logits_prim
.const_f32_like(targets_onehot, Shape::from_dims(&[3, 4]))
.unwrap();
let prim_loss = loss::cross_entropy_with_logits(&logits_prim, &targets_prim)
.unwrap()
.realize_f32()[0];
assert!(
(fused_loss - prim_loss).abs() < 1e-5,
"fused {fused_loss} vs primitive {prim_loss}",
);
}
#[test]
fn fused_softmax_cross_entropy_none_returns_per_row() {
let device = fuel::Device::cpu();
let logits_data: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0, 0.0, 0.0, 0.0, 0.0];
let logits = Tensor::from_f32(logits_data, Shape::from_dims(&[2, 4]), &device).unwrap();
let targets = lt_const_i64_like(&logits, vec![1_i64, 3], Shape::from_dims(&[2]));
let per_row = loss::fused_softmax_cross_entropy(&logits, &targets, Reduction::None, -100)
.realize_f32();
assert_eq!(per_row.len(), 2);
assert!(
(per_row[0] - 2.440_189_8).abs() < 1e-5,
"row 0: {}",
per_row[0]
);
assert!(
(per_row[1] - 1.386_294_4).abs() < 1e-5,
"row 1: {}",
per_row[1]
);
}
#[test]
fn fused_softmax_cross_entropy_ignore_index_masks_row() {
let device = fuel::Device::cpu();
let logits_data: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0, 0.0, 0.0, 0.0, 0.0];
let logits = Tensor::from_f32(logits_data, Shape::from_dims(&[2, 4]), &device).unwrap();
let targets = lt_const_i64_like(&logits, vec![1_i64, -100], Shape::from_dims(&[2]));
let loss_val = loss::fused_softmax_cross_entropy(&logits, &targets, Reduction::Mean, -100)
.realize_f32()[0];
assert!(
(loss_val - 2.440_189_8).abs() < 1e-5,
"got {loss_val}, expected ~2.44019",
);
}
#[test]
fn causal_conv1d_basic_end_to_end() {
let device = fuel::Device::cpu();
let x = Tensor::from_f32(
vec![0.0_f32, 0.0, 1.0, 2.0],
Shape::from_dims(&[1, 1, 4]),
&device,
)
.unwrap();
let w = x
.const_f32_like(vec![0.5_f32, 1.0, 2.0], Shape::from_dims(&[1, 1, 3]))
.unwrap();
let bias = x
.const_f32_like(vec![0.1_f32], Shape::from_dims(&[1]))
.unwrap();
let out = x.causal_conv1d(&w, &bias, false).realize_f32();
assert_eq!(out.len(), 2);
assert!((out[0] - 2.1).abs() < 1e-5, "out[0]={}", out[0]);
assert!((out[1] - 5.1).abs() < 1e-5, "out[1]={}", out[1]);
}
#[test]
fn causal_conv1d_with_silu_end_to_end() {
let device = fuel::Device::cpu();
let x = Tensor::from_f32(
vec![0.0_f32, 0.0, 1.0, 2.0],
Shape::from_dims(&[1, 1, 4]),
&device,
)
.unwrap();
let w = x
.const_f32_like(vec![0.5_f32, 1.0, 2.0], Shape::from_dims(&[1, 1, 3]))
.unwrap();
let bias = x
.const_f32_like(vec![0.1_f32], Shape::from_dims(&[1]))
.unwrap();
let out = x.causal_conv1d(&w, &bias, true).realize_f32();
let expected0 = 2.1_f32 / (1.0 + (-2.1_f32).exp());
let expected1 = 5.1_f32 / (1.0 + (-5.1_f32).exp());
assert!((out[0] - expected0).abs() < 1e-5, "out[0]={}", out[0]);
assert!((out[1] - expected1).abs() < 1e-5, "out[1]={}", out[1]);
}
#[test]
fn causal_conv1d_registry_entry_registered() {
let r = fuel_graph::registry::default_registry();
let e = r
.entry(fuel_graph::registry::FusedOps::CAUSAL_CONV1D)
.expect("CAUSAL_CONV1D registered");
assert_eq!(e.name, "CausalConv1d");
assert_eq!(
r.id_for_name("CausalConv1d"),
Some(fuel_graph::registry::FusedOps::CAUSAL_CONV1D),
);
let out_shape = (e.shape_rule)(
&[
Shape::from_dims(&[2, 4, 8]), Shape::from_dims(&[4, 1, 3]), Shape::from_dims(&[4]), ],
&fuel_graph::registry::FusedOpParams::CausalConv1d { use_silu: false },
);
assert_eq!(out_shape.dims(), &[2, 4, 6]);
}
#[test]
fn selective_scan_basic_end_to_end() {
let device = fuel::Device::cpu();
let u = Tensor::from_f32(vec![3.0_f32], Shape::from_dims(&[1, 1, 1]), &device).unwrap();
let delta = u
.const_f32_like(vec![1.0_f32], Shape::from_dims(&[1, 1, 1]))
.unwrap();
let a = u
.const_f32_like(vec![-1.0_f32], Shape::from_dims(&[1, 1]))
.unwrap();
let b = u
.const_f32_like(vec![2.0_f32], Shape::from_dims(&[1, 1, 1]))
.unwrap();
let c = u
.const_f32_like(vec![0.5_f32], Shape::from_dims(&[1, 1, 1]))
.unwrap();
let y = u.selective_scan(&delta, &a, &b, &c, false).realize_f32();
assert_eq!(y.len(), 1);
assert!((y[0] - 3.0).abs() < 1e-5, "got {}", y[0]);
}
#[test]
fn selective_scan_with_softplus_end_to_end() {
let device = fuel::Device::cpu();
let u = Tensor::from_f32(vec![1.0_f32], Shape::from_dims(&[1, 1, 1]), &device).unwrap();
let delta = u
.const_f32_like(vec![0.0_f32], Shape::from_dims(&[1, 1, 1]))
.unwrap();
let a = u
.const_f32_like(vec![0.0_f32], Shape::from_dims(&[1, 1]))
.unwrap();
let b = u
.const_f32_like(vec![1.0_f32], Shape::from_dims(&[1, 1, 1]))
.unwrap();
let c = u
.const_f32_like(vec![1.0_f32], Shape::from_dims(&[1, 1, 1]))
.unwrap();
let y = u.selective_scan(&delta, &a, &b, &c, true).realize_f32();
let expected = 2.0_f32.ln();
assert!(
(y[0] - expected).abs() < 1e-5,
"got {} expected {expected}",
y[0]
);
}
#[test]
fn selective_scan_registry_entry_registered() {
let r = fuel_graph::registry::default_registry();
let e = r
.entry(fuel_graph::registry::FusedOps::SELECTIVE_SCAN)
.expect("SELECTIVE_SCAN registered");
assert_eq!(e.name, "SelectiveScan");
assert_eq!(
r.id_for_name("SelectiveScan"),
Some(fuel_graph::registry::FusedOps::SELECTIVE_SCAN),
);
let out_shape = (e.shape_rule)(
&[
Shape::from_dims(&[2, 8, 64]), Shape::from_dims(&[2, 8, 64]), Shape::from_dims(&[64, 16]), Shape::from_dims(&[2, 8, 16]), Shape::from_dims(&[2, 8, 16]), ],
&fuel_graph::registry::FusedOpParams::SelectiveScan {
delta_softplus: false,
},
);
assert_eq!(out_shape.dims(), &[2, 8, 64]);
}
#[test]
fn ssd_chunk_scan_basic_end_to_end() {
let device = fuel::Device::cpu();
let x = Tensor::from_f32(vec![3.0_f32], Shape::from_dims(&[1, 1, 1, 1]), &device).unwrap();
let dt = x
.const_f32_like(vec![1.0_f32], Shape::from_dims(&[1, 1, 1]))
.unwrap();
let a = x
.const_f32_like(vec![-1.0_f32], Shape::from_dims(&[1]))
.unwrap();
let b = x
.const_f32_like(vec![2.0_f32], Shape::from_dims(&[1, 1, 1, 1]))
.unwrap();
let c = x
.const_f32_like(vec![0.5_f32], Shape::from_dims(&[1, 1, 1, 1]))
.unwrap();
let y = x.ssd_chunk_scan(&dt, &a, &b, &c, 1).realize_f32();
assert_eq!(y.len(), 1);
assert!((y[0] - 3.0).abs() < 1e-5, "got {}", y[0]);
}
#[test]
fn ssd_chunk_scan_registry_entry_registered() {
let r = fuel_graph::registry::default_registry();
let e = r
.entry(fuel_graph::registry::FusedOps::SSD_CHUNK_SCAN)
.expect("SSD_CHUNK_SCAN registered");
assert_eq!(e.name, "SsdChunkScan");
assert_eq!(
r.id_for_name("SsdChunkScan"),
Some(fuel_graph::registry::FusedOps::SSD_CHUNK_SCAN),
);
let out_shape = (e.shape_rule)(
&[
Shape::from_dims(&[2, 16, 8, 64]), Shape::from_dims(&[2, 16, 8]), Shape::from_dims(&[8]), Shape::from_dims(&[2, 16, 8, 128]), Shape::from_dims(&[2, 16, 8, 128]), ],
&fuel_graph::registry::FusedOpParams::SsdChunkScan { chunk_size: 16 },
);
assert_eq!(out_shape.dims(), &[2, 16, 8, 64]);
}
#[test]
fn nf4_matmul_basic_end_to_end() {
let device = fuel::Device::cpu();
let activations = Tensor::from_f32(
vec![1.0_f32, 2.0, 2.0, 4.0],
Shape::from_dims(&[1, 4]),
&device,
)
.unwrap();
let w_packed_t = activations
.graph_tensor()
.const_u8_like(vec![247_u8, 247, 127, 127], Shape::from_dims(&[2, 2]))
.unwrap();
let w_packed = Tensor::from_graph_tensor(w_packed_t);
let absmax = activations
.const_f32_like(vec![1.0_f32, 2.0, 10.0, 20.0], Shape::from_dims(&[2, 2]))
.unwrap();
let y = activations.nf4_matmul(&w_packed, &absmax, 2).realize_f32();
assert_eq!(y.len(), 2);
assert!((y[0] - 10.0).abs() < 1e-5, "out 0: {}", y[0]);
assert!((y[1] - 50.0).abs() < 1e-5, "out 1: {}", y[1]);
}
#[test]
fn nf4_matmul_registry_entry_registered() {
let r = fuel_graph::registry::default_registry();
let e = r
.entry(fuel_graph::registry::FusedOps::NF4_MATMUL)
.expect("NF4_MATMUL registered");
assert_eq!(e.name, "Nf4Matmul");
assert_eq!(
r.id_for_name("Nf4Matmul"),
Some(fuel_graph::registry::FusedOps::NF4_MATMUL),
);
let out_shape = (e.shape_rule)(
&[
Shape::from_dims(&[4, 32, 128]), Shape::from_dims(&[256, 64]), Shape::from_dims(&[256, 2]), ],
&fuel_graph::registry::FusedOpParams::Nf4Matmul { block_size: 64 },
);
assert_eq!(out_shape.dims(), &[4, 32, 256]);
let out_dtype = (e.dtype_rule)(
&[fuel_ir::DType::F16, fuel_ir::DType::U8, fuel_ir::DType::F32],
&fuel_graph::registry::FusedOpParams::Nf4Matmul { block_size: 64 },
);
assert_eq!(out_dtype, fuel_ir::DType::F16);
}
#[test]
fn fused_softmax_cross_entropy_registry_entry_registered() {
let r = fuel_graph::registry::default_registry();
let e = r
.entry(fuel_graph::registry::FusedOps::FUSED_SOFTMAX_CROSS_ENTROPY)
.expect("FUSED_SOFTMAX_CROSS_ENTROPY registered");
assert_eq!(e.name, "FusedSoftmaxCrossEntropy");
assert_eq!(
r.id_for_name("FusedSoftmaxCrossEntropy"),
Some(fuel_graph::registry::FusedOps::FUSED_SOFTMAX_CROSS_ENTROPY),
);
let out_dtype = (e.dtype_rule)(
&[DType::F32, DType::I64],
&fuel_graph::registry::FusedOpParams::FusedSoftmaxCrossEntropy {
reduction: Reduction::Mean,
ignore_index: -100,
},
);
assert_eq!(out_dtype, DType::F32);
}
#[test]
fn sgd_fits_linear_regression() {
let xs: Vec<f32> = (0..10).map(|i| i as f32).collect();
let ys: Vec<f32> = xs.iter().map(|&x| 2.0 * x + 3.0).collect();
let device = fuel::Device::cpu();
let params = vec![
Parameter::new_f32("w", Shape::from_dims(&[1]), vec![0.1f32]),
Parameter::new_f32("b", Shape::from_dims(&[1]), vec![0.1f32]),
];
let mut state = TrainState::new(¶ms, &device, OptimizerConfig::sgd(0.01)).unwrap();
let n_steps = 2000;
let x_arc: Arc<[f32]> = xs.clone().into();
let y_arc: Arc<[f32]> = ys.clone().into();
for step in 0..n_steps {
let x_arc_step = x_arc.clone();
let y_arc_step = y_arc.clone();
let len = xs.len();
let loss = state
.step(move |_graph, params| {
let w = ¶ms["w"];
let b = ¶ms["b"];
let x = w.const_f32_like(x_arc_step, Shape::from_dims(&[len]))?;
let y = w.const_f32_like(y_arc_step, Shape::from_dims(&[len]))?;
let w_b = w.broadcast_to(Shape::from_dims(&[len])).unwrap();
let b_b = b.broadcast_to(Shape::from_dims(&[len])).unwrap();
let y_hat = x.mul(&w_b).unwrap().add(&b_b).unwrap();
let diff = y_hat.sub(&y).unwrap();
let sq = diff.sqr();
Ok(sq.sum_all().mul_scalar(1.0 / len as f64))
})
.unwrap();
if step % 500 == 0 {
eprintln!("step {step}: loss = {loss}");
}
}
let w_final = state.param_to_host("w").unwrap()[0];
let b_final = state.param_to_host("b").unwrap()[0];
eprintln!("final: w = {w_final}, b = {b_final}");
assert!(
(w_final - 2.0).abs() < 0.05,
"w converged to {w_final}, expected ~2.0"
);
assert!(
(b_final - 3.0).abs() < 0.3,
"b converged to {b_final}, expected ~3.0"
);
}
#[test]
fn adamw_fits_linear_regression() {
let xs: Vec<f32> = (0..10).map(|i| i as f32).collect();
let ys: Vec<f32> = xs.iter().map(|&x| 2.0 * x + 3.0).collect();
let device = fuel::Device::cpu();
let params = vec![
Parameter::new_f32("w", Shape::from_dims(&[1]), vec![0.1f32]),
Parameter::new_f32("b", Shape::from_dims(&[1]), vec![0.1f32]),
];
let cfg = OptimizerConfig::AdamW {
lr: 0.1,
beta1: 0.9,
beta2: 0.999,
eps: 1e-8,
weight_decay: 0.0,
};
let mut state = TrainState::new(¶ms, &device, cfg).unwrap();
let x_arc: Arc<[f32]> = xs.clone().into();
let y_arc: Arc<[f32]> = ys.clone().into();
let mut final_loss = 0.0;
for step in 0..500 {
let x_arc_step = x_arc.clone();
let y_arc_step = y_arc.clone();
let len = xs.len();
let loss = state
.step(move |_graph, params| {
let w = ¶ms["w"];
let b = ¶ms["b"];
let x = w.const_f32_like(x_arc_step, Shape::from_dims(&[len]))?;
let y = w.const_f32_like(y_arc_step, Shape::from_dims(&[len]))?;
let w_b = w.broadcast_to(Shape::from_dims(&[len])).unwrap();
let b_b = b.broadcast_to(Shape::from_dims(&[len])).unwrap();
let y_hat = x.mul(&w_b).unwrap().add(&b_b).unwrap();
let diff = y_hat.sub(&y).unwrap();
Ok(diff.sqr().sum_all().mul_scalar(1.0 / len as f64))
})
.unwrap();
final_loss = loss;
if step % 100 == 0 {
eprintln!("adamw step {step}: loss = {loss}");
}
}
let w_final = state.param_to_host("w").unwrap()[0];
let b_final = state.param_to_host("b").unwrap()[0];
eprintln!("adamw final: w = {w_final}, b = {b_final}, loss = {final_loss}");
assert!((w_final - 2.0).abs() < 0.1);
assert!((b_final - 3.0).abs() < 0.5);
assert!(final_loss < 0.1);
}
#[test]
fn sgd_fits_2class_classifier_cross_entropy() {
let n = 20;
let n_feat = 2;
let n_class = 2;
let mut xs: Vec<f32> = Vec::with_capacity(n * n_feat);
let mut ys_onehot: Vec<f32> = vec![0.0; n * n_class];
for i in 0..n {
let cls = i % 2;
let jitter = ((i as f32) * 0.37).sin() * 0.1;
let sign = if cls == 0 { -1.0 } else { 1.0 };
xs.push(sign + jitter);
xs.push(sign - jitter);
ys_onehot[i * n_class + cls] = 1.0;
}
let device = fuel::Device::cpu();
let params = vec![
Parameter::new_f32(
"w",
Shape::from_dims(&[n_feat, n_class]),
vec![0.01f32, -0.01, 0.02, -0.02],
),
Parameter::new_f32("b", Shape::from_dims(&[n_class]), vec![0.0f32, 0.0]),
];
let mut state = TrainState::new(¶ms, &device, OptimizerConfig::sgd(0.1)).unwrap();
let x_arc: Arc<[f32]> = xs.into();
let y_arc: Arc<[f32]> = ys_onehot.into();
let mut final_loss = 0.0;
for step in 0..500 {
let x_arc_step = x_arc.clone();
let y_arc_step = y_arc.clone();
let loss = state
.step(move |_graph, params| {
let w = ¶ms["w"];
let b = ¶ms["b"];
let x = w.const_f32_like(x_arc_step, Shape::from_dims(&[n, n_feat]))?;
let y = w.const_f32_like(y_arc_step, Shape::from_dims(&[n, n_class]))?;
let logits_raw = x.matmul(w).unwrap();
let b_b = b
.reshape(Shape::from_dims(&[1, n_class]))
.unwrap()
.broadcast_to(Shape::from_dims(&[n, n_class]))
.unwrap();
let logits = logits_raw.add(&b_b).unwrap();
super::loss::cross_entropy_with_logits(&logits, &y)
})
.unwrap();
final_loss = loss;
if step % 100 == 0 {
eprintln!("ce step {step}: loss = {loss}");
}
}
eprintln!("ce final loss = {final_loss}");
assert!(
final_loss < 0.1,
"CE didn't converge: final loss = {final_loss}"
);
}
#[test]
fn sgd_trains_through_rms_norm() {
let n = 16;
let d = 4;
let xs: Vec<f32> = (0..n * d).map(|i| ((i as f32) * 0.37).sin()).collect();
let ys: Vec<f32> = (0..n)
.map(|row| {
let s: f32 = (0..d).map(|c| xs[row * d + c]).sum();
s
})
.collect();
let device = fuel::Device::cpu();
let params = vec![
Parameter::new_f32("w", Shape::from_dims(&[d, 1]), vec![0.1f32; d]),
Parameter::new_f32("b", Shape::from_dims(&[1]), vec![0.0f32]),
];
let mut state = TrainState::new(¶ms, &device, OptimizerConfig::sgd(0.05)).unwrap();
let x_arc: Arc<[f32]> = xs.into();
let y_arc: Arc<[f32]> = ys.into();
let mut initial_loss = 0.0;
let mut final_loss = 0.0;
for step in 0..300 {
let x_arc_step = x_arc.clone();
let y_arc_step = y_arc.clone();
let loss = state
.step(move |_graph, params| {
let w = ¶ms["w"];
let b = ¶ms["b"];
let x = w.const_f32_like(x_arc_step, Shape::from_dims(&[n, d]))?;
let y = w.const_f32_like(y_arc_step, Shape::from_dims(&[n, 1]))?;
let x_norm = x.rms_norm_last_dim(1e-6).unwrap();
let logits = x_norm.matmul(w).unwrap();
let b_b = b
.reshape(Shape::from_dims(&[1, 1]))
.unwrap()
.broadcast_to(Shape::from_dims(&[n, 1]))
.unwrap();
let pred = logits.add(&b_b).unwrap();
super::loss::mse(&pred, &y)
})
.unwrap();
if step == 0 {
initial_loss = loss;
}
final_loss = loss;
}
eprintln!("rms_norm training: initial={initial_loss} final={final_loss}");
assert!(
final_loss < initial_loss * 0.5,
"loss didn't decrease enough: {initial_loss} -> {final_loss}"
);
assert!(final_loss.is_finite(), "got non-finite loss: {final_loss}");
}
fn parity_step(
state: &mut TrainState,
x_arc: &Arc<[f32]>,
y_arc: &Arc<[f32]>,
len: usize,
) -> f32 {
let x_arc_step = x_arc.clone();
let y_arc_step = y_arc.clone();
state
.step(move |_g, p| {
let w = &p["w"];
let b = &p["b"];
let x = w.const_f32_like(x_arc_step, Shape::from_dims(&[len]))?;
let y = w.const_f32_like(y_arc_step, Shape::from_dims(&[len]))?;
let w_b = w.broadcast_to(Shape::from_dims(&[len])).unwrap();
let b_b = b.broadcast_to(Shape::from_dims(&[len])).unwrap();
let y_hat = x.mul(&w_b).unwrap().add(&b_b).unwrap();
Ok(y_hat
.sub(&y)
.unwrap()
.sqr()
.sum_all()
.mul_scalar(1.0 / len as f64))
})
.unwrap()
}
#[test]
fn sgd_trajectory_matches_legacy_executor_bitexact() {
let xs: Vec<f32> = (0..10).map(|i| i as f32).collect();
let ys: Vec<f32> = xs.iter().map(|&x| 2.0 * x + 3.0).collect();
let x_arc: Arc<[f32]> = xs.clone().into();
let y_arc: Arc<[f32]> = ys.into();
let device = fuel::Device::cpu();
let params = vec![
Parameter::new_f32("w", Shape::from_dims(&[1]), vec![0.1f32]),
Parameter::new_f32("b", Shape::from_dims(&[1]), vec![0.1f32]),
];
let mut state = TrainState::new(¶ms, &device, OptimizerConfig::sgd(0.01)).unwrap();
let mut losses = Vec::new();
for _ in 0..50 {
losses.push(parity_step(&mut state, &x_arc, &y_arc, xs.len()));
}
let w = state.param_to_host("w").unwrap()[0];
let b = state.param_to_host("b").unwrap()[0];
assert_eq!(losses[0].to_bits(), 0x4320e28f, "loss[0]={}", losses[0]);
assert_eq!(losses[10].to_bits(), 0x3fd44238, "loss[10]={}", losses[10]);
assert_eq!(losses[49].to_bits(), 0x3f887709, "loss[49]={}", losses[49]);
assert_eq!(w.to_bits(), 0x40137966, "w={w}");
assert_eq!(b.to_bits(), 0x3f8bc541, "b={b}");
}
#[test]
fn adamw_trajectory_matches_legacy_executor_bitexact() {
let xs: Vec<f32> = (0..10).map(|i| i as f32).collect();
let ys: Vec<f32> = xs.iter().map(|&x| 2.0 * x + 3.0).collect();
let x_arc: Arc<[f32]> = xs.clone().into();
let y_arc: Arc<[f32]> = ys.into();
let device = fuel::Device::cpu();
let params = vec![
Parameter::new_f32("w", Shape::from_dims(&[1]), vec![0.1f32]),
Parameter::new_f32("b", Shape::from_dims(&[1]), vec![0.1f32]),
];
let cfg = OptimizerConfig::AdamW {
lr: 0.1,
beta1: 0.9,
beta2: 0.999,
eps: 1e-8,
weight_decay: 0.0,
};
let mut state = TrainState::new(¶ms, &device, cfg).unwrap();
let mut losses = Vec::new();
for _ in 0..20 {
losses.push(parity_step(&mut state, &x_arc, &y_arc, xs.len()));
}
let w = state.param_to_host("w").unwrap()[0];
let b = state.param_to_host("b").unwrap()[0];
assert_eq!(losses[0].to_bits(), 0x4320e28f, "loss[0]={}", losses[0]);
assert_eq!(losses[10].to_bits(), 0x4230169d, "loss[10]={}", losses[10]);
assert_eq!(losses[19].to_bits(), 0x4088ff67, "loss[19]={}", losses[19]);
assert_eq!(w.to_bits(), 0x3ff1245b, "w={w}");
assert_eq!(b.to_bits(), 0x3ff231e0, "b={b}");
}
#[test]
fn warmup_cosine_schedule_curve() {
let sch = super::WarmupCosine {
warmup: 10,
total: 100,
peak: 1.0,
final_lr: 0.1,
};
assert_eq!(sch.lr_at(0), 0.0); assert!((sch.lr_at(5) - 0.5).abs() < 1e-6); assert!((sch.lr_at(10) - 1.0).abs() < 1e-6); assert!((sch.lr_at(100) - 0.1).abs() < 1e-6); let a = sch.lr_at(20);
let b = sch.lr_at(40);
let c = sch.lr_at(60);
assert!(
a > b && b > c,
"cosine decay not monotonic: {a} -> {b} -> {c}"
);
}
#[test]
fn warmup_linear_schedule_curve() {
let sch = super::WarmupLinear {
warmup: 10,
total: 30,
peak: 1.0,
final_lr: 0.2,
};
assert_eq!(sch.lr_at(0), 0.0);
assert!((sch.lr_at(10) - 1.0).abs() < 1e-6);
assert!((sch.lr_at(20) - 0.6).abs() < 1e-6);
assert_eq!(sch.lr_at(30), 0.2);
}
#[test]
fn grad_clip_prevents_divergence() {
let xs: Vec<f32> = (0..10).map(|i| i as f32 * 100.0).collect(); let ys: Vec<f32> = xs.iter().map(|&x| 2.0 * x + 3.0).collect();
let params = vec![
Parameter::new_f32("w", Shape::from_dims(&[1]), vec![0.1f32]),
Parameter::new_f32("b", Shape::from_dims(&[1]), vec![0.1f32]),
];
let x_arc: Arc<[f32]> = xs.clone().into();
let y_arc: Arc<[f32]> = ys.clone().into();
let len = xs.len();
{
let device = fuel::Device::cpu();
let mut state = TrainState::new(¶ms, &device, OptimizerConfig::sgd(0.1)).unwrap();
for _ in 0..10 {
let x_arc_step = x_arc.clone();
let y_arc_step = y_arc.clone();
let _ = state
.step(move |_g, p| {
let w = &p["w"];
let b = &p["b"];
let x = w.const_f32_like(x_arc_step, Shape::from_dims(&[len]))?;
let y = w.const_f32_like(y_arc_step, Shape::from_dims(&[len]))?;
let w_b = w.broadcast_to(Shape::from_dims(&[len])).unwrap();
let b_b = b.broadcast_to(Shape::from_dims(&[len])).unwrap();
let y_hat = x.mul(&w_b).unwrap().add(&b_b).unwrap();
Ok(y_hat
.sub(&y)
.unwrap()
.sqr()
.sum_all()
.mul_scalar(1.0 / len as f64))
})
.unwrap();
}
let w = state.param_to_host("w").unwrap()[0];
assert!(
!w.is_finite(),
"expected divergence without clipping, got w={w}"
);
}
{
let device = fuel::Device::cpu();
let mut state = TrainState::new(¶ms, &device, OptimizerConfig::sgd(0.1))
.unwrap()
.with_grad_clip(Some(GradClip::GlobalNorm(1.0)));
for _ in 0..200 {
let x_arc_step = x_arc.clone();
let y_arc_step = y_arc.clone();
let _ = state
.step(move |_g, p| {
let w = &p["w"];
let b = &p["b"];
let x = w.const_f32_like(x_arc_step, Shape::from_dims(&[len]))?;
let y = w.const_f32_like(y_arc_step, Shape::from_dims(&[len]))?;
let w_b = w.broadcast_to(Shape::from_dims(&[len])).unwrap();
let b_b = b.broadcast_to(Shape::from_dims(&[len])).unwrap();
let y_hat = x.mul(&w_b).unwrap().add(&b_b).unwrap();
Ok(y_hat
.sub(&y)
.unwrap()
.sqr()
.sum_all()
.mul_scalar(1.0 / len as f64))
})
.unwrap();
}
let w = state.param_to_host("w").unwrap()[0];
let b = state.param_to_host("b").unwrap()[0];
assert!(
w.is_finite() && b.is_finite(),
"clipped training should stay finite, got w={w} b={b}"
);
}
}
#[test]
fn sgd_with_warmup_cosine_converges() {
let xs: Vec<f32> = (0..10).map(|i| i as f32).collect();
let ys: Vec<f32> = xs.iter().map(|&x| 2.0 * x + 3.0).collect();
let device = fuel::Device::cpu();
let params = vec![
Parameter::new_f32("w", Shape::from_dims(&[1]), vec![0.1f32]),
Parameter::new_f32("b", Shape::from_dims(&[1]), vec![0.1f32]),
];
let mut state = TrainState::new(¶ms, &device, OptimizerConfig::sgd(0.0)).unwrap();
let sch = super::WarmupCosine {
warmup: 100,
total: 2000,
peak: 0.01,
final_lr: 0.001,
};
let x_arc: Arc<[f32]> = xs.clone().into();
let y_arc: Arc<[f32]> = ys.clone().into();
for _ in 0..2000 {
let x_arc_step = x_arc.clone();
let y_arc_step = y_arc.clone();
let len = xs.len();
state
.step_with_schedule(&sch, move |_graph, params| {
let w = ¶ms["w"];
let b = ¶ms["b"];
let x = w.const_f32_like(x_arc_step, Shape::from_dims(&[len]))?;
let y = w.const_f32_like(y_arc_step, Shape::from_dims(&[len]))?;
let w_b = w.broadcast_to(Shape::from_dims(&[len])).unwrap();
let b_b = b.broadcast_to(Shape::from_dims(&[len])).unwrap();
let y_hat = x.mul(&w_b).unwrap().add(&b_b).unwrap();
let diff = y_hat.sub(&y).unwrap();
Ok(diff.sqr().sum_all().mul_scalar(1.0 / len as f64))
})
.unwrap();
}
let w_final = state.param_to_host("w").unwrap()[0];
let b_final = state.param_to_host("b").unwrap()[0];
assert!((w_final - 2.0).abs() < 0.05);
assert!((b_final - 3.0).abs() < 0.3);
}
}