use faer::MatRef;
use crate::mlx::ffi::{
Array, MLX_FLOAT32, MLX_UINT32, MetalKernel, OutputSpec, Stream, eval_all,
install_error_handler,
};
use crate::prelude::*;
use crate::single_cell::mc_generation::seacells::{
FwArgminB, SEACells, SEACellsParams, SeacellsFitResult, assignments_to_metacells,
csr_column_segments,
};
const SIMD_WIDTH: usize = 32;
const B_TILE: usize = 256;
const B_TG_SIMDGROUPS: usize = 4;
const B_GROUPS: usize = 1024;
const REDUCE_TG: usize = 256;
const ARGMIN_SOURCE: &str = r#"
uint lane = thread_index_in_simdgroup;
uint sg = simdgroup_index_in_threadgroup;
uint grp = threadgroup_position_in_grid.x * uint(SG) + sg;
uint n_grp = threadgroups_per_grid.x * uint(SG);
uint tile = threadgroup_position_in_grid.y;
uint c0 = tile * uint(T);
uint width = min(uint(T), uint(K) - c0);
uint n = dims[0];
const uint J = uint(T) / SIMD_W;
const uint SEGS = uint(NT) + 1;
threadgroup float buf_all[SG * T];
threadgroup float* buf = buf_all + sg * uint(T);
float run_val[J];
uint run_idx[J];
for (uint j = 0; j < J; ++j) {
run_val[j] = FLT_MAX;
run_idx[j] = 0;
}
float gap = 0.0f;
for (uint row = grp; row < n; row += n_grp) {
for (uint j = 0; j < J; ++j) {
buf[lane + j * SIMD_W] = 0.0f;
}
simdgroup_barrier(mem_flags::mem_threadgroup);
uint lo = k2b_indptr[row];
uint hi = k2b_indptr[row + 1];
for (uint base = lo; base < hi; base += SIMD_W) {
uint e = base + lane;
uint m = 0;
float v = 0.0f;
if (e < hi) {
m = k2b_indices[e];
v = k2b_values[e];
}
uint cnt = min(SIMD_W, hi - base);
for (uint t = 0; t < cnt; ++t) {
uint mt = simd_shuffle(m, ushort(t));
float vt = simd_shuffle(v, ushort(t));
uint s1 = t1_seg[mt * SEGS + tile + 1];
for (uint p = t1_seg[mt * SEGS + tile] + lane; p < s1; p += SIMD_W) {
buf[t1_indices[p] - c0] += vt * t1_values[p];
}
simdgroup_barrier(mem_flags::mem_threadgroup);
}
}
uint q1 = t2_seg[row * SEGS + tile + 1];
for (uint p = t2_seg[row * SEGS + tile] + lane; p < q1; p += SIMD_W) {
buf[t2_indices[p] - c0] -= t2_values[p];
}
simdgroup_barrier(mem_flags::mem_threadgroup);
uint b1 = b_indptr[row + 1];
for (uint p = b_indptr[row] + lane; p < b1; p += SIMD_W) {
uint col = b_indices[p];
if (col >= c0 && col < c0 + width) {
gap += b_values[p] * buf[col - c0];
}
}
for (uint j = 0; j < J; ++j) {
uint col = lane + j * SIMD_W;
float g = buf[col];
if (col < width && g < run_val[j]) {
run_val[j] = g;
run_idx[j] = row;
}
}
simdgroup_barrier(mem_flags::mem_threadgroup);
}
size_t ob = size_t(grp) * uint(K) + c0;
for (uint j = 0; j < J; ++j) {
uint col = lane + j * SIMD_W;
if (col < width) {
part_val[ob + col] = run_val[j];
part_idx[ob + col] = run_idx[j];
}
}
gap = simd_sum(gap);
if (lane == 0) {
gap_part[tile * n_grp + grp] = gap;
}
"#;
const REDUCE_SOURCE: &str = r#"
uint c = thread_position_in_grid.x;
if (c >= uint(K)) {
return;
}
float best = FLT_MAX;
uint best_row = UINT_MAX;
for (uint g = 0; g < dims[0]; ++g) {
size_t o = size_t(g) * uint(K) + c;
float v = part_val[o];
uint i = part_idx[o];
if (v < best || (v == best && i < best_row)) {
best = v;
best_row = i;
}
}
out_val[c] = best;
out_idx[c] = best_row;
"#;
fn to_i32(x: usize) -> Result<i32, BixverseErrors> {
i32::try_from(x).map_err(|_| {
BixverseErrors::InvalidArgument(format!("MLX SEACells: size {x} exceeds the i32 range"))
})
}
fn upload_csr(csr: &CompressedSparseData2<f32>) -> Result<(Array, Array, Array), BixverseErrors> {
let nnz = to_i32(csr.indices.len())?;
Ok((
Array::from_u32(&csr.indptr, &[to_i32(csr.indptr.len())?]),
Array::from_u32(&csr.indices, &[nnz]),
Array::from_f32(&csr.data, &[nnz]),
))
}
struct TiledCsr {
indices: Array,
values: Array,
seg: Array,
}
impl TiledCsr {
fn new(
csr: &CompressedSparseData2<f32>,
n_tiles: usize,
tile: usize,
) -> Result<Self, BixverseErrors> {
let nnz = to_i32(csr.indices.len())?;
let seg = csr_column_segments(csr, n_tiles, tile);
let seg_len = to_i32(seg.len())?;
Ok(Self {
indices: Array::from_u32(&csr.indices, &[nnz]),
values: Array::from_f32(&csr.data, &[nnz]),
seg: Array::from_vec_u32(seg, &[seg_len]),
})
}
}
pub struct MlxFwArgminB {
s: Stream,
argmin: MetalKernel,
reduce: MetalKernel,
n: usize,
k: usize,
tile: usize,
n_tiles: usize,
groups: usize,
dims_n: Array,
dims_groups: Array,
t1: Option<TiledCsr>,
t2: Option<TiledCsr>,
}
impl MlxFwArgminB {
pub fn new(n: usize, k: usize) -> Result<Self, BixverseErrors> {
if n == 0 || k == 0 {
return Err(BixverseErrors::InvalidArgument(format!(
"MlxFwArgminB: empty problem, n = {n}, k = {k}"
)));
}
install_error_handler();
let header = format!("constant constexpr uint SIMD_W = {SIMD_WIDTH};\n");
let argmin = MetalKernel::new(
"bixverse_seacells_fw_argmin_b",
&[
"k2b_indptr",
"k2b_indices",
"k2b_values",
"t1_indices",
"t1_values",
"t1_seg",
"t2_indices",
"t2_values",
"t2_seg",
"b_indptr",
"b_indices",
"b_values",
"dims",
],
&["part_val", "part_idx", "gap_part"],
&header,
ARGMIN_SOURCE,
);
let reduce = MetalKernel::new(
"bixverse_seacells_reduce_argmin",
&["part_val", "part_idx", "dims"],
&["out_val", "out_idx"],
"",
REDUCE_SOURCE,
);
let tile = k.min(B_TILE).next_multiple_of(SIMD_WIDTH);
let n_tiles = k.div_ceil(tile);
let groups = B_GROUPS.min(n.next_multiple_of(B_TG_SIMDGROUPS));
to_i32(groups * k)?;
to_i32(groups * SIMD_WIDTH)?;
Ok(Self {
s: Stream::default_gpu(),
argmin,
reduce,
n,
k,
tile,
n_tiles,
groups,
dims_n: Array::from_u32(&[n as u32], &[1]),
dims_groups: Array::from_u32(&[groups as u32], &[1]),
t1: None,
t2: None,
})
}
}
impl FwArgminB for MlxFwArgminB {
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,
});
}
self.t1 = Some(TiledCsr::new(t1, self.n_tiles, self.tile)?);
self.t2 = Some(TiledCsr::new(t2, self.n_tiles, self.tile)?);
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_ptr, k2b_idx, k2b_val) = upload_csr(k2_b)?;
let (b_ptr, b_idx, b_val) = upload_csr(b)?;
let (k, groups) = (to_i32(self.k)?, to_i32(self.groups)?);
let parts = self.argmin.apply(
&[
&k2b_ptr,
&k2b_idx,
&k2b_val,
&t1.indices,
&t1.values,
&t1.seg,
&t2.indices,
&t2.values,
&t2.seg,
&b_ptr,
&b_idx,
&b_val,
&self.dims_n,
],
&[
OutputSpec {
shape: &[groups, k],
dtype: MLX_FLOAT32,
},
OutputSpec {
shape: &[groups, k],
dtype: MLX_UINT32,
},
OutputSpec {
shape: &[to_i32(self.n_tiles)?, groups],
dtype: MLX_FLOAT32,
},
],
[groups * SIMD_WIDTH as i32, to_i32(self.n_tiles)?, 1],
[to_i32(B_TG_SIMDGROUPS * SIMD_WIDTH)?, 1, 1],
&[
("K", k),
("T", to_i32(self.tile)?),
("NT", to_i32(self.n_tiles)?),
("SG", B_TG_SIMDGROUPS as i32),
],
&self.s,
)?;
let out = self.reduce.apply(
&[&parts[0], &parts[1], &self.dims_groups],
&[
OutputSpec {
shape: &[k],
dtype: MLX_FLOAT32,
},
OutputSpec {
shape: &[k],
dtype: MLX_UINT32,
},
],
[to_i32(self.k.next_multiple_of(REDUCE_TG))?, 1, 1],
[REDUCE_TG as i32, 1, 1],
&[("K", k)],
&self.s,
)?;
eval_all(&[&out[0], &out[1], &parts[2]], false)?;
let g_dot_b: f32 = parts[2].as_f32()?.iter().sum();
let g_dot_e: f32 = out[0].as_f32()?.iter().sum();
let argmins = out[1].as_u32()?.iter().map(|&i| i as usize).collect();
Ok((argmins, (g_dot_b - g_dot_e).abs()))
}
}
pub fn seacells_fit_mlx(
pca: MatRef<f32>,
knn_indices: &[Vec<usize>],
knn_distances: &[Vec<f32>],
params: &SEACellsParams,
seed: usize,
verbose: usize,
) -> Result<SeacellsFitResult, BixverseErrors> {
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,
n_landmarks,
verbose,
seed as u64,
)?,
None => model.initialise_archetypes(knn_indices, knn_distances, verbose, seed as u64)?,
}
let archetypes = model.get_archetypes()?;
let k = archetypes.len();
let mut backend = MlxFwArgminB::new(n, k)?;
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::single_cell::mc_generation::seacells::CpuFwArgminB;
use approx::assert_relative_eq;
fn to_csr(dense: &[f32], rows: usize, cols: usize) -> CompressedSparseData2<f32> {
let (mut data, mut indices, mut indptr) = (Vec::new(), Vec::new(), vec![0u32]);
for r in 0..rows {
for c in 0..cols {
let v = dense[r * cols + c];
if v != 0.0 {
data.push(v);
indices.push(c as u32);
}
}
indptr.push(indices.len() as u32);
}
CompressedSparseData2::new_csr(&data, &indices, &indptr, None, (rows, cols))
}
fn fixture(len: usize, seed: usize, zero_every: usize) -> Vec<f32> {
(0..len)
.map(|i| {
if (i + seed).is_multiple_of(zero_every) {
0.0
} else {
(((i * 37 + seed * 17) % 23) as f32 - 11.0) * 0.1
}
})
.collect()
}
fn sym_fixture(k: usize, seed: usize) -> Vec<f32> {
let raw = fixture(k * k, seed, 4);
(0..k * k)
.map(|x| {
let (i, j) = (x / k, x % k);
if i <= j {
raw[i * k + j]
} else {
raw[j * k + i]
}
})
.collect()
}
fn check_against_cpu(
k2b: &[f32],
t1: &[f32],
t2: &[f32],
b: &[f32],
n: usize,
k: usize,
) -> Vec<usize> {
let (k2b_s, t1_s, t2_s, b_s) = (
to_csr(k2b, n, k),
to_csr(t1, k, k),
to_csr(t2, n, k),
to_csr(b, n, k),
);
let mut cpu = CpuFwArgminB::default();
cpu.begin(&t1_s, &t2_s).unwrap();
let (want_idx, want_gap) = cpu.argmins(&k2b_s, &b_s).unwrap();
let mut mlx = MlxFwArgminB::new(n, k).unwrap();
mlx.begin(&t1_s, &t2_s).unwrap();
let (got_idx, got_gap) = mlx.argmins(&k2b_s, &b_s).unwrap();
let grad = |i: usize, c: usize| -> f32 {
let mut g = 0.0f32;
for m in 0..k {
g += k2b[i * k + m] * t1[m * k + c];
}
g - t2[i * k + c]
};
let mut ties = 0usize;
for c in 0..k {
if got_idx[c] != want_idx[c] {
ties += 1;
assert_relative_eq!(
grad(got_idx[c], c),
grad(want_idx[c], c),
max_relative = 1e-5,
epsilon = 1e-6
);
}
}
assert!(ties * 50 <= k, "{ties} / {k} columns disagree at n = {n}");
assert_relative_eq!(got_gap, want_gap, max_relative = 1e-3, epsilon = 1e-3);
got_idx
}
#[test]
fn test_mlx_fw_argmin_b_matches_cpu() {
for (n, k) in [(40usize, 7usize), (257, 33), (1000, 130), (3000, 1100)] {
let k2b = fixture(n * k, 1, 3);
let t1 = sym_fixture(k, 5);
let t2 = fixture(n * k, 9, 5);
let b = fixture(n * k, 13, 11);
check_against_cpu(&k2b, &t1, &t2, &b, n, k);
}
}
#[test]
fn test_mlx_fw_argmin_b_handles_empty_rows() {
let (n, k) = (64usize, 9usize);
let mut k2b = fixture(n * k, 1, 3);
for i in (0..n).step_by(3) {
k2b[i * k..(i + 1) * k].fill(0.0);
}
let t1 = sym_fixture(k, 5);
let t2 = fixture(n * k, 9, 5);
let b = fixture(n * k, 13, 11);
check_against_cpu(&k2b, &t1, &t2, &b, n, k);
}
#[test]
fn test_mlx_fw_argmin_b_tie_breaks_on_lowest_row() {
let (n, k) = (4096usize, 600usize);
let mut t1 = vec![0.0f32; k * k];
for c in 0..k {
t1[c * k + c] = 1.0;
}
let t2 = vec![0.0f32; n * k];
let b = vec![0.0f32; n * k];
let k2b: Vec<f32> = (0..n * k).map(|x| -1.0 - (x % k) as f32).collect();
let got = check_against_cpu(&k2b, &t1, &t2, &b, n, k);
assert!(got.iter().all(|&i| i == 0), "ties did not resolve to row 0");
}
}