use std::borrow::Cow;
use std::time::{Duration, Instant};
use faer::{Mat, MatRef};
use crate::core::math::matrix_helpers::rank_matrix_col;
use crate::mlx::ffi::{Array, MLX_FLOAT32, MetalKernel, OutputSpec, Stream};
use crate::mlx::ffi::{clear_cache, eval_all, install_error_handler};
use crate::prelude::*;
const CENTRE_SCALE_THREADS: i32 = 256;
const MAX_SIMD_GROUPS: u32 = 32;
const STD_EPS: f32 = 1e-10;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MlxCorCov {
#[default]
Covariance,
Pearson,
Spearman,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct MlxCorStages {
pub prep: Duration,
pub upload: Duration,
pub compute: Duration,
pub readback: Duration,
}
impl MlxCorStages {
pub fn total(&self) -> Duration {
self.prep + self.upload + self.compute + self.readback
}
}
const TG_SUM_HEADER: &str = r#"
inline float tg_sum(float v, threadgroup float* sh, uint sg, uint lane, uint nsg) {
v = simd_sum(v);
if (lane == 0) sh[sg] = v;
threadgroup_barrier(mem_flags::mem_threadgroup);
float r = 0.0f;
for (uint k = 0; k < nsg; k++) r += sh[k];
threadgroup_barrier(mem_flags::mem_threadgroup);
return r;
}
"#;
const CENTRE_SCALE_SOURCE: &str = r#"
uint feat = threadgroup_position_in_grid.x;
uint t = thread_position_in_threadgroup.x;
uint tpg = threads_per_threadgroup.x;
uint sg = simdgroup_index_in_threadgroup;
uint lane = thread_index_in_simdgroup;
uint nsg = simdgroups_per_threadgroup;
uint n = n_rows[0];
size_t base = size_t(feat) * n;
threadgroup float sh[MAX_SIMD_GROUPS];
float s = 0.0f;
for (uint i = t; i < n; i += tpg) s += data[base + i];
float mean = tg_sum(s, sh, sg, lane, nsg) / float(n);
float ss = 0.0f;
for (uint i = t; i < n; i += tpg) {
float c = data[base + i] - mean;
ss += c * c;
}
ss = tg_sum(ss, sh, sg, lane, nsg);
float nm1 = float(n) - 1.0f;
float inv = 1.0f / sqrt(nm1);
if (SCALE_SD) {
float sd = sqrt(ss / nm1);
if (sd >= STD_EPS) inv /= sd;
}
for (uint i = t; i < n; i += tpg) out[base + i] = (data[base + i] - mean) * inv;
"#;
fn feature_major(mat: MatRef<'_, f32>) -> Cow<'_, [f32]> {
let (n_rows, n_cols) = (mat.nrows(), mat.ncols());
if let Some(cm) = mat.try_as_col_major()
&& cm.col_stride() as usize == n_rows
{
return Cow::Borrowed(unsafe { std::slice::from_raw_parts(cm.as_ptr(), n_rows * n_cols) });
}
let mut buf = Vec::with_capacity(n_rows * n_cols);
for j in 0..n_cols {
buf.extend(mat.col(j).iter().copied());
}
Cow::Owned(buf)
}
fn centre_scale(
data: &Array,
n_rows: i32,
n_cols: i32,
scale_sd: bool,
s: &Stream,
) -> Result<Array, BixverseErrors> {
let header = format!(
"constant constexpr float STD_EPS = {STD_EPS:e}f;\n\
constant constexpr uint MAX_SIMD_GROUPS = {MAX_SIMD_GROUPS};\n\
{TG_SUM_HEADER}"
);
let kernel = MetalKernel::new(
"bixverse_centre_scale",
&["data", "n_rows"],
&["out"],
&header,
CENTRE_SCALE_SOURCE,
);
let n_arr = Array::from_u32(&[n_rows as u32], &[1]);
let mut out = kernel.apply(
&[data, &n_arr],
&[OutputSpec {
shape: &[n_cols, n_rows],
dtype: MLX_FLOAT32,
}],
[n_cols * CENTRE_SCALE_THREADS, 1, 1],
[CENTRE_SCALE_THREADS, 1, 1],
&[("SCALE_SD", scale_sd as i32)],
s,
)?;
Ok(out.remove(0))
}
pub fn column_pairwise_cor_mlx_staged(
mat: MatRef<f32>,
cor_type: MlxCorCov,
) -> Result<(Mat<f32>, MlxCorStages), BixverseErrors> {
let (n_rows, n_cols) = (mat.nrows(), mat.ncols());
if n_rows < 2 {
return Err(BixverseErrors::InvalidArgument(format!(
"MLX correlation needs at least 2 rows, got {n_rows}"
)));
}
let max_cols = (i32::MAX / CENTRE_SCALE_THREADS) as usize;
if n_rows > i32::MAX as usize || n_cols > max_cols {
return Err(BixverseErrors::InvalidArgument(format!(
"MLX correlation: {n_rows} x {n_cols} exceeds the MLX index range \
({} rows, {max_cols} columns)",
i32::MAX
)));
}
let (n_i, d_i) = (n_rows as i32, n_cols as i32);
let mut st = MlxCorStages::default();
install_error_handler();
let s = Stream::default_gpu();
let t = Instant::now();
let ranked = matches!(cor_type, MlxCorCov::Spearman).then(|| rank_matrix_col(&mat));
let mat = ranked.as_ref().map(|m| m.as_ref()).unwrap_or(mat);
let flat = feature_major(mat);
st.prep = t.elapsed();
let t = Instant::now();
let data = Array::from_f32(&flat, &[d_i, n_i]);
st.upload = t.elapsed();
let t = Instant::now();
let scale_sd = !matches!(cor_type, MlxCorCov::Covariance);
let scaled = centre_scale(&data, n_i, d_i, scale_sd, &s)?;
let sample_major = scaled.transpose(&s)?.contiguous(&s)?;
let gram = sample_major.transpose(&s)?.matmul(&sample_major, &s)?;
eval_all(&[&gram], false)?;
st.compute = t.elapsed();
let t = Instant::now();
let g = gram.as_f32()?;
let out = Mat::from_fn(n_cols, n_cols, |i, j| g[j * n_cols + i]);
st.readback = t.elapsed();
drop((gram, sample_major, scaled, data));
clear_cache()?;
Ok((out, st))
}
pub fn column_pairwise_cor_mlx(
mat: MatRef<f32>,
cor_type: MlxCorCov,
verbose: bool,
) -> Result<Mat<f32>, BixverseErrors> {
let (out, st) = column_pairwise_cor_mlx_staged(mat, cor_type)?;
if verbose {
println!(
"MLX correlation: prep {:.2?}, upload {:.2?}, compute {:.2?}, readback {:.2?}, \
total {:.2?}",
st.prep,
st.upload,
st.compute,
st.readback,
st.total()
);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::base::cors_similarity::{column_pairwise_cor, column_pairwise_cov};
const REL_TOL: f32 = 1e-3;
fn hash01(a: usize, b: usize) -> f32 {
let mut h = (a as u64)
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add((b as u64).wrapping_mul(0xC2B2_AE3D_27D4_EB4F));
h ^= h >> 29;
h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9);
h ^= h >> 32;
((h >> 40) as f32) / 16_777_216.0
}
fn make_data(n: usize, d: usize, offset: f32) -> Mat<f32> {
Mat::from_fn(n, d, |i, j| {
offset * (j as f32 + 1.0)
+ (hash01(i, 7) - 0.5) * (hash01(j, 11) - 0.5)
+ hash01(i, j + 1000)
- 0.5
})
}
fn cpu_ref(mat: &Mat<f32>, cor_type: MlxCorCov) -> Mat<f32> {
let m64 = Mat::from_fn(mat.nrows(), mat.ncols(), |i, j| mat[(i, j)] as f64);
let r = match cor_type {
MlxCorCov::Covariance => column_pairwise_cov(&m64.as_ref()),
MlxCorCov::Pearson => column_pairwise_cor(&m64.as_ref(), false),
MlxCorCov::Spearman => column_pairwise_cor(&m64.as_ref(), true),
};
Mat::from_fn(r.nrows(), r.ncols(), |i, j| r[(i, j)] as f32)
}
fn assert_matches_cpu(mat: &Mat<f32>, cor_type: MlxCorCov, label: &str) {
let got = column_pairwise_cor_mlx(mat.as_ref(), cor_type, false).unwrap();
let want = cpu_ref(mat, cor_type);
let (mut max_diff, mut want_max) = (0.0f32, 0.0f32);
for i in 0..want.nrows() {
for j in 0..want.ncols() {
want_max = want_max.max(want[(i, j)].abs());
max_diff = max_diff.max((got[(i, j)] - want[(i, j)]).abs());
}
}
eprintln!("{label}: max abs diff {max_diff:.3e} (ref max {want_max:.3e})");
assert!(
max_diff <= REL_TOL * want_max.max(1.0),
"{label}: MLX and CPU disagree by {max_diff:.3e} against a max of {want_max:.3e}"
);
}
#[test]
fn test_mlx_pearson_matches_cpu() {
assert_matches_cpu(&make_data(500, 64, 0.0), MlxCorCov::Pearson, "pearson");
}
#[test]
fn test_mlx_covariance_matches_cpu() {
assert_matches_cpu(&make_data(500, 64, 0.0), MlxCorCov::Covariance, "cov");
}
#[test]
fn test_mlx_large_mean_no_cancellation() {
let mat = make_data(2_000, 30, 1_000.0);
assert_matches_cpu(&mat, MlxCorCov::Pearson, "pearson offset");
assert_matches_cpu(&mat, MlxCorCov::Covariance, "cov offset");
}
#[test]
fn test_mlx_spearman_matches_cpu() {
let mat = Mat::from_fn(300, 40, |i, j| {
(hash01(i, j) * 8.0).floor() + (i % 5) as f32
});
assert_matches_cpu(&mat, MlxCorCov::Spearman, "spearman");
}
#[test]
fn test_mlx_ragged_shapes() {
for &(n, d) in &[(5, 3), (37, 1), (1_001, 67), (257, 300)] {
let mat = make_data(n, d, 2.0);
for c in [
MlxCorCov::Pearson,
MlxCorCov::Covariance,
MlxCorCov::Spearman,
] {
assert_matches_cpu(&mat, c, &format!("{c:?} {n}x{d}"));
}
}
}
#[test]
fn test_mlx_submatrix_input() {
let full = make_data(400, 50, 0.0);
let sub = full.as_ref().submatrix(10, 5, 300, 20).to_owned();
let got = column_pairwise_cor_mlx(
full.as_ref().submatrix(10, 5, 300, 20),
MlxCorCov::Pearson,
false,
)
.unwrap();
let want = column_pairwise_cor_mlx(sub.as_ref(), MlxCorCov::Pearson, false).unwrap();
assert_eq!(got, want);
}
#[test]
fn test_mlx_pearson_unit_diagonal() {
let got =
column_pairwise_cor_mlx(make_data(800, 50, 3.0).as_ref(), MlxCorCov::Pearson, false)
.unwrap();
for j in 0..got.nrows() {
assert!(
(got[(j, j)] - 1.0).abs() < 1e-4,
"diag[{j}] = {}",
got[(j, j)]
);
}
}
#[test]
fn test_mlx_output_symmetric() {
let mat = make_data(300, 70, 1.0);
for c in [
MlxCorCov::Pearson,
MlxCorCov::Covariance,
MlxCorCov::Spearman,
] {
let got = column_pairwise_cor_mlx(mat.as_ref(), c, false).unwrap();
for i in 0..got.nrows() {
for j in 0..i {
assert!(
(got[(i, j)] - got[(j, i)]).abs() < 1e-5,
"{c:?} not symmetric at ({i},{j}): {} vs {}",
got[(i, j)],
got[(j, i)]
);
}
}
}
}
#[test]
fn test_mlx_rejects_single_row() {
let mat = Mat::<f32>::zeros(1, 4);
assert!(column_pairwise_cor_mlx(mat.as_ref(), MlxCorCov::Pearson, false).is_err());
}
}