use ann_search_rs::gpu::tensor::GpuTensor;
use cubecl::prelude::*;
use crate::gpu::linalg::sparse_gpu::GpuCompressedSparseData;
use crate::gpu::sc_gpu::kernels::seacells_kernels::{
A_COLUMNS_MAX_SLOTS, A_COLUMNS_WG, B_ARGMIN_BLOCKS, a_columns_capacity, launch_fw_argmin_b,
launch_fw_columns_a,
};
use crate::prelude::*;
use crate::single_cell::mc_generation::seacells::{
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<GpuTensor<R, f32>>,
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>) -> Self {
Self::with_verbosity(n, k, client, 0)
}
pub fn with_verbosity(n: usize, k: usize, client: ComputeClient<R>, verbose: usize) -> Self {
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);
Self {
client,
n,
k,
blocks,
t1: 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 bytes = self.k * self.k * size_of::<f32>();
let limit = self.client.properties().memory.max_page_size as usize;
if bytes > limit {
return Err(BixverseErrors::GpuBindingTooLarge {
buffer: "t1",
bytes,
limit,
});
}
let mut dense = vec![0.0f32; self.k * self.k];
for row in 0..self.k {
for idx in t1.indptr[row] as usize..t1.indptr[row + 1] as usize {
dense[row * self.k + t1.indices[idx] as usize] += t1.data[idx];
}
}
self.t1 = Some(GpuTensor::<R, f32>::from_slice(
&dense,
vec![self.k * self.k],
&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> {
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 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,
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 slots = k.div_ceil(A_COLUMNS_WG as usize);
if cap > A_COLUMNS_WG as usize || slots > A_COLUMNS_MAX_SLOTS {
if parse_verbosity_level(self.verbose).detailed_verbosity() {
println!(
" A-column solve on CPU: cap {} (max {}), slots {} (max {})",
cap, A_COLUMNS_WG, slots, A_COLUMNS_MAX_SLOTS
);
}
return Ok(fw_columns_a(t1, a_prev_t, k2_b, k, n, n_iters, pruning));
}
let t1_bytes = k * k * size_of::<f32>();
let limit = self.client.properties().memory.max_page_size as usize;
if t1_bytes > limit {
return Err(BixverseErrors::GpuBindingTooLarge {
buffer: "t1 (A update)",
bytes: t1_bytes,
limit,
});
}
let mut t1_dense = vec![0.0f32; k * k];
for row in 0..k {
for idx in t1.indptr[row] as usize..t1.indptr[row + 1] as usize {
t1_dense[row * k + t1.indices[idx] as usize] += t1.data[idx];
}
}
let t1_gpu = GpuTensor::<R, f32>::from_slice(&t1_dense, vec![k * k], &self.client);
drop(t1_dense);
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,
&a_prev_gpu,
&k2b_gpu,
&atom_idx,
&atom_val,
&atom_cnt,
&threshold,
n,
k,
n_iters,
stride as u32,
pruning,
None,
&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()))?;
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::*;
use crate::gpu::sc_gpu::kernels::seacells_kernels::A_COLUMNS_BLOCKS;
use cubecl::wgpu::{WgpuDevice, WgpuRuntime};
use rand::prelude::*;
use rand::rngs::StdRng;
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]
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());
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
);
}
}
}
#[allow(clippy::too_many_arguments)]
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,
client: &ComputeClient<WgpuRuntime>,
) -> (Vec<u32>, Vec<f32>, Vec<u32>, usize) {
let cap = a_columns_capacity(a_prev_t, n_iters);
let mut dense = vec![0.0f32; k * k];
for row in 0..k {
for idx in t1.indptr[row] as usize..t1.indptr[row + 1] as usize {
dense[row * k + t1.indices[idx] as usize] += t1.data[idx];
}
}
let t1_gpu = GpuTensor::<WgpuRuntime, f32>::from_slice(&dense, vec![k * k], client);
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);
let atom_val = GpuTensor::<WgpuRuntime, f32>::empty(vec![n * cap], client);
let atom_cnt = GpuTensor::<WgpuRuntime, u32>::empty(vec![n], client);
let thr =
GpuTensor::<WgpuRuntime, f32>::from_slice(&[pruning.unwrap_or(0.0)], vec![1], client);
launch_fw_columns_a(
&t1_gpu,
&a_gpu,
&k2b_gpu,
&atom_idx,
&atom_val,
&atom_cnt,
&thr,
n,
k,
n_iters,
cap as u32,
pruning,
Some(use_plane),
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]
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, &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, &client,
);
assert_eq!(cap, tree_cap);
assert_eq!(
plane_cnt, tree_cnt,
"atom counts differ (pruning {:?})",
pruning
);
assert_eq!(
plane_idx, tree_idx,
"atom indices differ (pruning {:?})",
pruning
);
for (cell, (a, b)) in plane_val.iter().zip(tree_val.iter()).enumerate() {
assert!(
(a - b).abs() <= 1e-5 * a.abs().max(1e-3),
"weight {} differs at slot {} (pruning {:?}): {} vs {}",
cell,
cell % cap,
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());
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
);
}
}
}