CI / test (push) Failing after 15s
- Add .gitea/workflows/ci.yml running scripts/ci-test.sh (fmt, clippy,
test, no_std check) on push/PR to main.
- Fix stale rustyhdf5-py/rustyhdf5-format package names in
ci-test.sh/check-nostd.sh, which had been silently no-op'ing those
checks (cargo warns but doesn't fail on an unknown --exclude/-p
target).
- With those checks actually running, fix the real issues they surface:
- clippy: useless_conversion in chunked_write.rs, byte_char_slices in
global_heap.rs/object_header.rs.
- cargo fmt: apply formatting across the workspace (whitespace only).
- no_std (thumbv7em-none-eabihf) build errors in clawhdf5-format:
core::sync::atomic::AtomicU64 doesn't exist on that target (no
native 64-bit atomics) — switch profiling.rs's counters to
portable-atomic, which falls back to a CAS-based emulation there
and is a no-op wrapper elsewhere. Add missing alloc imports for
Box (filters.rs), Vec (filters_szip.rs), and format! (dict_encoding.rs)
on no_std paths. Replace f64::powi (std/libm-only) with a small
local exponentiation-by-squaring helper in the scale-offset filter.
438 lines
14 KiB
Rust
438 lines
14 KiB
Rust
//! GPU search backend for accelerated vector similarity.
|
|
//!
|
|
//! When the `gpu` feature is enabled and GPU hardware is available, uses
|
|
//! `clawhdf5_gpu::GpuAccelerator` for real GPU-accelerated cosine and L2
|
|
//! searches. Falls back gracefully to CPU SIMD search otherwise.
|
|
|
|
/// GPU search backend that manages vector data on the GPU.
|
|
///
|
|
/// When the `gpu` feature is enabled, this backend wraps a real
|
|
/// `clawhdf5_gpu::GpuAccelerator` for hardware-accelerated search.
|
|
/// Falls back to CPU SIMD search when GPU is unavailable.
|
|
pub struct GpuSearchBackend {
|
|
/// Real GPU accelerator (when gpu feature is enabled and hardware available).
|
|
#[cfg(feature = "gpu")]
|
|
accelerator: Option<clawhdf5_gpu::GpuAccelerator>,
|
|
/// Vector dimension.
|
|
dim: usize,
|
|
/// Minimum collection size to justify GPU overhead.
|
|
threshold: usize,
|
|
/// Number of vectors currently uploaded.
|
|
num_vectors: usize,
|
|
}
|
|
|
|
impl GpuSearchBackend {
|
|
/// Attempt to initialize GPU backend.
|
|
///
|
|
/// Returns a backend with GPU active only if hardware is detected,
|
|
/// the `gpu` feature is enabled, and the collection size exceeds the threshold.
|
|
pub fn try_init(vectors: &[Vec<f32>], norms: &[f32], dim: usize, threshold: usize) -> Self {
|
|
#[cfg(feature = "gpu")]
|
|
{
|
|
if vectors.len() >= threshold {
|
|
match clawhdf5_gpu::GpuAccelerator::new() {
|
|
Ok(mut accel) => {
|
|
let flat: Vec<f32> =
|
|
vectors.iter().flat_map(|v| v.iter().copied()).collect();
|
|
if accel.upload_vectors(&flat, dim).is_ok()
|
|
&& accel.upload_norms(norms).is_ok()
|
|
{
|
|
return Self {
|
|
accelerator: Some(accel),
|
|
dim,
|
|
threshold,
|
|
num_vectors: vectors.len(),
|
|
};
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log_gpu_fallback(&e.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
Self {
|
|
accelerator: None,
|
|
dim,
|
|
threshold,
|
|
num_vectors: vectors.len(),
|
|
}
|
|
}
|
|
|
|
#[cfg(not(feature = "gpu"))]
|
|
{
|
|
let _ = (vectors, norms);
|
|
Self {
|
|
dim,
|
|
threshold,
|
|
num_vectors: 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check if GPU acceleration is active.
|
|
pub fn is_available(&self) -> bool {
|
|
#[cfg(feature = "gpu")]
|
|
{
|
|
self.accelerator.is_some()
|
|
}
|
|
#[cfg(not(feature = "gpu"))]
|
|
{
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Get the dimension this backend was initialized with.
|
|
pub fn dim(&self) -> usize {
|
|
self.dim
|
|
}
|
|
|
|
/// Get the threshold for GPU activation.
|
|
pub fn threshold(&self) -> usize {
|
|
self.threshold
|
|
}
|
|
|
|
/// Re-upload vectors after mutation (save/compact).
|
|
pub fn re_upload(&mut self, vectors: &[Vec<f32>], norms: &[f32]) {
|
|
self.num_vectors = vectors.len();
|
|
|
|
#[cfg(feature = "gpu")]
|
|
{
|
|
// If we have an accelerator and still above threshold, re-upload
|
|
if let Some(ref mut accel) = self.accelerator {
|
|
if vectors.len() >= self.threshold {
|
|
let flat: Vec<f32> = vectors.iter().flat_map(|v| v.iter().copied()).collect();
|
|
if accel.upload_vectors(&flat, self.dim).is_err()
|
|
|| accel.upload_norms(norms).is_err()
|
|
{
|
|
self.accelerator = None;
|
|
}
|
|
} else {
|
|
// Below threshold, deactivate GPU
|
|
self.accelerator = None;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// If we don't have an accelerator but now above threshold, try init
|
|
if vectors.len() >= self.threshold
|
|
&& let Ok(mut accel) = clawhdf5_gpu::GpuAccelerator::new()
|
|
{
|
|
let flat: Vec<f32> = vectors.iter().flat_map(|v| v.iter().copied()).collect();
|
|
if accel.upload_vectors(&flat, self.dim).is_ok()
|
|
&& accel.upload_norms(norms).is_ok()
|
|
{
|
|
self.accelerator = Some(accel);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(not(feature = "gpu"))]
|
|
{
|
|
let _ = (vectors, norms);
|
|
}
|
|
}
|
|
|
|
/// Search using GPU-accelerated cosine similarity.
|
|
///
|
|
/// If GPU is not available, falls back to CPU SIMD prenorm search.
|
|
pub fn search_cosine(
|
|
&self,
|
|
query: &[f32],
|
|
vectors: &[Vec<f32>],
|
|
norms: &[f32],
|
|
tombstones: &[u8],
|
|
k: usize,
|
|
) -> Vec<(usize, f32)> {
|
|
#[cfg(feature = "gpu")]
|
|
{
|
|
if let Some(ref accel) = self.accelerator {
|
|
match accel.cosine_search(query, k.min(self.num_vectors.max(1))) {
|
|
Ok(mut results) => {
|
|
// Filter out tombstoned entries
|
|
results.retain(|(i, _)| *i < tombstones.len() && tombstones[*i] == 0);
|
|
results.truncate(k);
|
|
return results;
|
|
}
|
|
Err(_) => {
|
|
// Fall through to CPU
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
cpu_fallback_cosine(query, vectors, norms, tombstones, k)
|
|
}
|
|
|
|
/// Search using GPU-accelerated L2 distance.
|
|
pub fn search_l2(
|
|
&self,
|
|
query: &[f32],
|
|
vectors: &[Vec<f32>],
|
|
tombstones: &[u8],
|
|
k: usize,
|
|
) -> Vec<(usize, f32)> {
|
|
#[cfg(feature = "gpu")]
|
|
{
|
|
if let Some(ref accel) = self.accelerator {
|
|
match accel.l2_search(query, k.min(self.num_vectors.max(1))) {
|
|
Ok(mut results) => {
|
|
results.retain(|(i, _)| *i < tombstones.len() && tombstones[*i] == 0);
|
|
results.truncate(k);
|
|
return results;
|
|
}
|
|
Err(_) => {
|
|
// Fall through to CPU
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
cpu_fallback_l2(query, vectors, tombstones, k)
|
|
}
|
|
|
|
/// Get the device info string (for metrics/logging).
|
|
pub fn device_info(&self) -> String {
|
|
#[cfg(feature = "gpu")]
|
|
{
|
|
if let Some(ref accel) = self.accelerator {
|
|
return accel.device_info().to_string();
|
|
}
|
|
}
|
|
"none".to_string()
|
|
}
|
|
}
|
|
|
|
/// Check if GPU hardware is available at all.
|
|
pub fn detect_gpu() -> bool {
|
|
#[cfg(feature = "gpu")]
|
|
{
|
|
clawhdf5_gpu::GpuAccelerator::is_available()
|
|
}
|
|
#[cfg(not(feature = "gpu"))]
|
|
{
|
|
false
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "gpu")]
|
|
fn log_gpu_fallback(reason: &str) {
|
|
// Logging for GPU init failure; callers can check is_available()
|
|
eprintln!("[clawhdf5-agent] GPU init failed, falling back to CPU: {reason}");
|
|
}
|
|
|
|
/// CPU fallback for cosine search when GPU is not available.
|
|
fn cpu_fallback_cosine(
|
|
query: &[f32],
|
|
vectors: &[Vec<f32>],
|
|
norms: &[f32],
|
|
tombstones: &[u8],
|
|
k: usize,
|
|
) -> Vec<(usize, f32)> {
|
|
let query_norm = clawhdf5_accel::vector_norm(query);
|
|
if query_norm == 0.0 {
|
|
return Vec::new();
|
|
}
|
|
|
|
let mut results: Vec<(usize, f32)> = Vec::with_capacity(vectors.len());
|
|
|
|
for (i, vec) in vectors.iter().enumerate() {
|
|
if i < tombstones.len() && tombstones[i] != 0 {
|
|
continue;
|
|
}
|
|
let vec_norm = if i < norms.len() {
|
|
norms[i]
|
|
} else {
|
|
clawhdf5_accel::vector_norm(vec)
|
|
};
|
|
let score = crate::cosine_similarity_prenorm(query, query_norm, vec, vec_norm);
|
|
results.push((i, score));
|
|
}
|
|
|
|
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
|
results.truncate(k);
|
|
results
|
|
}
|
|
|
|
/// CPU fallback for L2 distance search.
|
|
fn cpu_fallback_l2(
|
|
query: &[f32],
|
|
vectors: &[Vec<f32>],
|
|
tombstones: &[u8],
|
|
k: usize,
|
|
) -> Vec<(usize, f32)> {
|
|
let mut results: Vec<(usize, f32)> = Vec::with_capacity(vectors.len());
|
|
|
|
for (i, vec) in vectors.iter().enumerate() {
|
|
if i < tombstones.len() && tombstones[i] != 0 {
|
|
continue;
|
|
}
|
|
let dist = clawhdf5_accel::l2_distance(query, vec);
|
|
results.push((i, dist));
|
|
}
|
|
|
|
// Sort ascending (smallest distance first)
|
|
results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
|
results.truncate(k);
|
|
results
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_vectors(n: usize, dim: usize, seed: u32) -> Vec<Vec<f32>> {
|
|
let mut s = seed;
|
|
let mut next = || -> f32 {
|
|
s = s.wrapping_mul(1103515245).wrapping_add(12345);
|
|
((s >> 16) as f32) / 65536.0 - 0.5
|
|
};
|
|
(0..n).map(|_| (0..dim).map(|_| next()).collect()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn gpu_detect_default_status() {
|
|
// Without GPU feature or hardware, detection depends on compilation
|
|
let detected = detect_gpu();
|
|
// Just verify it returns a bool without panicking
|
|
let _ = detected;
|
|
}
|
|
|
|
#[test]
|
|
fn gpu_backend_fallback_when_unavailable() {
|
|
let vectors = make_vectors(100, 32, 42);
|
|
let norms: Vec<f32> = vectors
|
|
.iter()
|
|
.map(|v| clawhdf5_accel::vector_norm(v))
|
|
.collect();
|
|
let backend = GpuSearchBackend::try_init(&vectors, &norms, 32, 50);
|
|
|
|
// On most CI/test environments GPU won't be available
|
|
let tombstones = vec![0u8; 100];
|
|
let query = vectors[0].clone();
|
|
let results = backend.search_cosine(&query, &vectors, &norms, &tombstones, 10);
|
|
|
|
assert!(!results.is_empty());
|
|
assert!(results.len() <= 10);
|
|
// First result should be the query vector itself (index 0)
|
|
assert_eq!(results[0].0, 0);
|
|
assert!((results[0].1 - 1.0).abs() < 1e-5);
|
|
}
|
|
|
|
#[test]
|
|
fn gpu_backend_below_threshold() {
|
|
let vectors = make_vectors(10, 32, 42);
|
|
let norms: Vec<f32> = vectors
|
|
.iter()
|
|
.map(|v| clawhdf5_accel::vector_norm(v))
|
|
.collect();
|
|
let backend = GpuSearchBackend::try_init(&vectors, &norms, 32, 100);
|
|
|
|
assert!(!backend.is_available());
|
|
assert_eq!(backend.dim(), 32);
|
|
assert_eq!(backend.threshold(), 100);
|
|
}
|
|
|
|
#[test]
|
|
fn gpu_cosine_cpu_fallback_matches() {
|
|
let vectors = make_vectors(200, 64, 42);
|
|
let norms: Vec<f32> = vectors
|
|
.iter()
|
|
.map(|v| clawhdf5_accel::vector_norm(v))
|
|
.collect();
|
|
let tombstones = vec![0u8; 200];
|
|
let query = vectors[5].clone();
|
|
|
|
let fallback = cpu_fallback_cosine(&query, &vectors, &norms, &tombstones, 10);
|
|
let backend = GpuSearchBackend::try_init(&vectors, &norms, 64, 50);
|
|
let backend_results = backend.search_cosine(&query, &vectors, &norms, &tombstones, 10);
|
|
|
|
assert_eq!(fallback.len(), backend_results.len());
|
|
for (f, b) in fallback.iter().zip(&backend_results) {
|
|
assert_eq!(f.0, b.0);
|
|
assert!((f.1 - b.1).abs() < 1e-6);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn gpu_l2_search_returns_nearest() {
|
|
let vectors = vec![
|
|
vec![0.0, 0.0, 0.0],
|
|
vec![1.0, 0.0, 0.0],
|
|
vec![10.0, 10.0, 10.0],
|
|
];
|
|
let tombstones = vec![0u8; 3];
|
|
let query = vec![0.1, 0.0, 0.0];
|
|
|
|
let results = cpu_fallback_l2(&query, &vectors, &tombstones, 3);
|
|
// Closest should be vector 0 (distance ~0.01), then vector 1 (distance ~0.81)
|
|
assert_eq!(results[0].0, 0);
|
|
assert_eq!(results[1].0, 1);
|
|
assert_eq!(results[2].0, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn gpu_search_respects_tombstones() {
|
|
let vectors = make_vectors(50, 16, 42);
|
|
let norms: Vec<f32> = vectors
|
|
.iter()
|
|
.map(|v| clawhdf5_accel::vector_norm(v))
|
|
.collect();
|
|
let mut tombstones = vec![0u8; 50];
|
|
tombstones[0] = 1;
|
|
tombstones[1] = 1;
|
|
|
|
let query = vectors[2].clone();
|
|
let results = cpu_fallback_cosine(&query, &vectors, &norms, &tombstones, 50);
|
|
assert!(results.iter().all(|r| r.0 != 0 && r.0 != 1));
|
|
}
|
|
|
|
#[test]
|
|
fn gpu_re_upload_updates_data() {
|
|
let vectors = make_vectors(10, 16, 42);
|
|
let norms: Vec<f32> = vectors
|
|
.iter()
|
|
.map(|v| clawhdf5_accel::vector_norm(v))
|
|
.collect();
|
|
let mut backend = GpuSearchBackend::try_init(&vectors, &norms, 16, 5);
|
|
|
|
// Re-upload with more vectors
|
|
let vectors2 = make_vectors(20, 16, 77);
|
|
let norms2: Vec<f32> = vectors2
|
|
.iter()
|
|
.map(|v| clawhdf5_accel::vector_norm(v))
|
|
.collect();
|
|
backend.re_upload(&vectors2, &norms2);
|
|
|
|
// Backend should still work (CPU fallback at minimum)
|
|
let tombstones = vec![0u8; 20];
|
|
let query = vectors2[0].clone();
|
|
let results = backend.search_cosine(&query, &vectors2, &norms2, &tombstones, 5);
|
|
assert!(!results.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn gpu_l2_search_respects_tombstones() {
|
|
let vectors = vec![vec![0.0, 0.0], vec![1.0, 0.0], vec![2.0, 0.0]];
|
|
let mut tombstones = vec![0u8; 3];
|
|
tombstones[0] = 1; // tombstone nearest vector
|
|
|
|
let query = vec![0.0, 0.0];
|
|
let results = cpu_fallback_l2(&query, &vectors, &tombstones, 3);
|
|
assert!(results.iter().all(|r| r.0 != 0));
|
|
assert_eq!(results[0].0, 1); // next nearest
|
|
}
|
|
|
|
#[test]
|
|
fn device_info_returns_string() {
|
|
let vectors = make_vectors(10, 16, 42);
|
|
let norms: Vec<f32> = vectors
|
|
.iter()
|
|
.map(|v| clawhdf5_accel::vector_norm(v))
|
|
.collect();
|
|
let backend = GpuSearchBackend::try_init(&vectors, &norms, 16, 5);
|
|
let info = backend.device_info();
|
|
assert!(!info.is_empty());
|
|
}
|
|
}
|