use crate::mlx::ffi::{Array, MLX_FLOAT32, MetalKernel, OutputSpec, Stream};
use crate::prelude::*;
const SIMD_WIDTH: usize = 32;
const MAX_LANES_PER_NNZ: usize = 16;
const SPMM_THREADGROUP: i32 = 256;
const SPMM_ACCUMULATE: &str = r#"
uint lane = thread_index_in_simdgroup;
uint row = thread_position_in_grid.x / SIMD_WIDTH;
if (row >= n_rows[0]) {
return;
}
const uint G = SIMD_WIDTH / uint(L);
uint sub = lane / uint(L);
uint sl = lane % uint(L);
uint lo = indptr[row];
uint hi = indptr[row + 1];
float4 acc[C4];
for (uint c = 0; c < uint(C4); ++c) {
acc[c] = float4(0.0f);
}
for (uint base = lo; base < hi; base += SIMD_WIDTH) {
uint e = base + lane;
uint j = 0;
float v = 0.0f;
if (e < hi) {
j = indices[e];
v = values[e];
}
// every lane stays in the loop so the shuffles never read an
// inactive lane; past the end of the row `v` is zero
uint cnt = min(SIMD_WIDTH, hi - base);
for (uint t0 = 0; t0 < cnt; t0 += G) {
uint t = t0 + sub;
uint jt = simd_shuffle(j, ushort(t));
float vt = simd_shuffle(v, ushort(t));
const device float* xr = x + size_t(jt) * uint(S);
for (uint c = 0; c < uint(C4); ++c) {
uint col = (sl + c * uint(L)) * 4;
if (col + 4 <= uint(S)) {
acc[c] += vt * float4(*(const device packed_float4*)(xr + col));
} else {
for (uint q = 0; col + q < uint(S); ++q) {
acc[c][q] += vt * xr[col + q];
}
}
}
}
}
for (uint c = 0; c < uint(C4); ++c) {
for (uint off = uint(L); off < SIMD_WIDTH; off *= 2) {
acc[c] += simd_shuffle_xor(acc[c], ushort(off));
}
}
if (sub != 0) {
return;
}
size_t yb = size_t(row) * uint(S);
"#;
const SPMM_AFFINE_STORE: &str = r#"
float rw = row_w[row];
float rd = row_div[row];
for (uint c = 0; c < uint(C4); ++c) {
uint col = (sl + c * uint(L)) * 4;
for (uint q = 0; q < 4 && col + q < uint(S); ++q) {
y[yb + col + q] = (acc[c][q] - col_a[col + q] - rw * col_b[col + q]) / rd;
}
}
"#;
const SPMM_PLAIN_STORE: &str = r#"
for (uint c = 0; c < uint(C4); ++c) {
uint col = (sl + c * uint(L)) * 4;
if (col + 4 <= uint(S)) {
*(device packed_float4*)(y + yb + col) = packed_float4(acc[c]);
} else {
for (uint q = 0; col + q < uint(S); ++q) {
y[yb + col + q] = acc[c][q];
}
}
}
"#;
pub(crate) struct MlxCsr {
indptr: Array,
indices: Array,
values: Array,
n_rows: usize,
}
impl MlxCsr {
pub(crate) fn new(
indptr: &[u32],
indices: &[u32],
values: &[f32],
n_rows: usize,
) -> Result<Self, BixverseErrors> {
check_csr(indptr, indices, values, n_rows)?;
let nnz = indices.len() as i32;
Ok(Self {
indptr: Array::from_u32(indptr, &[indptr.len() as i32]),
indices: Array::from_u32(indices, &[nnz]),
values: Array::from_f32(values, &[nnz]),
n_rows,
})
}
pub(crate) fn from_vecs(
indptr: Vec<u32>,
indices: Vec<u32>,
values: Vec<f32>,
n_rows: usize,
) -> Result<Self, BixverseErrors> {
check_csr(&indptr, &indices, &values, n_rows)?;
let (np, nnz) = (indptr.len() as i32, indices.len() as i32);
Ok(Self {
indptr: Array::from_vec_u32(indptr, &[np]),
indices: Array::from_vec_u32(indices, &[nnz]),
values: Array::from_vec_f32(values, &[nnz]),
n_rows,
})
}
}
fn check_csr(
indptr: &[u32],
indices: &[u32],
values: &[f32],
n_rows: usize,
) -> Result<(), BixverseErrors> {
let nnz = indices.len();
if indptr.len() != n_rows + 1
|| values.len() != nnz
|| indptr.last().map(|&x| x as usize) != Some(nnz)
{
return Err(BixverseErrors::InvalidArgument(format!(
"MLX CSR: indptr {} / indices {} / values {} inconsistent with {} rows",
indptr.len(),
nnz,
values.len(),
n_rows
)));
}
if n_rows > i32::MAX as usize / SIMD_WIDTH || nnz > i32::MAX as usize {
return Err(BixverseErrors::InvalidArgument(format!(
"MLX CSR: {n_rows} rows / {nnz} non-zeros exceed the MLX index range"
)));
}
Ok(())
}
fn simd_width_header() -> String {
format!("constant constexpr uint SIMD_WIDTH = {SIMD_WIDTH};\n")
}
fn spmm_template(s_width: usize) -> [(&'static str, i32); 3] {
let blocks = s_width.div_ceil(4).max(1);
let lanes = blocks.next_power_of_two().min(MAX_LANES_PER_NNZ);
[
("S", s_width as i32),
("L", lanes as i32),
("C4", blocks.div_ceil(lanes) as i32),
]
}
pub(crate) fn spmm_kernel() -> MetalKernel {
MetalKernel::new(
"bixverse_spmm_csr_affine",
&[
"indptr", "indices", "values", "x", "col_a", "row_w", "col_b", "row_div", "n_rows",
],
&["y"],
&simd_width_header(),
&format!("{SPMM_ACCUMULATE}{SPMM_AFFINE_STORE}"),
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn spmm_affine(
kernel: &MetalKernel,
csr: &MlxCsr,
x: &Array,
col_a: &Array,
row_w: &Array,
col_b: &Array,
row_div: &Array,
s_width: usize,
s: &Stream,
) -> Result<Array, BixverseErrors> {
let rows = csr.n_rows as i32;
let n_arr = Array::from_u32(&[csr.n_rows as u32], &[1]);
let mut out = kernel.apply(
&[
&csr.indptr,
&csr.indices,
&csr.values,
x,
col_a,
row_w,
col_b,
row_div,
&n_arr,
],
&[OutputSpec {
shape: &[rows, s_width as i32],
dtype: MLX_FLOAT32,
}],
[rows * SIMD_WIDTH as i32, 1, 1],
[SPMM_THREADGROUP, 1, 1],
&spmm_template(s_width),
s,
)?;
Ok(out.remove(0))
}
pub(crate) fn spmm_plain_kernel() -> MetalKernel {
MetalKernel::new(
"bixverse_spmm_csr_plain",
&["indptr", "indices", "values", "x", "n_rows"],
&["y"],
&simd_width_header(),
&format!("{SPMM_ACCUMULATE}{SPMM_PLAIN_STORE}"),
)
}
pub(crate) fn spmm_plain(
kernel: &MetalKernel,
csr: &MlxCsr,
x: &Array,
s_width: usize,
s: &Stream,
) -> Result<Array, BixverseErrors> {
let rows = csr.n_rows as i32;
let n_arr = Array::from_u32(&[csr.n_rows as u32], &[1]);
let mut out = kernel.apply(
&[&csr.indptr, &csr.indices, &csr.values, x, &n_arr],
&[OutputSpec {
shape: &[rows, s_width as i32],
dtype: MLX_FLOAT32,
}],
[rows * SIMD_WIDTH as i32, 1, 1],
[SPMM_THREADGROUP, 1, 1],
&spmm_template(s_width),
s,
)?;
Ok(out.remove(0))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mlx::ffi::{eval_all, install_error_handler};
fn dense_to_csr(a: &[f32], n: usize, m: usize) -> (Vec<f32>, Vec<u32>, Vec<u32>) {
let mut values = Vec::new();
let mut indices = Vec::new();
let mut indptr = vec![0u32];
for i in 0..n {
for j in 0..m {
let v = a[i * m + j];
if v != 0.0 {
values.push(v);
indices.push(j as u32);
}
}
indptr.push(values.len() as u32);
}
(values, indices, indptr)
}
fn dense_to_csc(a: &[f32], n: usize, m: usize) -> (Vec<f32>, Vec<u32>, Vec<u32>) {
let mut values = Vec::new();
let mut indices = Vec::new();
let mut indptr = vec![0u32];
for j in 0..m {
for i in 0..n {
let v = a[i * m + j];
if v != 0.0 {
values.push(v);
indices.push(i as u32);
}
}
indptr.push(values.len() as u32);
}
(values, indices, indptr)
}
#[allow(clippy::too_many_arguments)]
fn cpu_forward(
a: &[f32],
x: &[f32],
correction: &[f32],
row_offsets: &[f32],
x_sum: &[f32],
n: usize,
m: usize,
s: usize,
) -> Vec<f32> {
let mut y = vec![0.0f32; n * s];
for i in 0..n {
for col in 0..s {
let mut acc = 0.0f32;
for j in 0..m {
acc += a[i * m + j] * x[j * s + col];
}
y[i * s + col] = acc - correction[col] - row_offsets[i] * x_sum[col];
}
}
y
}
#[allow(clippy::too_many_arguments)]
fn cpu_transpose(
a: &[f32],
q: &[f32],
q_sum: &[f32],
mu: &[f32],
sigma: &[f32],
m_dot_q: &[f32],
n: usize,
m: usize,
s: usize,
) -> Vec<f32> {
let mut z = vec![0.0f32; m * s];
for j in 0..m {
for col in 0..s {
let mut acc = 0.0f32;
for i in 0..n {
acc += a[i * m + j] * q[i * s + col];
}
z[j * s + col] = (acc - mu[j] * q_sum[col] - m_dot_q[col]) / sigma[j];
}
}
z
}
#[allow(clippy::too_many_arguments)]
fn run(
csr: (Vec<f32>, Vec<u32>, Vec<u32>),
shape: (usize, usize),
x: &[f32],
col_a: &[f32],
row_w: &[f32],
col_b: &[f32],
row_div: &[f32],
s_width: usize,
) -> Vec<f32> {
install_error_handler();
let st = Stream::default_gpu();
let (values, indices, indptr) = csr;
let dev = MlxCsr::new(&indptr, &indices, &values, shape.0).unwrap();
let (r, k) = (shape.0 as i32, shape.1 as i32);
let s_i = s_width as i32;
let y = spmm_affine(
&spmm_kernel(),
&dev,
&Array::from_f32(x, &[k, s_i]),
&Array::from_f32(col_a, &[s_i]),
&Array::from_f32(row_w, &[r]),
&Array::from_f32(col_b, &[s_i]),
&Array::from_f32(row_div, &[r]),
s_width,
&st,
)
.unwrap();
eval_all(&[&y], false).unwrap();
y.as_f32().unwrap().to_vec()
}
fn assert_vec_close(got: &[f32], want: &[f32], tol: f32, label: &str) {
assert_eq!(got.len(), want.len());
let diff = got
.iter()
.zip(want)
.fold(0.0f32, |d, (g, w)| d.max((g - w).abs()));
eprintln!("{label}: max abs diff {diff:.3e}");
assert!(diff < tol, "{label}: max abs diff {diff:.3e} >= {tol:.1e}");
}
fn sparse_dense(n: usize, m: usize) -> Vec<f32> {
(0..n * m)
.map(|i| {
if i % 5 == 0 {
((i * 7 + 3) % 23) as f32 * 0.2
} else {
0.0
}
})
.collect()
}
#[test]
fn test_mlx_spmm_forward() {
let (n, m) = (100, 60);
let a = sparse_dense(n, m);
for s in [1, 4, 5, 8, 9, 16, 17, 30, 32, 33, 64, 65, 128, 129, 130] {
let x: Vec<f32> = (0..m * s)
.map(|i| ((i * 11 + 5) % 19) as f32 * 0.1)
.collect();
let correction: Vec<f32> = (0..s).map(|i| i as f32 * 0.05).collect();
let row_offsets: Vec<f32> = (0..n).map(|i| (i % 7) as f32 * 0.1).collect();
let x_sum: Vec<f32> = (0..s).map(|i| (i % 3) as f32 * 0.2).collect();
let got = run(
dense_to_csr(&a, n, m),
(n, m),
&x,
&correction,
&row_offsets,
&x_sum,
&vec![1.0; n],
s,
);
let want = cpu_forward(&a, &x, &correction, &row_offsets, &x_sum, n, m, s);
assert_vec_close(&got, &want, 1e-3, &format!("forward s={s}"));
}
}
#[test]
fn test_mlx_spmm_transpose() {
let (n, m, s) = (120, 50, 16);
let a = sparse_dense(n, m);
let q: Vec<f32> = (0..n * s)
.map(|i| ((i * 11 + 5) % 19) as f32 * 0.1)
.collect();
let q_sum: Vec<f32> = (0..s).map(|c| (0..n).map(|i| q[i * s + c]).sum()).collect();
let mu: Vec<f32> = (0..m).map(|j| ((j * 13 + 1) % 11) as f32 * 0.1).collect();
let sigma: Vec<f32> = (0..m)
.map(|j| 0.5 + ((j * 5 + 2) % 7) as f32 * 0.1)
.collect();
let m_dot_q: Vec<f32> = (0..s).map(|c| c as f32 * 0.05).collect();
let got = run(
dense_to_csc(&a, n, m),
(m, n),
&q,
&m_dot_q,
&mu,
&q_sum,
&sigma,
s,
);
let want = cpu_transpose(&a, &q, &q_sum, &mu, &sigma, &m_dot_q, n, m, s);
assert_vec_close(&got, &want, 1e-3, "transpose");
}
#[test]
fn test_mlx_spmm_long_rows() {
let (n, m, s) = (70, 100, 24);
let a: Vec<f32> = (0..n * m)
.map(|i| ((i * 13 + 7) % 29) as f32 * 0.05 - 0.6)
.collect();
let x: Vec<f32> = (0..m * s)
.map(|i| ((i * 11 + 5) % 19) as f32 * 0.1 - 0.9)
.collect();
let zeros_s = vec![0.0f32; s];
let got = run(
dense_to_csr(&a, n, m),
(n, m),
&x,
&zeros_s,
&vec![0.0; n],
&zeros_s,
&vec![1.0; n],
s,
);
let want = cpu_forward(&a, &x, &zeros_s, &vec![0.0; n], &zeros_s, n, m, s);
assert_vec_close(&got, &want, 1e-3, "long rows forward");
let q: Vec<f32> = (0..n * s)
.map(|i| ((i * 7 + 2) % 17) as f32 * 0.1 - 0.8)
.collect();
let got = run(
dense_to_csc(&a, n, m),
(m, n),
&q,
&zeros_s,
&vec![0.0; m],
&zeros_s,
&vec![1.0; m],
s,
);
let ones = vec![1.0f32; m];
let want = cpu_transpose(&a, &q, &zeros_s, &vec![0.0; m], &ones, &zeros_s, n, m, s);
assert_vec_close(&got, &want, 1e-3, "long rows transpose");
}
#[test]
fn test_mlx_spmm_forward_empty_row() {
let (n, m, s) = (8, 4, 4);
let mut a = vec![0.0f32; n * m];
a[1] = 1.5;
a[7 * m + 3] = -2.0;
let x: Vec<f32> = (0..m * s).map(|i| (i + 1) as f32 * 0.1).collect();
let correction: Vec<f32> = (0..s).map(|i| (i + 1) as f32 * 0.25).collect();
let zeros_s = vec![0.0f32; s];
let zeros_n = vec![0.0f32; n];
let got = run(
dense_to_csr(&a, n, m),
(n, m),
&x,
&correction,
&zeros_n,
&zeros_s,
&vec![1.0; n],
s,
);
let want = cpu_forward(&a, &x, &correction, &zeros_n, &zeros_s, n, m, s);
assert_vec_close(&got, &want, 1e-6, "empty rows");
for i in 1..7 {
for col in 0..s {
assert!(
(got[i * s + col] + correction[col]).abs() < 1e-6,
"empty row {i} col {col} not equal to -correction"
);
}
}
}
#[test]
fn test_mlx_csr_shape_mismatch_rejected() {
let res = MlxCsr::new(&[0, 1, 2], &[0, 1], &[1.0, 2.0], 3);
assert!(matches!(res, Err(BixverseErrors::InvalidArgument(_))));
let res = MlxCsr::new(&[0, 1, 3], &[0, 1], &[1.0, 2.0], 2);
assert!(matches!(res, Err(BixverseErrors::InvalidArgument(_))));
}
}