use std::time::{Duration, Instant};
use faer::linalg::triangular_solve::solve_upper_triangular_in_place;
use faer::{Mat, Side};
use rand::SeedableRng;
use rand::rngs::StdRng;
use rand_distr::{Distribution, Normal};
use crate::core::math::pca_svd::SvdResults;
use crate::core::math::{DEFAULT_N_POWER_ITERS_RAND_SVD, DEFAULT_OVERSAMPLING_RAND_SVD};
use crate::mlx::ffi::{Array, MetalKernel, Stream, clear_cache, eval_all, install_error_handler};
use crate::mlx::linalg::spmm::{MlxCsr, spmm_affine, spmm_kernel};
use crate::prelude::*;
use crate::utils::faer_parallelism;
#[derive(Clone, Copy, Debug)]
pub struct RandSvdMlxParams {
pub n_power_iters: usize,
pub oversampling: usize,
}
impl RandSvdMlxParams {
pub fn new(n_power_iters: usize, oversampling: usize) -> Self {
Self {
n_power_iters,
oversampling,
}
}
}
impl Default for RandSvdMlxParams {
fn default() -> Self {
Self {
n_power_iters: DEFAULT_N_POWER_ITERS_RAND_SVD,
oversampling: DEFAULT_OVERSAMPLING_RAND_SVD,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct MlxRandSvdStages {
pub prep: Duration,
pub upload: Duration,
pub spmm_forward: Duration,
pub spmm_transpose: Duration,
pub qr: Duration,
pub finalise: Duration,
}
impl MlxRandSvdStages {
pub fn total(&self) -> Duration {
self.prep + self.upload + self.spmm_forward + self.spmm_transpose + self.qr + self.finalise
}
}
fn r_inverse_from_gram(g: &[f32], s: usize) -> Result<Vec<f32>, BixverseErrors> {
let g = Mat::<f64>::from_fn(s, s, |i, j| g[i * s + j] as f64);
let chol = g.llt(Side::Lower)?;
let r = chol.L().transpose().to_owned();
let mut r_inv = Mat::<f64>::identity(s, s);
solve_upper_triangular_in_place(r.as_ref(), r_inv.as_mut(), faer_parallelism());
Ok((0..s * s).map(|k| r_inv[(k / s, k % s)] as f32).collect())
}
fn cholesky_qr_pass(y: &Array, s_width: usize, st: &Stream) -> Result<Array, BixverseErrors> {
let g = y.transpose(st)?.matmul(y, st)?;
eval_all(&[&g], false)?;
let r_inv = r_inverse_from_gram(g.as_f32()?, s_width)?;
let r_inv = Array::from_f32(&r_inv, &[s_width as i32, s_width as i32]);
y.matmul(&r_inv, st)
}
fn cholesky_qr2(y: &Array, s_width: usize, st: &Stream) -> Result<Array, BixverseErrors> {
let q1 = cholesky_qr_pass(y, s_width, st)?;
cholesky_qr_pass(&q1, s_width, st)
}
fn lap(
arrays: &[&Array],
staged: bool,
clock: &mut Instant,
slot: &mut Duration,
) -> Result<(), BixverseErrors> {
if staged {
eval_all(arrays, false)?;
}
*slot += clock.elapsed();
*clock = Instant::now();
Ok(())
}
struct Operator {
kernel: MetalKernel,
csr: MlxCsr,
csc: MlxCsr,
mu: Array,
offsets: Array,
ones_n: Array,
zeros_s: Array,
use_clr: bool,
s_width: usize,
}
impl Operator {
fn forward(&self, x: &Array, st: &Stream) -> Result<Array, BixverseErrors> {
let c = self.mu.transpose(st)?.matmul(x, st)?;
let x_sum = if self.use_clr {
Some(x.sum_axis(0, st)?)
} else {
None
};
spmm_affine(
&self.kernel,
&self.csr,
x,
&c,
&self.offsets,
x_sum.as_ref().unwrap_or(&self.zeros_s),
&self.ones_n,
self.s_width,
st,
)
}
fn transpose(&self, q: &Array, div: &Array, st: &Stream) -> Result<Array, BixverseErrors> {
let q_sum = q.sum_axis(0, st)?;
let m_dot_q = if self.use_clr {
Some(self.offsets.transpose(st)?.matmul(q, st)?)
} else {
None
};
spmm_affine(
&self.kernel,
&self.csc,
q,
m_dot_q.as_ref().unwrap_or(&self.zeros_s),
&self.mu,
&q_sum,
div,
self.s_width,
st,
)
}
}
#[allow(clippy::too_many_arguments)]
fn rand_svd_impl<T: BixverseFloat>(
data: CompressedSparseData2<T, T>,
col_means: &[T],
col_stds: &[T],
row_offsets: Option<&[T]>,
n_components: usize,
params: Option<RandSvdMlxParams>,
seed: u64,
staged: bool,
) -> Result<(SvdResults<T>, MlxRandSvdStages), BixverseErrors> {
let params = params.unwrap_or_default();
if !data.cs_type.is_csc() {
return Err(BixverseErrors::SparseLayoutMismatch {
expected: CompressedSparseFormat::Csc,
got: data.cs_type,
});
}
let (n, m) = data.shape;
if col_means.len() != m || col_stds.len() != m {
return Err(BixverseErrors::DimensionMisMatchSparse {
indices_len: col_means.len(),
data_len: m,
});
}
if let Some(off) = row_offsets
&& off.len() != n
{
return Err(BixverseErrors::DimensionMisMatchSparse {
indices_len: off.len(),
data_len: n,
});
}
let s_width = (n_components + params.oversampling).min(n).min(m);
if n_components == 0 || n_components > s_width {
return Err(BixverseErrors::InvalidArgument(format!(
"MLX randomised SVD: n_components = {n_components} must be in 1..={}",
n.min(m)
)));
}
let use_clr = row_offsets.is_some();
let to_f32 = |x: &T| x.to_f32().expect("finite float casts to f32");
let mut st_times = MlxRandSvdStages::default();
let mut clock = Instant::now();
install_error_handler();
let st = Stream::default_gpu();
let values: Vec<f32> = data
.data_2
.as_ref()
.ok_or(BixverseErrors::Data2NotAvailable)?
.iter()
.map(to_f32)
.collect();
let csc = CompressedSparseData2::<f32, f32>::from_parts(
Vec::new(),
data.indices,
data.indptr,
Some(values),
CompressedSparseFormat::Csc,
(n, m),
);
let csr = csc.transform_single_layer(true)?;
let mu: Vec<f32> = col_means.iter().map(to_f32).collect();
let sigma: Vec<f32> = col_stds.iter().map(to_f32).collect();
let sigma_sq: Vec<f32> = sigma.iter().map(|x| x * x).collect();
let offsets: Vec<f32> = match row_offsets {
Some(off) => off.iter().map(to_f32).collect(),
None => vec![0.0; n],
};
let mut rng = StdRng::seed_from_u64(seed);
let normal = Normal::new(0.0, 1.0).expect("unit normal is always valid");
let omega: Vec<f32> = (0..m * s_width)
.map(|i| (normal.sample(&mut rng) / sigma[i / s_width] as f64) as f32)
.collect();
lap(&[], false, &mut clock, &mut st_times.prep)?;
let (n_i, m_i, s_i) = (n as i32, m as i32, s_width as i32);
let op = Operator {
kernel: spmm_kernel(),
csr: MlxCsr::from_vecs(csr.indptr, csr.indices, csr.data_2.unwrap_or_default(), n)?,
csc: MlxCsr::from_vecs(csc.indptr, csc.indices, csc.data_2.unwrap_or_default(), m)?,
mu: Array::from_f32(&mu, &[m_i, 1]),
offsets: Array::from_f32(&offsets, &[n_i, 1]),
ones_n: Array::from_f32(&vec![1.0; n], &[n_i]),
zeros_s: Array::from_f32(&vec![0.0; s_width], &[s_i]),
use_clr,
s_width,
};
let sigma_dev = Array::from_f32(&sigma, &[m_i]);
let sigma_sq_dev = Array::from_f32(&sigma_sq, &[m_i]);
let omega_dev = Array::from_f32(&omega, &[m_i, s_i]);
lap(&[], false, &mut clock, &mut st_times.upload)?;
let y = op.forward(&omega_dev, &st)?;
lap(&[&y], staged, &mut clock, &mut st_times.spmm_forward)?;
let mut q = cholesky_qr2(&y, s_width, &st)?;
lap(&[&q], staged, &mut clock, &mut st_times.qr)?;
for _ in 0..params.n_power_iters {
let z = op.transpose(&q, &sigma_sq_dev, &st)?;
lap(&[&z], staged, &mut clock, &mut st_times.spmm_transpose)?;
let y = op.forward(&z, &st)?;
lap(&[&y], staged, &mut clock, &mut st_times.spmm_forward)?;
q = cholesky_qr2(&y, s_width, &st)?;
lap(&[&q], staged, &mut clock, &mut st_times.qr)?;
}
let z = op.transpose(&q, &sigma_dev, &st)?;
lap(&[&z], staged, &mut clock, &mut st_times.spmm_transpose)?;
eval_all(&[&z], false)?;
let z_host = z.as_f32()?;
let svd = Mat::<f64>::from_fn(m, s_width, |i, j| z_host[i * s_width + j] as f64)
.thin_svd()
.map_err(|e| BixverseErrors::FaerSvdError(format!("{e:?}")))?;
let v_z = svd.V();
let v_z_trunc: Vec<f32> = (0..s_width * n_components)
.map(|k| v_z[(k / n_components, k % n_components)] as f32)
.collect();
let v_z_dev = Array::from_f32(&v_z_trunc, &[s_i, n_components as i32]);
let u = q.matmul(&v_z_dev, &st)?;
eval_all(&[&u], false)?;
let u_host = u.as_f32()?;
let from = |x: f64| T::from_f64(x).expect("f64 casts to the crate float");
let res = SvdResults {
u: Mat::from_fn(n, n_components, |i, j| {
from(u_host[i * n_components + j] as f64)
}),
s: (0..n_components).map(|i| from(svd.S()[i])).collect(),
v: Mat::from_fn(m, n_components, |i, j| from(svd.U()[(i, j)])),
};
drop((u, v_z_dev, z, q, y, omega_dev, sigma_dev, sigma_sq_dev, op));
clear_cache()?;
lap(&[], false, &mut clock, &mut st_times.finalise)?;
Ok((res, st_times))
}
#[allow(clippy::too_many_arguments)]
pub fn randomised_sparse_svd_mlx<T: BixverseFloat>(
data: CompressedSparseData2<T, T>,
col_means: &[T],
col_stds: &[T],
row_offsets: Option<&[T]>,
n_components: usize,
params: Option<RandSvdMlxParams>,
seed: u64,
verbose: usize,
) -> Result<SvdResults<T>, BixverseErrors> {
let start = Instant::now();
let (res, _) = rand_svd_impl(
data,
col_means,
col_stds,
row_offsets,
n_components,
params,
seed,
false,
)?;
if parse_verbosity_level(verbose).normal_verbosity() {
println!(
" Finished randomised sparse SVD (MLX) in {:.2?}",
start.elapsed()
);
}
Ok(res)
}
#[allow(clippy::too_many_arguments)]
pub fn randomised_sparse_svd_mlx_staged<T: BixverseFloat>(
data: CompressedSparseData2<T, T>,
col_means: &[T],
col_stds: &[T],
row_offsets: Option<&[T]>,
n_components: usize,
params: Option<RandSvdMlxParams>,
seed: u64,
) -> Result<(SvdResults<T>, MlxRandSvdStages), BixverseErrors> {
rand_svd_impl(
data,
col_means,
col_stds,
row_offsets,
n_components,
params,
seed,
true,
)
}
#[cfg(test)]
mod tests {
use super::*;
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)
}
fn build_dense(n: usize, m: usize) -> Vec<f32> {
(0..n * m)
.map(|k| {
let (i, j) = (k / m, k % m);
if (i + 2 * j).is_multiple_of(5) {
((i * 7 + j * 13 + 3) % 19) as f32 * 0.3 + 0.5
} else {
0.0
}
})
.collect()
}
fn csc_of(a: &[f32], n: usize, m: usize) -> CompressedSparseData2<f32, f32> {
let (values, indices, indptr) = dense_to_csc(a, n, m);
CompressedSparseData2::new_csc(&values, &indices, &indptr, Some(&values), (n, m))
}
fn scaled_dense(n: usize, m: usize) -> Vec<f32> {
let mut dense = vec![0.0f32; n * m];
for i in 0..n {
for j in 0..m {
if (i * 3 + j) % 4 == 0 {
continue;
}
let mut v = 0.0f32;
for f in 0..6 {
let a = ((i * (f + 3) + 7 * f) % 17) as f32 / 17.0 - 0.5;
let b = ((j * (2 * f + 5) + 3 * f) % 13) as f32 / 13.0 - 0.5;
v += 4.0 * a * b / (1.0 + f as f32);
}
v += 0.05 * ((i * 31 + j * 17) % 11) as f32 / 11.0 + 0.3;
let scale = 0.2 + 4.8 * j as f32 / (m - 1) as f32;
dense[i * m + j] = v * scale;
}
}
dense
}
fn col_stats(dense: &[f32], n: usize, m: usize) -> (Vec<f32>, Vec<f32>) {
let mut mu = vec![0.0f32; m];
let mut sigma = vec![0.0f32; m];
for j in 0..m {
let mean = (0..n).map(|i| dense[i * m + j] as f64).sum::<f64>() / n as f64;
let var = (0..n)
.map(|i| (dense[i * m + j] as f64 - mean).powi(2))
.sum::<f64>()
/ (n - 1) as f64;
mu[j] = mean as f32;
sigma[j] = var.sqrt() as f32;
}
(mu, sigma)
}
fn assert_svd_parity(
got: &SvdResults<f32>,
want: &faer::linalg::solvers::Svd<f64>,
k: usize,
tol: f64,
cos_tol: f64,
label: &str,
) {
let n = got.u.nrows();
let mut worst = 0.0f64;
for i in 0..k {
let rel = (got.s[i] as f64 - want.S()[i]).abs() / want.S()[i];
worst = worst.max(rel);
}
let u_got = Mat::<f64>::from_fn(n, k, |i, j| got.u[(i, j)] as f64);
let cross = want.U().subcols(0, k).transpose() * &u_got;
let cos_min = cross
.thin_svd()
.unwrap()
.S()
.column_vector()
.iter()
.fold(f64::MAX, |a, &b| a.min(b));
eprintln!("{label}: worst rel s err {worst:.2e}, min principal cosine {cos_min:.7}");
assert!(worst < tol, "{label}: singular value rel err {worst:.2e}");
assert!(
cos_min > cos_tol,
"{label}: score subspace drifted, {cos_min:.5}"
);
}
fn gram_host(q: &[f32], n: usize, s: usize) -> Vec<f32> {
let mut out = vec![0.0f32; s * s];
for i in 0..s {
for j in 0..s {
out[i * s + j] = (0..n).map(|k| q[k * s + i] * q[k * s + j]).sum();
}
}
out
}
fn max_from_identity(mat: &[f32], s: usize) -> f32 {
(0..s * s).fold(0.0f32, |d, k| {
let want = if k / s == k % s { 1.0 } else { 0.0 };
d.max((mat[k] - want).abs())
})
}
#[test]
fn test_mlx_r_inverse_from_gram_round_trip() {
let s = 4;
let g: Vec<f32> = vec![
4.0, 1.0, 0.0, 0.0, 1.0, 3.0, 1.0, 0.0, 0.0, 1.0, 2.5, 0.5, 0.0, 0.0, 0.5, 1.5,
];
let r_inv = r_inverse_from_gram(&g, s).unwrap();
for i in 0..s {
for j in 0..i {
assert!(r_inv[i * s + j].abs() < 1e-6, "not upper at ({i}, {j})");
}
}
let mut prod = vec![0.0f32; s * s];
for i in 0..s {
for j in 0..s {
for l in 0..s {
let g_inv: f32 = (0..s).map(|t| r_inv[l * s + t] * r_inv[j * s + t]).sum();
prod[i * s + j] += g[i * s + l] * g_inv;
}
}
}
assert!(max_from_identity(&prod, s) < 1e-4);
}
#[test]
fn test_mlx_cholesky_qr2_orthogonality() {
install_error_handler();
let st = Stream::default_gpu();
let (n, s) = (200, 8);
let y_host: Vec<f32> = (0..n * s)
.map(|i| {
let x = ((i / s) as f32 + 0.5) / n as f32;
(2.0 * std::f32::consts::PI * ((i % s) + 1) as f32 * x).sin()
})
.collect();
let y = Array::from_f32(&y_host, &[n as i32, s as i32]);
let q = cholesky_qr2(&y, s, &st).unwrap();
eval_all(&[&q], false).unwrap();
let diff = max_from_identity(&gram_host(q.as_f32().unwrap(), n, s), s);
eprintln!("cholesky_qr2: max |Q^T Q - I| {diff:.3e}");
assert!(diff < 1e-4);
}
#[test]
fn test_mlx_randomised_sparse_svd_singular_values() {
let (n, m, k) = (60usize, 20usize, 3usize);
let a = build_dense(n, m);
let svd = randomised_sparse_svd_mlx(
csc_of(&a, n, m),
&vec![0.0f32; m],
&vec![1.0f32; m],
None,
k,
Some(RandSvdMlxParams::new(2, 8)),
42,
0,
)
.unwrap();
let want = Mat::<f32>::from_fn(n, m, |i, j| a[i * m + j])
.thin_svd()
.unwrap();
let mut worst = 0.0f32;
for i in 0..k {
worst = worst.max((svd.s[i] - want.S()[i]).abs() / want.S()[i].abs());
assert!(svd.s[i] >= 0.0);
if i > 0 {
assert!(svd.s[i - 1] >= svd.s[i], "not sorted at {i}");
}
}
eprintln!("singular values: worst rel err {worst:.2e}");
assert!(worst < 1e-3);
}
#[test]
fn test_mlx_randomised_sparse_svd_scaled_parity() {
let (n, m, k) = (400usize, 40usize, 5usize);
let dense = scaled_dense(n, m);
let (mu, sigma) = col_stats(&dense, n, m);
let got = randomised_sparse_svd_mlx(
csc_of(&dense, n, m),
&mu,
&sigma,
None,
k,
Some(RandSvdMlxParams::new(2, 10)),
42,
0,
)
.unwrap();
let want = Mat::<f64>::from_fn(n, m, |i, j| {
(dense[i * m + j] as f64 - mu[j] as f64) / sigma[j] as f64
})
.thin_svd()
.unwrap();
assert_svd_parity(&got, &want, k, 1e-4, 0.9999, "scaled parity");
}
#[test]
fn test_mlx_randomised_sparse_svd_row_offsets_parity() {
let (n, m, k) = (400usize, 40usize, 5usize);
let dense = scaled_dense(n, m);
let (mu, sigma) = col_stats(&dense, n, m);
let off: Vec<f32> = (0..n).map(|i| ((i * 7) % 13) as f32 * 0.03).collect();
let got = randomised_sparse_svd_mlx(
csc_of(&dense, n, m),
&mu,
&sigma,
Some(&off),
k,
Some(RandSvdMlxParams::new(2, 10)),
42,
0,
)
.unwrap();
let want = Mat::<f64>::from_fn(n, m, |i, j| {
(dense[i * m + j] as f64 - off[i] as f64 - mu[j] as f64) / sigma[j] as f64
})
.thin_svd()
.unwrap();
assert_svd_parity(&got, &want, k, 1e-4, 0.9999, "row offsets parity");
}
#[test]
fn test_mlx_randomised_sparse_svd_staged_matches() {
let (n, m, k) = (400usize, 40usize, 5usize);
let dense = scaled_dense(n, m);
let (mu, sigma) = col_stats(&dense, n, m);
let params = Some(RandSvdMlxParams::new(2, 10));
let plain =
randomised_sparse_svd_mlx(csc_of(&dense, n, m), &mu, &sigma, None, k, params, 42, 0)
.unwrap();
let (staged, st) = randomised_sparse_svd_mlx_staged(
csc_of(&dense, n, m),
&mu,
&sigma,
None,
k,
params,
42,
)
.unwrap();
assert_eq!(plain.s, staged.s);
assert!(st.total() > Duration::ZERO);
}
#[test]
#[cfg(feature = "large-test")]
fn test_mlx_randomised_sparse_svd_accuracy_vs_dense() {
let (n, m, k, oversampling) = (3000usize, 300usize, 20usize, 30usize);
let hash = |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
};
let mut dense = vec![0.0f32; n * m];
for i in 0..n {
for j in 0..m {
if (i + j) % 10 != 0 {
continue;
}
let mut v = 0.0f32;
for f in 0..24 {
v += 6.0 * (hash(i, f) - 0.5) * (hash(j, f + 100) - 0.5)
/ (1.0 + f as f32).sqrt();
}
dense[i * m + j] = v + 0.15 * hash(i, j + 7) + 0.1;
}
}
let mut mu = vec![0.0f32; m];
let mut sigma = vec![0.0f32; m];
for j in 0..m {
let mean: f64 = (0..n).map(|i| dense[i * m + j] as f64).sum::<f64>() / n as f64;
let var: f64 = (0..n)
.map(|i| (dense[i * m + j] as f64 - mean).powi(2))
.sum::<f64>()
/ n as f64;
mu[j] = mean as f32;
sigma[j] = (var.sqrt() as f32).max(1e-6);
}
let want = Mat::<f32>::from_fn(n, m, |i, j| (dense[i * m + j] - mu[j]) / sigma[j])
.thin_svd()
.unwrap();
let mut worst_at = Vec::new();
for n_power in [0usize, 1, 2] {
let got = randomised_sparse_svd_mlx(
csc_of(&dense, n, m),
&mu,
&sigma,
None,
k,
Some(RandSvdMlxParams::new(n_power, oversampling)),
42,
0,
)
.unwrap();
let worst = (0..k).fold(0.0f32, |w, i| {
w.max((got.s[i] - want.S()[i]).abs() / want.S()[i].abs())
});
eprintln!("n_power_iters={n_power}: worst relative error {worst:.5}");
worst_at.push(worst);
}
assert!(
worst_at[0] > 4.0 * worst_at[2],
"error barely moved across power iterations ({:.5} -> {:.5})",
worst_at[0],
worst_at[2]
);
assert!(worst_at[2] < 0.015, "worst rel error {:.5}", worst_at[2]);
}
#[test]
fn test_mlx_randomised_sparse_svd_csr_input_rejected() {
let (n, m) = (10usize, 4usize);
let a = build_dense(n, m);
let csc = csc_of(&a, n, m);
let csr = csc.transform();
let res = randomised_sparse_svd_mlx(
csr,
&vec![0.0f32; m],
&vec![1.0f32; m],
None,
2,
None,
42,
0,
);
assert!(matches!(
res,
Err(BixverseErrors::SparseLayoutMismatch { .. })
));
}
#[test]
fn test_mlx_randomised_sparse_svd_dim_mismatch_rejected() {
let (n, m) = (10usize, 4usize);
let a = build_dense(n, m);
let res = randomised_sparse_svd_mlx(
csc_of(&a, n, m),
&vec![0.0f32; m + 1],
&vec![1.0f32; m + 1],
None,
2,
None,
42,
0,
);
assert!(matches!(
res,
Err(BixverseErrors::DimensionMisMatchSparse { .. })
));
let res = randomised_sparse_svd_mlx(
csc_of(&a, n, m),
&vec![0.0f32; m],
&vec![1.0f32; m],
Some(&vec![0.0f32; n - 1]),
2,
None,
42,
0,
);
assert!(matches!(
res,
Err(BixverseErrors::DimensionMisMatchSparse { .. })
));
}
}