#![allow(missing_docs)]
use cubecl::prelude::*;
use cubecl_utils_rs::prelude::*;
use crate::gpu::{WORKGROUP_32, WORKGROUP_64, WORKGROUP_128, WORKGROUP_256};
use crate::prelude::*;
pub const NMF_SWEEP_SMEM_ELEMS: u32 = 4096;
pub const NMF_MAX_RANK: usize = 128;
const NMF_ELEMENTWISE_WG: u32 = WORKGROUP_256;
pub fn sweep_tier(k: usize) -> Option<(u32, u32)> {
match k {
0 => None,
1..=16 => Some((16, WORKGROUP_256)),
17..=32 => Some((32, WORKGROUP_128)),
33..=64 => Some((64, WORKGROUP_64)),
65..=NMF_MAX_RANK => Some((128, WORKGROUP_32)),
_ => None,
}
}
#[cube(launch_unchecked)]
pub fn hals_sweep_gpu<F: Float>(
x: &mut Tensor<F>,
g: &Tensor<F>,
p: &Tensor<F>,
eps: &Tensor<F>,
n_rows: u32,
k: u32,
#[comptime] k_cap: u32,
#[comptime] wg_size: u32,
) {
let mut stage = SharedMemory::<F>::new((wg_size * k_cap) as usize);
let tx = UNIT_POS_X;
let row = (CUBE_POS_Y * CUBE_COUNT_X + CUBE_POS_X) * wg_size + tx;
if row >= n_rows {
terminate!();
}
let base = row as usize * k as usize;
let mut s = 0u32;
while s < k {
stage[(s * wg_size + tx) as usize] = x[base + s as usize];
s += 1u32;
}
let zero = F::new(0.0);
let floor = eps[0];
let mut r = 0u32;
while r < k {
let g_row = r * k;
let grr = g[(g_row + r) as usize];
if grr > zero {
let mut dot = zero;
let mut t = 0u32;
while t < k {
dot += g[(g_row + t) as usize] * stage[(t * wg_size + tx) as usize];
t += 1u32;
}
let slot = (r * wg_size + tx) as usize;
let mut updated = stage[slot] + (p[base + r as usize] - dot) / grr;
if updated < floor {
updated = floor;
}
stage[slot] = updated;
}
r += 1u32;
}
let mut w = 0u32;
while w < k {
x[base + w as usize] = stage[(w * wg_size + tx) as usize];
w += 1u32;
}
}
#[cube(launch_unchecked)]
pub fn hals_norm_factors_gpu<F: Float>(
sq: &Tensor<F>,
norm: &mut Tensor<F>,
inv_norm: &mut Tensor<F>,
k: u32,
) {
let c = ABSOLUTE_POS_X;
if c >= k {
terminate!();
}
let zero = F::new(0.0);
let one = F::new(1.0);
let value = sq[c as usize];
if value > zero {
let n = F::sqrt(value);
norm[c as usize] = n;
inv_norm[c as usize] = one / n;
} else {
norm[c as usize] = one;
inv_norm[c as usize] = one;
}
}
#[cube(launch_unchecked)]
pub fn scale_columns_gpu<F: Float>(
x: &mut Tensor<F>,
factors: &Tensor<F>,
total: u32,
k: u32,
#[comptime] wg_size: u32,
) {
let idx = (CUBE_POS_Y * CUBE_COUNT_X + CUBE_POS_X) * wg_size + UNIT_POS_X;
if idx >= total {
terminate!();
}
let c = idx % k;
x[idx as usize] *= factors[c as usize];
}
#[cube(launch_unchecked)]
pub fn row_dot_partials_gpu<F: Float>(
x: &Tensor<F>,
y: &Tensor<F>,
out: &mut Tensor<F>,
n_rows: u32,
k: u32,
#[comptime] wg_size: u32,
) {
let row = (CUBE_POS_Y * CUBE_COUNT_X + CUBE_POS_X) * wg_size + UNIT_POS_X;
if row >= n_rows {
terminate!();
}
let base = row as usize * k as usize;
let mut acc = F::new(0.0);
let mut s = 0u32;
while s < k {
acc += x[base + s as usize] * y[base + s as usize];
s += 1u32;
}
out[row as usize] = acc;
}
#[cube(launch_unchecked)]
pub fn fill_constant_gpu<F: Float>(
x: &mut Tensor<F>,
value: &Tensor<F>,
total: u32,
#[comptime] wg_size: u32,
) {
let idx = (CUBE_POS_Y * CUBE_COUNT_X + CUBE_POS_X) * wg_size + UNIT_POS_X;
if idx >= total {
terminate!();
}
x[idx as usize] = value[0];
}
pub fn launch_hals_sweep<R, F>(
x: &GpuTensor<R, F>,
g: &GpuTensor<R, F>,
p: &GpuTensor<R, F>,
eps: &GpuTensor<R, F>,
n_rows: usize,
k: usize,
client: &ComputeClient<R>,
) -> Result<(), BixverseErrors>
where
R: Runtime,
F: Float + cubecl::CubeElement,
{
let (k_cap, wg) = sweep_tier(k).ok_or(BixverseErrors::GpuNmfRankTooLarge {
k,
max: NMF_MAX_RANK,
})?;
let limits = GpuLimits::from_client(client);
fits_shared_memory(
"hals_sweep_gpu",
(k_cap * wg) as usize * size_of::<F>(),
&limits,
)?;
let blocks = (n_rows as u32).div_ceil(wg);
let (gx, gy) = grid_2d(blocks, &limits)?;
let count = checked_cube_count("hals_sweep_gpu", gx, gy, 1, &limits)?;
macro_rules! dispatch {
($cap:expr, $wg:expr) => {
unsafe {
hals_sweep_gpu::launch_unchecked::<F, R>(
client,
count,
CubeDim::new_1d($wg),
x.clone().into_tensor_arg(),
g.clone().into_tensor_arg(),
p.clone().into_tensor_arg(),
eps.clone().into_tensor_arg(),
n_rows as u32,
k as u32,
$cap,
$wg,
);
}
};
}
match k_cap {
16 => dispatch!(16, WORKGROUP_256),
32 => dispatch!(32, WORKGROUP_128),
64 => dispatch!(64, WORKGROUP_64),
_ => dispatch!(128, WORKGROUP_32),
}
Ok(())
}
pub fn launch_hals_norm_factors<R, F>(
sq: &GpuTensor<R, F>,
norm: &GpuTensor<R, F>,
inv_norm: &GpuTensor<R, F>,
k: usize,
client: &ComputeClient<R>,
) -> Result<(), BixverseErrors>
where
R: Runtime,
F: Float + cubecl::CubeElement,
{
let limits = GpuLimits::from_client(client);
let blocks = (k as u32).div_ceil(NMF_ELEMENTWISE_WG);
let count = checked_cube_count("hals_norm_factors_gpu", blocks, 1, 1, &limits)?;
unsafe {
hals_norm_factors_gpu::launch_unchecked::<F, R>(
client,
count,
CubeDim::new_1d(NMF_ELEMENTWISE_WG),
sq.clone().into_tensor_arg(),
norm.clone().into_tensor_arg(),
inv_norm.clone().into_tensor_arg(),
k as u32,
);
}
Ok(())
}
pub fn launch_scale_columns<R, F>(
x: &GpuTensor<R, F>,
factors: &GpuTensor<R, F>,
n_rows: usize,
k: usize,
client: &ComputeClient<R>,
) -> Result<(), BixverseErrors>
where
R: Runtime,
F: Float + cubecl::CubeElement,
{
let limits = GpuLimits::from_client(client);
let total = (n_rows * k) as u32;
let (gx, gy) = grid_2d(total.div_ceil(NMF_ELEMENTWISE_WG), &limits)?;
let count = checked_cube_count("scale_columns_gpu", gx, gy, 1, &limits)?;
unsafe {
scale_columns_gpu::launch_unchecked::<F, R>(
client,
count,
CubeDim::new_1d(NMF_ELEMENTWISE_WG),
x.clone().into_tensor_arg(),
factors.clone().into_tensor_arg(),
total,
k as u32,
NMF_ELEMENTWISE_WG,
);
}
Ok(())
}
pub fn launch_row_dot_partials<R, F>(
x: &GpuTensor<R, F>,
y: &GpuTensor<R, F>,
out: &GpuTensor<R, F>,
n_rows: usize,
k: usize,
client: &ComputeClient<R>,
) -> Result<(), BixverseErrors>
where
R: Runtime,
F: Float + cubecl::CubeElement,
{
let limits = GpuLimits::from_client(client);
let (gx, gy) = grid_2d((n_rows as u32).div_ceil(NMF_ELEMENTWISE_WG), &limits)?;
let count = checked_cube_count("row_dot_partials_gpu", gx, gy, 1, &limits)?;
unsafe {
row_dot_partials_gpu::launch_unchecked::<F, R>(
client,
count,
CubeDim::new_1d(NMF_ELEMENTWISE_WG),
x.clone().into_tensor_arg(),
y.clone().into_tensor_arg(),
out.clone().into_tensor_arg(),
n_rows as u32,
k as u32,
NMF_ELEMENTWISE_WG,
);
}
Ok(())
}
pub fn launch_fill_constant<R, F>(
x: &GpuTensor<R, F>,
value: &GpuTensor<R, F>,
total: usize,
client: &ComputeClient<R>,
) -> Result<(), BixverseErrors>
where
R: Runtime,
F: Float + cubecl::CubeElement,
{
let limits = GpuLimits::from_client(client);
let (gx, gy) = grid_2d((total as u32).div_ceil(NMF_ELEMENTWISE_WG), &limits)?;
let count = checked_cube_count("fill_constant_gpu", gx, gy, 1, &limits)?;
unsafe {
fill_constant_gpu::launch_unchecked::<F, R>(
client,
count,
CubeDim::new_1d(NMF_ELEMENTWISE_WG),
x.clone().into_tensor_arg(),
value.clone().into_tensor_arg(),
total as u32,
NMF_ELEMENTWISE_WG,
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use cubecl::wgpu::{WgpuDevice, WgpuRuntime};
use faer::{Mat, MatRef};
use crate::gpu::linalg::spmm::launch_dense_column_sq_norm;
use crate::methods::nmf_hals::{
gram_h_ht, gram_wt_w, hals_sweep_cols, hals_sweep_rows, normalise_w_cols,
};
fn try_device() -> Option<WgpuDevice> {
let device = WgpuDevice::DefaultDevice;
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
WgpuRuntime::client(&device);
}))
.ok()
.map(|_| device)
}
fn build_mat(rows: usize, cols: usize, salt: usize) -> Mat<f32> {
Mat::from_fn(rows, cols, |i, j| {
(((i * 37 + j * 17 + salt * 7) % 23) as f32) * 0.1 + 0.05
})
}
fn to_row_major(x: MatRef<f32>) -> Vec<f32> {
let (rows, k) = (x.nrows(), x.ncols());
let mut out = vec![0f32; rows * k];
for i in 0..rows {
for c in 0..k {
out[i * k + c] = x[(i, c)];
}
}
out
}
fn assert_close(got: &[f32], want: &[f32], tol: f32) {
assert_eq!(got.len(), want.len(), "length mismatch");
assert!(
got.iter().any(|v| v.abs() > 1e-6),
"output is all zeros, the GPU did no work"
);
for (i, (&a, &b)) in got.iter().zip(want.iter()).enumerate() {
assert!(
(a - b).abs() <= tol * b.abs().max(1.0),
"index {i}: got {a} want {b}"
);
}
}
fn run_sweep(x: &[f32], g: &[f32], p: &[f32], rows: usize, k: usize, eps: f32) -> Vec<f32> {
let device = try_device().expect("no device");
let client = WgpuRuntime::client(&device);
let x_gpu = GpuTensor::<WgpuRuntime, f32>::from_slice(x, vec![rows, k], &client).unwrap();
let g_gpu = GpuTensor::<WgpuRuntime, f32>::from_slice(g, vec![k, k], &client).unwrap();
let p_gpu = GpuTensor::<WgpuRuntime, f32>::from_slice(p, vec![rows, k], &client).unwrap();
let eps_gpu = GpuTensor::<WgpuRuntime, f32>::from_slice(&[eps], vec![1], &client).unwrap();
launch_hals_sweep::<WgpuRuntime, f32>(&x_gpu, &g_gpu, &p_gpu, &eps_gpu, rows, k, &client)
.unwrap();
x_gpu.read(&client).unwrap()
}
#[test]
fn test_sweep_matches_cpu_row_sweep() {
if try_device().is_none() {
return;
}
let (n, k) = (61usize, 7usize);
let eps = 1e-10f32;
let w = build_mat(29, k, 1);
let mut b = Mat::<f32>::zeros(k, k);
gram_wt_w(w.as_ref(), &mut b);
let mut h = build_mat(k, n, 2);
let a = build_mat(k, n, 3);
let got = run_sweep(
&to_row_major(h.transpose()),
&to_row_major(b.as_ref()),
&to_row_major(a.transpose()),
n,
k,
eps,
);
hals_sweep_rows(&mut h, b.as_ref(), a.as_ref(), eps);
assert_close(&got, &to_row_major(h.transpose()), 1e-5);
}
#[test]
fn test_sweep_matches_cpu_col_sweep() {
if try_device().is_none() {
return;
}
let (m, k) = (53usize, 7usize);
let eps = 1e-10f32;
let h = build_mat(k, 31, 4);
let mut d = Mat::<f32>::zeros(k, k);
gram_h_ht(h.as_ref(), &mut d);
let mut w = build_mat(m, k, 5);
let c = build_mat(m, k, 6);
let w_flat = to_row_major(w.as_ref());
let got = run_sweep(
&w_flat,
&to_row_major(d.as_ref()),
&to_row_major(c.as_ref()),
m,
k,
eps,
);
hals_sweep_cols(&mut w, d.as_ref(), c.as_ref(), eps);
assert_close(&got, &to_row_major(w.as_ref()), 1e-5);
}
#[test]
fn test_sweep_skips_zero_gram_diagonal() {
if try_device().is_none() {
return;
}
let (m, k) = (40usize, 5usize);
let eps = 1e-10f32;
let mut d = Mat::<f32>::identity(k, k);
d[(2, 2)] = 0.0;
let mut w = build_mat(m, k, 8);
let c = build_mat(m, k, 9);
let w_flat = to_row_major(w.as_ref());
let got = run_sweep(
&w_flat,
&to_row_major(d.as_ref()),
&to_row_major(c.as_ref()),
m,
k,
eps,
);
hals_sweep_cols(&mut w, d.as_ref(), c.as_ref(), eps);
assert_close(&got, &to_row_major(w.as_ref()), 1e-5);
for i in 0..m {
assert_eq!(got[i * k + 2], w_flat[i * k + 2]);
}
}
#[test]
fn test_sweep_floors_at_eps() {
if try_device().is_none() {
return;
}
let (m, k) = (16usize, 3usize);
let eps = 1e-3f32;
let d = Mat::<f32>::identity(k, k);
let w = Mat::<f32>::full(m, k, 0.5f32);
let c = Mat::<f32>::full(m, k, -100.0f32);
let got = run_sweep(
&to_row_major(w.as_ref()),
&to_row_major(d.as_ref()),
&to_row_major(c.as_ref()),
m,
k,
eps,
);
for (i, &v) in got.iter().enumerate() {
assert_eq!(v, eps, "index {i} was not floored to eps");
}
}
#[test]
fn test_sweep_every_rank_tier() {
if try_device().is_none() {
return;
}
let eps = 1e-10f32;
for k in [16usize, 17, 32, 33, 64, 65, 128] {
let m = 3 * k + 5;
let h = build_mat(k, 2 * k + 3, k);
let mut d = Mat::<f32>::zeros(k, k);
gram_h_ht(h.as_ref(), &mut d);
let mut w = build_mat(m, k, k + 1);
let c = build_mat(m, k, k + 2);
let got = run_sweep(
&to_row_major(w.as_ref()),
&to_row_major(d.as_ref()),
&to_row_major(c.as_ref()),
m,
k,
eps,
);
hals_sweep_cols(&mut w, d.as_ref(), c.as_ref(), eps);
assert_close(&got, &to_row_major(w.as_ref()), 1e-4);
}
}
#[test]
fn test_sweep_rejects_rank_above_the_largest_tier() {
let Some(device) = try_device() else {
return;
};
let client = WgpuRuntime::client(&device);
let k = NMF_MAX_RANK + 1;
let x = GpuTensor::<WgpuRuntime, f32>::empty(vec![4, k], &client).unwrap();
let g = GpuTensor::<WgpuRuntime, f32>::empty(vec![k, k], &client).unwrap();
let eps = GpuTensor::<WgpuRuntime, f32>::from_slice(&[1e-10], vec![1], &client).unwrap();
let err = launch_hals_sweep::<WgpuRuntime, f32>(&x, &g, &x, &eps, 4, k, &client)
.expect_err("rank above the largest tier must error");
assert!(matches!(
err,
BixverseErrors::GpuNmfRankTooLarge { k: got, .. } if got == k
));
}
#[test]
fn test_normalisation_matches_cpu() {
let Some(device) = try_device() else {
return;
};
let client = WgpuRuntime::client(&device);
let (m, n, k) = (37usize, 23usize, 6usize);
let mut w = build_mat(m, k, 11);
let mut h = build_mat(k, n, 12);
let w_gpu = GpuTensor::<WgpuRuntime, f32>::from_slice(
&to_row_major(w.as_ref()),
vec![m, k],
&client,
)
.unwrap();
let h_gpu = GpuTensor::<WgpuRuntime, f32>::from_slice(
&to_row_major(h.transpose()),
vec![n, k],
&client,
)
.unwrap();
let sq = GpuTensor::<WgpuRuntime, f32>::empty(vec![k], &client).unwrap();
let norm = GpuTensor::<WgpuRuntime, f32>::empty(vec![k], &client).unwrap();
let inv = GpuTensor::<WgpuRuntime, f32>::empty(vec![k], &client).unwrap();
launch_dense_column_sq_norm::<WgpuRuntime, f32>(&w_gpu, &sq, m, k, &client).unwrap();
launch_hals_norm_factors::<WgpuRuntime, f32>(&sq, &norm, &inv, k, &client).unwrap();
launch_scale_columns::<WgpuRuntime, f32>(&w_gpu, &inv, m, k, &client).unwrap();
launch_scale_columns::<WgpuRuntime, f32>(&h_gpu, &norm, n, k, &client).unwrap();
let w_got = w_gpu.read(&client).unwrap();
let h_got = h_gpu.read(&client).unwrap();
normalise_w_cols(&mut w, &mut h);
assert_close(&w_got, &to_row_major(w.as_ref()), 1e-5);
assert_close(&h_got, &to_row_major(h.transpose()), 1e-5);
for c in 0..k {
let sq: f32 = (0..m).map(|i| w_got[i * k + c] * w_got[i * k + c]).sum();
assert!(
(sq.sqrt() - 1.0).abs() < 1e-4,
"column {c} is not unit norm"
);
}
}
#[test]
fn test_norm_factors_leave_a_collapsed_column_alone() {
let Some(device) = try_device() else {
return;
};
let client = WgpuRuntime::client(&device);
let k = 4usize;
let sq_host = [4.0f32, 0.0, 9.0, 0.0];
let sq = GpuTensor::<WgpuRuntime, f32>::from_slice(&sq_host, vec![k], &client).unwrap();
let norm = GpuTensor::<WgpuRuntime, f32>::empty(vec![k], &client).unwrap();
let inv = GpuTensor::<WgpuRuntime, f32>::empty(vec![k], &client).unwrap();
launch_hals_norm_factors::<WgpuRuntime, f32>(&sq, &norm, &inv, k, &client).unwrap();
assert_close(&norm.read(&client).unwrap(), &[2.0, 1.0, 3.0, 1.0], 1e-6);
assert_close(
&inv.read(&client).unwrap(),
&[0.5, 1.0, 1.0 / 3.0, 1.0],
1e-6,
);
}
#[test]
fn test_row_dot_partials_sum_to_the_frobenius_inner_product() {
let Some(device) = try_device() else {
return;
};
let client = WgpuRuntime::client(&device);
let (rows, k) = (301usize, 9usize);
let x_flat = to_row_major(build_mat(rows, k, 13).as_ref());
let y_flat = to_row_major(build_mat(rows, k, 14).as_ref());
let x_gpu =
GpuTensor::<WgpuRuntime, f32>::from_slice(&x_flat, vec![rows, k], &client).unwrap();
let y_gpu =
GpuTensor::<WgpuRuntime, f32>::from_slice(&y_flat, vec![rows, k], &client).unwrap();
let out = GpuTensor::<WgpuRuntime, f32>::empty(vec![rows], &client).unwrap();
launch_row_dot_partials::<WgpuRuntime, f32>(&x_gpu, &y_gpu, &out, rows, k, &client)
.unwrap();
let got = out.read(&client).unwrap();
assert!(
got.iter().any(|v| v.abs() > 1e-6),
"output is all zeros, the GPU did no work"
);
let total: f64 = got.iter().map(|&v| v as f64).sum();
let want: f64 = x_flat
.iter()
.zip(y_flat.iter())
.map(|(&a, &b)| a as f64 * b as f64)
.sum();
assert!(
(total - want).abs() <= 1e-4 * want.abs().max(1.0),
"got {total} want {want}"
);
}
#[test]
fn test_fill_constant() {
let Some(device) = try_device() else {
return;
};
let client = WgpuRuntime::client(&device);
let total = 1000usize;
let x = GpuTensor::<WgpuRuntime, f32>::empty(vec![total], &client).unwrap();
let v = GpuTensor::<WgpuRuntime, f32>::from_slice(&[0.25f32], vec![1], &client).unwrap();
launch_fill_constant::<WgpuRuntime, f32>(&x, &v, total, &client).unwrap();
assert!(
x.read(&client).unwrap().iter().all(|&v| v == 0.25),
"buffer was not filled"
);
}
#[test]
fn test_sweep_shared_memory_budget_and_residency() {
for k in [1usize, 16, 17, 32, 33, 64, 65, 128] {
let (k_cap, wg) = sweep_tier(k).expect("tier must exist");
assert_eq!(
k_cap * wg,
NMF_SWEEP_SMEM_ELEMS,
"tier for k = {k} does not hold the staged block constant"
);
assert!(wg >= WORKGROUP_32, "tier for k = {k} is below one plane");
assert!(k as u32 <= k_cap, "tier for k = {k} does not fit the rank");
let footprint = (k_cap * wg) as usize * size_of::<f32>();
assert_eq!(footprint, 16 * 1024);
assert!(
32 * 1024 / footprint >= 2,
"only one workgroup would be resident at k = {k}"
);
}
assert!(sweep_tier(0).is_none());
assert!(sweep_tier(NMF_MAX_RANK + 1).is_none());
}
#[test]
fn test_sweep_grid_within_dispatch_limit() {
let (max_x, max_y, _) = WgpuRuntime::max_cube_count();
let cap = max_x.min(max_y);
assert!((16_800_000u32).div_ceil(sweep_tier(16).unwrap().1) > max_x);
assert!((2_100_000u32).div_ceil(sweep_tier(128).unwrap().1) > max_x);
for rows in [1_000_000u32, 4_000_000, 16_800_000] {
for k in [16usize, 128] {
let (_, wg) = sweep_tier(k).unwrap();
let blocks = rows.div_ceil(wg);
let (gx, gy) = grid_2d_limited(blocks, cap).unwrap();
assert!(gx <= max_x && gy <= max_y);
assert!(gx as u64 * gy as u64 >= blocks as u64, "grid misses work");
}
}
}
#[test]
fn test_elementwise_grid_within_dispatch_limit() {
let (max_x, max_y, _) = WgpuRuntime::max_cube_count();
let cap = max_x.min(max_y);
for total in [1_000_000u32, 100_000_000, 1_000_000_000] {
let blocks = total.div_ceil(NMF_ELEMENTWISE_WG);
let (gx, gy) = grid_2d_limited(blocks, cap).unwrap();
assert!(gx <= max_x && gy <= max_y);
assert!(gx as u64 * gy as u64 >= blocks as u64, "grid misses work");
}
}
}