Skip to main content

ailake_index/
gpu.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! GPU-accelerated vector search and k-means.
3//!
4//! Two independent GPU backends, both via runtime `libloading` — no compile-time
5//! GPU SDK required. Either backend returns `None` when its hardware/libraries
6//! are absent; callers fall back to CPU automatically.
7//!
8//!   - NVIDIA CUDA: cuBLAS SGEMM via dlopen of `libcudart` + `libcublas`.
9//!   - AMD ROCm:    hipBLAS SGEMM via dlopen of `libamdhip64` + `libhipblas`.
10
11pub use nvidia_impl::{try_nvidia_kmeans, try_nvidia_search_batch};
12pub use rocm_impl::{try_rocm_kmeans, try_rocm_search_batch};
13
14// ── NVIDIA CUDA backend ───────────────────────────────────────────────────────
15//
16// Always compiled. Returns `None` at runtime when:
17//   - No NVIDIA CUDA driver found (`detect_cuda()` is false)
18//   - cuBLAS / CUDA runtime libraries not installed
19//   - Any GPU allocation or compute error
20//
21// SGEMM formulation identical to ROCm backend; only library names and
22// operation constants differ (CUBLAS_OP_N=0/CUBLAS_OP_T=1 vs HIP 111/112).
23
24mod nvidia_impl {
25    use std::ffi::c_void;
26
27    use ailake_core::{RowId, VectorMetric};
28    use libloading::{Library, Symbol};
29    use tracing::warn;
30
31    // cudaMemcpyKind constants
32    const H2D: i32 = 1; // cudaMemcpyHostToDevice
33    const D2H: i32 = 2; // cudaMemcpyDeviceToHost
34
35    // cublasOperation_t constants
36    const OP_T: i32 = 1; // CUBLAS_OP_T — transpose
37    const OP_N: i32 = 0; // CUBLAS_OP_N — no-transpose
38
39    // Type aliases for CUDA runtime function pointers.
40    type CudaMallocFn = unsafe extern "C" fn(*mut *mut c_void, usize) -> i32;
41    type CudaFreeFn = unsafe extern "C" fn(*mut c_void) -> i32;
42    type CudaMemcpyFn = unsafe extern "C" fn(*mut c_void, *const c_void, usize, i32) -> i32;
43    type CudaSyncFn = unsafe extern "C" fn() -> i32;
44
45    #[cfg(target_os = "linux")]
46    const RT_LIBS: &[&str] = &["libcudart.so", "libcudart.so.12", "libcudart.so.11"];
47    #[cfg(windows)]
48    const RT_LIBS: &[&str] = &["cudart64_12.dll", "cudart64_11.dll"];
49    #[cfg(not(any(target_os = "linux", windows)))]
50    const RT_LIBS: &[&str] = &[];
51
52    #[cfg(target_os = "linux")]
53    const BLAS_LIBS: &[&str] = &["libcublas.so", "libcublas.so.12", "libcublas.so.11"];
54    #[cfg(windows)]
55    const BLAS_LIBS: &[&str] = &["cublas64_12.dll", "cublas64_11.dll"];
56    #[cfg(not(any(target_os = "linux", windows)))]
57    const BLAS_LIBS: &[&str] = &[];
58
59    // cuBLAS SGEMM function pointer type (v2 API, stable since CUDA 4.1).
60    type SgemmFn = unsafe extern "C" fn(
61        *mut c_void, // handle
62        i32,         // transa
63        i32,         // transb
64        i32,         // m
65        i32,         // n
66        i32,         // k
67        *const f32,  // alpha
68        *const c_void,
69        i32, // A, lda
70        *const c_void,
71        i32,        // B, ldb
72        *const f32, // beta
73        *mut c_void,
74        i32, // C, ldc
75    ) -> i32;
76
77    /// RAII guard that frees a CUDA device buffer on drop.
78    struct DevBuf {
79        ptr: *mut c_void,
80        free_fn: CudaFreeFn,
81    }
82
83    impl Drop for DevBuf {
84        fn drop(&mut self) {
85            if !self.ptr.is_null() {
86                unsafe { (self.free_fn)(self.ptr) };
87            }
88        }
89    }
90
91    /// RAII guard that destroys a cuBLAS handle on drop.
92    struct BlasHandle {
93        handle: *mut c_void,
94        destroy_fn: unsafe extern "C" fn(*mut c_void) -> i32,
95    }
96
97    impl Drop for BlasHandle {
98        fn drop(&mut self) {
99            if !self.handle.is_null() {
100                unsafe { (self.destroy_fn)(self.handle) };
101            }
102        }
103    }
104
105    fn try_open(names: &[&str]) -> Option<Library> {
106        names
107            .iter()
108            .find_map(|name| unsafe { Library::new(name) }.ok())
109    }
110
111    // ── Public API ────────────────────────────────────────────────────────────
112
113    /// Batch top-k vector search on an NVIDIA GPU via cuBLAS SGEMM.
114    ///
115    /// Computes the [Q×N] distance matrix in a single SGEMM call, then sorts
116    /// top-k on CPU. Returns `None` when no CUDA device is found or on any
117    /// GPU error — the caller must fall back to CPU.
118    pub fn try_nvidia_search_batch(
119        queries: &[&[f32]],
120        row_ids: &[u64],
121        flat_vecs: &[f32],
122        dim: usize,
123        metric: VectorMetric,
124        top_k: usize,
125    ) -> Option<Vec<Vec<(RowId, f32)>>> {
126        if !crate::hardware::detect_cuda() {
127            return None;
128        }
129        if RT_LIBS.is_empty() || BLAS_LIBS.is_empty() {
130            return None;
131        }
132        let q = queries.len();
133        if row_ids.is_empty() || q == 0 {
134            return Some(vec![vec![]; q]);
135        }
136        let result = batch_inner(queries, row_ids, flat_vecs, dim, metric, top_k);
137        if result.is_none() {
138            warn!(
139                "ailake: NVIDIA GPU search failed at runtime (cuBLAS error or allocation failure); \
140                 falling back to CPU SIMD — check CUDA runtime libraries and available GPU memory"
141            );
142        }
143        result
144    }
145
146    /// k-means on an NVIDIA GPU via cuBLAS SGEMM.
147    ///
148    /// Distance matrix (assignment step) computed on GPU.
149    /// Centroid update runs on CPU. Returns `None` on any GPU error.
150    pub fn try_nvidia_kmeans(
151        vectors: &[Vec<f32>],
152        k: usize,
153        max_iter: usize,
154    ) -> Option<Vec<Vec<f32>>> {
155        if !crate::hardware::detect_cuda() {
156            return None;
157        }
158        if RT_LIBS.is_empty() || BLAS_LIBS.is_empty() {
159            return None;
160        }
161        if vectors.is_empty() {
162            return Some(vec![]);
163        }
164        let n = vectors.len();
165        let dim = vectors[0].len();
166        let k = k.min(n);
167        let result = kmeans_inner(vectors, k, max_iter, n, dim);
168        if result.is_none() {
169            warn!(
170                "ailake: NVIDIA GPU k-means failed at runtime (cuBLAS error or allocation failure); \
171                 falling back to CPU k-means — check CUDA runtime libraries and available GPU memory"
172            );
173        }
174        result
175    }
176
177    // ── Internal helpers ──────────────────────────────────────────────────────
178
179    unsafe fn load_cuda_fns(
180        rt: &Library,
181    ) -> Option<(CudaMallocFn, CudaFreeFn, CudaMemcpyFn, CudaSyncFn)> {
182        let malloc_sym: Symbol<CudaMallocFn> = rt.get(b"cudaMalloc\0").ok()?;
183        let free_sym: Symbol<CudaFreeFn> = rt.get(b"cudaFree\0").ok()?;
184        let memcpy_sym: Symbol<CudaMemcpyFn> = rt.get(b"cudaMemcpy\0").ok()?;
185        let sync_sym: Symbol<CudaSyncFn> = rt.get(b"cudaDeviceSynchronize\0").ok()?;
186        Some((*malloc_sym, *free_sym, *memcpy_sym, *sync_sym))
187    }
188
189    unsafe fn upload(
190        data: &[f32],
191        malloc_fn: CudaMallocFn,
192        free_fn: CudaFreeFn,
193        memcpy_fn: CudaMemcpyFn,
194    ) -> Option<DevBuf> {
195        let bytes = std::mem::size_of_val(data);
196        let mut ptr: *mut c_void = std::ptr::null_mut();
197        if malloc_fn(&mut ptr, bytes) != 0 {
198            return None;
199        }
200        let buf = DevBuf { ptr, free_fn };
201        if memcpy_fn(ptr, data.as_ptr() as *const c_void, bytes, H2D) != 0 {
202            return None;
203        }
204        Some(buf)
205    }
206
207    unsafe fn alloc_dev(
208        len: usize,
209        malloc_fn: CudaMallocFn,
210        free_fn: CudaFreeFn,
211    ) -> Option<DevBuf> {
212        let bytes = len * std::mem::size_of::<f32>();
213        let mut ptr: *mut c_void = std::ptr::null_mut();
214        if malloc_fn(&mut ptr, bytes) != 0 {
215            return None;
216        }
217        Some(DevBuf { ptr, free_fn })
218    }
219
220    fn normalize_rows(mut data: Vec<f32>, dim: usize) -> Vec<f32> {
221        for row in data.chunks_mut(dim) {
222            let norm: f32 = row.iter().map(|x| x * x).sum::<f32>().sqrt();
223            if norm > 1e-8 {
224                row.iter_mut().for_each(|x| *x /= norm);
225            }
226        }
227        data
228    }
229
230    fn batch_inner(
231        queries: &[&[f32]],
232        row_ids: &[u64],
233        flat_vecs: &[f32],
234        dim: usize,
235        metric: VectorMetric,
236        top_k: usize,
237    ) -> Option<Vec<Vec<(RowId, f32)>>> {
238        let n = row_ids.len();
239        let q = queries.len();
240
241        let rt = try_open(RT_LIBS)?;
242        let blas_lib = try_open(BLAS_LIBS)?;
243
244        let (cuda_malloc, cuda_free, cuda_memcpy, cuda_sync) = unsafe { load_cuda_fns(&rt) }?;
245
246        let blas_create: Symbol<unsafe extern "C" fn(*mut *mut c_void) -> i32> =
247            unsafe { blas_lib.get(b"cublasCreate_v2\0") }.ok()?;
248        let blas_destroy: unsafe extern "C" fn(*mut c_void) -> i32 = *unsafe {
249            blas_lib.get::<unsafe extern "C" fn(*mut c_void) -> i32>(b"cublasDestroy_v2\0")
250        }
251        .ok()?;
252        let sgemm: Symbol<SgemmFn> = unsafe { blas_lib.get(b"cublasSgemm_v2\0") }.ok()?;
253
254        let mut raw_handle: *mut c_void = std::ptr::null_mut();
255        if unsafe { blas_create(&mut raw_handle) } != 0 {
256            return None;
257        }
258        let _blas = BlasHandle {
259            handle: raw_handle,
260            destroy_fn: blas_destroy,
261        };
262
263        let q_flat: Vec<f32>;
264        let db_data: &[f32];
265        let q_data: &[f32];
266        let q_owned;
267        let db_owned;
268
269        match metric {
270            VectorMetric::Cosine => {
271                q_owned = normalize_rows(
272                    queries.iter().flat_map(|q| q.iter().copied()).collect(),
273                    dim,
274                );
275                db_owned = normalize_rows(flat_vecs.to_vec(), dim);
276                q_data = &q_owned;
277                db_data = &db_owned;
278            }
279            _ => {
280                q_flat = queries.iter().flat_map(|q| q.iter().copied()).collect();
281                q_data = &q_flat;
282                db_data = flat_vecs;
283            }
284        }
285
286        let db_dev = unsafe { upload(db_data, cuda_malloc, cuda_free, cuda_memcpy) }?;
287        let q_dev = unsafe { upload(q_data, cuda_malloc, cuda_free, cuda_memcpy) }?;
288        let c_dev = unsafe { alloc_dev(n * q, cuda_malloc, cuda_free) }?;
289
290        // SGEMM: C[N×Q col-major] = alpha * db[N×dim]^T * queries[Q×dim]
291        // C[n + q*N] = dot(db[n], query[q])
292        let (alpha, beta) = match metric {
293            VectorMetric::DotProduct | VectorMetric::NormalizedCosine => (-1.0f32, 0.0f32),
294            VectorMetric::Cosine => (-1.0f32, 0.0f32),
295            VectorMetric::Euclidean => (-2.0f32, 0.0f32),
296        };
297
298        let rc = unsafe {
299            sgemm(
300                raw_handle,
301                OP_T,
302                OP_N,
303                n as i32,
304                q as i32,
305                dim as i32,
306                &alpha,
307                db_dev.ptr as *const c_void,
308                dim as i32,
309                q_dev.ptr as *const c_void,
310                dim as i32,
311                &beta,
312                c_dev.ptr,
313                n as i32,
314            )
315        };
316        if rc != 0 {
317            return None;
318        }
319        if unsafe { cuda_sync() } != 0 {
320            return None;
321        }
322
323        let mut c_host = vec![0.0f32; n * q];
324        if unsafe {
325            cuda_memcpy(
326                c_host.as_mut_ptr() as *mut c_void,
327                c_dev.ptr as *const c_void,
328                n * q * std::mem::size_of::<f32>(),
329                D2H,
330            )
331        } != 0
332        {
333            return None;
334        }
335
336        let db_sq: Option<Vec<f32>> = if matches!(metric, VectorMetric::Euclidean) {
337            Some(
338                (0..n)
339                    .map(|ni| {
340                        flat_vecs[ni * dim..(ni + 1) * dim]
341                            .iter()
342                            .map(|x| x * x)
343                            .sum()
344                    })
345                    .collect(),
346            )
347        } else {
348            None
349        };
350
351        let results = (0..q)
352            .map(|qi| {
353                let dists: Vec<f32> = (0..n)
354                    .map(|ni| {
355                        let raw = c_host[ni + qi * n];
356                        match metric {
357                            VectorMetric::DotProduct => raw,
358                            VectorMetric::Cosine | VectorMetric::NormalizedCosine => 1.0 + raw,
359                            VectorMetric::Euclidean => {
360                                let q_sq: f32 = queries[qi].iter().map(|x| x * x).sum();
361                                (q_sq + db_sq.as_ref().unwrap()[ni] + raw).max(0.0).sqrt()
362                            }
363                        }
364                    })
365                    .collect();
366
367                let mut indexed: Vec<(usize, f32)> = dists.into_iter().enumerate().collect();
368                indexed.sort_unstable_by(|a, b| {
369                    a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)
370                });
371                indexed.truncate(top_k);
372                indexed
373                    .into_iter()
374                    .map(|(i, d)| (RowId::new(row_ids[i]), d))
375                    .collect()
376            })
377            .collect();
378
379        Some(results)
380    }
381
382    fn kmeans_inner(
383        vectors: &[Vec<f32>],
384        k: usize,
385        max_iter: usize,
386        n: usize,
387        dim: usize,
388    ) -> Option<Vec<Vec<f32>>> {
389        let rt = try_open(RT_LIBS)?;
390        let blas_lib = try_open(BLAS_LIBS)?;
391
392        let (cuda_malloc, cuda_free, cuda_memcpy, cuda_sync) = unsafe { load_cuda_fns(&rt) }?;
393
394        let blas_create: Symbol<unsafe extern "C" fn(*mut *mut c_void) -> i32> =
395            unsafe { blas_lib.get(b"cublasCreate_v2\0") }.ok()?;
396        let blas_destroy: unsafe extern "C" fn(*mut c_void) -> i32 = *unsafe {
397            blas_lib.get::<unsafe extern "C" fn(*mut c_void) -> i32>(b"cublasDestroy_v2\0")
398        }
399        .ok()?;
400        let sgemm: Symbol<SgemmFn> = unsafe { blas_lib.get(b"cublasSgemm_v2\0") }.ok()?;
401
402        let mut raw_handle: *mut c_void = std::ptr::null_mut();
403        if unsafe { blas_create(&mut raw_handle) } != 0 {
404            return None;
405        }
406        let _blas = BlasHandle {
407            handle: raw_handle,
408            destroy_fn: blas_destroy,
409        };
410
411        let flat: Vec<f32> = vectors.iter().flat_map(|v| v.iter().copied()).collect();
412        let x_dev = unsafe { upload(&flat, cuda_malloc, cuda_free, cuda_memcpy) }?;
413
414        let x_sq: Vec<f32> = vectors
415            .iter()
416            .map(|v| v.iter().map(|x| x * x).sum())
417            .collect();
418
419        let step = n / k;
420        let mut centroids_flat: Vec<f32> = (0..k)
421            .flat_map(|i| vectors[(i * step) % n].iter().copied())
422            .collect();
423
424        let mut prev_asgn: Vec<u32> = vec![];
425
426        for _ in 0..max_iter {
427            let c_dev = unsafe { upload(&centroids_flat, cuda_malloc, cuda_free, cuda_memcpy) }?;
428            let cross_dev = unsafe { alloc_dev(k * n, cuda_malloc, cuda_free) }?;
429
430            // SGEMM: cross[K×N col-major] = -2 * centroids[K×dim] * vectors[N×dim]^T
431            let alpha = -2.0f32;
432            let beta = 0.0f32;
433            let rc = unsafe {
434                sgemm(
435                    raw_handle,
436                    OP_T,
437                    OP_N,
438                    k as i32,
439                    n as i32,
440                    dim as i32,
441                    &alpha,
442                    c_dev.ptr as *const c_void,
443                    dim as i32,
444                    x_dev.ptr as *const c_void,
445                    dim as i32,
446                    &beta,
447                    cross_dev.ptr,
448                    k as i32,
449                )
450            };
451            if rc != 0 {
452                return None;
453            }
454            if unsafe { cuda_sync() } != 0 {
455                return None;
456            }
457
458            let mut cross_host = vec![0.0f32; k * n];
459            if unsafe {
460                cuda_memcpy(
461                    cross_host.as_mut_ptr() as *mut c_void,
462                    cross_dev.ptr as *const c_void,
463                    k * n * std::mem::size_of::<f32>(),
464                    D2H,
465                )
466            } != 0
467            {
468                return None;
469            }
470
471            let c_sq: Vec<f32> = centroids_flat
472                .chunks(dim)
473                .map(|c| c.iter().map(|x| x * x).sum())
474                .collect();
475
476            let asgn: Vec<u32> = (0..n)
477                .map(|ni| {
478                    let base = &cross_host[ni * k..(ni + 1) * k];
479                    let best = (0..k)
480                        .min_by(|&a, &b| {
481                            let da = x_sq[ni] + c_sq[a] + base[a];
482                            let db = x_sq[ni] + c_sq[b] + base[b];
483                            da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
484                        })
485                        .unwrap_or(0);
486                    best as u32
487                })
488                .collect();
489
490            if asgn == prev_asgn {
491                break;
492            }
493
494            let mut new_flat = vec![0.0f32; k * dim];
495            let mut counts = vec![0usize; k];
496            for (i, &ci) in asgn.iter().enumerate() {
497                let ci = ci as usize;
498                for (d, &v) in vectors[i].iter().enumerate() {
499                    new_flat[ci * dim + d] += v;
500                }
501                counts[ci] += 1;
502            }
503            for j in 0..k {
504                if counts[j] > 0 {
505                    let inv = 1.0 / counts[j] as f32;
506                    new_flat[j * dim..(j + 1) * dim]
507                        .iter_mut()
508                        .for_each(|x| *x *= inv);
509                } else {
510                    new_flat[j * dim..(j + 1) * dim]
511                        .copy_from_slice(&centroids_flat[j * dim..(j + 1) * dim]);
512                }
513            }
514
515            centroids_flat = new_flat;
516            prev_asgn = asgn;
517        }
518
519        Some(centroids_flat.chunks(dim).map(|c| c.to_vec()).collect())
520    }
521}
522
523// ── Tests ─────────────────────────────────────────────────────────────────────
524
525#[cfg(test)]
526mod tests {
527    use ailake_core::{RowId, VectorMetric};
528
529    fn gpu_backend() -> String {
530        std::env::var("AILAKE_GPU_BACKEND").unwrap_or_else(|_| "none".into())
531    }
532
533    // Deterministic synthetic vectors: sin((i*dim + d + 1) as f32)
534    fn make_vecs(n: usize, dim: usize) -> Vec<Vec<f32>> {
535        (0..n)
536            .map(|i| (0..dim).map(|d| ((i * dim + d + 1) as f32).sin()).collect())
537            .collect()
538    }
539
540    // Fire a real SGEMM on whichever GPU backend is present.
541    // Query == vecs[0] → top-1 must be row 0 at distance ≈ 0.
542    #[test]
543    fn gpu_search_batch_cosine_top1_exact() {
544        let backend = gpu_backend();
545        if backend == "none" {
546            println!("AILAKE_GPU_BACKEND=none — skipping gpu_search_batch_cosine_top1_exact");
547            return;
548        }
549        let dim = 16;
550        let vecs = make_vecs(64, dim);
551        let flat: Vec<f32> = vecs.iter().flat_map(|v| v.iter().copied()).collect();
552        let row_ids: Vec<u64> = (0..64).collect();
553        let q = vecs[0].clone();
554        let queries: &[&[f32]] = &[q.as_slice()];
555
556        let got = match backend.as_str() {
557            "cuda" => super::try_nvidia_search_batch(
558                queries,
559                &row_ids,
560                &flat,
561                dim,
562                VectorMetric::Cosine,
563                5,
564            ),
565            "rocm" => {
566                super::try_rocm_search_batch(queries, &row_ids, &flat, dim, VectorMetric::Cosine, 5)
567            }
568            other => panic!("unknown AILAKE_GPU_BACKEND={other}"),
569        };
570
571        let got = got.expect("GPU cosine search returned None — check driver/library installation");
572        assert_eq!(got.len(), 1);
573        let (top_row, top_dist) = got[0][0];
574        assert_eq!(top_row, RowId::new(0), "top-1 must be the query itself");
575        assert!(
576            top_dist < 1e-3,
577            "cosine dist to self must be ≈0, got {top_dist}"
578        );
579    }
580
581    // Same but with Euclidean metric and a different anchor vector.
582    #[test]
583    fn gpu_search_batch_euclidean_top1_exact() {
584        let backend = gpu_backend();
585        if backend == "none" {
586            println!("AILAKE_GPU_BACKEND=none — skipping gpu_search_batch_euclidean_top1_exact");
587            return;
588        }
589        let dim = 8;
590        let vecs = make_vecs(32, dim);
591        let flat: Vec<f32> = vecs.iter().flat_map(|v| v.iter().copied()).collect();
592        let row_ids: Vec<u64> = (0..32).collect();
593        let q = vecs[7].clone();
594        let queries: &[&[f32]] = &[q.as_slice()];
595
596        let got = match backend.as_str() {
597            "cuda" => super::try_nvidia_search_batch(
598                queries,
599                &row_ids,
600                &flat,
601                dim,
602                VectorMetric::Euclidean,
603                3,
604            ),
605            "rocm" => super::try_rocm_search_batch(
606                queries,
607                &row_ids,
608                &flat,
609                dim,
610                VectorMetric::Euclidean,
611                3,
612            ),
613            other => panic!("unknown AILAKE_GPU_BACKEND={other}"),
614        };
615
616        let got = got.expect("GPU euclidean search returned None");
617        let (top_row, top_dist) = got[0][0];
618        assert_eq!(top_row, RowId::new(7), "top-1 must be the query itself");
619        assert!(
620            top_dist < 1e-4,
621            "euclidean dist to self must be 0, got {top_dist}"
622        );
623    }
624
625    // k-means on GPU must return exactly k centroids of the right dimension.
626    #[test]
627    fn gpu_kmeans_returns_k_centroids() {
628        let backend = gpu_backend();
629        if backend == "none" {
630            println!("AILAKE_GPU_BACKEND=none — skipping gpu_kmeans_returns_k_centroids");
631            return;
632        }
633        let dim = 8;
634        let k = 4usize;
635        // 4 well-separated clusters × 10 vectors each
636        let vecs: Vec<Vec<f32>> = (0..k)
637            .flat_map(|c| {
638                (0..10).map(move |_| {
639                    (0..dim)
640                        .map(|d| c as f32 * 20.0 + d as f32 * 0.01)
641                        .collect()
642                })
643            })
644            .collect();
645
646        let centroids = match backend.as_str() {
647            "cuda" => super::try_nvidia_kmeans(&vecs, k, 20),
648            "rocm" => super::try_rocm_kmeans(&vecs, k, 20),
649            other => panic!("unknown AILAKE_GPU_BACKEND={other}"),
650        };
651
652        let centroids =
653            centroids.expect("GPU k-means returned None — check driver/library installation");
654        assert_eq!(
655            centroids.len(),
656            k,
657            "expected {k} centroids, got {}",
658            centroids.len()
659        );
660        for c in &centroids {
661            assert_eq!(
662                c.len(),
663                dim,
664                "centroid dim mismatch: expected {dim}, got {}",
665                c.len()
666            );
667        }
668    }
669}
670
671// ── AMD ROCm backend ─────────────────────────────────────────────────────────
672//
673// Always compiled (no feature gate). Returns `None` at runtime when:
674//   - No AMD HIP driver found (`detect_rocm()` is false)
675//   - hipBLAS library not installed
676//   - Any GPU allocation or compute error
677//
678// Distance matrix computed via hipBLAS SGEMM. Norm computation and argmin
679// run on CPU (O((n+k)·dim) vs O(n·k·dim) for SGEMM — negligible overhead).
680
681mod rocm_impl {
682    use std::ffi::c_void;
683
684    use ailake_core::{RowId, VectorMetric};
685    use libloading::{Library, Symbol};
686    use tracing::warn;
687
688    // hipMemcpyKind constants
689    const H2D: i32 = 1; // hipMemcpyHostToDevice
690    const D2H: i32 = 2; // hipMemcpyDeviceToHost
691
692    // hipblasOperation_t constants (same values as cuBLAS)
693    const OP_T: i32 = 112; // HIPBLAS_OP_T — transpose
694    const OP_N: i32 = 111; // HIPBLAS_OP_N — no-transpose
695
696    // Type aliases for HIP runtime function pointers (avoids clippy::type_complexity).
697    type HipMallocFn = unsafe extern "C" fn(*mut *mut c_void, usize) -> i32;
698    type HipFreeFn = unsafe extern "C" fn(*mut c_void) -> i32;
699    type HipMemcpyFn = unsafe extern "C" fn(*mut c_void, *const c_void, usize, i32) -> i32;
700    type HipSyncFn = unsafe extern "C" fn() -> i32;
701
702    #[cfg(target_os = "linux")]
703    const HIP_LIB: &str = "libamdhip64.so";
704    #[cfg(windows)]
705    const HIP_LIB: &str = "amdhip64.dll";
706    #[cfg(not(any(target_os = "linux", windows)))]
707    const HIP_LIB: &str = "";
708
709    #[cfg(target_os = "linux")]
710    const BLAS_LIB: &str = "libhipblas.so";
711    #[cfg(windows)]
712    const BLAS_LIB: &str = "hipblas.dll";
713    #[cfg(not(any(target_os = "linux", windows)))]
714    const BLAS_LIB: &str = "";
715
716    // hipBLAS SGEMM function pointer type.
717    type SgemmFn = unsafe extern "C" fn(
718        *mut c_void, // handle
719        i32,         // transa
720        i32,         // transb
721        i32,         // m
722        i32,         // n
723        i32,         // k
724        *const f32,  // alpha
725        *const c_void,
726        i32, // A, lda
727        *const c_void,
728        i32,        // B, ldb
729        *const f32, // beta
730        *mut c_void,
731        i32, // C, ldc
732    ) -> i32;
733
734    /// RAII guard that frees a HIP device buffer on drop.
735    struct DevBuf {
736        ptr: *mut c_void,
737        free_fn: unsafe extern "C" fn(*mut c_void) -> i32,
738    }
739
740    impl Drop for DevBuf {
741        fn drop(&mut self) {
742            if !self.ptr.is_null() {
743                unsafe { (self.free_fn)(self.ptr) };
744            }
745        }
746    }
747
748    /// RAII guard that destroys a hipBLAS handle on drop.
749    struct BlasHandle {
750        handle: *mut c_void,
751        destroy_fn: unsafe extern "C" fn(*mut c_void) -> i32,
752    }
753
754    impl Drop for BlasHandle {
755        fn drop(&mut self) {
756            if !self.handle.is_null() {
757                unsafe { (self.destroy_fn)(self.handle) };
758            }
759        }
760    }
761
762    // ── Public API ────────────────────────────────────────────────────────────
763
764    /// Batch top-k vector search on an AMD ROCm GPU via hipBLAS SGEMM.
765    ///
766    /// Computes the [Q×N] distance matrix in a single SGEMM call, then sorts
767    /// top-k on the CPU. Returns `None` when no ROCm device is found or on any
768    /// GPU error — the caller must fall back to CPU.
769    pub fn try_rocm_search_batch(
770        queries: &[&[f32]],
771        row_ids: &[u64],
772        flat_vecs: &[f32],
773        dim: usize,
774        metric: VectorMetric,
775        top_k: usize,
776    ) -> Option<Vec<Vec<(RowId, f32)>>> {
777        if !crate::hardware::detect_rocm() {
778            return None;
779        }
780        if HIP_LIB.is_empty() || BLAS_LIB.is_empty() {
781            return None;
782        }
783        let n = row_ids.len();
784        let q = queries.len();
785        if n == 0 || q == 0 {
786            return Some(vec![vec![]; q]);
787        }
788        let result = batch_inner(queries, row_ids, flat_vecs, dim, metric, top_k);
789        if result.is_none() {
790            warn!(
791                "ailake: AMD ROCm GPU search failed at runtime (hipBLAS error or allocation failure); \
792                 falling back to CPU SIMD — check ROCm runtime libraries and available GPU memory"
793            );
794        }
795        result
796    }
797
798    /// k-means on an AMD ROCm GPU.
799    ///
800    /// Distance matrix (assignment step) computed via hipBLAS SGEMM.
801    /// Centroid update runs on CPU. Returns `None` on any GPU error.
802    pub fn try_rocm_kmeans(
803        vectors: &[Vec<f32>],
804        k: usize,
805        max_iter: usize,
806    ) -> Option<Vec<Vec<f32>>> {
807        if !crate::hardware::detect_rocm() {
808            return None;
809        }
810        if HIP_LIB.is_empty() || BLAS_LIB.is_empty() {
811            return None;
812        }
813        let n = vectors.len();
814        if n == 0 {
815            return Some(vec![]);
816        }
817        let dim = vectors[0].len();
818        let k = k.min(n);
819        let result = kmeans_inner(vectors, k, max_iter, n, dim);
820        if result.is_none() {
821            warn!(
822                "ailake: AMD ROCm GPU k-means failed at runtime (hipBLAS error or allocation failure); \
823                 falling back to CPU k-means — check ROCm runtime libraries and available GPU memory"
824            );
825        }
826        result
827    }
828
829    // ── Internal helpers ──────────────────────────────────────────────────────
830
831    /// Load HIP memory functions from `libamdhip64`.
832    ///
833    /// Returns: (malloc_fn, free_fn, memcpy_fn, sync_fn)
834    unsafe fn load_hip_fns(
835        lib: &Library,
836    ) -> Option<(HipMallocFn, HipFreeFn, HipMemcpyFn, HipSyncFn)> {
837        let malloc_sym: Symbol<HipMallocFn> = lib.get(b"hipMalloc\0").ok()?;
838        let free_sym: Symbol<HipFreeFn> = lib.get(b"hipFree\0").ok()?;
839        let memcpy_sym: Symbol<HipMemcpyFn> = lib.get(b"hipMemcpy\0").ok()?;
840        let sync_sym: Symbol<HipSyncFn> = lib.get(b"hipDeviceSynchronize\0").ok()?;
841        Some((*malloc_sym, *free_sym, *memcpy_sym, *sync_sym))
842    }
843
844    /// Allocate a device buffer and upload host data.
845    unsafe fn upload(
846        data: &[f32],
847        malloc_fn: HipMallocFn,
848        free_fn: HipFreeFn,
849        memcpy_fn: HipMemcpyFn,
850    ) -> Option<DevBuf> {
851        let bytes = std::mem::size_of_val(data);
852        let mut ptr: *mut c_void = std::ptr::null_mut();
853        if malloc_fn(&mut ptr, bytes) != 0 {
854            return None;
855        }
856        let buf = DevBuf { ptr, free_fn };
857        if memcpy_fn(ptr, data.as_ptr() as *const c_void, bytes, H2D) != 0 {
858            return None;
859        }
860        Some(buf)
861    }
862
863    /// Allocate an uninitialised device buffer.
864    unsafe fn alloc_dev(len: usize, malloc_fn: HipMallocFn, free_fn: HipFreeFn) -> Option<DevBuf> {
865        let bytes = len * std::mem::size_of::<f32>();
866        let mut ptr: *mut c_void = std::ptr::null_mut();
867        if malloc_fn(&mut ptr, bytes) != 0 {
868            return None;
869        }
870        Some(DevBuf { ptr, free_fn })
871    }
872
873    /// Per-row L2 normalisation (CPU, in-place on owned Vec).
874    fn normalize_rows(mut data: Vec<f32>, dim: usize) -> Vec<f32> {
875        for row in data.chunks_mut(dim) {
876            let norm: f32 = row.iter().map(|x| x * x).sum::<f32>().sqrt();
877            if norm > 1e-8 {
878                row.iter_mut().for_each(|x| *x /= norm);
879            }
880        }
881        data
882    }
883
884    fn batch_inner(
885        queries: &[&[f32]],
886        row_ids: &[u64],
887        flat_vecs: &[f32],
888        dim: usize,
889        metric: VectorMetric,
890        top_k: usize,
891    ) -> Option<Vec<Vec<(RowId, f32)>>> {
892        let n = row_ids.len();
893        let q = queries.len();
894        // Load libraries
895        let hip = unsafe { Library::new(HIP_LIB) }.ok()?;
896        let blas_lib = unsafe { Library::new(BLAS_LIB) }.ok()?;
897
898        let (hip_malloc, hip_free, hip_memcpy, hip_sync) = unsafe { load_hip_fns(&hip) }?;
899
900        let blas_create: Symbol<unsafe extern "C" fn(*mut *mut c_void) -> i32> =
901            unsafe { blas_lib.get(b"hipblasCreate\0") }.ok()?;
902        let blas_destroy: unsafe extern "C" fn(*mut c_void) -> i32 = *unsafe {
903            blas_lib.get::<unsafe extern "C" fn(*mut c_void) -> i32>(b"hipblasDestroy\0")
904        }
905        .ok()?;
906        let sgemm: Symbol<SgemmFn> = unsafe { blas_lib.get(b"hipblasSgemm\0") }.ok()?;
907
908        // Create hipBLAS handle
909        let mut raw_handle: *mut c_void = std::ptr::null_mut();
910        if unsafe { blas_create(&mut raw_handle) } != 0 {
911            return None;
912        }
913        let _blas = BlasHandle {
914            handle: raw_handle,
915            destroy_fn: blas_destroy,
916        };
917
918        // Optionally normalise before computing dot products (Cosine metric)
919        let q_flat: Vec<f32>;
920        let db_data: &[f32];
921        let q_data: &[f32];
922        let q_owned;
923        let db_owned;
924
925        match metric {
926            VectorMetric::Cosine => {
927                q_owned = normalize_rows(
928                    queries.iter().flat_map(|q| q.iter().copied()).collect(),
929                    dim,
930                );
931                db_owned = normalize_rows(flat_vecs.to_vec(), dim);
932                q_data = &q_owned;
933                db_data = &db_owned;
934            }
935            _ => {
936                q_flat = queries.iter().flat_map(|q| q.iter().copied()).collect();
937                q_data = &q_flat;
938                db_data = flat_vecs;
939            }
940        }
941
942        // Upload matrices
943        let db_dev = unsafe { upload(db_data, hip_malloc, hip_free, hip_memcpy) }?;
944        let q_dev = unsafe { upload(q_data, hip_malloc, hip_free, hip_memcpy) }?;
945        let c_dev = unsafe { alloc_dev(n * q, hip_malloc, hip_free) }?;
946
947        // SGEMM: C[N×Q col-major] = db[N×dim row-major] * queries[Q×dim row-major]ᵀ
948        //
949        // BLAS col-major call: C = op(A) * op(B)
950        //   op(A) = db^T  (OP_T, col-major dim×N → transpose to N×dim), lda=dim
951        //   op(B) = queries (OP_N, col-major dim×Q), ldb=dim
952        //   m=N, n=Q, k=dim → C is N×Q col-major, ldc=N
953        //
954        // Result: C[n + q*N] = dot(db[n], query[q])
955        let (alpha, beta) = match metric {
956            VectorMetric::DotProduct | VectorMetric::NormalizedCosine => (-1.0f32, 0.0f32), // negate → min-distance semantics
957            VectorMetric::Cosine => (-1.0f32, 0.0f32), // 1 − cos added below
958            VectorMetric::Euclidean => (-2.0f32, 0.0f32), // −2·q·dᵀ; norms added below
959        };
960
961        let rc = unsafe {
962            sgemm(
963                raw_handle,
964                OP_T,
965                OP_N,
966                n as i32,
967                q as i32,
968                dim as i32,
969                &alpha,
970                db_dev.ptr as *const c_void,
971                dim as i32,
972                q_dev.ptr as *const c_void,
973                dim as i32,
974                &beta,
975                c_dev.ptr,
976                n as i32,
977            )
978        };
979        if rc != 0 {
980            return None;
981        }
982        if unsafe { hip_sync() } != 0 {
983            return None;
984        }
985
986        // Copy result back: c_host[n + q*N] = dot(db[n], query[q])
987        let mut c_host = vec![0.0f32; n * q];
988        if unsafe {
989            hip_memcpy(
990                c_host.as_mut_ptr() as *mut c_void,
991                c_dev.ptr as *const c_void,
992                n * q * std::mem::size_of::<f32>(),
993                D2H,
994            )
995        } != 0
996        {
997            return None;
998        }
999
1000        // Pre-compute per-vector norms for Euclidean distance
1001        let db_sq: Option<Vec<f32>> = if matches!(metric, VectorMetric::Euclidean) {
1002            Some(
1003                (0..n)
1004                    .map(|ni| {
1005                        flat_vecs[ni * dim..(ni + 1) * dim]
1006                            .iter()
1007                            .map(|x| x * x)
1008                            .sum()
1009                    })
1010                    .collect(),
1011            )
1012        } else {
1013            None
1014        };
1015
1016        let results = (0..q)
1017            .map(|qi| {
1018                let dists: Vec<f32> = (0..n)
1019                    .map(|ni| {
1020                        let raw = c_host[ni + qi * n];
1021                        match metric {
1022                            // raw = −dot → already min-distance order
1023                            VectorMetric::DotProduct => raw,
1024                            // raw = −cos_sim → 1 − cos_sim = 1 + raw
1025                            VectorMetric::Cosine | VectorMetric::NormalizedCosine => 1.0 + raw,
1026                            // raw = −2·q·d → add ||q||² + ||d||², clamp, sqrt
1027                            VectorMetric::Euclidean => {
1028                                let q_sq: f32 = queries[qi].iter().map(|x| x * x).sum();
1029                                (q_sq + db_sq.as_ref().unwrap()[ni] + raw).max(0.0).sqrt()
1030                            }
1031                        }
1032                    })
1033                    .collect();
1034
1035                let mut indexed: Vec<(usize, f32)> = dists.into_iter().enumerate().collect();
1036                indexed.sort_unstable_by(|a, b| {
1037                    a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)
1038                });
1039                indexed.truncate(top_k);
1040                indexed
1041                    .into_iter()
1042                    .map(|(i, d)| (RowId::new(row_ids[i]), d))
1043                    .collect()
1044            })
1045            .collect();
1046
1047        Some(results)
1048    }
1049
1050    fn kmeans_inner(
1051        vectors: &[Vec<f32>],
1052        k: usize,
1053        max_iter: usize,
1054        n: usize,
1055        dim: usize,
1056    ) -> Option<Vec<Vec<f32>>> {
1057        let hip = unsafe { Library::new(HIP_LIB) }.ok()?;
1058        let blas_lib = unsafe { Library::new(BLAS_LIB) }.ok()?;
1059
1060        let (hip_malloc, hip_free, hip_memcpy, hip_sync) = unsafe { load_hip_fns(&hip) }?;
1061
1062        let blas_create: Symbol<unsafe extern "C" fn(*mut *mut c_void) -> i32> =
1063            unsafe { blas_lib.get(b"hipblasCreate\0") }.ok()?;
1064        let blas_destroy: unsafe extern "C" fn(*mut c_void) -> i32 = *unsafe {
1065            blas_lib.get::<unsafe extern "C" fn(*mut c_void) -> i32>(b"hipblasDestroy\0")
1066        }
1067        .ok()?;
1068        let sgemm: Symbol<SgemmFn> = unsafe { blas_lib.get(b"hipblasSgemm\0") }.ok()?;
1069
1070        let mut raw_handle: *mut c_void = std::ptr::null_mut();
1071        if unsafe { blas_create(&mut raw_handle) } != 0 {
1072            return None;
1073        }
1074        let _blas = BlasHandle {
1075            handle: raw_handle,
1076            destroy_fn: blas_destroy,
1077        };
1078
1079        // Upload all vectors once (never changes across iterations)
1080        let flat: Vec<f32> = vectors.iter().flat_map(|v| v.iter().copied()).collect();
1081        let x_dev = unsafe { upload(&flat, hip_malloc, hip_free, hip_memcpy) }?;
1082
1083        // Per-vector squared norms (constant — computed once on CPU)
1084        let x_sq: Vec<f32> = vectors
1085            .iter()
1086            .map(|v| v.iter().map(|x| x * x).sum())
1087            .collect();
1088
1089        // Initialise centroids via evenly-spaced sampling (deterministic)
1090        let step = n / k;
1091        let mut centroids_flat: Vec<f32> = (0..k)
1092            .flat_map(|i| vectors[(i * step) % n].iter().copied())
1093            .collect();
1094
1095        let mut prev_asgn: Vec<u32> = vec![];
1096
1097        for _ in 0..max_iter {
1098            // Upload current centroids
1099            let c_dev = unsafe { upload(&centroids_flat, hip_malloc, hip_free, hip_memcpy) }?;
1100
1101            // Result buffer for cross = −2 * centroids * vectors^T, shape [K×N col-major]
1102            // c_cross[n*K .. (n+1)*K] gives the partial distances for vector n
1103            let cross_dev = unsafe { alloc_dev(k * n, hip_malloc, hip_free) }?;
1104
1105            // SGEMM: cross[K×N col-major] = −2 * centroids[K×dim] * vectors[N×dim]^T
1106            //   op(A) = centroids^T (OP_T, col-major dim×K → K×dim), lda=dim
1107            //   op(B) = vectors (OP_N, col-major dim×N), ldb=dim
1108            //   m=K, n=N, k=dim → result K×N col-major, ldc=K
1109            let alpha = -2.0f32;
1110            let beta = 0.0f32;
1111            let rc = unsafe {
1112                sgemm(
1113                    raw_handle,
1114                    OP_T,
1115                    OP_N,
1116                    k as i32,
1117                    n as i32,
1118                    dim as i32,
1119                    &alpha,
1120                    c_dev.ptr as *const c_void,
1121                    dim as i32,
1122                    x_dev.ptr as *const c_void,
1123                    dim as i32,
1124                    &beta,
1125                    cross_dev.ptr,
1126                    k as i32,
1127                )
1128            };
1129            if rc != 0 {
1130                return None;
1131            }
1132            if unsafe { hip_sync() } != 0 {
1133                return None;
1134            }
1135
1136            let mut cross_host = vec![0.0f32; k * n];
1137            if unsafe {
1138                hip_memcpy(
1139                    cross_host.as_mut_ptr() as *mut c_void,
1140                    cross_dev.ptr as *const c_void,
1141                    k * n * std::mem::size_of::<f32>(),
1142                    D2H,
1143                )
1144            } != 0
1145            {
1146                return None;
1147            }
1148
1149            // Per-centroid squared norms (CPU, k*dim work)
1150            let c_sq: Vec<f32> = centroids_flat
1151                .chunks(dim)
1152                .map(|c| c.iter().map(|x| x * x).sum())
1153                .collect();
1154
1155            // Argmin assignment: dists[n][ci] = x_sq[n] + c_sq[ci] + cross[n*K + ci]
1156            let asgn: Vec<u32> = (0..n)
1157                .map(|ni| {
1158                    let base = &cross_host[ni * k..(ni + 1) * k];
1159                    let best = (0..k)
1160                        .min_by(|&a, &b| {
1161                            let da = x_sq[ni] + c_sq[a] + base[a];
1162                            let db = x_sq[ni] + c_sq[b] + base[b];
1163                            da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
1164                        })
1165                        .unwrap_or(0);
1166                    best as u32
1167                })
1168                .collect();
1169
1170            if asgn == prev_asgn {
1171                break;
1172            }
1173
1174            // Centroid update on CPU
1175            let mut new_flat = vec![0.0f32; k * dim];
1176            let mut counts = vec![0usize; k];
1177            for (i, &ci) in asgn.iter().enumerate() {
1178                let ci = ci as usize;
1179                for (d, &v) in vectors[i].iter().enumerate() {
1180                    new_flat[ci * dim + d] += v;
1181                }
1182                counts[ci] += 1;
1183            }
1184            for j in 0..k {
1185                if counts[j] > 0 {
1186                    let inv = 1.0 / counts[j] as f32;
1187                    new_flat[j * dim..(j + 1) * dim]
1188                        .iter_mut()
1189                        .for_each(|x| *x *= inv);
1190                } else {
1191                    // Empty cluster: keep previous centroid
1192                    new_flat[j * dim..(j + 1) * dim]
1193                        .copy_from_slice(&centroids_flat[j * dim..(j + 1) * dim]);
1194                }
1195            }
1196
1197            centroids_flat = new_flat;
1198            prev_asgn = asgn;
1199        }
1200
1201        Some(centroids_flat.chunks(dim).map(|c| c.to_vec()).collect())
1202    }
1203}