use super::*;
use crate::tensor::{DType, TensorOptions, gpu_device_count, gpu_synchronize, test_device};
fn require_multi_gpu() -> bool {
if !test_device().is_cuda() || gpu_device_count() < 2 {
return false;
}
for i in 0..2 {
let opts = TensorOptions {
dtype: DType::Float32,
device: Device::CUDA(i),
};
if Tensor::zeros(&[1], opts).is_err() {
eprintln!("Device CUDA({i}) cannot run compute kernels, skipping multi-GPU test");
return false;
}
}
true
}
#[test]
fn test_cross_device_autograd_gradient_flow() {
if !require_multi_gpu() {
return;
}
let opts0 = TensorOptions {
dtype: DType::Float32,
device: Device::CUDA(0),
};
let opts1 = TensorOptions {
dtype: DType::Float32,
device: Device::CUDA(1),
};
let w0 = Variable::new(Tensor::ones(&[4, 3], opts0).unwrap(), true);
let w1 = Variable::new(Tensor::ones(&[4, 3], opts1).unwrap(), true);
let input = Variable::new(Tensor::ones(&[4, 4], opts0).unwrap(), false);
let chunks = input.chunk(2, 0).unwrap();
assert_eq!(chunks.len(), 2);
let out0 = chunks[0].matmul(&w0).unwrap();
let shard1_dev1 = chunks[1].to_device(Device::CUDA(1)).unwrap();
let out1_dev1 = shard1_dev1.matmul(&w1).unwrap(); let out1_dev0 = out1_dev1.to_device(Device::CUDA(0)).unwrap();
let gathered = Variable::cat_many(&[&out0, &out1_dev0], 0).unwrap();
let loss = gathered.sum().unwrap();
loss.backward().unwrap();
let grad0 = w0.grad();
let grad1 = w1.grad();
assert!(
grad0.is_some(),
"w0 on device 0 should have gradient after backward"
);
assert!(
grad1.is_some(),
"w1 on device 1 should have gradient after backward"
);
let g0 = grad0.unwrap();
let g1 = grad1.unwrap();
assert_eq!(
g0.device(),
Device::CUDA(0),
"w0 gradient should be on device 0"
);
assert_eq!(
g1.device(),
Device::CUDA(1),
"w1 gradient should be on device 1"
);
let g0_sum = g0.sum().unwrap().item().unwrap();
let g1_sum = g1.sum().unwrap().item().unwrap();
assert!(
g0_sum.abs() > 1e-6,
"w0 gradient should be non-zero, got {g0_sum}"
);
assert!(
g1_sum.abs() > 1e-6,
"w1 gradient should be non-zero, got {g1_sum}"
);
gpu_synchronize(0);
gpu_synchronize(1);
}
#[test]
fn test_cross_device_autograd_values() {
if !require_multi_gpu() {
return;
}
let w_data = Tensor::from_f32(
&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0],
&[4, 2],
Device::CUDA(0),
)
.unwrap();
let w_ref = Variable::new(w_data.clone(), true);
let x = Tensor::from_f32(
&[
1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
],
&[4, 4],
Device::CUDA(0),
)
.unwrap();
let x_var = Variable::new(x.clone(), false);
let out_ref = x_var.matmul(&w_ref).unwrap();
let loss_ref = out_ref.sum().unwrap();
loss_ref.backward().unwrap();
let grad_ref = w_ref.grad().unwrap();
let grad_ref_vals = grad_ref.to_f32_vec().unwrap();
let w0 = Variable::new(
Tensor::from_f32(
&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0],
&[4, 2],
Device::CUDA(0),
)
.unwrap(),
true,
);
let w1 = Variable::new(
Tensor::from_f32(
&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0],
&[4, 2],
Device::CUDA(1),
)
.unwrap(),
true,
);
let x_var2 = Variable::new(x, false);
let chunks = x_var2.chunk(2, 0).unwrap();
let out0 = chunks[0].matmul(&w0).unwrap();
let shard1 = chunks[1].to_device(Device::CUDA(1)).unwrap();
let out1_dev1 = shard1.matmul(&w1).unwrap();
let out1_dev0 = out1_dev1.to_device(Device::CUDA(0)).unwrap();
let gathered = Variable::cat_many(&[&out0, &out1_dev0], 0).unwrap();
let loss = gathered.sum().unwrap();
loss.backward().unwrap();
let g0 = w0.grad().unwrap().to_f32_vec().unwrap();
let g1 = w1.grad().unwrap().to_f32_vec().unwrap();
for i in 0..g0.len() {
let cross_sum = g0[i] + g1[i];
let diff = (cross_sum - grad_ref_vals[i]).abs();
assert!(
diff < 1e-5,
"gradient mismatch at index {i}: cross-device sum {cross_sum} vs reference {}",
grad_ref_vals[i]
);
}
gpu_synchronize(0);
gpu_synchronize(1);
}
#[test]
fn test_graph_set_optimizer_and_step() {
use crate::graph::FlowBuilder;
use crate::nn::{Adam, Linear, ReLU, mse_loss};
let model = FlowBuilder::from(Linear::new(4, 8).unwrap())
.through(ReLU::new())
.through(Linear::new(8, 2).unwrap())
.build()
.unwrap();
model.set_optimizer(|p| Adam::new(p, 0.01));
model.set_training(true);
let params_before: Vec<f32> = model
.parameters()
.iter()
.flat_map(|p| p.variable.data().to_f32_vec().unwrap())
.collect();
let x = Variable::new(Tensor::randn(&[4, 4], Default::default()).unwrap(), false);
let target = Variable::new(Tensor::randn(&[4, 2], Default::default()).unwrap(), false);
let out = model.forward(&x).unwrap();
let loss = mse_loss(&out, &target).unwrap();
loss.backward().unwrap();
model.step().unwrap();
let params_after: Vec<f32> = model
.parameters()
.iter()
.flat_map(|p| p.variable.data().to_f32_vec().unwrap())
.collect();
let changed = params_before
.iter()
.zip(¶ms_after)
.any(|(a, b)| (a - b).abs() > 1e-8);
assert!(changed, "parameters should change after step()");
}
#[test]
fn test_graph_step_without_optimizer() {
use crate::graph::FlowBuilder;
use crate::nn::Linear;
let model = FlowBuilder::from(Linear::new(4, 2).unwrap())
.build()
.unwrap();
let result = model.step();
assert!(result.is_ok());
}
#[test]
fn test_graph_set_lr() {
use crate::graph::FlowBuilder;
use crate::nn::{Adam, Linear};
let model = FlowBuilder::from(Linear::new(4, 2).unwrap())
.build()
.unwrap();
model.set_optimizer(|p| Adam::new(p, 0.01));
model.set_lr(0.001);
}
#[test]
fn test_cadence_initial_equal() {
let c = ElChe::new(2, 10);
assert_eq!(c.batches(0), 10);
assert_eq!(c.batches(1), 10);
assert_eq!(c.total_batches(), 20);
assert_eq!(c.anchor(), 10);
assert!(!c.is_calibrated());
}
#[test]
fn test_cadence_initial_three_devices() {
let c = ElChe::new(3, 15);
assert_eq!(c.batches(0), 15);
assert_eq!(c.batches(1), 15);
assert_eq!(c.batches(2), 15);
assert_eq!(c.total_batches(), 45);
}
#[test]
fn test_cadence_ratio_discovery_2x() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.50); let bc = c.batch_counts().to_vec();
c.report_timing(&[500.0, 1000.0], &bc, 10.0);
assert!(c.is_calibrated());
assert_eq!(c.batches(1), 10);
assert_eq!(c.batches(0), 20);
}
#[test]
fn test_cadence_ratio_discovery_fbrl_like() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.50);
let bc = c.batch_counts().to_vec();
c.report_timing(&[730.0, 1640.0], &bc, 50.0);
assert!(c.is_calibrated());
assert_eq!(c.batches(1), 10); let fast = c.batches(0);
assert!((22..=23).contains(&fast), "expected ~22-23, got {fast}");
}
#[test]
fn test_cadence_anchor_auto_tune() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.10);
for _ in 0..5 {
let bc = c.batch_counts().to_vec();
c.report_timing(&[1000.0, 1000.0], &bc, 5.0);
}
let bc = c.batch_counts().to_vec();
c.report_timing(&[1000.0, 1000.0], &bc, 500.0);
c.commit_proposed_anchor();
assert_eq!(c.anchor(), 20);
assert_eq!(c.batches(0), 20);
assert_eq!(c.batches(1), 20);
}
#[test]
fn test_cadence_anchor_auto_tune_with_speed_ratio() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.10);
for _ in 0..5 {
c.report_timing(&[500.0, 1000.0], &[10, 10], 5.0);
}
c.report_timing(&[500.0, 1000.0], &[10, 10], 400.0);
c.commit_proposed_anchor();
assert_eq!(c.anchor(), 20);
assert_eq!(c.batches(1), 20); assert_eq!(c.batches(0), 40);
}
#[test]
fn test_cap_binding_suppresses_anchor_growth() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.10);
c.set_max_total_batches(30);
for _ in 0..5 {
c.report_timing(&[500.0, 1000.0], &[10, 10], 5.0);
}
c.report_timing(&[500.0, 1000.0], &[10, 10], 400.0);
c.commit_proposed_anchor();
let anchor_after_first = c.anchor();
for _ in 0..10 {
c.report_timing(&[500.0, 1000.0], &[10, 10], 400.0);
c.commit_proposed_anchor();
}
assert!(
c.anchor() <= anchor_after_first,
"anchor ratcheted under a binding window cap: {} -> {}",
anchor_after_first,
c.anchor(),
);
let total = c.batches(0) + c.batches(1);
assert!(total <= 30, "cap still enforced: total={total}");
}
#[test]
fn test_speed_ratio_clamped_against_degenerate_sample() {
let mut c = ElChe::new(2, 10);
for _ in 0..6 {
c.report_timing(&[0.1, 1000.0], &[10, 10], 1.0);
}
assert!(
c.batches(0) <= 10 * 64,
"ratio clamp failed: fast rank got {} batches",
c.batches(0),
);
}
#[test]
fn test_warmup_unsticks_when_pinned_anchor_never_reports() {
let mut c = ElChe::new(2, 10).with_initial_anchor(1);
for _ in 0..10 {
c.report_timing(&[100.0, 0.0], &[10, 0], 1.0);
}
assert!(
c.is_calibrated(),
"controller stayed un-calibrated: pinned dead anchor froze Warmup",
);
}
#[test]
fn test_nudge_anchor_down_ignores_nan_factor() {
let mut c = ElChe::new(2, 10);
for _ in 0..6 {
c.report_timing(&[100.0, 100.0], &[10, 10], 1.0);
}
let before = c.anchor();
c.nudge_anchor_down(f64::NAN);
assert_eq!(c.anchor(), before, "NaN factor must be a no-op");
c.nudge_anchor_down(0.5);
assert!(c.anchor() < before, "finite factor still nudges");
}
#[test]
fn test_cadence_window_capped_to_max_total() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.10);
c.set_max_total_batches(40);
for _ in 0..5 {
c.report_timing(&[500.0, 1000.0], &[10, 10], 5.0);
}
c.report_timing(&[500.0, 1000.0], &[10, 10], 400.0);
c.commit_proposed_anchor();
let total = c.batches(0) + c.batches(1);
assert!(
total <= 40,
"window capped to max_total: total={total} (<= 40)"
);
assert!(
c.batches(0) > c.batches(1),
"speed ratio preserved after cap: fast={} slow={}",
c.batches(0),
c.batches(1),
);
}
#[test]
fn test_cadence_anchor_capped_at_max() {
let mut c = ElChe::new(2, 10)
.with_overhead_target(0.01)
.with_max_anchor(15);
for _ in 0..5 {
let bc = c.batch_counts().to_vec();
c.report_timing(&[100.0, 100.0], &bc, 0.5);
}
let bc = c.batch_counts().to_vec();
c.report_timing(&[100.0, 100.0], &bc, 500.0);
c.commit_proposed_anchor();
assert_eq!(c.anchor(), 15);
assert_eq!(c.batches(0), 15);
}
#[test]
fn test_cadence_stable_when_overhead_low() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.10);
let bc = c.batch_counts().to_vec();
c.report_timing(&[1000.0, 1000.0], &bc, 5.0);
assert_eq!(c.anchor(), 10); }
#[test]
fn test_overhead_proposal_committed_on_stable_verdict() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.10);
for _ in 0..5 {
c.report_timing(&[1000.0, 1000.0], &[10, 10], 5.0);
}
c.report_timing(&[1000.0, 1000.0], &[10, 10], 500.0);
assert_eq!(c.anchor(), 10, "report_timing must not mutate anchor");
c.commit_proposed_anchor();
assert_eq!(c.anchor(), 20, "commit applies the ×2-capped grow");
}
#[test]
fn test_overhead_grow_vetoed_on_suppress_growth() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.10);
for _ in 0..5 {
c.report_timing(&[1000.0, 1000.0], &[10, 10], 5.0);
}
c.report_timing(&[1000.0, 1000.0], &[10, 10], 500.0);
c.veto_proposed_growth();
assert_eq!(c.anchor(), 10, "SuppressGrowth vetoes the grow proposal");
}
#[test]
fn test_growth_latched_off_after_suppress_growth() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.10);
for _ in 0..5 {
c.report_timing(&[1000.0, 1000.0], &[10, 10], 5.0);
}
c.report_timing(&[1000.0, 1000.0], &[10, 10], 500.0);
c.veto_proposed_growth();
assert_eq!(c.anchor(), 10, "SuppressGrowth vetoes the grow");
assert!(!c.growth_enabled(), "growth latched off");
for _ in 0..5 {
c.report_timing(&[1000.0, 1000.0], &[10, 10], 500.0);
c.commit_proposed_anchor();
assert_eq!(c.anchor(), 10, "no growth while latched off / re-arming");
}
assert!(
c.growth_enabled(),
"5 consecutive Stable verdicts re-arm growth"
);
c.report_timing(&[1000.0, 1000.0], &[10, 10], 500.0);
c.commit_proposed_anchor();
assert!(c.anchor() > 10, "growth resumes after re-arm");
}
#[test]
fn test_overhead_proposal_discarded_on_nudge_down() {
let mut c = ElChe::new(2, 20).with_overhead_target(0.10);
for _ in 0..5 {
c.report_timing(&[1000.0, 1000.0], &[20, 20], 5.0);
}
c.report_timing(&[1000.0, 1000.0], &[20, 20], 500.0);
c.discard_proposed_anchor();
c.nudge_anchor_down(0.5);
assert_eq!(c.anchor(), 10, "nudge halves the pre-proposal anchor");
}
#[test]
fn test_cadence_three_devices_mixed_speed() {
let mut c = ElChe::new(3, 10).with_overhead_target(0.50);
let bc = c.batch_counts().to_vec();
c.report_timing(&[333.0, 500.0, 1000.0], &bc, 10.0);
assert_eq!(c.batches(2), 10); assert_eq!(c.batches(0), 30);
assert_eq!(c.batches(1), 20);
}
#[test]
fn test_cadence_successive_reports_refine() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.50);
let bc = c.batch_counts().to_vec();
c.report_timing(&[500.0, 1000.0], &bc, 10.0);
assert_eq!(c.batches(0), 20);
assert_eq!(c.batches(1), 10);
let bc = c.batch_counts().to_vec();
c.report_timing(&[1000.0, 1000.0], &bc, 10.0);
assert_eq!(c.batches(0), 20);
assert_eq!(c.batches(1), 10);
}
#[test]
fn test_callback_slack_reduces_firing_rank_count() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.50);
c.report_timing(&[500.0, 1000.0], &[10, 10], 10.0);
assert_eq!(c.batches(0), 20);
assert_eq!(c.batches(1), 10);
c.apply_callback_slack(&[200.0, 0.0]);
c.report_timing(&[1000.0, 1000.0], &[20, 10], 10.0);
assert_eq!(c.batches(0), 16);
assert_eq!(c.batches(1), 10);
c.report_timing(&[800.0, 1000.0], &[16, 10], 10.0);
assert_eq!(c.batches(0), 20);
assert_eq!(c.batches(1), 10);
}
#[test]
fn test_callback_slack_clamps_at_one() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.50);
let bc = c.batch_counts().to_vec();
c.report_timing(&[500.0, 1000.0], &bc, 10.0);
assert_eq!(c.batches(0), 20);
c.apply_callback_slack(&[10_000.0, 0.0]);
let bc = c.batch_counts().to_vec();
c.report_timing(&[500.0, 1000.0], &bc, 10.0);
assert_eq!(c.batches(0), 1, "slack must clamp at 1, not starve to 0");
assert_eq!(c.batches(1), 10);
}
#[test]
fn test_callback_slack_size_mismatch_is_noop() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.50);
c.report_timing(&[500.0, 1000.0], &[10, 10], 10.0);
assert_eq!(c.batches(0), 20);
c.apply_callback_slack(&[200.0, 0.0, 0.0]);
assert_eq!(c.pending_callback_slack_ms(), &[0.0, 0.0]);
c.apply_callback_slack(&[]);
assert_eq!(c.pending_callback_slack_ms(), &[0.0, 0.0]);
c.report_timing(&[1000.0, 1000.0], &[20, 10], 10.0);
assert_eq!(c.batches(0), 20);
}
#[test]
fn test_callback_slack_multi_rank() {
let mut c = ElChe::new(3, 10).with_overhead_target(0.50);
c.report_timing(&[333.0, 500.0, 1000.0], &[10, 10, 10], 10.0);
let baseline_0 = c.batches(0);
let baseline_1 = c.batches(1);
let baseline_2 = c.batches(2);
c.apply_callback_slack(&[100.0, 100.0, 0.0]);
c.report_timing(
&[baseline_0 as f64 * 33.3, baseline_1 as f64 * 50.0, 1000.0],
&[baseline_0, baseline_1, baseline_2],
10.0,
);
assert!(
c.batches(0) == baseline_0 - 4 || c.batches(0) == baseline_0 - 3,
"rank 0 expected baseline-3 or baseline-4, got {} (baseline {baseline_0})",
c.batches(0),
);
assert_eq!(c.batches(1), baseline_1 - 2);
assert_eq!(c.batches(2), baseline_2);
}
#[test]
fn test_cadence_clamp_total() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.50);
let bc = c.batch_counts().to_vec();
c.report_timing(&[500.0, 1000.0], &bc, 10.0);
let clamped = c.clamp_total(15);
assert_eq!(clamped.iter().sum::<usize>(), 15);
assert!(
clamped[0] >= clamped[1],
"fast device should still get more"
);
}
#[test]
fn test_cadence_clamp_total_no_op_when_within() {
let c = ElChe::new(2, 10);
let clamped = c.clamp_total(30);
assert_eq!(clamped, vec![10, 10]);
}
#[test]
fn test_cadence_builders() {
let c = ElChe::new(2, 10)
.with_overhead_target(0.20)
.with_max_anchor(100);
assert_eq!(c.anchor(), 10);
assert!(!c.is_calibrated());
let c2 = ElChe::new(2, 5).with_overhead_target(0.001); let _ = c2;
}
#[test]
fn test_cadence_max_batch_diff() {
let c = ElChe::new(2, 10).with_max_batch_diff(5);
assert_eq!(c.max_batch_diff(), Some(5));
let c2 = ElChe::new(2, 10);
assert_eq!(c2.max_batch_diff(), None);
}
#[test]
fn test_batch_count_clamped_to_max_diff() {
let mut c = ElChe::new(2, 10).with_max_batch_diff(3);
let bc = c.batch_counts().to_vec();
c.report_timing(&[100.0, 20.0], &bc, 0.0);
assert!(c.is_calibrated());
let counts_after_cal = c.batch_counts().to_vec();
assert_eq!(counts_after_cal[0], 10);
assert_eq!(counts_after_cal[1], 50);
let bc = c.batch_counts().to_vec();
c.report_timing(&[100.0, 450.0], &bc, 0.0);
let counts = c.batch_counts();
assert!(
counts[1] >= counts_after_cal[1] - 3,
"batch count drop should be clamped to 3, was {} now {}",
counts_after_cal[1],
counts[1]
);
}
#[test]
fn test_cadence_weighted_allreduce_validation() {
let c = ElChe::new(2, 10);
assert_eq!(c.batch_counts().len(), 2);
}
#[test]
#[should_panic(expected = "El Che requires at least 2 devices")]
fn test_cadence_requires_two_devices() {
ElChe::new(1, 10);
}
#[test]
#[should_panic(expected = "anchor must be >= 1")]
fn test_cadence_requires_positive_anchor() {
ElChe::new(2, 0);
}
#[test]
fn test_cadence_speed_ratio_2x() {
let c = ElChe::new(2, 10).with_speed_ratio(1, 2.0);
assert_eq!(c.batches(0), 20);
assert_eq!(c.batches(1), 10);
}
#[test]
fn test_cadence_speed_ratio_fbrl() {
let c = ElChe::new(2, 10).with_speed_ratio(1, 2.3);
assert_eq!(c.batches(0), 23);
assert_eq!(c.batches(1), 10);
}
#[test]
fn test_cadence_speed_ratio_slow_rank_0() {
let c = ElChe::new(2, 10).with_speed_ratio(0, 3.0);
assert_eq!(c.batches(0), 10);
assert_eq!(c.batches(1), 30);
}
#[test]
fn test_cadence_speed_ratio_equal() {
let c = ElChe::new(2, 10).with_speed_ratio(1, 1.0);
assert_eq!(c.batches(0), 10);
assert_eq!(c.batches(1), 10);
}
#[test]
fn test_cadence_speed_ratio_three_devices() {
let c = ElChe::new(3, 10).with_speed_ratio(2, 3.0);
assert_eq!(c.batches(0), 30);
assert_eq!(c.batches(1), 30);
assert_eq!(c.batches(2), 10);
}
#[test]
fn test_cadence_speed_ratio_three_devices_mid_slow() {
let c = ElChe::new(3, 10).with_speed_ratio(1, 2.0);
assert_eq!(c.batches(0), 20);
assert_eq!(c.batches(1), 10);
assert_eq!(c.batches(2), 20);
}
#[test]
fn test_cadence_max_anchor_one() {
let mut c = ElChe::new(2, 1).with_max_anchor(1).with_speed_ratio(1, 2.0);
assert_eq!(c.batches(0), 2);
assert_eq!(c.batches(1), 1);
let bc = c.batch_counts().to_vec();
c.report_timing(&[100.0, 200.0], &bc, 500.0);
assert_eq!(c.anchor(), 1);
}
#[test]
fn test_nudge_anchor_down() {
let mut c = ElChe::new(2, 20).with_overhead_target(0.50); let bc = c.batch_counts().to_vec();
c.report_timing(&[50.0, 100.0], &bc, 0.0);
assert!(c.is_calibrated());
assert_eq!(c.anchor(), 20);
assert_eq!(c.batches(0), 40); assert_eq!(c.batches(1), 20);
c.nudge_anchor_down(0.5);
assert_eq!(c.anchor(), 10);
assert_eq!(c.batches(0), 20);
assert_eq!(c.batches(1), 10);
}
#[test]
fn test_nudge_anchor_down_clamped_to_one() {
let mut c = ElChe::new(2, 5);
assert_eq!(c.anchor(), 5);
c.nudge_anchor_down(0.1);
assert_eq!(c.anchor(), 1, "should clamp to 1");
}
#[test]
fn test_nudge_anchor_down_never_increases() {
let mut c = ElChe::new(2, 10);
c.nudge_anchor_down(2.0);
assert_eq!(c.anchor(), 10, "should never increase");
}
#[test]
fn test_cadence_speed_ratio_self_corrects() {
let mut c = ElChe::new(2, 10)
.with_overhead_target(0.50)
.with_speed_ratio(0, 2.0);
assert_eq!(c.batches(0), 10);
assert_eq!(c.batches(1), 20);
for _ in 0..6 {
c.report_timing(&[500.0, 2000.0], &[10, 20], 10.0);
}
assert_eq!(c.batches(1), c.anchor());
assert!(
c.batches(0) > c.batches(1),
"fast device should get more batches"
);
}
use crate::distributed::Phase;
#[test]
fn test_phase_starts_at_probe() {
let c = ElChe::new(3, 10);
assert_eq!(c.phase(), Phase::Probe);
assert_eq!(c.anchor_rank(), None);
}
#[test]
fn test_phase_advances_on_first_calibration() {
let mut c = ElChe::new(3, 10).with_overhead_target(0.50);
let bc = c.batch_counts().to_vec();
c.report_timing(&[100.0, 380.0, 395.0], &bc, 10.0);
assert_eq!(c.phase(), Phase::Warmup);
assert!(c.anchor_rank().is_some());
}
#[test]
fn test_phase_warmup_to_stable_at_5() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.50);
for _ in 0..5 {
let bc = c.batch_counts().to_vec();
c.report_timing(&[500.0, 1000.0], &bc, 10.0);
}
assert_eq!(c.phase(), Phase::Stable);
}
#[test]
fn test_phase_stable_to_mature_at_20() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.50);
for _ in 0..20 {
let bc = c.batch_counts().to_vec();
c.report_timing(&[500.0, 1000.0], &bc, 10.0);
}
assert_eq!(c.phase(), Phase::Mature);
}
#[test]
fn test_anchor_stable_under_tied_slow_ranks() {
let mut c = ElChe::new(3, 10).with_overhead_target(0.50);
let bc = c.batch_counts().to_vec();
c.report_timing(&[100.0, 380.0, 395.0], &bc, 10.0);
let first = c.anchor_rank().expect("anchor elected");
for (a, b) in &[(390.0, 380.0), (385.0, 388.0), (392.0, 386.0)] {
let bc = c.batch_counts().to_vec();
c.report_timing(&[100.0, *a, *b], &bc, 10.0);
assert_eq!(
c.anchor_rank(),
Some(first),
"anchor must stay sticky across tied slow-rank fluctuations",
);
}
}
#[test]
fn test_anchor_switches_when_clear_winner_emerges() {
let mut c = ElChe::new(3, 10).with_overhead_target(0.50);
c.report_timing(&[100.0, 400.0, 200.0], &[10, 10, 10], 10.0);
assert_eq!(c.anchor_rank(), Some(1));
for _ in 0..5 {
c.report_timing(&[100.0, 200.0, 600.0], &[10, 10, 10], 10.0);
}
assert_eq!(c.anchor_rank(), Some(2), "real slowdown must be tracked");
}
#[test]
fn test_relax_anchor_up_grows_anchor() {
let mut c = ElChe::new(2, 10).with_overhead_target(0.50);
let bc = c.batch_counts().to_vec();
c.report_timing(&[500.0, 1000.0], &bc, 5.0);
let before = c.anchor();
c.relax_anchor_up();
assert_eq!(c.anchor(), before + 1, "anchor should grow by 1 on relax");
}
#[test]
fn test_relax_anchor_up_capped_by_max_batch_diff() {
let mut c = ElChe::new(2, 10)
.with_overhead_target(0.50)
.with_max_batch_diff(20);
let bc = c.batch_counts().to_vec();
c.report_timing(&[300.0, 900.0], &bc, 5.0); let before = c.anchor();
c.relax_anchor_up();
assert_eq!(
c.anchor(),
before,
"relax must refuse when projected diff exceeds cap"
);
}
#[test]
fn test_relax_anchor_up_capped_by_max_anchor() {
let mut c = ElChe::new(2, 10)
.with_overhead_target(0.50)
.with_max_anchor(11);
let bc = c.batch_counts().to_vec();
c.report_timing(&[500.0, 1000.0], &bc, 5.0);
c.relax_anchor_up();
assert_eq!(c.anchor(), 11);
c.relax_anchor_up();
assert_eq!(c.anchor(), 11, "relax must respect max_anchor");
}
#[test]
fn test_anchor_election_lowest_rank_tiebreak() {
let mut c = ElChe::new(3, 10).with_overhead_target(0.50);
let bc = c.batch_counts().to_vec();
c.report_timing(&[100.0, 100.0, 100.0], &bc, 10.0);
assert_eq!(c.anchor_rank(), Some(0));
}