use cubecl::prelude::*;
use cubecl_utils_rs::prelude::*;
use crate::gpu::linalg::sparse_gpu::GpuCompressedSparseData;
use crate::gpu::sc_gpu::kernels::seacells_kernels::{
A_COLUMNS_MAX_SLOTS, A_COLUMNS_WG_TIERS, B_ARGMIN_BLOCKS, B_ARGMIN_WG_TIERS,
a_columns_capacity, a_columns_segments, a_columns_workgroup, b_argmin_workgroup,
launch_fw_argmin_b, launch_fw_columns_a,
};
use crate::prelude::*;
use crate::single_cell::mc_generation::seacells::{
CpuFwArgminB, FwArgminB, FwAtoms, SEACells, SEACellsParams, assignments_to_metacells,
fw_columns_a,
};
pub type SeacellsFitResult = (Vec<usize>, Vec<Vec<usize>>, Vec<usize>, Vec<f32>);
pub struct GpuFwArgminB<R: Runtime> {
client: ComputeClient<R>,
n: usize,
k: usize,
blocks: usize,
t1: Option<GpuCompressedSparseData<R, f32>>,
t1_seg: Option<GpuTensor<R, u32>>,
b_wg: u32,
b_cpu: Option<CpuFwArgminB>,
t2: Option<GpuCompressedSparseData<R, f32>>,
part_val: GpuTensor<R, f32>,
part_idx: GpuTensor<R, u32>,
gap_partial: GpuTensor<R, f32>,
out_val: GpuTensor<R, f32>,
out_idx: GpuTensor<R, u32>,
a_cap: usize,
a_n: usize,
a_atom_idx: Option<GpuTensor<R, u32>>,
a_atom_val: Option<GpuTensor<R, f32>>,
a_atom_cnt: Option<GpuTensor<R, u32>>,
verbose: usize,
}
impl<R: Runtime> GpuFwArgminB<R> {
pub fn new(n: usize, k: usize, client: ComputeClient<R>) -> Result<Self, BixverseErrors> {
Self::with_verbosity(n, k, client, 0)
}
pub fn with_verbosity(
n: usize,
k: usize,
client: ComputeClient<R>,
verbose: usize,
) -> Result<Self, BixverseErrors> {
let blocks = B_ARGMIN_BLOCKS.min(n.max(1) as u32) as usize;
let part_val = GpuTensor::<R, f32>::empty(vec![blocks * k], &client)?;
let part_idx = GpuTensor::<R, u32>::empty(vec![blocks * k], &client)?;
let gap_partial = GpuTensor::<R, f32>::empty(vec![blocks], &client)?;
let out_val = GpuTensor::<R, f32>::empty(vec![k], &client)?;
let out_idx = GpuTensor::<R, u32>::empty(vec![k], &client)?;
Ok(Self {
client,
n,
k,
blocks,
t1: None,
t1_seg: None,
b_wg: 0,
b_cpu: None,
t2: None,
part_val,
part_idx,
gap_partial,
out_val,
out_idx,
a_cap: 0,
a_n: 0,
a_atom_idx: None,
a_atom_val: None,
a_atom_cnt: None,
verbose,
})
}
pub fn vram_bytes(&self) -> usize {
let a_update: usize = [
self.a_atom_idx.as_ref().map(|t| t.vram_bytes()),
self.a_atom_val.as_ref().map(|t| t.vram_bytes()),
self.a_atom_cnt.as_ref().map(|t| t.vram_bytes()),
]
.into_iter()
.flatten()
.sum();
self.part_val.vram_bytes()
+ self.part_idx.vram_bytes()
+ self.gap_partial.vram_bytes()
+ self.out_val.vram_bytes()
+ self.out_idx.vram_bytes()
+ a_update
}
}
impl<R: Runtime> FwArgminB for GpuFwArgminB<R> {
fn begin(
&mut self,
t1: &CompressedSparseData2<f32>,
t2: &CompressedSparseData2<f32>,
) -> Result<(), BixverseErrors> {
if t1.shape != (self.k, self.k) {
return Err(BixverseErrors::ShapeMismatch {
expected: (self.k, self.k),
got: t1.shape,
});
}
if t2.shape != (self.n, self.k) {
return Err(BixverseErrors::ShapeMismatch {
expected: (self.n, self.k),
got: t2.shape,
});
}
let Some(wg) = b_argmin_workgroup(self.k) else {
if parse_verbosity_level(self.verbose).detailed_verbosity() {
println!(
" B-gradient argmin on CPU: k {} fits no tier {:?}",
self.k, B_ARGMIN_WG_TIERS
);
}
let mut cpu = CpuFwArgminB::default();
cpu.begin(t1, t2)?;
self.b_cpu = Some(cpu);
return Ok(());
};
self.b_cpu = None;
let seg_host = a_columns_segments(t1, wg, self.k.div_ceil(wg as usize));
self.t1_seg = Some(GpuTensor::<R, u32>::from_slice(
&seg_host,
vec![seg_host.len()],
&self.client,
)?);
drop(seg_host);
self.b_wg = wg;
self.t1 = Some(
GpuCompressedSparseData::<R, f32>::from_compressed_sparse_data_2(
t1,
false,
&self.client,
)?,
);
self.t2 = Some(
GpuCompressedSparseData::<R, f32>::from_compressed_sparse_data_2(
t2,
false,
&self.client,
)?,
);
Ok(())
}
fn argmins(
&mut self,
k2_b: &CompressedSparseData2<f32>,
b: &CompressedSparseData2<f32>,
) -> Result<(Vec<usize>, f32), BixverseErrors> {
if let Some(cpu) = self.b_cpu.as_mut() {
return cpu.argmins(k2_b, b);
}
for mat in [k2_b, b] {
if mat.shape != (self.n, self.k) {
return Err(BixverseErrors::ShapeMismatch {
expected: (self.n, self.k),
got: mat.shape,
});
}
}
let t1 = self
.t1
.as_ref()
.ok_or(BixverseErrors::SEACellsModelNotFitted)?;
let t1_seg = self
.t1_seg
.as_ref()
.ok_or(BixverseErrors::SEACellsModelNotFitted)?;
let t2 = self
.t2
.as_ref()
.ok_or(BixverseErrors::SEACellsModelNotFitted)?;
let k2b_gpu = GpuCompressedSparseData::<R, f32>::from_compressed_sparse_data_2(
k2_b,
false,
&self.client,
)?;
let b_gpu = GpuCompressedSparseData::<R, f32>::from_compressed_sparse_data_2(
b,
false,
&self.client,
)?;
launch_fw_argmin_b(
&k2b_gpu,
t1,
t1_seg,
t2,
&b_gpu,
&self.part_val,
&self.part_idx,
&self.gap_partial,
&self.out_val,
&self.out_idx,
self.n,
self.k,
&self.client,
)?;
let min_vals = self
.out_val
.clone()
.read(&self.client)
.map_err(|e| BixverseErrors::GpuMatmul(e.to_string()))?;
let argmins = self
.out_idx
.clone()
.read(&self.client)
.map_err(|e| BixverseErrors::GpuMatmul(e.to_string()))?;
let gaps = self
.gap_partial
.clone()
.read(&self.client)
.map_err(|e| BixverseErrors::GpuMatmul(e.to_string()))?;
let g_dot_b: f32 = gaps.iter().take(self.blocks).sum();
let g_dot_e: f32 = min_vals.iter().sum();
Ok((
argmins.into_iter().map(|i| i as usize).collect(),
(g_dot_b - g_dot_e).abs(),
))
}
fn columns_a(
&mut self,
t1: &CompressedSparseData2<f32>,
a_prev_t: &CompressedSparseData2<f32>,
k2_b: &CompressedSparseData2<f32>,
k: usize,
n: usize,
n_iters: usize,
pruning: Option<f32>,
) -> Result<Vec<FwAtoms>, BixverseErrors> {
if t1.shape != (k, k) {
return Err(BixverseErrors::ShapeMismatch {
expected: (k, k),
got: t1.shape,
});
}
for mat in [a_prev_t, k2_b] {
if mat.shape != (n, k) {
return Err(BixverseErrors::ShapeMismatch {
expected: (n, k),
got: mat.shape,
});
}
}
let cap = a_columns_capacity(a_prev_t, n_iters);
let Some(wg) = a_columns_workgroup(k, cap) else {
if parse_verbosity_level(self.verbose).detailed_verbosity() {
println!(
" A-column solve on CPU: k {} at cap {} fits no tier {:?} within {} slots",
k, cap, A_COLUMNS_WG_TIERS, A_COLUMNS_MAX_SLOTS
);
}
return Ok(fw_columns_a(t1, a_prev_t, k2_b, k, n, n_iters, pruning));
};
let t1_gpu = GpuCompressedSparseData::<R, f32>::from_compressed_sparse_data_2(
t1,
false,
&self.client,
)?;
let seg_host = a_columns_segments(t1, wg, k.div_ceil(wg as usize));
let t1_seg =
GpuTensor::<R, u32>::from_slice(&seg_host, vec![seg_host.len()], &self.client)?;
drop(seg_host);
let a_prev_gpu = GpuCompressedSparseData::<R, f32>::from_compressed_sparse_data_2(
a_prev_t,
false,
&self.client,
)?;
let k2b_gpu = GpuCompressedSparseData::<R, f32>::from_compressed_sparse_data_2(
k2_b,
false,
&self.client,
)?;
let required = n * cap;
if self.a_atom_idx.is_none() || self.a_n * self.a_cap < required || self.a_n < n {
self.a_cap = cap;
self.a_n = n;
self.a_atom_idx = Some(GpuTensor::<R, u32>::empty(vec![required], &self.client)?);
self.a_atom_val = Some(GpuTensor::<R, f32>::empty(vec![required], &self.client)?);
self.a_atom_cnt = Some(GpuTensor::<R, u32>::empty(vec![n], &self.client)?);
}
let stride = self.a_cap;
let atom_idx = self.a_atom_idx.as_ref().expect("just allocated").clone();
let atom_val = self.a_atom_val.as_ref().expect("just allocated").clone();
let atom_cnt = self.a_atom_cnt.as_ref().expect("just allocated").clone();
let threshold =
GpuTensor::<R, f32>::from_slice(&[pruning.unwrap_or(0.0)], vec![1], &self.client)?;
launch_fw_columns_a(
&t1_gpu,
&t1_seg,
&a_prev_gpu,
&k2b_gpu,
&atom_idx,
&atom_val,
&atom_cnt,
&threshold,
n,
k,
n_iters,
stride as u32,
pruning,
None,
Some(wg),
&self.client,
)?;
let idx_host = atom_idx
.clone()
.read(&self.client)
.map_err(|e| BixverseErrors::GpuMatmul(e.to_string()))?;
let val_host = atom_val
.clone()
.read(&self.client)
.map_err(|e| BixverseErrors::GpuMatmul(e.to_string()))?;
let cnt_host = atom_cnt
.clone()
.read(&self.client)
.map_err(|e| BixverseErrors::GpuMatmul(e.to_string()))?;
if n_iters > 0 && cnt_host.contains(&0) {
let dead = cnt_host.iter().filter(|c| **c == 0).count();
println!(
" A-column solve fell back to CPU: the kernel wrote no atoms for {} of {} cells \
at k = {}, workgroup {}, {} slots. This is a rejected dispatch, not a result. \
Please report the device and shape.",
dead,
n,
k,
wg,
k.div_ceil(wg as usize)
);
return Ok(fw_columns_a(t1, a_prev_t, k2_b, k, n, n_iters, pruning));
}
let drop_zeros = pruning.is_some();
let mut columns = Vec::with_capacity(n);
let mut keep_idx: Vec<u32> = Vec::with_capacity(stride);
let mut keep_val: Vec<f32> = Vec::with_capacity(stride);
for cell in 0..n {
let count = cnt_host[cell] as usize;
keep_idx.clear();
keep_val.clear();
for slot in 0..count {
let weight = val_host[cell * stride + slot];
if !drop_zeros || weight != 0.0 {
keep_idx.push(idx_host[cell * stride + slot]);
keep_val.push(weight);
}
}
let mut atoms = FwAtoms::with_capacity(keep_idx.len());
atoms.reset_from(&keep_idx, &keep_val);
columns.push(atoms);
}
Ok(columns)
}
}
#[allow(clippy::too_many_arguments)]
pub fn seacells_fit_gpu<R: Runtime>(
pca: faer::MatRef<f32>,
knn_indices: &[Vec<usize>],
knn_distances: &[Vec<f32>],
squared_dist: bool,
params: &SEACellsParams,
seed: usize,
device: R::Device,
verbose: usize,
) -> Result<SeacellsFitResult, BixverseErrors> {
let verbosity = parse_verbosity_level(verbose);
let client = R::client(&device);
let n = pca.nrows();
let mut model = SEACells::new(n, params);
model.construct_kernel_mat(pca, knn_indices, knn_distances, verbose);
match params.n_landmarks {
Some(n_landmarks) => model.initialise_archetypes_landmark(
pca,
knn_indices,
knn_distances,
squared_dist,
n_landmarks,
verbose,
seed as u64,
)?,
None => model.initialise_archetypes(
knn_indices,
knn_distances,
verbose,
squared_dist,
seed as u64,
)?,
}
let archetypes = model.get_archetypes()?;
let k = archetypes.len();
let mut backend = GpuFwArgminB::<R>::with_verbosity(n, k, client, verbose)?;
if verbosity.detailed_verbosity() {
println!(
"GPU B-argmin scratch: {:.1} MB",
backend.vram_bytes() as f64 / (1024.0 * 1024.0)
);
}
model.fit_with(seed, verbose, &mut backend)?;
let assignments = model.get_hard_assignments()?;
let metacells = assignments_to_metacells(&assignments, k);
let rss_history = model.get_rss_history().to_vec();
Ok((assignments, metacells, archetypes, rss_history))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "large_scale_diagnostics")]
use crate::gpu::sc_gpu::kernels::seacells_kernels::A_COLUMNS_BLOCKS;
use cubecl::wgpu::{WgpuDevice, WgpuRuntime};
use rand::prelude::*;
use rand::rngs::StdRng;
use crate::gpu::sc_gpu::kernels::seacells_kernels::A_COLUMNS_WG;
fn try_device() -> Option<WgpuDevice> {
let device = WgpuDevice::DefaultDevice;
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
WgpuRuntime::client(&device);
}))
.ok()
.map(|_| device)
}
fn random_csr(
n_rows: usize,
n_cols: usize,
per_row: usize,
normalise: bool,
coarse: bool,
rng: &mut StdRng,
) -> CompressedSparseData2<f32> {
let mut data = Vec::new();
let mut indices = Vec::new();
let mut indptr = vec![0u32];
for _ in 0..n_rows {
let mut cols: Vec<u32> = (0..n_cols as u32).collect();
cols.shuffle(rng);
cols.truncate(per_row.min(n_cols));
cols.sort_unstable();
let mut vals: Vec<f32> = (0..cols.len())
.map(|_| {
if coarse {
(rng.random_range(1..4) as f32) * 0.25
} else {
rng.random::<f32>() + 0.05
}
})
.collect();
if normalise {
let sum: f32 = vals.iter().sum();
for v in vals.iter_mut() {
*v /= sum;
}
}
indices.extend_from_slice(&cols);
data.extend_from_slice(&vals);
indptr.push(indices.len() as u32);
}
CompressedSparseData2 {
data,
indices,
indptr,
cs_type: CompressedSparseFormat::Csr,
data_2: None,
shape: (n_rows, n_cols),
}
}
#[test]
#[cfg(feature = "large_scale_diagnostics")]
fn test_fw_columns_a_gpu_matches_cpu() {
let Some(device) = try_device() else {
eprintln!("no GPU available, skipping");
return;
};
let client = WgpuRuntime::client(&device);
let n = 2_500usize;
let k = 300usize;
let n_iters = 15usize;
assert!(n > A_COLUMNS_BLOCKS as usize, "grid stride not exercised");
assert!(k > A_COLUMNS_WG as usize, "slots == 1, axes not separated");
for coarse in [false, true] {
let mut rng = StdRng::seed_from_u64(11);
let t1 = random_csr(k, k, 12, false, coarse, &mut rng);
let a_prev_t = random_csr(n, k, 6, true, coarse, &mut rng);
let k2_b = random_csr(n, k, 8, false, coarse, &mut rng);
let mut backend = GpuFwArgminB::<WgpuRuntime>::new(n, k, client.clone()).unwrap();
for pruning in [None, Some(1e-7f32), Some(5e-2f32)] {
let expected = fw_columns_a(&t1, &a_prev_t, &k2_b, k, n, n_iters, pruning);
let actual = backend
.columns_a(&t1, &a_prev_t, &k2_b, k, n, n_iters, pruning)
.expect("GPU column solve failed");
assert_eq!(actual.len(), n);
let total_mass: f64 = actual
.iter()
.map(|atoms| atoms.atoms().1.iter().map(|&w| w as f64).sum::<f64>())
.sum();
assert!(
(total_mass - n as f64).abs() < 0.01 * n as f64,
"columns do not sum to one: total mass {} over {} cells (pruning {:?}, \
coarse {:?}), the kernel probably did no work",
total_mass,
n,
pruning,
coarse
);
let sorted = |atoms: &FwAtoms| {
let (idx, val) = atoms.atoms();
let mut pairs: Vec<(u32, f32)> =
idx.iter().copied().zip(val.iter().copied()).collect();
pairs.sort_unstable_by_key(|&(i, _)| i);
pairs
};
let mut differing = 0usize;
for (cell, (want, got)) in expected.iter().zip(actual.iter()).enumerate() {
let want_pairs = sorted(want);
let got_pairs = sorted(got);
let (want_idx, want_val): (Vec<u32>, Vec<f32>) =
want_pairs.iter().copied().unzip();
let (got_idx, got_val): (Vec<u32>, Vec<f32>) =
got_pairs.iter().copied().unzip();
let same = want_idx == got_idx
&& want_val
.iter()
.zip(got_val.iter())
.all(|(a, b)| (a - b).abs() <= 1e-4 * a.abs().max(1e-3));
if !same {
if differing == 0 {
eprintln!(
"first mismatch at cell {} (pruning {:?}):\n cpu {:?}\n gpu {:?}",
cell, pruning, want_pairs, got_pairs
);
}
differing += 1;
}
}
assert!(
differing * 50 <= n,
"{} / {} columns differ (pruning {:?}, coarse {:?})",
differing,
n,
pruning,
coarse
);
}
}
}
#[test]
#[cfg(feature = "large_scale_diagnostics")]
fn test_fw_columns_a_large_k_matches_cpu() {
let Some(device) = try_device() else {
eprintln!("no GPU available, skipping");
return;
};
let client = WgpuRuntime::client(&device);
let n = 1_500usize;
let n_iters = 12usize;
for k in [3_000usize, 4_500, 9_000] {
let wg = a_columns_workgroup(k, n_iters + 6).expect("no tier for k");
let slots = k.div_ceil(wg as usize);
assert!(
wg as usize * slots > k,
"k = {} is an exact multiple of wg * slots, tail not exercised",
k
);
let mut rng = StdRng::seed_from_u64(k as u64);
let t1 = random_csr(k, k, 20, false, false, &mut rng);
let a_prev_t = random_csr(n, k, 6, true, false, &mut rng);
let k2_b = random_csr(n, k, 10, false, false, &mut rng);
let mut backend = GpuFwArgminB::<WgpuRuntime>::new(n, k, client.clone()).unwrap();
for pruning in [None, Some(1e-7f32)] {
let expected = fw_columns_a(&t1, &a_prev_t, &k2_b, k, n, n_iters, pruning);
let actual = backend
.columns_a(&t1, &a_prev_t, &k2_b, k, n, n_iters, pruning)
.expect("GPU column solve failed");
let total_mass: f64 = actual
.iter()
.map(|atoms| atoms.atoms().1.iter().map(|&w| w as f64).sum::<f64>())
.sum();
assert!(
(total_mass - n as f64).abs() < 0.01 * n as f64,
"k = {} (wg {}, {} slots): total mass {} over {} cells, the kernel probably \
did no work",
k,
wg,
slots,
total_mass,
n
);
let mut differing = 0usize;
for (want, got) in expected.iter().zip(actual.iter()) {
let key = |atoms: &FwAtoms| {
let (idx, val) = atoms.atoms();
let mut pairs: Vec<(u32, f32)> =
idx.iter().copied().zip(val.iter().copied()).collect();
pairs.sort_unstable_by_key(|&(i, _)| i);
pairs
};
let (want_pairs, got_pairs) = (key(want), key(got));
let same = want_pairs.len() == got_pairs.len()
&& want_pairs.iter().zip(got_pairs.iter()).all(|(a, b)| {
a.0 == b.0 && (a.1 - b.1).abs() <= 1e-4 * a.1.abs().max(1e-3)
});
if !same {
differing += 1;
}
}
assert!(
differing * 50 <= n,
"k = {} (wg {}, {} slots): {} / {} columns differ (pruning {:?})",
k,
wg,
slots,
differing,
n,
pruning
);
}
}
}
#[test]
fn test_a_columns_segments_bracket_owned_columns() {
let k = 300usize;
let wg = 128u32;
let slots = k.div_ceil(wg as usize);
let mut rng = StdRng::seed_from_u64(7);
let t1 = random_csr(k, k, 9, false, false, &mut rng);
let seg = a_columns_segments(&t1, wg, slots);
let stride = wg as usize + 1;
assert_eq!(seg.len(), k * stride);
let mut saw_empty = false;
for row in 0..k {
let (start, end) = (t1.indptr[row], t1.indptr[row + 1]);
assert_eq!(seg[row * stride], start, "row {} start", row);
assert_eq!(seg[row * stride + wg as usize], end, "row {} end", row);
for t in 0..wg as usize {
let (lo, hi) = (seg[row * stride + t], seg[row * stride + t + 1]);
assert!(lo <= hi, "row {} thread {}: bounds inverted", row, t);
if lo == hi {
saw_empty = true;
}
let base = (t * slots) as u32;
for p in lo..hi {
let col = t1.indices[p as usize];
assert!(
col >= base && col < base + slots as u32,
"row {} thread {}: column {} outside [{}, {})",
row,
t,
col,
base,
base + slots as u32
);
}
}
}
assert!(
saw_empty,
"no empty block seen, the sparse case is untested"
);
}
#[allow(clippy::too_many_arguments)]
#[cfg(feature = "large_scale_diagnostics")]
fn run_columns_a_raw(
t1: &CompressedSparseData2<f32>,
a_prev_t: &CompressedSparseData2<f32>,
k2_b: &CompressedSparseData2<f32>,
n: usize,
k: usize,
n_iters: usize,
pruning: Option<f32>,
use_plane: bool,
wg: Option<u32>,
client: &ComputeClient<WgpuRuntime>,
) -> (Vec<u32>, Vec<f32>, Vec<u32>, usize) {
let cap = a_columns_capacity(a_prev_t, n_iters);
let t1_gpu = GpuCompressedSparseData::<WgpuRuntime, f32>::from_compressed_sparse_data_2(
t1, false, client,
)
.expect("upload failed");
let width = wg.unwrap_or(A_COLUMNS_WG);
let seg_host = a_columns_segments(t1, width, k.div_ceil(width as usize));
let t1_seg =
GpuTensor::<WgpuRuntime, u32>::from_slice(&seg_host, vec![seg_host.len()], client)
.unwrap();
let a_gpu = GpuCompressedSparseData::<WgpuRuntime, f32>::from_compressed_sparse_data_2(
a_prev_t, false, client,
)
.expect("upload failed");
let k2b_gpu = GpuCompressedSparseData::<WgpuRuntime, f32>::from_compressed_sparse_data_2(
k2_b, false, client,
)
.expect("upload failed");
let atom_idx = GpuTensor::<WgpuRuntime, u32>::empty(vec![n * cap], client).unwrap();
let atom_val = GpuTensor::<WgpuRuntime, f32>::empty(vec![n * cap], client).unwrap();
let atom_cnt = GpuTensor::<WgpuRuntime, u32>::empty(vec![n], client).unwrap();
let thr =
GpuTensor::<WgpuRuntime, f32>::from_slice(&[pruning.unwrap_or(0.0)], vec![1], client)
.unwrap();
launch_fw_columns_a(
&t1_gpu,
&t1_seg,
&a_gpu,
&k2b_gpu,
&atom_idx,
&atom_val,
&atom_cnt,
&thr,
n,
k,
n_iters,
cap as u32,
pruning,
Some(use_plane),
wg,
client,
)
.expect("launch failed");
(
atom_idx.clone().read(client).expect("read failed"),
atom_val.clone().read(client).expect("read failed"),
atom_cnt.clone().read(client).expect("read failed"),
cap,
)
}
#[test]
#[cfg(feature = "large_scale_diagnostics")]
fn test_fw_columns_a_reduction_arms_agree() {
let Some(device) = try_device() else {
eprintln!("no GPU available, skipping");
return;
};
let client = WgpuRuntime::client(&device);
let n = 1_500usize;
let k = 300usize;
let n_iters = 12usize;
let mut rng = StdRng::seed_from_u64(5);
let t1 = random_csr(k, k, 12, false, true, &mut rng);
let a_prev_t = random_csr(n, k, 6, true, true, &mut rng);
let k2_b = random_csr(n, k, 8, false, true, &mut rng);
for pruning in [None, Some(1e-7f32)] {
let (plane_idx, plane_val, plane_cnt, cap) = run_columns_a_raw(
&t1, &a_prev_t, &k2_b, n, k, n_iters, pruning, true, None, &client,
);
let (tree_idx, tree_val, tree_cnt, tree_cap) = run_columns_a_raw(
&t1, &a_prev_t, &k2_b, n, k, n_iters, pruning, false, None, &client,
);
assert_eq!(cap, tree_cap);
assert_eq!(
plane_cnt, tree_cnt,
"atom counts differ (pruning {:?})",
pruning
);
for cell in 0..n {
let cnt = plane_cnt[cell] as usize;
let lo = cell * cap;
let hi = lo + cnt;
assert_eq!(
&plane_idx[lo..hi],
&tree_idx[lo..hi],
"atom indices differ at cell {} (pruning {:?})",
cell,
pruning
);
for slot in 0..cnt {
let a = plane_val[lo + slot];
let b = tree_val[lo + slot];
assert!(
(a - b).abs() <= 1e-5 * a.abs().max(1e-3),
"weight differs at cell {} slot {} (pruning {:?}): {} vs {}",
cell,
slot,
pruning,
a,
b
);
}
}
}
}
#[test]
fn test_fw_columns_a_capacity_boundary() {
let Some(device) = try_device() else {
eprintln!("no GPU available, skipping");
return;
};
let client = WgpuRuntime::client(&device);
let n = 300usize;
let k = 300usize;
let n_iters = 15usize;
let wg = A_COLUMNS_WG as usize;
for widest in [wg - n_iters - 1, wg - n_iters, wg - n_iters + 1] {
let mut rng = StdRng::seed_from_u64(3);
let t1 = random_csr(k, k, 12, false, false, &mut rng);
let a_prev_t = random_csr(n, k, widest, true, false, &mut rng);
let k2_b = random_csr(n, k, 8, false, false, &mut rng);
let cap = a_columns_capacity(&a_prev_t, n_iters);
assert_eq!(cap, widest + n_iters);
let expected = fw_columns_a(&t1, &a_prev_t, &k2_b, k, n, n_iters, Some(1e-7));
let mut backend = GpuFwArgminB::<WgpuRuntime>::new(n, k, client.clone()).unwrap();
let actual = backend
.columns_a(&t1, &a_prev_t, &k2_b, k, n, n_iters, Some(1e-7))
.expect("column solve failed");
assert_eq!(actual.len(), n);
let mass: f64 = actual
.iter()
.map(|atoms| atoms.atoms().1.iter().map(|&w| w as f64).sum::<f64>())
.sum();
assert!(
(mass - n as f64).abs() < 0.01 * n as f64,
"columns do not sum to one at cap {}: mass {}",
cap,
mass
);
let differing = expected
.iter()
.zip(actual.iter())
.filter(|(want, got)| {
let mut w: Vec<_> = want.atoms().0.to_vec();
let mut g: Vec<_> = got.atoms().0.to_vec();
w.sort_unstable();
g.sort_unstable();
w != g
})
.count();
assert!(
differing * 100 <= n,
"{} / {} columns differ at cap {} (ceiling {})",
differing,
n,
cap,
wg
);
}
}
}