Skip to main content

amari_gpu/
network.rs

1//! GPU-accelerated geometric network analysis
2//!
3//! This module provides GPU acceleration for network analysis operations.
4//!
5//! The current GPU path is intentionally narrow and honest: it accelerates
6//! pairwise Euclidean distances for vector-only `Cl(P,0,0)` embeddings with
7//! `P <= 3`. Centrality and clustering reuse those GPU distances but complete
8//! their reductions/medoid updates on the CPU. Adaptive dispatch falls back to
9//! the `amari-network` CPU geometric-distance baseline for unsupported
10//! signatures, non-vector multivectors, or small networks.
11
12use crate::GpuError;
13use amari_network::{Community, GeometricNetwork};
14use bytemuck::{Pod, Zeroable};
15use futures::channel::oneshot;
16use thiserror::Error;
17use wgpu::util::DeviceExt;
18
19#[derive(Error, Debug)]
20pub enum GpuNetworkError {
21    #[error("GPU error: {0}")]
22    Gpu(#[from] GpuError),
23
24    #[error("Network error: {0}")]
25    Network(#[from] amari_network::NetworkError),
26
27    #[error("Invalid network size: {0}")]
28    InvalidSize(usize),
29
30    #[error("Unsupported GPU embedding: {0}")]
31    UnsupportedEmbedding(String),
32
33    #[error("Invalid position: {0}")]
34    InvalidPosition(String),
35
36    #[error("Buffer error: {0}")]
37    BufferError(String),
38}
39
40pub type GpuNetworkResult<T> = Result<T, GpuNetworkError>;
41
42/// GPU-accelerated geometric network analysis
43pub struct GpuGeometricNetwork {
44    device: wgpu::Device,
45    queue: wgpu::Queue,
46    distance_pipeline: wgpu::ComputePipeline,
47    #[allow(dead_code)]
48    centrality_pipeline: wgpu::ComputePipeline,
49    #[allow(dead_code)]
50    clustering_pipeline: wgpu::ComputePipeline,
51}
52
53/// Node position data for GPU computation
54#[repr(C)]
55#[derive(Copy, Clone, Pod, Zeroable)]
56struct GpuNodePosition {
57    x: f32,
58    y: f32,
59    z: f32,
60    padding: f32, // For 16-byte alignment
61}
62
63/// Edge data for GPU computation
64#[repr(C)]
65#[derive(Copy, Clone, Pod, Zeroable)]
66struct GpuEdgeData {
67    source: u32,
68    target: u32,
69    weight: f32,
70    padding: f32,
71}
72
73impl GpuGeometricNetwork {
74    /// Initialize GPU context for network analysis
75    pub async fn new() -> GpuNetworkResult<Self> {
76        let instance = wgpu::Instance::default();
77
78        let adapter = instance
79            .request_adapter(&wgpu::RequestAdapterOptions {
80                power_preference: wgpu::PowerPreference::HighPerformance,
81                compatible_surface: None,
82                force_fallback_adapter: false,
83            })
84            .await
85            .ok_or_else(|| GpuError::InitializationError("No GPU adapter found".to_string()))?;
86
87        let (device, queue) = adapter
88            .request_device(
89                &wgpu::DeviceDescriptor {
90                    label: Some("Amari Network GPU Device"),
91                    required_features: wgpu::Features::empty(),
92                    required_limits: wgpu::Limits::default(),
93                },
94                None,
95            )
96            .await
97            .map_err(|e| GpuError::InitializationError(e.to_string()))?;
98
99        let distance_pipeline = Self::create_distance_pipeline(&device)?;
100        let centrality_pipeline = Self::create_centrality_pipeline(&device)?;
101        let clustering_pipeline = Self::create_clustering_pipeline(&device)?;
102
103        Ok(Self {
104            device,
105            queue,
106            distance_pipeline,
107            centrality_pipeline,
108            clustering_pipeline,
109        })
110    }
111
112    /// Compute all pairwise Euclidean distances using GPU acceleration.
113    ///
114    /// This GPU path supports vector-only `Cl(P,0,0)` embeddings with `P <= 3`.
115    /// Use [`AdaptiveNetworkCompute`] for automatic CPU fallback on unsupported
116    /// signatures or non-vector multivectors.
117    pub async fn compute_all_pairwise_distances<const P: usize, const Q: usize, const R: usize>(
118        &self,
119        network: &GeometricNetwork<P, Q, R>,
120    ) -> GpuNetworkResult<Vec<Vec<f64>>> {
121        let num_nodes = network.num_nodes();
122        if num_nodes == 0 {
123            return Ok(Vec::new());
124        }
125        self.validate_gpu_distance_network(network)?;
126
127        // Convert node positions to GPU format
128        let gpu_positions: Vec<GpuNodePosition> = (0..num_nodes)
129            .map(|i| {
130                let pos = network.get_node(i).unwrap();
131                GpuNodePosition {
132                    x: pos.vector_component(0) as f32,
133                    y: pos.vector_component(1) as f32,
134                    z: pos.vector_component(2) as f32,
135                    padding: 0.0,
136                }
137            })
138            .collect();
139
140        // Create GPU buffers
141        let positions_buffer = self
142            .device
143            .create_buffer_init(&wgpu::util::BufferInitDescriptor {
144                label: Some("Node Positions"),
145                contents: bytemuck::cast_slice(&gpu_positions),
146                usage: wgpu::BufferUsages::STORAGE,
147            });
148
149        let output_size = num_nodes * num_nodes * 4; // f32 = 4 bytes
150        let output_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
151            label: Some("Distance Output"),
152            size: output_size as u64,
153            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
154            mapped_at_creation: false,
155        });
156
157        let staging_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
158            label: Some("Distance Staging"),
159            size: output_size as u64,
160            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
161            mapped_at_creation: false,
162        });
163
164        // Create bind group
165        let bind_group_layout = self.distance_pipeline.get_bind_group_layout(0);
166        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
167            label: Some("Distance Compute Bind Group"),
168            layout: &bind_group_layout,
169            entries: &[
170                wgpu::BindGroupEntry {
171                    binding: 0,
172                    resource: positions_buffer.as_entire_binding(),
173                },
174                wgpu::BindGroupEntry {
175                    binding: 1,
176                    resource: output_buffer.as_entire_binding(),
177                },
178            ],
179        });
180
181        // Dispatch compute shader
182        let mut encoder = self
183            .device
184            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
185                label: Some("Distance Compute Encoder"),
186            });
187
188        {
189            let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
190                label: Some("Distance Compute Pass"),
191                timestamp_writes: None,
192            });
193            compute_pass.set_pipeline(&self.distance_pipeline);
194            compute_pass.set_bind_group(0, &bind_group, &[]);
195            let workgroup_count = num_nodes.div_ceil(8);
196            compute_pass.dispatch_workgroups(workgroup_count as u32, workgroup_count as u32, 1);
197        }
198
199        encoder.copy_buffer_to_buffer(&output_buffer, 0, &staging_buffer, 0, output_size as u64);
200
201        self.queue.submit(std::iter::once(encoder.finish()));
202
203        // Read results
204        let buffer_slice = staging_buffer.slice(..);
205        let (sender, receiver) = oneshot::channel();
206        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
207            let _ = sender.send(result);
208        });
209
210        self.device.poll(wgpu::Maintain::Wait);
211
212        receiver
213            .await
214            .map_err(|_| {
215                GpuNetworkError::BufferError("Failed to receive buffer mapping".to_string())
216            })?
217            .map_err(|e| GpuNetworkError::BufferError(format!("Buffer mapping failed: {:?}", e)))?;
218
219        let data = buffer_slice.get_mapped_range();
220        let result_f32: &[f32] = bytemuck::cast_slice(&data);
221
222        // Convert back to nested Vec<Vec<f64>>
223        let mut distances = vec![vec![0.0; num_nodes]; num_nodes];
224        for i in 0..num_nodes {
225            for j in 0..num_nodes {
226                distances[i][j] = result_f32[i * num_nodes + j] as f64;
227            }
228        }
229
230        drop(data);
231        staging_buffer.unmap();
232
233        Ok(distances)
234    }
235
236    /// Compute geometric centrality using GPU distances plus CPU reduction.
237    pub async fn compute_geometric_centrality<const P: usize, const Q: usize, const R: usize>(
238        &self,
239        network: &GeometricNetwork<P, Q, R>,
240    ) -> GpuNetworkResult<Vec<f64>> {
241        // For centrality, we need the distance matrix first
242        let distances = self.compute_all_pairwise_distances(network).await?;
243
244        // For now, use CPU computation for centrality based on GPU distances
245        // In a full implementation, this would also be GPU-accelerated
246        let num_nodes = network.num_nodes();
247        let mut centrality = vec![0.0; num_nodes];
248
249        for i in 0..num_nodes {
250            let total_distance: f64 = distances[i].iter().sum();
251            centrality[i] = if total_distance > 0.0 {
252                (num_nodes as f64 - 1.0) / total_distance
253            } else {
254                0.0
255            };
256        }
257
258        Ok(centrality)
259    }
260
261    /// GPU-distance-assisted k-medoids clustering for community detection.
262    ///
263    /// Pairwise distances are computed by the GPU path; assignment, medoid
264    /// updates, and cohesion scoring are CPU-side for 0.20.0.
265    pub async fn geometric_clustering<const P: usize, const Q: usize, const R: usize>(
266        &self,
267        network: &GeometricNetwork<P, Q, R>,
268        k: usize,
269        max_iterations: usize,
270    ) -> GpuNetworkResult<Vec<Community<P, Q, R>>> {
271        let num_nodes = network.num_nodes();
272        if k > num_nodes || k == 0 {
273            return Err(GpuNetworkError::InvalidSize(k));
274        }
275        if max_iterations == 0 {
276            return Err(GpuNetworkError::InvalidSize(max_iterations));
277        }
278
279        // For simplicity, use CPU-based k-means with GPU distance calculations
280        let distances = self.compute_all_pairwise_distances(network).await?;
281
282        // Initialize centroids (use first k nodes)
283        let mut centroids = Vec::with_capacity(k);
284        for i in 0..k {
285            let centroid_idx = (i * num_nodes) / k;
286            centroids.push(centroid_idx);
287        }
288
289        let mut assignments = vec![0; num_nodes];
290
291        for _iteration in 0..max_iterations {
292            let mut changed = false;
293
294            // Assign nodes to nearest centroid
295            for node in 0..num_nodes {
296                let mut best_cluster = 0;
297                let mut best_distance = f64::INFINITY;
298
299                for (cluster, &centroid) in centroids.iter().enumerate().take(k) {
300                    let distance = distances[node][centroid];
301
302                    if distance < best_distance {
303                        best_distance = distance;
304                        best_cluster = cluster;
305                    }
306                }
307
308                if assignments[node] != best_cluster {
309                    assignments[node] = best_cluster;
310                    changed = true;
311                }
312            }
313
314            if !changed {
315                break;
316            }
317
318            // Update centroids (find medoid of each cluster)
319            for (cluster, centroid) in centroids.iter_mut().enumerate().take(k) {
320                let cluster_nodes: Vec<usize> = assignments
321                    .iter()
322                    .enumerate()
323                    .filter(|(_, &c)| c == cluster)
324                    .map(|(node, _)| node)
325                    .collect();
326
327                if !cluster_nodes.is_empty() {
328                    let mut best_medoid = cluster_nodes[0];
329                    let mut best_total_distance = f64::INFINITY;
330
331                    for &candidate in &cluster_nodes {
332                        let total_distance: f64 = cluster_nodes
333                            .iter()
334                            .map(|&other| distances[candidate][other])
335                            .sum();
336
337                        if total_distance < best_total_distance {
338                            best_total_distance = total_distance;
339                            best_medoid = candidate;
340                        }
341                    }
342
343                    *centroid = best_medoid;
344                }
345            }
346        }
347
348        // Convert assignments to communities
349        let mut communities = Vec::with_capacity(k);
350        for (cluster, &centroid) in centroids.iter().enumerate().take(k) {
351            let nodes: Vec<usize> = assignments
352                .iter()
353                .enumerate()
354                .filter(|(_, &c)| c == cluster)
355                .map(|(node, _)| node)
356                .collect();
357
358            if !nodes.is_empty() {
359                let centroid_pos = network.get_node(centroid).unwrap().clone();
360                let cohesion_score = Self::compute_cluster_cohesion(&nodes, &distances);
361                communities.push(Community {
362                    nodes,
363                    geometric_centroid: centroid_pos,
364                    cohesion_score,
365                });
366            }
367        }
368
369        Ok(communities)
370    }
371
372    /// Determine if GPU acceleration should be used based on network size.
373    pub fn should_use_gpu(num_nodes: usize) -> bool {
374        // GPU is beneficial for networks with many nodes
375        num_nodes >= 100
376    }
377
378    /// Return whether this signature can use the current GPU distance kernel.
379    pub fn supports_gpu_distance<const P: usize, const Q: usize, const R: usize>() -> bool {
380        Q == 0 && R == 0 && P <= 3
381    }
382
383    // Private helper methods
384
385    fn validate_gpu_distance_network<const P: usize, const Q: usize, const R: usize>(
386        &self,
387        network: &GeometricNetwork<P, Q, R>,
388    ) -> GpuNetworkResult<()> {
389        if !Self::supports_gpu_distance::<P, Q, R>() {
390            return Err(GpuNetworkError::UnsupportedEmbedding(format!(
391                "GPU network distance supports vector-only Cl(P,0,0) with P <= 3; got Cl({P},{Q},{R})"
392            )));
393        }
394
395        for node_idx in 0..network.num_nodes() {
396            let node = network.get_node(node_idx).ok_or_else(|| {
397                GpuNetworkError::InvalidPosition(format!("node {node_idx} is missing"))
398            })?;
399            for (coeff_idx, &coeff) in node.as_slice().iter().enumerate() {
400                if !coeff.is_finite() {
401                    return Err(GpuNetworkError::InvalidPosition(format!(
402                        "node {node_idx} coefficient {coeff_idx} is not finite"
403                    )));
404                }
405                let is_vector_component = coeff_idx.is_power_of_two()
406                    && coeff_idx > 0
407                    && coeff_idx.trailing_zeros() < P as u32;
408                if !is_vector_component && coeff.abs() > 1e-12 {
409                    return Err(GpuNetworkError::UnsupportedEmbedding(format!(
410                        "node {node_idx} has non-vector coefficient at blade {coeff_idx}"
411                    )));
412                }
413            }
414        }
415
416        Ok(())
417    }
418
419    fn compute_cluster_cohesion(nodes: &[usize], distances: &[Vec<f64>]) -> f64 {
420        if nodes.len() <= 1 {
421            return 1.0;
422        }
423
424        let mut total = 0.0;
425        let mut count = 0usize;
426        for (idx, &a) in nodes.iter().enumerate() {
427            for &b in nodes.iter().skip(idx + 1) {
428                total += distances[a][b];
429                count += 1;
430            }
431        }
432
433        if count == 0 {
434            1.0
435        } else {
436            1.0 / (1.0 + total / count as f64)
437        }
438    }
439
440    fn create_distance_pipeline(device: &wgpu::Device) -> Result<wgpu::ComputePipeline, GpuError> {
441        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
442            label: Some("Distance Compute Shader"),
443            source: wgpu::ShaderSource::Wgsl(DISTANCE_COMPUTE_SHADER.into()),
444        });
445
446        let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
447            label: Some("Distance Compute Pipeline"),
448            layout: None,
449            module: &shader,
450            entry_point: "main",
451        });
452
453        Ok(pipeline)
454    }
455
456    fn create_centrality_pipeline(
457        device: &wgpu::Device,
458    ) -> Result<wgpu::ComputePipeline, GpuError> {
459        // For now, reuse distance pipeline
460        Self::create_distance_pipeline(device)
461    }
462
463    fn create_clustering_pipeline(
464        device: &wgpu::Device,
465    ) -> Result<wgpu::ComputePipeline, GpuError> {
466        // For now, reuse distance pipeline
467        Self::create_distance_pipeline(device)
468    }
469}
470
471/// WGSL compute shader for pairwise distance calculations
472const DISTANCE_COMPUTE_SHADER: &str = r#"
473struct NodePosition {
474    x: f32,
475    y: f32,
476    z: f32,
477    padding: f32,
478}
479
480@group(0) @binding(0)
481var<storage, read> positions: array<NodePosition>;
482
483@group(0) @binding(1)
484var<storage, read_write> distances: array<f32>;
485
486@compute @workgroup_size(8, 8)
487fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
488    let i = global_id.x;
489    let j = global_id.y;
490    let num_nodes = arrayLength(&positions);
491
492    if (i >= num_nodes || j >= num_nodes) {
493        return;
494    }
495
496    let idx = i * num_nodes + j;
497
498    if (i == j) {
499        distances[idx] = 0.0;
500        return;
501    }
502
503    let pos_i = positions[i];
504    let pos_j = positions[j];
505
506    let dx = pos_i.x - pos_j.x;
507    let dy = pos_i.y - pos_j.y;
508    let dz = pos_i.z - pos_j.z;
509
510    let distance = sqrt(dx * dx + dy * dy + dz * dz);
511    distances[idx] = distance;
512}
513"#;
514
515/// Adaptive GPU/CPU dispatcher for network operations
516pub struct AdaptiveNetworkCompute {
517    gpu: Option<GpuGeometricNetwork>,
518}
519
520impl AdaptiveNetworkCompute {
521    fn compute_pairwise_geometric_distances_cpu<const P: usize, const Q: usize, const R: usize>(
522        network: &GeometricNetwork<P, Q, R>,
523    ) -> GpuNetworkResult<Vec<Vec<f64>>> {
524        let num_nodes = network.num_nodes();
525        let mut distances = vec![vec![0.0; num_nodes]; num_nodes];
526
527        for (i, row) in distances.iter_mut().enumerate() {
528            for (j, distance) in row.iter_mut().enumerate() {
529                *distance = network.geometric_distance(i, j)?;
530            }
531        }
532
533        Ok(distances)
534    }
535
536    /// Create with optional GPU acceleration
537    pub async fn new() -> Self {
538        // Use panic-safe GPU detection like in adaptive verification
539        let gpu = {
540            let panic_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
541                pollster::block_on(async { GpuGeometricNetwork::new().await.ok() })
542            }));
543
544            // GPU initialization panicked or failed - gracefully fall back to CPU
545            panic_result.unwrap_or_default()
546        };
547
548        Self { gpu }
549    }
550
551    /// Compute pairwise distances with adaptive dispatch
552    pub async fn compute_all_pairwise_distances<const P: usize, const Q: usize, const R: usize>(
553        &self,
554        network: &GeometricNetwork<P, Q, R>,
555    ) -> GpuNetworkResult<Vec<Vec<f64>>> {
556        let num_nodes = network.num_nodes();
557
558        if let Some(gpu) = &self.gpu {
559            if GpuGeometricNetwork::should_use_gpu(num_nodes)
560                && GpuGeometricNetwork::supports_gpu_distance::<P, Q, R>()
561            {
562                if let Ok(distances) = gpu.compute_all_pairwise_distances(network).await {
563                    return Ok(distances);
564                }
565            }
566        }
567
568        // CPU geometric-distance fallback. Do not use graph shortest paths here:
569        // this API returns geometric distances between embedded node positions.
570        Self::compute_pairwise_geometric_distances_cpu(network)
571    }
572
573    /// Compute centrality with adaptive dispatch
574    pub async fn compute_geometric_centrality<const P: usize, const Q: usize, const R: usize>(
575        &self,
576        network: &GeometricNetwork<P, Q, R>,
577    ) -> GpuNetworkResult<Vec<f64>> {
578        let num_nodes = network.num_nodes();
579
580        if let Some(gpu) = &self.gpu {
581            if GpuGeometricNetwork::should_use_gpu(num_nodes)
582                && GpuGeometricNetwork::supports_gpu_distance::<P, Q, R>()
583            {
584                if let Ok(centrality) = gpu.compute_geometric_centrality(network).await {
585                    return Ok(centrality);
586                }
587            }
588        }
589
590        // CPU fallback
591        network
592            .compute_geometric_centrality()
593            .map_err(GpuNetworkError::Network)
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    #[test]
602    fn test_should_use_gpu() {
603        assert!(!GpuGeometricNetwork::should_use_gpu(10));
604        assert!(GpuGeometricNetwork::should_use_gpu(1000));
605    }
606
607    #[tokio::test]
608    async fn test_adaptive_network_creation() {
609        // Test adaptive behavior: should work with or without GPU
610        let adaptive = AdaptiveNetworkCompute::new().await;
611
612        // Should always succeed - adaptive design gracefully falls back to CPU
613        match &adaptive.gpu {
614            Some(_) => {
615                println!("✅ GPU network acceleration available");
616            }
617            None => {
618                println!("✅ GPU not available, using CPU fallback for network operations");
619            }
620        }
621
622        // The adaptive compute should be created successfully regardless of GPU availability
623        // This tests the core adaptive design principle
624    }
625}