Skip to main content

trueno_graph/gpu/
pagerank.rs

1//! GPU `PageRank` implementation
2//!
3//! Sparse matrix-vector multiplication (`SpMV`) based `PageRank`.
4//! Based on Page et al. (1999) and `GraphBLAST` (Yang et al., ACM `ToMS` 2022).
5
6use super::{GpuCsrBuffers, GpuDevice};
7use anyhow::{Context, Result};
8
9/// `PageRank` parameters for GPU shader
10#[repr(C)]
11#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
12struct PageRankParams {
13    num_nodes: u32,
14    damping: f32,
15    iteration: u32,
16    dangling_sum: f32,
17}
18
19/// GPU `PageRank` result
20#[derive(Debug, Clone)]
21pub struct GpuPageRankResult {
22    /// `PageRank` scores for each node
23    pub scores: Vec<f32>,
24
25    /// Number of iterations performed
26    pub iterations: usize,
27}
28
29impl GpuPageRankResult {
30    /// Get `PageRank` score for a specific node
31    #[must_use]
32    pub fn score(&self, node_id: usize) -> Option<f32> {
33        self.scores.get(node_id).copied()
34    }
35}
36
37/// Helper: Read scores array from GPU buffer
38async fn read_scores(
39    device: &GpuDevice,
40    scores_buffer: &wgpu::Buffer,
41    num_nodes: usize,
42) -> Result<Vec<f32>> {
43    let size = (num_nodes * std::mem::size_of::<f32>()) as u64;
44    let staging_buffer = device.create_buffer(
45        "Scores Staging",
46        size,
47        wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
48    )?;
49
50    let mut encoder =
51        device.device().create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
52    encoder.copy_buffer_to_buffer(scores_buffer, 0, &staging_buffer, 0, size);
53    device.queue().submit(Some(encoder.finish()));
54
55    let buffer_slice = staging_buffer.slice(..);
56    let (tx, rx) = futures_intrusive::channel::shared::oneshot_channel();
57
58    buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
59        let _ = tx.send(result);
60    });
61
62    device.device().poll(wgpu::Maintain::Wait);
63    rx.receive().await.context("Failed to receive map result")?.context("Buffer mapping failed")?;
64
65    let data = buffer_slice.get_mapped_range();
66    let scores: Vec<f32> = bytemuck::cast_slice(&data).to_vec();
67    drop(data);
68    staging_buffer.unmap();
69
70    Ok(scores)
71}
72
73/// Run GPU `PageRank` algorithm
74///
75/// # Arguments
76///
77/// * `device` - GPU device
78/// * `buffers` - CSR graph buffers
79/// * `out_degrees` - Out-degree for each node (computed from CSR)
80/// * `max_iterations` - Maximum number of iterations (typically 20)
81/// * `damping` - Damping factor (typically 0.85)
82///
83/// # Errors
84///
85/// Returns error if:
86/// - GPU shader compilation fails
87/// - Buffer creation fails
88/// - Shader dispatch fails
89/// - Result readback fails
90///
91/// # Example
92///
93/// ```ignore
94/// # use trueno_graph::gpu::{GpuDevice, GpuCsrBuffers, gpu_pagerank};
95/// # use trueno_graph::CsrGraph;
96/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
97/// let device = GpuDevice::new().await?;
98/// let mut graph = CsrGraph::new();
99/// // ... add edges ...
100///
101/// let buffers = GpuCsrBuffers::from_csr_graph(&device, &graph)?;
102/// let out_degrees: Vec<u32> = (0..graph.num_nodes())
103///     .map(|i| graph.outgoing_neighbors(i).len() as u32)
104///     .collect();
105/// let result = gpu_pagerank(&device, &buffers, &out_degrees, 20, 0.85).await?;
106///
107/// println!("Node 0 score: {:?}", result.score(0));
108/// # Ok(())
109/// # }
110/// ```
111#[allow(clippy::too_many_lines)]
112#[allow(clippy::cast_possible_truncation)]
113#[allow(clippy::cast_precision_loss)]
114pub async fn gpu_pagerank(
115    device: &GpuDevice,
116    buffers: &GpuCsrBuffers,
117    out_degrees: &[u32],
118    max_iterations: usize,
119    damping: f32,
120) -> Result<GpuPageRankResult> {
121    // Step 1: Load WGSL shader
122    const SHADER: &str = include_str!("shaders/pagerank.wgsl");
123    let shader_module = device.device().create_shader_module(wgpu::ShaderModuleDescriptor {
124        label: Some("PageRank Shader"),
125        source: wgpu::ShaderSource::Wgsl(SHADER.into()),
126    });
127
128    // Step 2: Create bind group layout
129    let bind_group_layout =
130        device.device().create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
131            label: Some("PageRank Bind Group Layout"),
132            entries: &[
133                // @binding(0): uniform params
134                wgpu::BindGroupLayoutEntry {
135                    binding: 0,
136                    visibility: wgpu::ShaderStages::COMPUTE,
137                    ty: wgpu::BindingType::Buffer {
138                        ty: wgpu::BufferBindingType::Uniform,
139                        has_dynamic_offset: false,
140                        min_binding_size: None,
141                    },
142                    count: None,
143                },
144                // @binding(1): storage row_offsets (read)
145                wgpu::BindGroupLayoutEntry {
146                    binding: 1,
147                    visibility: wgpu::ShaderStages::COMPUTE,
148                    ty: wgpu::BindingType::Buffer {
149                        ty: wgpu::BufferBindingType::Storage { read_only: true },
150                        has_dynamic_offset: false,
151                        min_binding_size: None,
152                    },
153                    count: None,
154                },
155                // @binding(2): storage col_indices (read)
156                wgpu::BindGroupLayoutEntry {
157                    binding: 2,
158                    visibility: wgpu::ShaderStages::COMPUTE,
159                    ty: wgpu::BindingType::Buffer {
160                        ty: wgpu::BufferBindingType::Storage { read_only: true },
161                        has_dynamic_offset: false,
162                        min_binding_size: None,
163                    },
164                    count: None,
165                },
166                // @binding(3): storage current_scores (read)
167                wgpu::BindGroupLayoutEntry {
168                    binding: 3,
169                    visibility: wgpu::ShaderStages::COMPUTE,
170                    ty: wgpu::BindingType::Buffer {
171                        ty: wgpu::BufferBindingType::Storage { read_only: true },
172                        has_dynamic_offset: false,
173                        min_binding_size: None,
174                    },
175                    count: None,
176                },
177                // @binding(4): storage next_scores (read_write)
178                wgpu::BindGroupLayoutEntry {
179                    binding: 4,
180                    visibility: wgpu::ShaderStages::COMPUTE,
181                    ty: wgpu::BindingType::Buffer {
182                        ty: wgpu::BufferBindingType::Storage { read_only: false },
183                        has_dynamic_offset: false,
184                        min_binding_size: None,
185                    },
186                    count: None,
187                },
188                // @binding(5): storage out_degrees (read)
189                wgpu::BindGroupLayoutEntry {
190                    binding: 5,
191                    visibility: wgpu::ShaderStages::COMPUTE,
192                    ty: wgpu::BindingType::Buffer {
193                        ty: wgpu::BufferBindingType::Storage { read_only: true },
194                        has_dynamic_offset: false,
195                        min_binding_size: None,
196                    },
197                    count: None,
198                },
199            ],
200        });
201
202    // Step 3: Create compute pipeline
203    let pipeline_layout = device.device().create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
204        label: Some("PageRank Pipeline Layout"),
205        bind_group_layouts: &[&bind_group_layout],
206        push_constant_ranges: &[],
207    });
208
209    let compute_pipeline =
210        device.device().create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
211            label: Some("PageRank Pipeline"),
212            layout: Some(&pipeline_layout),
213            module: &shader_module,
214            entry_point: "pagerank_iteration",
215            compilation_options: wgpu::PipelineCompilationOptions::default(),
216            cache: None,
217        });
218
219    // Step 4: Create auxiliary buffers
220    let num_nodes = buffers.num_nodes();
221
222    // Initialize scores to 1/N
223    let initial_score = 1.0 / num_nodes as f32;
224
225    // Compute initial dangling sum (nodes with out_degree = 0)
226    let dangling_count = out_degrees.iter().filter(|&&d| d == 0).count();
227    let initial_dangling_sum = dangling_count as f32 * initial_score;
228
229    // Params buffer (uniform)
230    let params_buffer = device.create_buffer_init(
231        "PageRank Params",
232        bytemuck::bytes_of(&PageRankParams {
233            num_nodes: num_nodes as u32,
234            damping,
235            iteration: 0,
236            dangling_sum: initial_dangling_sum,
237        }),
238        wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
239    )?;
240    let initial_scores = vec![initial_score; num_nodes];
241
242    // Current scores buffer (storage)
243    let current_scores_buffer = device.create_buffer_init(
244        "PageRank Current Scores",
245        bytemuck::cast_slice(&initial_scores),
246        wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
247    )?;
248
249    // Next scores buffer (storage)
250    let next_scores_buffer = device.create_buffer_init(
251        "PageRank Next Scores",
252        bytemuck::cast_slice(&initial_scores),
253        wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
254    )?;
255
256    // Out-degrees buffer (storage)
257    let out_degrees_buffer = device.create_buffer_init(
258        "PageRank Out Degrees",
259        bytemuck::cast_slice(out_degrees),
260        wgpu::BufferUsages::STORAGE,
261    )?;
262
263    // Step 5: Create bind groups (need to recreate each iteration for buffer swap)
264    let workgroup_size = 256;
265    let num_workgroups = (num_nodes as u32).div_ceil(workgroup_size).max(1);
266
267    // Iteration loop
268    for iteration in 0..max_iterations {
269        // Compute dangling sum (sum of ranks from nodes with out_degree = 0)
270        let current_scores = read_scores(device, &current_scores_buffer, num_nodes).await?;
271        let dangling_sum: f32 =
272            (0..num_nodes).filter(|&i| out_degrees[i] == 0).map(|i| current_scores[i]).sum();
273
274        // Update params with current iteration and dangling sum
275        device.queue().write_buffer(
276            &params_buffer,
277            0,
278            bytemuck::bytes_of(&PageRankParams {
279                num_nodes: num_nodes as u32,
280                damping,
281                iteration: iteration as u32,
282                dangling_sum,
283            }),
284        );
285
286        // Create bind group for this iteration
287        let bind_group = device.device().create_bind_group(&wgpu::BindGroupDescriptor {
288            label: Some("PageRank Bind Group"),
289            layout: &bind_group_layout,
290            entries: &[
291                wgpu::BindGroupEntry { binding: 0, resource: params_buffer.as_entire_binding() },
292                wgpu::BindGroupEntry {
293                    binding: 1,
294                    resource: buffers.row_offsets.as_entire_binding(),
295                },
296                wgpu::BindGroupEntry {
297                    binding: 2,
298                    resource: buffers.col_indices.as_entire_binding(),
299                },
300                wgpu::BindGroupEntry {
301                    binding: 3,
302                    resource: current_scores_buffer.as_entire_binding(),
303                },
304                wgpu::BindGroupEntry {
305                    binding: 4,
306                    resource: next_scores_buffer.as_entire_binding(),
307                },
308                wgpu::BindGroupEntry {
309                    binding: 5,
310                    resource: out_degrees_buffer.as_entire_binding(),
311                },
312            ],
313        });
314
315        // Create command encoder
316        let mut encoder = device.device().create_command_encoder(&wgpu::CommandEncoderDescriptor {
317            label: Some("PageRank Command Encoder"),
318        });
319
320        {
321            let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
322                label: Some("PageRank Compute Pass"),
323                timestamp_writes: None,
324            });
325
326            compute_pass.set_pipeline(&compute_pipeline);
327            compute_pass.set_bind_group(0, &bind_group, &[]);
328            compute_pass.dispatch_workgroups(num_workgroups, 1, 1);
329        }
330
331        // Submit commands
332        device.queue().submit(Some(encoder.finish()));
333
334        // Wait for GPU to ensure correctness (future optimization: async polling)
335        device.device().poll(wgpu::Maintain::Wait);
336
337        // Swap buffers: copy next_scores to current_scores
338        let mut encoder = device.device().create_command_encoder(&wgpu::CommandEncoderDescriptor {
339            label: Some("PageRank Buffer Swap"),
340        });
341
342        let buffer_size = (num_nodes * std::mem::size_of::<f32>()) as u64;
343        encoder.copy_buffer_to_buffer(
344            &next_scores_buffer,
345            0,
346            &current_scores_buffer,
347            0,
348            buffer_size,
349        );
350
351        device.queue().submit(Some(encoder.finish()));
352        device.device().poll(wgpu::Maintain::Wait);
353    }
354
355    // Step 6: Read back final results
356    let scores = read_scores(device, &current_scores_buffer, num_nodes).await?;
357
358    Ok(GpuPageRankResult { scores, iterations: max_iterations })
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::{CsrGraph, NodeId};
365
366    #[tokio::test]
367    #[allow(clippy::cast_possible_truncation)]
368    async fn test_gpu_pagerank_simple_chain() {
369        if !GpuDevice::is_gpu_available().await {
370            eprintln!("⚠️  Skipping test_gpu_pagerank_simple_chain: GPU not available");
371            return;
372        }
373
374        let device = GpuDevice::new().await.unwrap();
375
376        // Create chain: 0 -> 1 -> 2
377        let mut graph = CsrGraph::new();
378        graph.add_edge(NodeId(0), NodeId(1), 1.0).unwrap();
379        graph.add_edge(NodeId(1), NodeId(2), 1.0).unwrap();
380
381        let buffers = GpuCsrBuffers::from_csr_graph(&device, &graph).unwrap();
382        let out_degrees: Vec<u32> = (0..graph.num_nodes())
383            .map(|i| graph.outgoing_neighbors(NodeId(i as u32)).unwrap().len() as u32)
384            .collect();
385        let result = gpu_pagerank(&device, &buffers, &out_degrees, 20, 0.85).await.unwrap();
386
387        // Verify scores are reasonable
388        let score_0 = result.score(0).unwrap();
389        let score_1 = result.score(1).unwrap();
390        let score_2 = result.score(2).unwrap();
391
392        println!("GPU PageRank scores: node0={score_0}, node1={score_1}, node2={score_2}");
393
394        // Scores should be positive and non-zero
395        assert!(score_0 > 0.0, "Score 0 should be positive");
396        assert!(score_1 > 0.0, "Score 1 should be positive");
397        assert!(score_2 > 0.0, "Score 2 should be positive");
398
399        // Node 2 (sink) should have highest score in chain graph
400        assert!(score_2 > score_1);
401        assert!(score_1 > score_0);
402
403        // Verify scores sum to approximately 1.0 (within tolerance for GPU floating point)
404        let sum = score_0 + score_1 + score_2;
405        println!("Sum: {sum}");
406        assert!((sum - 1.0).abs() < 0.1, "Sum should be approximately 1.0, got {sum}");
407    }
408
409    #[test]
410    fn test_gpu_pagerank_result_api() {
411        let result = GpuPageRankResult { scores: vec![0.1, 0.3, 0.6], iterations: 20 };
412
413        assert_eq!(result.score(0), Some(0.1));
414        assert_eq!(result.score(1), Some(0.3));
415        assert_eq!(result.score(2), Some(0.6));
416        assert_eq!(result.score(3), None); // Out of bounds
417    }
418}