#![cfg(feature = "cpu")]
#![allow(clippy::needless_range_loop)]
use rlx_ir::op::{Activation, BinaryOp, CmpOp, ReduceOp};
use rlx_ir::{DType, Graph, NodeId, Op, Shape};
use rlx_opt::autodiff::grad_with_loss;
use rlx_runtime::{Device, Session, is_available, supports_graph};
mod common;
const N: usize = 64;
#[allow(clippy::vec_init_then_push)]
fn available_backends() -> Vec<Device> {
let mut v: Vec<Device> = Vec::new();
#[cfg(all(feature = "metal", target_os = "macos"))]
v.push(Device::Metal);
#[cfg(all(feature = "mlx", target_os = "macos"))]
v.push(Device::Mlx);
#[cfg(feature = "gpu")]
v.push(Device::Gpu);
#[cfg(feature = "cuda")]
v.push(Device::Cuda);
#[cfg(feature = "rocm")]
v.push(Device::Rocm);
#[cfg(feature = "vulkan")]
v.push(Device::Vulkan);
v.retain(|&d| is_available(d));
v
}
fn run(device: Device, g: &Graph, inputs: &[(&str, &[f32])]) -> Vec<f32> {
let _guard = common::GpuTestGuard::acquire(device);
Session::new(device)
.compile(g.clone())
.run(inputs)
.pop()
.expect("graph produced no output")
}
fn worst_rel(cpu: &[f32], got: &[f32]) -> f32 {
assert_eq!(cpu.len(), got.len(), "length mismatch");
cpu.iter()
.zip(got)
.map(|(c, g)| (c - g).abs() / c.abs().max(1.0))
.fold(0.0, f32::max)
}
const PARITY_TOL: f32 = 5e-3;
fn activation_graph(act: Activation) -> Graph {
let mut g = Graph::new("act");
let x = g.input("x", Shape::new(&[N], DType::F32));
let y = g.activation(act, x, Shape::new(&[N], DType::F32));
g.set_outputs(vec![y]);
g
}
fn activation_input(act: Activation) -> Vec<f32> {
let base: Vec<f32> = (0..N)
.map(|i| -2.0 + 4.0 * (i as f32) / (N as f32 - 1.0))
.collect();
match act {
Activation::Log | Activation::Sqrt | Activation::Rsqrt => {
base.iter().map(|v| v.abs() + 0.5).collect()
}
Activation::Recip => base
.iter()
.map(|v| if v.abs() < 0.3 { 0.6 } else { *v })
.collect(),
Activation::Tan => base.iter().map(|v| v * 0.5).collect(),
_ => base,
}
}
#[test]
fn activation_forward_parity() {
let backends = available_backends();
eprintln!("activation parity over backends: {backends:?}");
for &act in &Activation::ALL {
let g = activation_graph(act);
let x = activation_input(act);
let cpu = run(Device::Cpu, &g, &[("x", &x)]);
assert!(
cpu.iter().all(|v| v.is_finite()),
"CPU oracle for {act:?} produced non-finite output"
);
for &dev in &backends {
if !supports_graph(dev, &g) {
eprintln!("skip {act:?} on {dev:?} (unsupported)");
continue;
}
let got = run(dev, &g, &[("x", &x)]);
let err = worst_rel(&cpu, &got);
assert!(
err < PARITY_TOL,
"activation {act:?} on {dev:?}: worst_rel={err:.3e} \
(opcode/kernel mismatch?)"
);
}
}
}
fn binary_graph(op: BinaryOp) -> Graph {
let mut g = Graph::new("bin");
let a = g.input("a", Shape::new(&[N], DType::F32));
let b = g.input("b", Shape::new(&[N], DType::F32));
let y = g.binary(op, a, b, Shape::new(&[N], DType::F32));
g.set_outputs(vec![y]);
g
}
fn binary_inputs(op: BinaryOp) -> (Vec<f32>, Vec<f32>) {
let a: Vec<f32> = (0..N)
.map(|i| (i as f32 * 0.19).sin() * 2.0 + 0.05)
.collect();
let b: Vec<f32> = (0..N)
.map(|i| (i as f32 * 0.11).cos() * 2.0 + 0.05)
.collect();
match op {
BinaryOp::Pow => (
a.iter().map(|v| v.abs() + 0.2).collect(),
b.iter().map(|v| v * 0.5).collect(),
),
BinaryOp::Div | BinaryOp::Mod => (
a,
b.iter()
.map(|v| if v.abs() < 0.5 { 0.9 } else { *v })
.collect(),
),
BinaryOp::BitAnd | BinaryOp::BitOr | BinaryOp::BitXor => (
(0..N).map(|i| (i % 13) as f32).collect(),
(0..N).map(|i| (i % 7) as f32).collect(),
),
BinaryOp::Shl | BinaryOp::Shr => (
(0..N).map(|i| (i % 17) as f32).collect(),
(0..N).map(|i| (i % 4) as f32).collect(),
),
_ => (a, b),
}
}
#[test]
fn binary_pow_negative_base_matches_cpu() {
let backends = available_backends();
let g = binary_graph(BinaryOp::Pow);
let a: Vec<f32> = (0..N).map(|i| -1.0 - (i % 5) as f32 * 0.5).collect(); let b: Vec<f32> = (0..N).map(|i| (i % 4) as f32).collect(); let cpu = run(Device::Cpu, &g, &[("a", &a), ("b", &b)]);
assert!(
cpu.iter().all(|v| v.is_finite()),
"CPU pow neg-base oracle not finite"
);
for &dev in &backends {
if !supports_graph(dev, &g) {
continue;
}
let got = run(dev, &g, &[("a", &a), ("b", &b)]);
let err = worst_rel(&cpu, &got);
assert!(
err < PARITY_TOL,
"pow neg-base on {dev:?}: worst_rel={err:.3e} (bare GPU pow NaNs on negative base?)"
);
}
}
#[test]
fn binary_forward_parity() {
let backends = available_backends();
for &op in &BinaryOp::ALL {
let g = binary_graph(op);
let (a, b) = binary_inputs(op);
let cpu = run(Device::Cpu, &g, &[("a", &a), ("b", &b)]);
assert!(
cpu.iter().all(|v| v.is_finite()),
"CPU oracle for {op:?} produced non-finite output"
);
for &dev in &backends {
if !supports_graph(dev, &g) {
eprintln!("skip {op:?} on {dev:?} (unsupported)");
continue;
}
let got = run(dev, &g, &[("a", &a), ("b", &b)]);
let err = worst_rel(&cpu, &got);
assert!(
err < PARITY_TOL,
"binary {op:?} on {dev:?}: worst_rel={err:.3e} (opcode/kernel mismatch?)"
);
}
}
}
fn compare_graph(op: CmpOp) -> Graph {
let mut g = Graph::new("cmp");
let a = g.input("a", Shape::new(&[N], DType::F32));
let b = g.input("b", Shape::new(&[N], DType::F32));
let mask = g.add_node(Op::Compare(op), vec![a, b], Shape::new(&[N], DType::Bool));
let y = g.add_node(
Op::Cast { to: DType::F32 },
vec![mask],
Shape::new(&[N], DType::F32),
);
g.set_outputs(vec![y]);
g
}
#[test]
fn compare_forward_parity() {
let backends = available_backends();
let a: Vec<f32> = (0..N).map(|i| (i % 5) as f32 - 2.0).collect();
let b: Vec<f32> = (0..N).map(|i| (i % 4) as f32 - 2.0).collect();
for &op in &CmpOp::ALL {
let g = compare_graph(op);
let cpu = run(Device::Cpu, &g, &[("a", &a), ("b", &b)]);
for &dev in &backends {
if !supports_graph(dev, &g) {
eprintln!("skip {op:?} on {dev:?} (unsupported)");
continue;
}
let got = run(dev, &g, &[("a", &a), ("b", &b)]);
let err = worst_rel(&cpu, &got);
assert!(
err < PARITY_TOL,
"compare {op:?} on {dev:?}: worst_rel={err:.3e} (wrong predicate?)"
);
}
}
}
#[test]
fn kernel_dsl_matches_cpu_activation_oracle() {
for &act in &Activation::ALL {
let g = activation_graph(act);
let x = activation_input(act);
let cpu = run(Device::Cpu, &g, &[("x", &x)]);
for (i, &xi) in x.iter().enumerate() {
let dsl = rlxsl::eval_activation(act, xi);
let c = cpu[i];
let err = (dsl - c).abs() / c.abs().max(1.0);
assert!(
err < 5e-3,
"{act:?} at x={xi}: manifest={dsl} cpu_kernel={c} err={err:e}"
);
}
}
}
const FD_M: usize = 8;
fn activation_loss_graph(act: Activation) -> (Graph, NodeId) {
let mut g = Graph::new("actloss");
let x = g.param("x", Shape::new(&[FD_M], DType::F32));
let y = g.activation(act, x, Shape::new(&[FD_M], DType::F32));
let loss = g.add_node(
Op::Reduce {
op: ReduceOp::Sum,
axes: vec![0],
keep_dim: false,
},
vec![y],
Shape::new(&[1], DType::F32),
);
g.set_outputs(vec![loss]);
(g, x)
}
fn activation_forward_loss(act: Activation, x: &[f32]) -> f32 {
let (g, _) = activation_loss_graph(act);
let mut c = Session::new(Device::Cpu).compile(g);
c.set_param("x", x);
c.run(&[])[0][0]
}
fn fd_input(act: Activation) -> Vec<f32> {
let base: Vec<f32> = (0..FD_M)
.map(|i| -1.2 + 2.4 * (i as f32) / (FD_M as f32 - 1.0))
.collect();
match act {
Activation::Log | Activation::Sqrt | Activation::Rsqrt => {
base.iter().map(|v| v.abs() + 0.4).collect()
}
Activation::Recip => base
.iter()
.map(|v| if v.abs() < 0.3 { 0.6 } else { *v })
.collect(),
_ => base,
}
}
const SMOOTH: &[Activation] = &[
Activation::Gelu,
Activation::GeluApprox,
Activation::Silu,
Activation::Sigmoid,
Activation::Tanh,
Activation::Exp,
Activation::Log,
Activation::Sqrt,
Activation::Rsqrt,
Activation::Softplus,
Activation::Erf,
Activation::Mish,
Activation::Softsign,
Activation::Atan,
Activation::Recip,
];
#[test]
fn activation_vjp_finite_difference_cpu() {
let eps = 1e-3f32;
let abs_tol = 5e-3f32;
let rel_tol = 5e-3f32;
for &act in SMOOTH {
let x = fd_input(act);
let (g, x_id) = activation_loss_graph(act);
let bwd = grad_with_loss(&g, &[x_id]);
let mut c = Session::new(Device::Cpu).compile(bwd);
c.set_param("x", &x);
let outs = c.run(&[("d_output", &[1.0f32])]);
assert_eq!(outs.len(), 2, "{act:?}: expected [loss, grad_x]");
let grad = &outs[1];
assert_eq!(grad.len(), FD_M, "{act:?}: grad length");
for i in 0..FD_M {
let mut xp = x.clone();
let mut xm = x.clone();
xp[i] += eps;
xm[i] -= eps;
let fd = (activation_forward_loss(act, &xp) - activation_forward_loss(act, &xm))
/ (2.0 * eps);
let ad = grad[i];
let abs_err = (fd - ad).abs();
let rel_err = abs_err / fd.abs().max(1e-6);
assert!(
abs_err < abs_tol || rel_err < rel_tol,
"{act:?} grad[{i}]: autodiff {ad:e} vs FD {fd:e} (abs {abs_err:e}, rel {rel_err:e})"
);
}
}
}