#![allow(missing_docs)]
use ann_search_rs::gpu::tensor::GpuTensor;
use ann_search_rs::gpu::*;
use ann_search_rs::prelude::*;
use ann_search_rs::utils::dist::Dist;
use ann_search_rs::utils::{k_means_utils::*, matrix_to_flat};
use cubecl::prelude::*;
use faer::{Mat, MatRef};
use std::iter::Sum;
use std::time::Instant;
use crate::gpu::WORKGROUP_128;
use crate::prelude::*;
#[derive(Clone, Copy, Debug)]
pub struct KMeansGpuParams {
pub iters: usize,
pub init: Option<KMeansInit>,
pub fixed: bool,
pub quantise_to_f16: bool,
}
impl KMeansGpuParams {
pub fn new(iters: usize, init: Option<KMeansInit>, fixed: bool, quantise_to_f16: bool) -> Self {
Self {
iters,
init,
fixed,
quantise_to_f16,
}
}
}
impl Default for KMeansGpuParams {
fn default() -> Self {
Self::new(50, None, true, false)
}
}
fn assign_k_tile(dim_padded: usize) -> usize {
match dim_padded {
0..=64 => 32,
65..=256 => 16,
257..=512 => 8,
513..=1024 => 4,
1025..=2048 => 2,
_ => 1,
}
}
#[cube(launch_unchecked)]
pub fn flash_assign_euclidean_tiled<S: Float, A: Float, N: Size>(
data: &Tensor<Vector<S, N>>,
centroids: &Tensor<Vector<A, N>>,
assignments: &mut Tensor<u32>,
n_samples: u32,
k: u32,
#[comptime] dim_lines: usize,
#[comptime] k_tile: usize,
#[comptime] wg_size: u32,
) {
let lanes = LINE_SIZE;
let dim_scalars = dim_lines * lanes;
let tx = UNIT_POS_X;
let wg = wg_size;
let point_idx = (CUBE_POS_Y * CUBE_COUNT_X + CUBE_POS_X) * wg + tx;
let active = point_idx < n_samples;
let p_idx_safe = if active {
point_idx
} else {
#[allow(clippy::useless_conversion)]
0u32.into()
};
let p_base = p_idx_safe as usize * dim_lines;
let mut p = Array::<A>::new(dim_scalars);
for i in 0..dim_lines {
let pl = data[p_base + i];
#[unroll]
for lane in 0..lanes {
p[i * lanes + lane] = A::cast_from(pl[lane]);
}
}
let mut s_cents = SharedMemory::<A>::new(k_tile * dim_scalars);
let mut best_dist = A::new(f32::MAX);
let mut best_idx = 0u32;
let kt = k_tile as u32;
let n_tiles = k.div_ceil(kt);
let mut tile = 0u32;
while tile < n_tiles {
let tile_c0 = tile * kt;
let total_elems = k_tile * dim_scalars;
let mut load_idx = tx as usize;
while load_idx < total_elems {
let c_local = load_idx / dim_scalars;
let elem = load_idx % dim_scalars;
let c_global = tile_c0 + c_local as u32;
if c_global < k {
let line_idx = elem / lanes;
let lane = elem % lanes;
let cl = centroids[c_global as usize * dim_lines + line_idx];
s_cents[load_idx] = cl[lane];
} else {
s_cents[load_idx] = A::new(0.0);
}
load_idx += wg as usize;
}
sync_cube();
let mut c_local = 0u32;
while c_local < kt {
let c_global = tile_c0 + c_local;
if c_global < k {
let cbase = c_local as usize * dim_scalars;
let mut sum = A::new(0.0);
for e in 0..dim_scalars {
let diff = p[e] - s_cents[cbase + e];
sum += diff * diff;
}
if sum < best_dist {
best_dist = sum;
best_idx = c_global;
}
}
c_local += 1u32;
}
sync_cube();
tile += 1u32;
}
if active {
assignments[point_idx as usize] = best_idx;
}
}
#[cube(launch_unchecked)]
pub fn flash_assign_cosine_tiled<S: Float, A: Float, N: Size>(
data: &Tensor<Vector<S, N>>,
centroids: &Tensor<Vector<A, N>>,
point_norms: &Tensor<A>,
centroid_norms: &Tensor<A>,
assignments: &mut Tensor<u32>,
n_samples: u32,
k: u32,
#[comptime] dim_lines: usize,
#[comptime] k_tile: usize,
#[comptime] wg_size: u32,
) {
let lanes = LINE_SIZE;
let dim_scalars = dim_lines * lanes;
let tx = UNIT_POS_X;
let wg = wg_size;
let point_idx = (CUBE_POS_Y * CUBE_COUNT_X + CUBE_POS_X) * wg + tx;
let active = point_idx < n_samples;
let p_idx_safe = if active {
point_idx
} else {
#[allow(clippy::useless_conversion)]
0u32.into()
};
let p_base = p_idx_safe as usize * dim_lines;
let mut p = Array::<A>::new(dim_scalars);
for i in 0..dim_lines {
let pl = data[p_base + i];
#[unroll]
for lane in 0..lanes {
p[i * lanes + lane] = A::cast_from(pl[lane]);
}
}
let pnorm = point_norms[p_idx_safe as usize];
let mut s_cents = SharedMemory::<A>::new(k_tile * dim_scalars);
let mut best_dist = A::new(f32::MAX);
let mut best_idx = 0u32;
let kt = k_tile as u32;
let n_tiles = k.div_ceil(kt);
let mut tile = 0u32;
while tile < n_tiles {
let tile_c0 = tile * kt;
let total_elems = k_tile * dim_scalars;
let mut load_idx = tx as usize;
while load_idx < total_elems {
let c_local = load_idx / dim_scalars;
let elem = load_idx % dim_scalars;
let c_global = tile_c0 + c_local as u32;
if c_global < k {
let line_idx = elem / lanes;
let lane = elem % lanes;
let cl = centroids[c_global as usize * dim_lines + line_idx];
s_cents[load_idx] = cl[lane];
} else {
s_cents[load_idx] = A::new(0.0);
}
load_idx += wg as usize;
}
sync_cube();
let mut c_local = 0u32;
while c_local < kt {
let c_global = tile_c0 + c_local;
if c_global < k {
let cbase = c_local as usize * dim_scalars;
let mut dot = A::new(0.0);
for e in 0..dim_scalars {
dot += p[e] * s_cents[cbase + e];
}
let cnorm = centroid_norms[c_global as usize];
let dist = A::new(1.0) - dot / (pnorm * cnorm);
if dist < best_dist {
best_dist = dist;
best_idx = c_global;
}
}
c_local += 1u32;
}
sync_cube();
tile += 1u32;
}
if active {
assignments[point_idx as usize] = best_idx;
}
}
#[allow(clippy::too_many_arguments)]
fn flash_assign_device<S, A, R>(
client: &ComputeClient<R>,
data_gpu: &GpuTensor<R, S>,
cent_gpu: &GpuTensor<R, A>,
pnorm_gpu: &GpuTensor<R, A>,
cnorm_gpu: &GpuTensor<R, A>,
assign_gpu: &GpuTensor<R, u32>,
n: usize,
k: usize,
dim: usize,
metric: &Dist,
) where
R: Runtime,
S: Float + cubecl::CubeElement,
A: Float + cubecl::CubeElement,
{
let vec_size = LINE_SIZE;
let dim_lines = dim / vec_size;
let k_tile = assign_k_tile(dim);
let n_workgroups = (n as u32).div_ceil(WORKGROUP_128);
let (gx, gy) = grid_2d(n_workgroups);
let count = CubeCount::Static(gx, gy, 1);
let cdim = CubeDim::new_1d(WORKGROUP_128);
match *metric {
Dist::SquaredEuclidean => unsafe {
flash_assign_euclidean_tiled::launch_unchecked::<S, A, R>(
client,
count,
cdim,
vec_size,
data_gpu.clone().into_tensor_arg(),
cent_gpu.clone().into_tensor_arg(),
assign_gpu.clone().into_tensor_arg(),
n as u32,
k as u32,
dim_lines,
k_tile,
WORKGROUP_128,
);
},
Dist::Cosine => unsafe {
flash_assign_cosine_tiled::launch_unchecked::<S, A, R>(
client,
count,
cdim,
vec_size,
data_gpu.clone().into_tensor_arg(),
cent_gpu.clone().into_tensor_arg(),
pnorm_gpu.clone().into_tensor_arg(),
cnorm_gpu.clone().into_tensor_arg(),
assign_gpu.clone().into_tensor_arg(),
n as u32,
k as u32,
dim_lines,
k_tile,
WORKGROUP_128,
);
},
Dist::Manhattan => unreachable!("Manhattan distance is not supported!"),
}
}
#[cube(launch_unchecked)]
pub fn histogram_clusters_privatised(
assignments: &Tensor<u32>,
privatised_counts: &mut Tensor<Atomic<u32>>,
n: u32,
k: u32,
) {
let r = CUBE_POS_X;
let tx = UNIT_POS_X;
let wg = WORKGROUP_SIZE_X;
let mut c = tx;
while c < k {
let idx = r * k + c;
Atomic::fetch_and(&privatised_counts[idx as usize], 0u32);
c += wg;
}
sync_cube();
let total_threads = wg * CUBE_COUNT_X;
let mut i = r * wg + tx;
while i < n {
let chunk_c = assignments[i as usize];
let idx = r * k + chunk_c;
Atomic::fetch_add(&privatised_counts[idx as usize], 1u32);
i += total_threads;
}
}
#[cube(launch_unchecked)]
pub fn scan_columns_and_sum(
privatised_counts: &mut Tensor<u32>,
counts: &mut Tensor<u32>,
k: u32,
cube_count: u32,
) {
let c = ABSOLUTE_POS_X;
if c >= k {
terminate!();
}
let mut acc = 0u32;
let mut r = 0u32;
while r < cube_count {
let idx = r * k + c;
let val = privatised_counts[idx as usize];
privatised_counts[idx as usize] = acc;
acc += val;
r += 1u32;
}
counts[c as usize] = acc;
}
#[cube(launch_unchecked)]
pub fn exclusive_scan_offsets_2(counts: &Tensor<u32>, offsets: &mut Tensor<u32>, k: u32) {
if UNIT_POS_X == 0u32 {
offsets[0] = 0u32;
let mut acc = 0u32;
let mut c = 0u32;
while c < k {
acc += counts[c as usize];
offsets[(c + 1u32) as usize] = acc;
c += 1u32;
}
}
}
#[cube(launch_unchecked)]
pub fn merge_offsets_to_cursors(
privatised_counts: &mut Tensor<u32>,
offsets: &Tensor<u32>,
k: u32,
cube_count: u32,
) {
let idx = ABSOLUTE_POS_X as usize;
let total_elements = (cube_count * k) as usize;
if idx < total_elements {
let c = (idx as u32) % k;
privatised_counts[idx] += offsets[c as usize];
}
}
#[cube(launch_unchecked)]
pub fn scatter_csr_privatised(
assignments: &Tensor<u32>,
privatised_cursors: &mut Tensor<Atomic<u32>>,
all_indices: &mut Tensor<u32>,
n: u32,
k: u32,
) {
let r = CUBE_POS_X;
let tx = UNIT_POS_X;
let wg = WORKGROUP_SIZE_X;
let total_threads = wg * CUBE_COUNT_X;
let mut i = r * wg + tx;
while i < n {
let c = assignments[i as usize];
let idx = r * k + c;
let pos = Atomic::fetch_add(&privatised_cursors[idx as usize], 1u32);
all_indices[pos as usize] = i;
i += total_threads;
}
}
#[allow(clippy::too_many_arguments)]
pub fn build_csr_gpu_privatised<R>(
assignments: &GpuTensor<R, u32>,
n: usize,
k: usize,
cube_count: usize,
privatised_counts: &GpuTensor<R, u32>,
counts: &GpuTensor<R, u32>,
offsets: &GpuTensor<R, u32>,
all_indices: &GpuTensor<R, u32>,
client: &ComputeClient<R>,
) where
R: Runtime,
{
unsafe {
histogram_clusters_privatised::launch_unchecked::<R>(
client,
CubeCount::Static(cube_count as u32, 1, 1),
CubeDim::new_1d(WORKGROUP_SIZE_X),
assignments.clone().into_tensor_arg(),
privatised_counts.clone().into_tensor_arg(),
n as u32,
k as u32,
);
scan_columns_and_sum::launch_unchecked::<R>(
client,
CubeCount::Static(k.div_ceil(256) as u32, 1, 1),
CubeDim::new_1d(256),
privatised_counts.clone().into_tensor_arg(),
counts.clone().into_tensor_arg(),
k as u32,
cube_count as u32,
);
exclusive_scan_offsets_2::launch_unchecked::<R>(
client,
CubeCount::Static(1, 1, 1),
CubeDim::new_1d(1),
counts.clone().into_tensor_arg(),
offsets.clone().into_tensor_arg(),
k as u32,
);
let total_elements = cube_count * k;
merge_offsets_to_cursors::launch_unchecked::<R>(
client,
CubeCount::Static(total_elements.div_ceil(256) as u32, 1, 1),
CubeDim::new_1d(256),
privatised_counts.clone().into_tensor_arg(),
offsets.clone().into_tensor_arg(),
k as u32,
cube_count as u32,
);
scatter_csr_privatised::launch_unchecked::<R>(
client,
CubeCount::Static(cube_count as u32, 1, 1),
CubeDim::new_1d(WORKGROUP_SIZE_X),
assignments.clone().into_tensor_arg(),
privatised_counts.clone().into_tensor_arg(),
all_indices.clone().into_tensor_arg(),
n as u32,
k as u32,
);
}
}
#[cube(launch_unchecked)]
pub fn segmented_centroid_update<S: Float, A: Float>(
data: &Tensor<S>,
all_indices: &Tensor<u32>,
offsets: &Tensor<u32>,
centroids: &mut Tensor<A>,
k: u32,
#[comptime] dim: usize,
) {
let cluster = CUBE_POS_Y * CUBE_COUNT_X + CUBE_POS_X;
if cluster >= k {
terminate!();
}
let tx = UNIT_POS_X as usize;
let wg = WORKGROUP_SIZE_X as usize;
let seg_start = offsets[cluster as usize];
let seg_end = offsets[(cluster + 1u32) as usize];
let count = seg_end - seg_start;
if count == 0u32 {
terminate!();
}
let inv_count = A::new(1.0) / A::cast_from(count);
let cent_base = cluster as usize * dim;
let mut e = tx;
while e < dim {
let mut acc = A::new(0.0);
let mut p = 0u32;
while p < count {
let global = all_indices[(seg_start + p) as usize];
acc += A::cast_from(data[global as usize * dim + e]);
p += 1u32;
}
centroids[cent_base + e] = acc * inv_count;
e += wg;
}
}
pub fn segmented_update<R, S, A>(
data: &GpuTensor<R, S>,
all_indices: &GpuTensor<R, u32>,
offsets: &GpuTensor<R, u32>,
centroids: &GpuTensor<R, A>,
k: usize,
dim: usize,
client: &ComputeClient<R>,
) where
R: Runtime,
S: Float + cubecl::CubeElement,
A: Float + cubecl::CubeElement,
{
let (gx, gy) = grid_2d(k as u32);
unsafe {
segmented_centroid_update::launch_unchecked::<S, A, R>(
client,
CubeCount::Static(gx, gy, 1),
CubeDim::new_1d(WORKGROUP_SIZE_X),
data.clone().into_tensor_arg(),
all_indices.clone().into_tensor_arg(),
offsets.clone().into_tensor_arg(),
centroids.clone().into_tensor_arg(),
k as u32,
dim,
);
}
}
#[cube(launch_unchecked)]
pub fn count_changed(
curr: &Tensor<u32>,
prev: &Tensor<u32>,
changed: &mut Tensor<Atomic<u32>>,
n: u32,
) {
let i = ABSOLUTE_POS_X;
if i >= n {
terminate!();
}
if curr[i as usize] != prev[i as usize] {
Atomic::fetch_add(&changed[0], 1u32);
}
}
#[cube(launch_unchecked)]
pub fn centroid_norms_l2<F: Float>(
centroids: &Tensor<F>,
norms: &mut Tensor<F>,
k: u32,
#[comptime] dim: usize,
) {
let c = CUBE_POS_Y * CUBE_COUNT_X + CUBE_POS_X;
if c >= k {
terminate!();
}
if UNIT_POS_X == 0u32 {
let base = c as usize * dim;
let mut acc = F::new(0.0);
for e in 0..dim {
let v = centroids[base + e];
acc += v * v;
}
norms[c as usize] = F::sqrt(acc);
}
}
#[allow(clippy::too_many_arguments)]
fn lloyd_step<S, A, R>(
client: &ComputeClient<R>,
data_gpu: &GpuTensor<R, S>,
cent_gpu: &GpuTensor<R, A>,
pnorm_gpu: &GpuTensor<R, A>,
cnorm_gpu: &GpuTensor<R, A>,
assign_gpu: &GpuTensor<R, u32>,
n: usize,
k: usize,
dim: usize,
metric: &Dist,
cube_count: usize,
privatised_counts: &GpuTensor<R, u32>,
counts: &GpuTensor<R, u32>,
offsets: &GpuTensor<R, u32>,
all_indices: &GpuTensor<R, u32>,
) where
R: Runtime,
S: Float + cubecl::CubeElement,
A: Float + cubecl::CubeElement,
{
flash_assign_device::<S, A, R>(
client, data_gpu, cent_gpu, pnorm_gpu, cnorm_gpu, assign_gpu, n, k, dim, metric,
);
build_csr_gpu_privatised::<R>(
assign_gpu,
n,
k,
cube_count,
privatised_counts,
counts,
offsets,
all_indices,
client,
);
segmented_update::<R, S, A>(data_gpu, all_indices, offsets, cent_gpu, k, dim, client);
if *metric == Dist::Cosine {
let (gx, gy) = grid_2d(k as u32);
unsafe {
centroid_norms_l2::launch_unchecked::<A, R>(
client,
CubeCount::Static(gx, gy, 1),
CubeDim::new_1d(1),
cent_gpu.clone().into_tensor_arg(),
cnorm_gpu.clone().into_tensor_arg(),
k as u32,
dim,
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_kmeans_loop<S, A, R>(
client: &ComputeClient<R>,
data_gpu: &GpuTensor<R, S>,
cent_gpu: &GpuTensor<R, A>,
pnorm_gpu: &GpuTensor<R, A>,
cnorm_gpu: &GpuTensor<R, A>,
n: usize,
n_centroids: usize,
dim_padded: usize,
dist: &Dist,
params: &KMeansGpuParams,
verbose: bool,
) -> Result<(Vec<usize>, Vec<A>), BixverseErrors>
where
R: Runtime,
S: Float + cubecl::CubeElement,
A: Float + cubecl::CubeElement,
{
let assign_gpu = GpuTensor::<R, u32>::empty(vec![n], client);
let cube_count = 512usize;
let privatised_counts = GpuTensor::<R, u32>::empty(vec![cube_count, n_centroids], client);
let counts = GpuTensor::<R, u32>::empty(vec![n_centroids], client);
let offsets = GpuTensor::<R, u32>::empty(vec![n_centroids + 1], client);
let all_indices = GpuTensor::<R, u32>::empty(vec![n], client);
if params.fixed {
if verbose {
println!(
" Dispatching the {:?} iters to the GPU kernel.",
params.iters
)
}
for _ in 0..params.iters {
lloyd_step::<S, A, R>(
client,
data_gpu,
cent_gpu,
pnorm_gpu,
cnorm_gpu,
&assign_gpu,
n,
n_centroids,
dim_padded,
dist,
cube_count,
&privatised_counts,
&counts,
&offsets,
&all_indices,
);
}
} else {
let change_floor = (n / 10_000).max(1) as u32;
let assign_alt_gpu = GpuTensor::<R, u32>::empty(vec![n], client);
let (cnt_gx, cnt_gy) = grid_2d((n as u32).div_ceil(WORKGROUP_SIZE_X));
for iter in 0..params.iters {
let (cur, prev) = if iter % 2 == 0 {
(&assign_gpu, &assign_alt_gpu)
} else {
(&assign_alt_gpu, &assign_gpu)
};
lloyd_step::<S, A, R>(
client,
data_gpu,
cent_gpu,
pnorm_gpu,
cnorm_gpu,
cur,
n,
n_centroids,
dim_padded,
dist,
cube_count,
&privatised_counts,
&counts,
&offsets,
&all_indices,
);
let changed_gpu = GpuTensor::<R, u32>::from_slice(&[0u32], vec![1], client);
unsafe {
count_changed::launch_unchecked::<R>(
client,
CubeCount::Static(cnt_gx, cnt_gy, 1),
CubeDim::new_1d(WORKGROUP_SIZE_X),
cur.clone().into_tensor_arg(),
prev.clone().into_tensor_arg(),
changed_gpu.clone().into_tensor_arg(),
n as u32,
);
}
let changed = changed_gpu.read(client)?[0];
if iter > 0 && changed <= change_floor {
if verbose {
println!(
" Converged at iteration {} ({} assignments changed)",
iter + 1,
changed
);
}
break;
}
if verbose && (iter + 1) % 10 == 0 {
println!(
" Iteration {} complete ({} assignments changed)",
iter + 1,
changed
);
}
}
}
flash_assign_device::<S, A, R>(
client,
data_gpu,
cent_gpu,
pnorm_gpu,
cnorm_gpu,
&assign_gpu,
n,
n_centroids,
dim_padded,
dist,
);
let assignments: Vec<usize> = assign_gpu
.read(client)?
.into_iter()
.map(|x| x as usize)
.collect();
let final_cents = cent_gpu.clone().read(client)?;
Ok((assignments, final_cents))
}
#[allow(clippy::too_many_arguments)]
pub fn k_means_clusters_gpu<T, R>(
data: MatRef<T>,
dist: &str,
n_centroids: usize,
kmeans_params: Option<KMeansGpuParams>,
seed: usize,
device: R::Device,
verbose: bool,
) -> Result<(Mat<T>, Vec<usize>), BixverseErrors>
where
R: Runtime,
T: cubecl::prelude::Float + cubecl::CubeElement + num_traits::Float + Sum + AnnSearchFloat,
{
let start = Instant::now();
let params = kmeans_params.unwrap_or_default();
let dist = parse_ann_dist(dist).unwrap_or_else(|| {
println!(
"Unknown string provided ({:?}). Defaulting to Squared Euclidean",
dist
);
Dist::default()
});
if dist == Dist::Manhattan {
return Err(BixverseErrors::DistanceNotSupported(dist));
}
let (data_flat, n, dim) = matrix_to_flat(data);
let dim_padded = dim.next_multiple_of(LINE_SIZE);
let data_padded = if dim_padded != dim {
pad_vectors(&data_flat, n, dim, dim_padded)
} else {
data_flat.clone()
};
let init_method = params.init.unwrap_or(if n_centroids > 200 {
KMeansInit::Random
} else {
KMeansInit::KMeansParallel
});
let centroids = match init_method {
KMeansInit::Random => {
if verbose {
println!(" Initialising centroids via fast random selection");
}
fast_random_init(&data_padded, dim_padded, n, n_centroids, seed)
}
KMeansInit::KMeansParallel => {
if verbose {
println!(" Initialising centroids via k-means||");
}
let init_norms: Vec<T> = (0..n)
.map(|i| T::calculate_l2_norm(&data_padded[i * dim_padded..(i + 1) * dim_padded]))
.collect();
kmeans_parallel_init(
&data_padded,
&init_norms,
dim_padded,
n,
n_centroids,
&dist,
seed,
)
}
};
if verbose {
println!(" Moving data to GPU.");
}
let client = R::client(&device);
let cent_gpu =
GpuTensor::<R, T>::from_slice(¢roids, vec![n_centroids, dim_padded], &client);
let pnorm_gpu = if dist == Dist::Cosine {
let pnorms: Vec<T> = (0..n)
.map(|i| T::calculate_l2_norm(&data_padded[i * dim_padded..(i + 1) * dim_padded]))
.collect();
GpuTensor::<R, T>::from_slice(&pnorms, vec![n], &client)
} else {
GpuTensor::<R, T>::from_slice(&[T::one()], vec![1], &client)
};
let cnorm_gpu = if dist == Dist::Cosine {
let cnorms: Vec<T> = (0..n_centroids)
.map(|c| T::calculate_l2_norm(¢roids[c * dim_padded..(c + 1) * dim_padded]))
.collect();
GpuTensor::<R, T>::from_slice(&cnorms, vec![n_centroids], &client)
} else {
GpuTensor::<R, T>::from_slice(&[T::one()], vec![1], &client)
};
if verbose {
println!(
" Running Lloyd's iterations (GPU, {}, {})",
if params.fixed {
"fixed"
} else {
"assignment-checked"
},
if params.quantise_to_f16 {
"fp16 data / fp32 accum"
} else {
"native precision"
}
);
}
let (assignments, final_cents) = if params.quantise_to_f16 {
let data_f16: Vec<half::f16> = data_padded
.iter()
.map(|x| half::f16::from_f32(x.to_f32().unwrap_or(0.0)))
.collect();
let data_gpu =
GpuTensor::<R, half::f16>::from_slice(&data_f16, vec![n, dim_padded], &client);
if verbose {
println!(" ... moved data to GPU (fp16): {:.2?}", start.elapsed());
}
run_kmeans_loop::<half::f16, T, R>(
&client,
&data_gpu,
¢_gpu,
&pnorm_gpu,
&cnorm_gpu,
n,
n_centroids,
dim_padded,
&dist,
¶ms,
verbose,
)?
} else {
let data_gpu = GpuTensor::<R, T>::from_slice(&data_padded, vec![n, dim_padded], &client);
if verbose {
println!(" ... moved data to GPU: {:.2?}", start.elapsed());
}
run_kmeans_loop::<T, T, R>(
&client,
&data_gpu,
¢_gpu,
&pnorm_gpu,
&cnorm_gpu,
n,
n_centroids,
dim_padded,
&dist,
¶ms,
verbose,
)?
};
let centroid_mat = Mat::from_fn(n_centroids, dim, |i, j| final_cents[i * dim_padded + j]);
if verbose {
println!(
"Finished GPU-accelerated k-means in {:.2?}.",
start.elapsed()
);
}
Ok((centroid_mat, assignments))
}
#[cfg(test)]
mod tests {
use super::*;
use ann_search_rs::utils::dist::compute_l2_norm;
use cubecl::wgpu::{WgpuDevice, WgpuRuntime};
fn try_device() -> Option<WgpuDevice> {
let device = WgpuDevice::DefaultDevice;
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
WgpuRuntime::client(&device);
}))
.ok()
.map(|_| device)
}
fn run_assign(
data: &[f32],
dim: usize,
n: usize,
cents: &[f32],
k: usize,
metric: &Dist,
device: &WgpuDevice,
) -> Vec<usize> {
let client = WgpuRuntime::client(device);
let data_gpu = GpuTensor::<WgpuRuntime, f32>::from_slice(data, vec![n, dim], &client);
let cent_gpu = GpuTensor::<WgpuRuntime, f32>::from_slice(cents, vec![k, dim], &client);
let assign_gpu = GpuTensor::<WgpuRuntime, u32>::empty(vec![n], &client);
let pnorm_gpu = if *metric == Dist::Cosine {
let v: Vec<f32> = (0..n)
.map(|i| compute_l2_norm(&data[i * dim..(i + 1) * dim]))
.collect();
GpuTensor::<WgpuRuntime, f32>::from_slice(&v, vec![n], &client)
} else {
GpuTensor::<WgpuRuntime, f32>::from_slice(&[1.0f32], vec![1], &client)
};
let cnorm_gpu = if *metric == Dist::Cosine {
let v: Vec<f32> = (0..k)
.map(|c| compute_l2_norm(¢s[c * dim..(c + 1) * dim]))
.collect();
GpuTensor::<WgpuRuntime, f32>::from_slice(&v, vec![k], &client)
} else {
GpuTensor::<WgpuRuntime, f32>::from_slice(&[1.0f32], vec![1], &client)
};
flash_assign_device(
&client,
&data_gpu,
¢_gpu,
&pnorm_gpu,
&cnorm_gpu,
&assign_gpu,
n,
k,
dim,
metric,
);
assign_gpu
.read(&client)
.unwrap()
.into_iter()
.map(|x| x as usize)
.collect()
}
fn run_update(
data: &[f32],
assignments: &[u32],
init_cents: &[f32],
n: usize,
k: usize,
dim: usize,
device: &WgpuDevice,
) -> Vec<f32> {
let client = WgpuRuntime::client(device);
let cube_count = 64usize;
let data_gpu = GpuTensor::<WgpuRuntime, f32>::from_slice(data, vec![n, dim], &client);
let assign_gpu = GpuTensor::<WgpuRuntime, u32>::from_slice(assignments, vec![n], &client);
let cent_gpu = GpuTensor::<WgpuRuntime, f32>::from_slice(init_cents, vec![k, dim], &client);
let privatised_counts = GpuTensor::<WgpuRuntime, u32>::from_slice(
&vec![0u32; cube_count * k],
vec![cube_count * k],
&client,
);
let counts = GpuTensor::<WgpuRuntime, u32>::from_slice(&vec![0u32; k], vec![k], &client);
let offsets =
GpuTensor::<WgpuRuntime, u32>::from_slice(&vec![0u32; k + 1], vec![k + 1], &client);
let all_indices = GpuTensor::<WgpuRuntime, u32>::empty(vec![n], &client);
build_csr_gpu_privatised::<WgpuRuntime>(
&assign_gpu,
n,
k,
cube_count,
&privatised_counts,
&counts,
&offsets,
&all_indices,
&client,
);
segmented_update::<WgpuRuntime, f32, f32>(
&data_gpu,
&all_indices,
&offsets,
¢_gpu,
k,
dim,
&client,
);
cent_gpu.read(&client).unwrap()
}
fn cpu_assign_euclidean(
data: &[f32],
cents: &[f32],
n: usize,
k: usize,
dim: usize,
) -> Vec<usize> {
(0..n)
.map(|i| {
(0..k)
.map(|c| {
(0..dim)
.map(|j| (data[i * dim + j] - cents[c * dim + j]).powi(2))
.sum::<f32>()
})
.enumerate()
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
.map(|(idx, _)| idx)
.unwrap()
})
.collect()
}
fn cpu_assign_cosine(
data: &[f32],
cents: &[f32],
n: usize,
k: usize,
dim: usize,
) -> Vec<usize> {
(0..n)
.map(|i| {
let pnorm = compute_l2_norm(&data[i * dim..(i + 1) * dim]);
(0..k)
.map(|c| {
let cnorm = compute_l2_norm(¢s[c * dim..(c + 1) * dim]);
let dot: f32 = (0..dim)
.map(|j| data[i * dim + j] * cents[c * dim + j])
.sum();
1.0 - dot / (pnorm * cnorm)
})
.enumerate()
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
.map(|(idx, _)| idx)
.unwrap()
})
.collect()
}
fn cpu_centroid_means(
data: &[f32],
assignments: &[usize],
init: &[f32],
n: usize,
k: usize,
dim: usize,
) -> Vec<f32> {
let mut sums = vec![0.0f32; k * dim];
let mut counts = vec![0usize; k];
for i in 0..n {
let c = assignments[i];
counts[c] += 1;
for j in 0..dim {
sums[c * dim + j] += data[i * dim + j];
}
}
let mut out = init.to_vec();
for c in 0..k {
if counts[c] > 0 {
for j in 0..dim {
out[c * dim + j] = sums[c * dim + j] / counts[c] as f32;
}
}
}
out
}
#[test]
fn test_assign_euclidean() {
let Some(device) = try_device() else { return };
let (n, k, dim) = (200, 16, 32);
let data: Vec<f32> = (0..n * dim)
.map(|i| ((i * 7 + 3) % 23) as f32 * 0.2)
.collect();
let cents: Vec<f32> = (0..k * dim)
.map(|i| ((i * 11 + 5) % 19) as f32 * 0.2)
.collect();
let got = run_assign(&data, dim, n, ¢s, k, &Dist::SquaredEuclidean, &device);
assert_eq!(got, cpu_assign_euclidean(&data, ¢s, n, k, dim));
}
#[test]
fn test_assign_cosine() {
let Some(device) = try_device() else { return };
let (n, k, dim) = (120, 8, 32);
let data: Vec<f32> = (0..n * dim)
.map(|i| ((i * 7 + 1) % 11) as f32 + 0.5)
.collect();
let cents: Vec<f32> = (0..k * dim)
.map(|i| ((i * 13 + 3) % 17) as f32 + 0.5)
.collect();
let got = run_assign(&data, dim, n, ¢s, k, &Dist::Cosine, &device);
assert_eq!(got, cpu_assign_cosine(&data, ¢s, n, k, dim));
}
#[test]
fn test_assign_ragged_n() {
let Some(device) = try_device() else { return };
let (n, k, dim) = (137, 5, 8);
let data: Vec<f32> = (0..n * dim).map(|i| ((i * 5 + 2) % 13) as f32).collect();
let cents: Vec<f32> = (0..k * dim).map(|i| ((i * 9 + 4) % 17) as f32).collect();
let got = run_assign(&data, dim, n, ¢s, k, &Dist::SquaredEuclidean, &device);
assert_eq!(got.len(), n);
assert_eq!(got, cpu_assign_euclidean(&data, ¢s, n, k, dim));
}
#[test]
fn test_csr_privatised_matches_cpu() {
let Some(device) = try_device() else { return };
let client = WgpuRuntime::client(&device);
let assignments = vec![0u32, 1, 0, 2, 1, 0, 2, 2];
let (n, k, cube_count) = (assignments.len(), 3usize, 64usize);
let assign_gpu = GpuTensor::<WgpuRuntime, u32>::from_slice(&assignments, vec![n], &client);
let privatised_counts = GpuTensor::<WgpuRuntime, u32>::from_slice(
&vec![0u32; cube_count * k],
vec![cube_count * k],
&client,
);
let counts = GpuTensor::<WgpuRuntime, u32>::from_slice(&vec![0u32; k], vec![k], &client);
let offsets =
GpuTensor::<WgpuRuntime, u32>::from_slice(&vec![0u32; k + 1], vec![k + 1], &client);
let all_indices = GpuTensor::<WgpuRuntime, u32>::empty(vec![n], &client);
build_csr_gpu_privatised::<WgpuRuntime>(
&assign_gpu,
n,
k,
cube_count,
&privatised_counts,
&counts,
&offsets,
&all_indices,
&client,
);
let idx = all_indices.read(&client).unwrap();
let off = offsets.read(&client).unwrap();
let (cpu_idx, cpu_off) = ann_search_rs::utils::k_means_utils::build_csr_layout(
assignments.iter().map(|&c| c as usize).collect(),
n,
k,
);
assert_eq!(off.iter().map(|&o| o as usize).collect::<Vec<_>>(), cpu_off);
for c in 0..k {
let s = off[c] as usize;
let e = off[c + 1] as usize;
let got: std::collections::HashSet<u32> = idx[s..e].iter().copied().collect();
let want: std::collections::HashSet<u32> = cpu_idx[cpu_off[c]..cpu_off[c + 1]]
.iter()
.map(|&i| i as u32)
.collect();
assert_eq!(got, want, "cluster {} membership mismatch", c);
}
}
#[test]
fn test_segmented_update() {
let Some(device) = try_device() else { return };
let (n, k, dim) = (300, 8, 32);
let data: Vec<f32> = (0..n * dim)
.map(|i| ((i * 11 + 5) % 23) as f32 * 0.3)
.collect();
let assignments: Vec<u32> = (0..n).map(|i| (i % k) as u32).collect();
let init = vec![-1.0f32; k * dim];
let got = run_update(&data, &assignments, &init, n, k, dim, &device);
let want = cpu_centroid_means(
&data,
&assignments.iter().map(|&c| c as usize).collect::<Vec<_>>(),
&init,
n,
k,
dim,
);
for j in 0..k * dim {
assert!(
(got[j] - want[j]).abs() < 1e-3,
"elem {}: {} != {}",
j,
got[j],
want[j]
);
}
}
#[test]
fn test_segmented_update_empty_cluster() {
let Some(device) = try_device() else { return };
let (n, k, dim) = (6, 3, 4);
let data: Vec<f32> = (0..n * dim).map(|i| (i + 1) as f32).collect();
let assignments = vec![0u32, 0, 2, 2, 0, 2];
let init = vec![7.0f32; k * dim];
let got = run_update(&data, &assignments, &init, n, k, dim, &device);
for j in 0..dim {
assert!(
(got[dim + j] - 7.0).abs() < 1e-6,
"empty cluster overwritten at dim {}",
j
);
}
}
}