use crate::{
AutodiffError, BackendError, DeviceParamStore, Gradients, LinearVars, ResidentLeaf, Tape,
Tensor, Var,
};
use fandhe_ai_autodiff::nn::activation::{Relu, Sigmoid, Tanh};
use fandhe_ai_autodiff::nn::{Linear, Module};
use fandhe_ai_tensor_core::{Activation, BackendOps};
pub struct Sequential {
layers: Vec<Box<dyn Module>>,
}
impl Default for Sequential {
fn default() -> Self {
Self::new()
}
}
impl Sequential {
pub fn new() -> Self {
Sequential { layers: Vec::new() }
}
pub fn add_linear(
mut self,
in_features: usize,
out_features: usize,
seed: u64,
) -> Result<Self, AutodiffError> {
let linear = Linear::new(in_features, out_features, true, seed)?;
self.layers.push(Box::new(linear));
Ok(self)
}
pub fn add_relu(mut self) -> Self {
self.layers.push(Box::new(Relu));
self
}
pub fn add_sigmoid(mut self) -> Self {
self.layers.push(Box::new(Sigmoid));
self
}
pub fn add_tanh(mut self) -> Self {
self.layers.push(Box::new(Tanh));
self
}
pub fn forward<'t>(&self, tape: &'t Tape, input: &Var<'t>) -> Result<Var<'t>, AutodiffError> {
let mut current = *input;
let mut i = 0;
while i < self.layers.len() {
let layer = &self.layers[i];
if let Some(linear) = layer.as_linear() {
let fuse_relu = self.layers.get(i + 1).is_some_and(|next| next.as_relu());
let bound = linear.bind(&tape.0);
current = if fuse_relu {
bound.forward_with_activation(¤t, Activation::Relu)?
} else {
bound.forward(¤t)?
};
i += if fuse_relu { 2 } else { 1 };
} else {
current = layer.forward(&tape.0, ¤t)?;
i += 1;
}
}
Ok(current)
}
pub fn predict(&self, input: &Tensor<f32>) -> Result<Tensor<f32>, AutodiffError> {
match self.predict_tape_free(input) {
Err(AutodiffError::Backend(BackendError::Unsupported(_))) => {
self.predict_via_tape(input)
}
other => other,
}
}
fn predict_via_tape(&self, input: &Tensor<f32>) -> Result<Tensor<f32>, AutodiffError> {
let tape = crate::tape();
let input_var = tape.var(input);
let output = self.forward(&tape, &input_var)?;
Ok(output.to_tensor())
}
fn predict_tape_free(&self, input: &Tensor<f32>) -> Result<Tensor<f32>, AutodiffError> {
let ops = fandhe_ai_backend_cpu::CpuBackendOps::new();
self.predict_tape_free_with_ops(&ops, input)
}
fn predict_tape_free_with_ops(
&self,
ops: &dyn BackendOps,
input: &Tensor<f32>,
) -> Result<Tensor<f32>, AutodiffError> {
let mut current = input.clone();
let mut i = 0;
while i < self.layers.len() {
let layer = &self.layers[i];
if let Some(linear) = layer.as_linear() {
let fuse_relu = self.layers.get(i + 1).is_some_and(|next| next.as_relu());
current = if fuse_relu {
linear.forward_host_with_activation(ops, ¤t, Activation::Relu)?
} else {
linear.forward_host(ops, ¤t)?
};
i += if fuse_relu { 2 } else { 1 };
} else {
current = layer.forward_host(ops, ¤t)?;
i += 1;
}
}
Ok(current)
}
pub fn bind<'m, 't>(&'m self, tape: &'t Tape) -> SequentialVars<'m, 't> {
let linears = self
.layers
.iter()
.filter_map(|layer| layer.as_linear())
.map(|linear| linear.bind(&tape.0))
.collect();
SequentialVars {
model: self,
linears,
}
}
pub fn trainable_parameters(&self) -> Vec<&Tensor<f32>> {
let mut out = Vec::new();
for layer in &self.layers {
if let Some(linear) = layer.as_linear() {
out.push(linear.weight());
if let Some(bias) = linear.bias() {
out.push(bias);
}
}
}
out
}
pub fn apply_parameters(&mut self, updated: Vec<Tensor<f32>>) -> Result<(), AutodiffError> {
let linears: Vec<&mut Linear> = self
.layers
.iter_mut()
.filter_map(|layer| layer.as_linear_mut())
.collect();
let mut updated = updated.into_iter();
let mut rebuilt = Vec::with_capacity(linears.len());
for (layer_index, linear) in linears.iter().enumerate() {
let has_bias = linear.bias().is_some();
let new_weight = updated.next().ok_or_else(|| {
AutodiffError::InvalidArgument(
"Sequential::apply_parameters: updated has fewer elements than \
trainable_parameters() (weight missing)"
.to_string(),
)
})?;
let expected_shape = linear.weight().shape();
if new_weight.shape() != expected_shape {
return Err(AutodiffError::InvalidArgument(format!(
"Sequential::apply_parameters: layer {layer_index} weight shape changed \
from {expected_shape:?} to {:?} (apply_parameters only supports \
shape-preserving updates; #426)",
new_weight.shape()
)));
}
let new_bias = if has_bias {
Some(updated.next().ok_or_else(|| {
AutodiffError::InvalidArgument(
"Sequential::apply_parameters: updated has fewer elements than \
trainable_parameters() (bias missing)"
.to_string(),
)
})?)
} else {
None
};
rebuilt.push(Linear::from_parameters(new_weight, new_bias)?);
}
if updated.next().is_some() {
return Err(AutodiffError::InvalidArgument(
"Sequential::apply_parameters: updated has more elements than \
trainable_parameters()"
.to_string(),
));
}
for (linear, new_linear) in linears.into_iter().zip(rebuilt) {
*linear = new_linear;
}
Ok(())
}
pub fn init_device_param_store(&self, tape: &Tape) -> Result<DeviceParamStore, BackendError> {
let params = self.trainable_parameters();
DeviceParamStore::new(&tape.0, ¶ms)
}
pub fn forward_resident<'t>(
&self,
tape: &'t Tape,
input: &Var<'t>,
store: &mut DeviceParamStore,
) -> Result<Var<'t>, AutodiffError> {
let leaves = store.register_resident_params(&tape.0)?;
match self.forward_from_flat_leaves(&tape.0, input, &leaves, store) {
Ok(output) => Ok(output),
Err(e) => {
store.abandon_pending_forward();
Err(e)
}
}
}
pub fn predict_resident(
&self,
store: &DeviceParamStore,
input: &Tensor<f32>,
) -> Result<Tensor<f32>, AutodiffError> {
let tape = crate::tape_for(store.device())?;
let leaves = store.snapshot_resident_params(&tape.0)?;
let input_var = tape.var(input);
let output = self.forward_from_flat_leaves(&tape.0, &input_var, &leaves, store)?;
Ok(output.to_tensor())
}
fn forward_from_flat_leaves<'t>(
&self,
tape: &'t fandhe_ai_autodiff::Tape,
input: &Var<'t>,
leaves: &[ResidentLeaf<'t>],
store: &DeviceParamStore,
) -> Result<Var<'t>, AutodiffError> {
let mut current = *input;
let mut cursor = leaves.iter();
let mut i = 0;
while i < self.layers.len() {
let layer = &self.layers[i];
if let Some(linear) = layer.as_linear() {
let weight = cursor.next().ok_or_else(|| {
AutodiffError::InvalidArgument(
"Sequential::forward_from_flat_leaves: leaves has fewer elements than \
trainable_parameters() (weight missing)"
.to_string(),
)
})?;
let bias = if linear.bias().is_some() {
Some(cursor.next().ok_or_else(|| {
AutodiffError::InvalidArgument(
"Sequential::forward_from_flat_leaves: leaves has fewer elements \
than trainable_parameters() (bias missing)"
.to_string(),
)
})?)
} else {
None
};
let act = if self.layers.get(i + 1).is_some_and(|next| next.as_relu()) {
Activation::Relu
} else {
Activation::None
};
current = store
.linear_forward_with_activation(tape, ¤t, weight, bias, act)
.map_err(AutodiffError::Backend)?;
i += if matches!(act, Activation::Relu) {
2
} else {
1
};
} else {
current = layer.forward(tape, ¤t)?;
i += 1;
}
}
if cursor.next().is_some() {
return Err(AutodiffError::InvalidArgument(
"Sequential::forward_from_flat_leaves: leaves has more elements than \
trainable_parameters() requires (extra weight/bias ignored)"
.to_string(),
));
}
Ok(current)
}
}
pub struct SequentialVars<'m, 't> {
model: &'m Sequential,
linears: Vec<LinearVars<'t>>,
}
impl<'m, 't> SequentialVars<'m, 't> {
pub fn forward(&self, tape: &'t Tape, input: &Var<'t>) -> Result<Var<'t>, AutodiffError> {
let mut current = *input;
let mut linears = self.linears.iter();
let layers = &self.model.layers;
let mut i = 0;
while i < layers.len() {
let layer = &layers[i];
if layer.as_linear().is_some() {
let vars = linears.next().ok_or_else(|| {
AutodiffError::InvalidArgument(
"SequentialVars::forward: bind 済み LinearVars が model.layers の \
Linear 層数より少ない(bind/forward 間の Linear 層数対応が崩れた)"
.to_string(),
)
})?;
let fuse_relu = layers.get(i + 1).is_some_and(|next| next.as_relu());
current = if fuse_relu {
vars.forward_with_activation(¤t, Activation::Relu)?
} else {
vars.forward(¤t)?
};
i += if fuse_relu { 2 } else { 1 };
} else {
current = layer.forward(&tape.0, ¤t)?;
i += 1;
}
}
Ok(current)
}
pub fn linears(&self) -> &[LinearVars<'t>] {
&self.linears
}
pub fn trainable_vars(&self) -> Vec<&Var<'t>> {
let mut out = Vec::new();
for vars in &self.linears {
out.push(&vars.weight);
if let Some(bias) = &vars.bias {
out.push(bias);
}
}
out
}
pub fn trainable_grads<'g>(
&self,
grads: &'g Gradients,
) -> Result<Vec<&'g Tensor<f32>>, AutodiffError> {
let mut out = Vec::new();
for vars in &self.linears {
let weight_grad = grads.get(&vars.weight)?.ok_or_else(|| {
AutodiffError::InvalidArgument(
"SequentialVars::trainable_grads: weight に到達する勾配がない \
(loss へ未到達)"
.to_string(),
)
})?;
out.push(weight_grad);
if let Some(bias) = &vars.bias {
let bias_grad = grads.get(bias)?.ok_or_else(|| {
AutodiffError::InvalidArgument(
"SequentialVars::trainable_grads: bias に到達する勾配がない \
(loss へ未到達)"
.to_string(),
)
})?;
out.push(bias_grad);
}
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
const SEED1: u64 = 1001;
const SEED2: u64 = 2002;
fn dense_vec(t: &Tensor<f32>) -> Vec<f32> {
t.contiguous()
.as_slice()
.expect("contiguous() 直後は必ず as_slice() が Some を返す")
.to_vec()
}
#[test]
fn sequential_builds_and_predicts_two_layer_mlp() {
let model = Sequential::new()
.add_linear(8, 16, SEED1)
.unwrap()
.add_relu()
.add_linear(16, 4, SEED2)
.unwrap();
let batch = 3;
let input = Tensor::new(vec![0.1_f32; batch * 8], &[batch, 8]).unwrap();
let output = model.predict(&input).unwrap();
assert_eq!(output.shape(), &[batch, 4]);
}
#[test]
fn sequential_forward_matches_manual_forward_bit_exact() {
let linear1 = Linear::new(8, 16, true, SEED1).unwrap();
let linear2 = Linear::new(16, 4, true, SEED2).unwrap();
let batch = 2;
let input_data: Vec<f32> = (0..batch * 8).map(|i| i as f32 * 0.05).collect();
let input_tensor = Tensor::new(input_data, &[batch, 8]).unwrap();
let manual_tape = crate::tape();
let manual_input = manual_tape.var(&input_tensor);
let h = linear1.bind(&manual_tape.0).forward(&manual_input).unwrap();
let h = h.relu();
let manual_output = linear2.bind(&manual_tape.0).forward(&h).unwrap();
let model = Sequential {
layers: vec![Box::new(linear1), Box::new(Relu), Box::new(linear2)],
};
let seq_tape = crate::tape();
let seq_input = seq_tape.var(&input_tensor);
let seq_output = model.forward(&seq_tape, &seq_input).unwrap();
assert_eq!(
dense_vec(&manual_output.to_tensor()),
dense_vec(&seq_output.to_tensor())
);
}
#[test]
fn sequential_forward_on_external_tape_supports_backward() {
let model = Sequential::new()
.add_linear(4, 3, SEED1)
.unwrap()
.add_sigmoid();
let tape = crate::tape();
let input = tape.var(&Tensor::new(vec![0.1_f32, 0.2, 0.3, 0.4], &[1, 4]).unwrap());
let output = model.forward(&tape, &input).unwrap();
let loss = output.sum(None).unwrap();
let grads = tape.backward(&loss).unwrap();
let input_grad = grads
.get(&input)
.unwrap()
.expect("入力は loss に寄与している");
assert_eq!(input_grad.shape(), input.to_tensor().shape());
}
#[test]
fn add_linear_propagates_invalid_argument() {
let result = Sequential::new().add_linear(0, 4, SEED1);
match result {
Err(err) => assert!(matches!(err, AutodiffError::InvalidArgument(_))),
Ok(_) => panic!("in_features == 0 は拒否されるはず"),
}
}
#[test]
fn sequential_predict_tape_free_matches_via_tape_bit_exact() {
for batch in [1usize, 3, 8] {
let model = Sequential {
layers: vec![
Box::new(Linear::new(8, 16, true, SEED1).unwrap()),
Box::new(Relu),
Box::new(Linear::new(16, 12, false, SEED2).unwrap()),
Box::new(Sigmoid),
Box::new(Linear::new(12, 4, true, SEED1 + SEED2).unwrap()),
Box::new(Tanh),
],
};
let input_data: Vec<f32> = (0..batch * 8).map(|i| (i as f32) * 0.05 - 0.3).collect();
let input = Tensor::new(input_data, &[batch, 8]).unwrap();
let via_tape_fast = model.predict(&input).unwrap();
let via_tape_slow = model.predict_via_tape(&input).unwrap();
assert_eq!(
via_tape_fast.shape(),
via_tape_slow.shape(),
"batch={batch}"
);
assert_eq!(
dense_vec(&via_tape_fast),
dense_vec(&via_tape_slow),
"predict(tape 不要経路)は predict_via_tape(旧経路)と \
bit 完全一致するはず(batch={batch})"
);
}
}
#[test]
fn sequential_predict_public_builder_tape_free_matches_via_tape() {
let model = Sequential::new()
.add_linear(4, 6, SEED1)
.unwrap()
.add_relu()
.add_linear(6, 2, SEED2)
.unwrap();
let input = Tensor::new(vec![0.1_f32, -0.2, 0.3, -0.4], &[1, 4]).unwrap();
let fast = model.predict(&input).unwrap();
let slow = model.predict_via_tape(&input).unwrap();
assert_eq!(dense_vec(&fast), dense_vec(&slow));
}
#[test]
fn sequential_predict_matches_manual_unfused_composition_bit_exact() {
let ops = fandhe_ai_backend_cpu::CpuBackendOps::new();
for batch in [1usize, 3, 65, 129] {
let l1 = Linear::new(784, 256, true, SEED1).unwrap();
let l2 = Linear::new(256, 10, true, SEED2).unwrap();
let input_data: Vec<f32> = (0..batch * 784)
.map(|i| ((i % 97) as f32) * 0.01 - 0.5)
.collect();
let input = Tensor::new(input_data, &[batch, 784]).unwrap();
let h = ops.gemm(&input, l1.weight()).unwrap();
let h = ops.add(&h, l1.bias().unwrap()).unwrap();
let h = ops.relu(&h).unwrap();
let y = ops.gemm(&h, l2.weight()).unwrap();
let expected = ops.add(&y, l2.bias().unwrap()).unwrap();
let model = Sequential {
layers: vec![Box::new(l1), Box::new(Relu), Box::new(l2)],
};
let actual = model.predict(&input).unwrap();
assert_eq!(actual.shape(), expected.shape(), "batch={batch}");
assert_eq!(
dense_vec(&actual),
dense_vec(&expected),
"predict(Linear→ReLU 融合結線)は手動非融合合成と bit 完全一致するはず \
(batch={batch})"
);
}
}
#[test]
fn sequential_predict_linear_sigmoid_linear_matches_manual_composition() {
let ops = fandhe_ai_backend_cpu::CpuBackendOps::new();
let l1 = Linear::new(6, 5, true, SEED1).unwrap();
let l2 = Linear::new(5, 3, false, SEED2).unwrap();
let input = Tensor::new(vec![0.1_f32, -0.2, 0.3, -0.4, 0.5, -0.6], &[1, 6]).unwrap();
let h = ops.gemm(&input, l1.weight()).unwrap();
let h = ops.add(&h, l1.bias().unwrap()).unwrap();
let h = Sigmoid.forward_host(&ops, &h).unwrap();
let expected = ops.gemm(&h, l2.weight()).unwrap();
let model = Sequential {
layers: vec![Box::new(l1), Box::new(Sigmoid), Box::new(l2)],
};
let actual = model.predict(&input).unwrap();
assert_eq!(dense_vec(&actual), dense_vec(&expected));
}
#[test]
fn sequential_predict_tape_free_fuses_linear_relu_into_single_gemm_bias_act_call() {
let model = Sequential::new()
.add_linear(4, 8, SEED1)
.unwrap()
.add_relu()
.add_linear(8, 2, SEED2)
.unwrap();
let counters = Counters::default();
let ops = CountingOps::new(counters.clone());
let input = Tensor::new(vec![0.25_f32; 3 * 4], &[3, 4]).unwrap();
model.predict_tape_free_with_ops(&ops, &input).unwrap();
assert_eq!(
Counters::get(&counters.gemm_bias_act),
1,
"Linear -> ReLU に融合された 1 個目の Linear のみ gemm_bias_act が 1 回呼ばれるはず"
);
assert_eq!(
Counters::get(&counters.gemm),
1,
"ReLU が続かない 2 個目の Linear は非融合合成(gemm)のままのはず"
);
assert_eq!(
Counters::get(&counters.add),
1,
"2 個目の Linear の bias 加算は非融合合成(add)のままのはず(tape 不要経路は \
Var::add の遅延がなく即時実行されるため、学習側テストと異なり 1 回計上される)"
);
assert_eq!(
Counters::get(&counters.relu),
0,
"ReLU は Linear へ融合され、別ノードとしての relu 呼び出しは発生しないはず"
);
}
#[test]
fn sequential_predict_tape_free_does_not_fuse_non_relu_activation() {
let model = Sequential::new()
.add_linear(4, 5, SEED1)
.unwrap()
.add_sigmoid()
.add_linear(5, 2, SEED2)
.unwrap();
let counters = Counters::default();
let ops = CountingOps::new(counters.clone());
let input = Tensor::new(vec![0.1_f32; 3 * 4], &[3, 4]).unwrap();
model.predict_tape_free_with_ops(&ops, &input).unwrap();
assert_eq!(
Counters::get(&counters.gemm_bias_act),
0,
"Sigmoid は Activation に対応する variant を持たないため融合対象外のはず"
);
}
#[test]
fn bind_returns_linear_vars_in_layer_order_with_mixed_bias() {
let l1 = Linear::new(4, 3, true, SEED1).unwrap();
let l2 = Linear::new(3, 2, false, SEED2).unwrap();
let model = Sequential {
layers: vec![Box::new(l1), Box::new(Relu), Box::new(l2)],
};
let tape = crate::tape();
let bound = model.bind(&tape);
assert_eq!(bound.linears().len(), 2);
assert!(bound.linears()[0].bias.is_some());
assert!(bound.linears()[1].bias.is_none());
}
#[test]
fn sequential_vars_forward_matches_manual_forward_bit_exact() {
let batch = 2;
let input_data: Vec<f32> = (0..batch * 8).map(|i| i as f32 * 0.05).collect();
let input_tensor = Tensor::new(input_data, &[batch, 8]).unwrap();
let module_model = Sequential {
layers: vec![
Box::new(Linear::new(8, 16, true, SEED1).unwrap()),
Box::new(Relu),
Box::new(Linear::new(16, 4, true, SEED2).unwrap()),
],
};
let module_tape = crate::tape();
let module_input = module_tape.var(&input_tensor);
let module_output = module_model.forward(&module_tape, &module_input).unwrap();
let train_model = Sequential {
layers: vec![
Box::new(Linear::new(8, 16, true, SEED1).unwrap()),
Box::new(Relu),
Box::new(Linear::new(16, 4, true, SEED2).unwrap()),
],
};
let train_tape = crate::tape();
let bound = train_model.bind(&train_tape);
let train_input = train_tape.var(&input_tensor);
let train_output = bound.forward(&train_tape, &train_input).unwrap();
assert_eq!(
dense_vec(&module_output.to_tensor()),
dense_vec(&train_output.to_tensor())
);
}
#[test]
fn trainable_parameters_order_contract_weight_then_bias_per_layer() {
let model = Sequential::new()
.add_linear(4, 3, SEED1) .unwrap()
.add_relu()
.add_linear(3, 2, SEED2) .unwrap();
let params = model.trainable_parameters();
assert_eq!(params.len(), 4);
assert_eq!(params[0].shape(), &[4, 3]); assert_eq!(params[1].shape(), &[3]); assert_eq!(params[2].shape(), &[3, 2]); assert_eq!(params[3].shape(), &[2]); }
#[test]
fn apply_parameters_rejects_element_count_mismatch() {
let mut model = Sequential::new().add_linear(4, 3, SEED1).unwrap();
let updated = vec![Tensor::new(vec![0.0f32; 12], &[4, 3]).unwrap()];
let err = model.apply_parameters(updated).unwrap_err();
assert!(matches!(err, AutodiffError::InvalidArgument(_)));
}
#[test]
fn apply_parameters_propagates_bias_shape_mismatch() {
let mut model = Sequential::new().add_linear(4, 3, SEED1).unwrap();
let weight = Tensor::new(vec![0.0f32; 12], &[4, 3]).unwrap();
let bad_bias = Tensor::new(vec![0.0f32; 5], &[5]).unwrap();
let err = model.apply_parameters(vec![weight, bad_bias]).unwrap_err();
assert!(matches!(err, AutodiffError::Shape(_)));
}
#[test]
fn apply_parameters_leaves_model_unchanged_on_excess_element_count() {
let mut model = Sequential::new().add_linear(2, 1, SEED1).unwrap();
let original_params: Vec<Tensor<f32>> =
model.trainable_parameters().into_iter().cloned().collect();
let weight = Tensor::new(vec![9.0f32, 9.0], &[2, 1]).unwrap();
let bias = Tensor::new(vec![9.0f32], &[1]).unwrap();
let extra = Tensor::new(vec![9.0f32], &[1]).unwrap();
let err = model
.apply_parameters(vec![weight, bias, extra])
.unwrap_err();
assert!(matches!(err, AutodiffError::InvalidArgument(_)));
let after_params = model.trainable_parameters();
assert_eq!(original_params.len(), after_params.len());
for (before, after) in original_params.iter().zip(after_params.iter()) {
assert_eq!(dense_vec(before), dense_vec(after));
}
}
#[test]
fn apply_parameters_updates_weights_used_by_subsequent_predict() {
let mut model = Sequential::new().add_linear(2, 1, SEED1).unwrap();
let zero_weight = Tensor::new(vec![0.0f32, 0.0], &[2, 1]).unwrap();
let zero_bias = Tensor::new(vec![7.0f32], &[1]).unwrap();
model
.apply_parameters(vec![zero_weight, zero_bias])
.unwrap();
let input = Tensor::new(vec![1.0f32, 2.0], &[1, 2]).unwrap();
let output = model.predict(&input).unwrap();
assert!((output.get(&[0, 0]).unwrap() - 7.0).abs() < 1e-6);
}
#[test]
fn apply_parameters_updates_bias_less_layer() {
let linear = Linear::new(2, 1, false, SEED1).unwrap();
let mut model = Sequential {
layers: vec![Box::new(linear)],
};
assert_eq!(model.trainable_parameters().len(), 1);
let zero_weight = Tensor::new(vec![0.0f32, 0.0], &[2, 1]).unwrap();
model.apply_parameters(vec![zero_weight]).unwrap();
let input = Tensor::new(vec![1.0f32, 2.0], &[1, 2]).unwrap();
let output = model.predict(&input).unwrap();
assert!((output.get(&[0, 0]).unwrap()).abs() < 1e-6);
}
#[test]
fn apply_parameters_leaves_model_unchanged_on_mid_sequence_shape_error() {
let mut model = Sequential::new()
.add_linear(2, 3, SEED1)
.unwrap()
.add_relu()
.add_linear(3, 1, SEED2)
.unwrap();
let original_params: Vec<Tensor<f32>> =
model.trainable_parameters().into_iter().cloned().collect();
let l1_weight = Tensor::new(vec![9.0f32; 6], &[2, 3]).unwrap();
let l1_bias = Tensor::new(vec![9.0f32; 3], &[3]).unwrap();
let l2_weight = Tensor::new(vec![9.0f32; 3], &[3, 1]).unwrap();
let l2_bad_bias = Tensor::new(vec![9.0f32; 5], &[5]).unwrap();
let err = model
.apply_parameters(vec![l1_weight, l1_bias, l2_weight, l2_bad_bias])
.unwrap_err();
assert!(matches!(err, AutodiffError::Shape(_)));
let after_params = model.trainable_parameters();
assert_eq!(original_params.len(), after_params.len());
for (before, after) in original_params.iter().zip(after_params.iter()) {
assert_eq!(dense_vec(before), dense_vec(after));
}
}
#[test]
fn apply_parameters_rejects_weight_shape_change() {
let mut model = Sequential::new().add_linear(4, 3, SEED1).unwrap();
let original_params: Vec<Tensor<f32>> =
model.trainable_parameters().into_iter().cloned().collect();
let new_weight = Tensor::new(vec![0.0f32; 9], &[3, 3]).unwrap();
let new_bias = Tensor::new(vec![0.0f32; 3], &[3]).unwrap();
let err = model
.apply_parameters(vec![new_weight, new_bias])
.unwrap_err();
assert!(matches!(err, AutodiffError::InvalidArgument(_)));
let after_params = model.trainable_parameters();
assert_eq!(original_params.len(), after_params.len());
for (before, after) in original_params.iter().zip(after_params.iter()) {
assert_eq!(dense_vec(before), dense_vec(after));
}
}
#[test]
fn apply_parameters_rejects_interlayer_consistent_resize() {
let mut model = Sequential::new()
.add_linear(2, 3, SEED1)
.unwrap()
.add_relu()
.add_linear(3, 1, SEED2)
.unwrap();
let l1_weight = Tensor::new(vec![0.0f32; 8], &[2, 4]).unwrap();
let l1_bias = Tensor::new(vec![0.0f32; 4], &[4]).unwrap();
let l2_weight = Tensor::new(vec![0.0f32; 4], &[4, 1]).unwrap();
let l2_bias = Tensor::new(vec![0.0f32; 1], &[1]).unwrap();
let err = model
.apply_parameters(vec![l1_weight, l1_bias, l2_weight, l2_bias])
.unwrap_err();
assert!(matches!(err, AutodiffError::InvalidArgument(_)));
}
#[test]
fn apply_parameters_leaves_model_unchanged_on_mid_sequence_weight_shape_error() {
let mut model = Sequential::new()
.add_linear(2, 3, SEED1)
.unwrap()
.add_relu()
.add_linear(3, 1, SEED2)
.unwrap();
let original_params: Vec<Tensor<f32>> =
model.trainable_parameters().into_iter().cloned().collect();
let l1_weight = Tensor::new(vec![9.0f32; 6], &[2, 3]).unwrap();
let l1_bias = Tensor::new(vec![9.0f32; 3], &[3]).unwrap();
let l2_weight = Tensor::new(vec![9.0f32; 6], &[3, 2]).unwrap();
let l2_bias = Tensor::new(vec![9.0f32; 2], &[2]).unwrap();
let err = model
.apply_parameters(vec![l1_weight, l1_bias, l2_weight, l2_bias])
.unwrap_err();
assert!(matches!(err, AutodiffError::InvalidArgument(_)));
let after_params = model.trainable_parameters();
assert_eq!(original_params.len(), after_params.len());
for (before, after) in original_params.iter().zip(after_params.iter()) {
assert_eq!(dense_vec(before), dense_vec(after));
}
}
#[derive(Clone, Default)]
struct Counters {
gemm: std::sync::Arc<std::sync::atomic::AtomicUsize>,
add: std::sync::Arc<std::sync::atomic::AtomicUsize>,
relu: std::sync::Arc<std::sync::atomic::AtomicUsize>,
gemm_bias_act: std::sync::Arc<std::sync::atomic::AtomicUsize>,
run_fused: std::sync::Arc<std::sync::atomic::AtomicUsize>,
gemm_resident_rhs: std::sync::Arc<std::sync::atomic::AtomicUsize>,
gemm_resident_rhs_act: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
impl Counters {
fn get(counter: &std::sync::atomic::AtomicUsize) -> usize {
counter.load(std::sync::atomic::Ordering::SeqCst)
}
}
struct CountingOps {
inner: fandhe_ai_backend_cpu::CpuBackendOps,
counters: Counters,
}
impl CountingOps {
fn new(counters: Counters) -> Self {
Self {
inner: fandhe_ai_backend_cpu::CpuBackendOps::new(),
counters,
}
}
}
impl fandhe_ai_tensor_core::BackendOps for CountingOps {
fn device(&self) -> fandhe_ai_tensor_core::device::Device {
self.inner.device()
}
fn gemm(
&self,
a: &Tensor<f32>,
b: &Tensor<f32>,
) -> Result<Tensor<f32>, fandhe_ai_tensor_core::BackendError> {
self.counters
.gemm
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.inner.gemm(a, b)
}
fn add(
&self,
a: &Tensor<f32>,
b: &Tensor<f32>,
) -> Result<Tensor<f32>, fandhe_ai_tensor_core::BackendError> {
self.counters
.add
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.inner.add(a, b)
}
fn mul(
&self,
a: &Tensor<f32>,
b: &Tensor<f32>,
) -> Result<Tensor<f32>, fandhe_ai_tensor_core::BackendError> {
self.inner.mul(a, b)
}
fn relu(
&self,
a: &Tensor<f32>,
) -> Result<Tensor<f32>, fandhe_ai_tensor_core::BackendError> {
self.counters
.relu
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.inner.relu(a)
}
fn exp(&self, a: &Tensor<f32>) -> Result<Tensor<f32>, fandhe_ai_tensor_core::BackendError> {
self.inner.exp(a)
}
fn tanh(
&self,
a: &Tensor<f32>,
) -> Result<Tensor<f32>, fandhe_ai_tensor_core::BackendError> {
self.inner.tanh(a)
}
fn sum(
&self,
a: &Tensor<f32>,
dim: Option<usize>,
) -> Result<Tensor<f32>, fandhe_ai_tensor_core::BackendError> {
self.inner.sum(a, dim)
}
fn max(
&self,
a: &Tensor<f32>,
dim: Option<usize>,
) -> Result<Tensor<f32>, fandhe_ai_tensor_core::BackendError> {
self.inner.max(a, dim)
}
fn gemm_bias_act(
&self,
a: &Tensor<f32>,
b: &Tensor<f32>,
bias: Option<&Tensor<f32>>,
act: Activation,
) -> Result<Tensor<f32>, fandhe_ai_tensor_core::BackendError> {
self.counters
.gemm_bias_act
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.inner.gemm_bias_act(a, b, bias, act)
}
fn gemm_resident_rhs(
&self,
a: &Tensor<f32>,
w: fandhe_ai_tensor_core::DeviceBufferView<'_>,
bias: Option<fandhe_ai_tensor_core::DeviceBufferView<'_>>,
) -> Result<Tensor<f32>, fandhe_ai_tensor_core::BackendError> {
self.counters
.gemm_resident_rhs
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.inner.gemm_resident_rhs(a, w, bias)
}
fn gemm_resident_rhs_act(
&self,
a: &Tensor<f32>,
w: fandhe_ai_tensor_core::DeviceBufferView<'_>,
bias: Option<fandhe_ai_tensor_core::DeviceBufferView<'_>>,
act: Activation,
) -> Result<Tensor<f32>, fandhe_ai_tensor_core::BackendError> {
self.counters
.gemm_resident_rhs_act
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.inner.gemm_resident_rhs_act(a, w, bias, act)
}
fn run_fused(
&self,
plan: &fandhe_ai_tensor_core::FusionPlan,
leaves: &[&Tensor<f32>],
) -> Result<Tensor<f32>, fandhe_ai_tensor_core::BackendError> {
self.counters
.run_fused
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.inner.run_fused(plan, leaves)
}
}
#[test]
fn sequential_forward_fuses_linear_relu_into_single_gemm_bias_act_launch() {
let model = Sequential::new()
.add_linear(4, 8, SEED1)
.unwrap()
.add_relu()
.add_linear(8, 2, SEED2)
.unwrap();
let counters = Counters::default();
let ops = CountingOps::new(counters.clone());
let tape = Tape(fandhe_ai_autodiff::Tape::new_with_ops(Box::new(ops)));
let input = Tensor::new(vec![0.25_f32; 3 * 4], &[3, 4]).unwrap();
let input_var = tape.var(&input);
let vars = model.bind(&tape);
vars.forward(&tape, &input_var).unwrap();
assert_eq!(
Counters::get(&counters.gemm_bias_act),
1,
"Linear -> ReLU に融合された 1 個目の Linear のみ gemm_bias_act が \
1 回呼ばれるはず"
);
assert_eq!(
Counters::get(&counters.gemm),
1,
"ReLU が続かない 2 個目の Linear は非融合合成(gemm)のままのはず"
);
assert_eq!(
Counters::get(&counters.add),
0,
"2 個目の Linear の bias 加算(Var::add)は遅延ノードのまま実体化されない \
(本テストは to_tensor() 等で結果を実体化しないため Op::Add は記録されるのみで \
BackendOps::add は呼ばれない。Var::add の遅延契約は var.rs 参照)"
);
assert_eq!(
Counters::get(&counters.relu),
0,
"ReLU は Linear へ融合され、別ノードとしての relu 呼び出しは発生しないはず"
);
assert_eq!(
Counters::get(&counters.run_fused),
0,
"本経路は run_fused(elementwise 融合)を経由しないはず"
);
}
#[test]
fn sequential_vars_forward_with_activation_grad_matches_manual_composition() {
let linear1 = Linear::new(4, 8, true, SEED1).unwrap();
let linear2 = Linear::new(8, 2, true, SEED2).unwrap();
let batch = 3;
let input_data: Vec<f32> = (0..batch * 4).map(|i| i as f32 * 0.07 - 0.5).collect();
let input_tensor = Tensor::new(input_data, &[batch, 4]).unwrap();
let fused_tape = crate::tape();
let fused_input = fused_tape.var(&input_tensor);
let fused_vars1 = linear1.bind(&fused_tape.0);
let fused_vars2 = linear2.bind(&fused_tape.0);
let h = fused_vars1
.forward_with_activation(&fused_input, Activation::Relu)
.unwrap();
let out = fused_vars2
.forward_with_activation(&h, Activation::None)
.unwrap();
let loss = out.sum(None).unwrap();
let fused_grads = fused_tape.backward(&loss).unwrap();
let manual_tape = crate::tape();
let manual_input = manual_tape.var(&input_tensor);
let manual_vars1 = linear1.bind(&manual_tape.0);
let manual_vars2 = linear2.bind(&manual_tape.0);
let h = manual_vars1.forward(&manual_input).unwrap();
let h = h.relu();
let out = manual_vars2.forward(&h).unwrap();
let loss = out.sum(None).unwrap();
let manual_grads = manual_tape.backward(&loss).unwrap();
for (label, fused_var, manual_var) in [
("linear1.weight", &fused_vars1.weight, &manual_vars1.weight),
("linear2.weight", &fused_vars2.weight, &manual_vars2.weight),
] {
let fused_g = fused_grads.get(fused_var).unwrap().unwrap();
let manual_g = manual_grads.get(manual_var).unwrap().unwrap();
assert_eq!(
dense_vec(fused_g),
dense_vec(manual_g),
"{label}: 融合経路(Op::LinearAct)と非融合合成の勾配がビット一致しない"
);
}
for (label, fused_var, manual_var) in [
(
"linear1.bias",
fused_vars1.bias.as_ref().unwrap(),
manual_vars1.bias.as_ref().unwrap(),
),
(
"linear2.bias",
fused_vars2.bias.as_ref().unwrap(),
manual_vars2.bias.as_ref().unwrap(),
),
] {
let fused_g = fused_grads.get(fused_var).unwrap().unwrap();
let manual_g = manual_grads.get(manual_var).unwrap().unwrap();
assert_eq!(
dense_vec(fused_g),
dense_vec(manual_g),
"{label}: 融合経路(Op::LinearAct)と非融合合成の勾配がビット一致しない"
);
}
}
}