Skip to main content

rssn_advanced/gpu/
compiler.rs

1//! WGSL compute shader compiler and WebGPU execution runtime.
2//!
3//! Provides the AST-to-WGSL translation pass and the wgpu buffer dispatch
4//! logic for executing JIT compiled expressions on the GPU.
5
6use crate::ast::projection::AstProjection;
7use crate::dag::symbol::{OpKind, SymbolKind, SymbolRegistry};
8use bytemuck;
9
10/// JIT compiles the given AST projection into a fully-functional WebGPU WGSL compute shader.
11///
12/// Automatically registers all variable bindings, maps constant literals, and resolves
13/// transcendental/intrinsic functions to their native GPU hardware shader equivalents.
14///
15/// # Errors
16/// Returns a `String` describing the error if compilation fails due to:
17/// - An empty AST projection.
18/// - Invalid node indices in the AST.
19/// - Unsupported infinite or NaN constants.
20/// - Unknown variable or function symbol IDs.
21/// - Incorrect number of children for operators.
22/// - Unsupported functions or control flow nodes on the GPU backend.
23pub fn compile_to_wgsl(
24    ast: &AstProjection,
25    var_registry: &SymbolRegistry,
26    fn_registry: &SymbolRegistry,
27    var_order: &[&str],
28) -> Result<String, String> {
29    if ast.is_empty() {
30        return Err("Cannot compile empty AST projection to WGSL".to_string());
31    }
32
33    // Format the root node recursively.
34    let expr_str = format_node(ast, 0, var_registry, fn_registry, var_order)?;
35
36    use std::fmt::Write; // Add this import at the top of the file
37
38    // Construct the storage bindings and shader entry point template.
39    let mut bindings = String::new();
40    for (i, _) in var_order.iter().enumerate() {
41        writeln!(
42            &mut bindings,
43            "@group(0) @binding({i}) var<storage, read> var_{i}: array<f32>;"
44        )
45        .unwrap(); // Using unwrap assuming write! to String never fails.
46    }
47    // The output buffer is bound directly after the variables.
48    let out_binding = var_order.len();
49    writeln!(
50        &mut bindings,
51        "@group(0) @binding({out_binding}) var<storage, read_write> out_col: array<f32>;"
52    )
53    .unwrap();
54
55    let wgsl_code = format!(
56        "{bindings}
57@compute @workgroup_size(256)
58fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
59    let idx = global_id.x;
60    if (idx >= arrayLength(&out_col)) {{
61        return;
62    }}
63
64    let val = {expr_str};
65    out_col[idx] = val;
66}}
67"
68    );
69
70    Ok(wgsl_code)
71}
72
73/// Recursive helper to translate an AST node into a WGSL expression string.
74fn format_node(
75    projection: &AstProjection,
76    node_idx: usize,
77    var_registry: &SymbolRegistry,
78    fn_registry: &SymbolRegistry,
79    var_order: &[&str],
80) -> Result<String, String> {
81    let node = projection
82        .nodes
83        .get(node_idx)
84        .ok_or_else(|| "Invalid node index in AST projection".to_string())?;
85
86    match node.kind {
87        SymbolKind::Constant(v) => {
88            // WebGPU requires precise float representation for constants.
89            if v.is_infinite() || v.is_nan() {
90                return Err("GPU JIT does not support infinity or NaN constants".to_string());
91            }
92            // Ensure valid floating-point literal representation in WGSL (force a decimal place).
93            if v.fract() == 0.0 {
94                Ok(format!("{v:.1}f"))
95            } else {
96                Ok(format!("{v}f"))
97            }
98        }
99        SymbolKind::Variable(sym_id) => {
100            let name = var_registry
101                .name(sym_id)
102                .ok_or_else(|| "Unknown variable symbol ID".to_string())?;
103            let var_idx = var_order
104                .iter()
105                .position(|&s| s == name)
106                .ok_or_else(|| format!("Variable '{name}' not found in var_order"))?;
107            Ok(format!("var_{var_idx}[idx]"))
108        }
109        SymbolKind::Operator(op) => {
110            let children = node.children.as_slice_with_pool(&projection.children_pool);
111            match op {
112                OpKind::Neg => {
113                    if children.len() != 1 {
114                        return Err("Neg operator must have exactly 1 child".to_string());
115                    }
116                    let child_idx = children[0]
117                        .resolve(node_idx)
118                        .ok_or_else(|| "Unresolved relative child pointer".to_string())?;
119                    let inner =
120                        format_node(projection, child_idx, var_registry, fn_registry, var_order)?;
121                    Ok(format!("(-{inner})"))
122                }
123                OpKind::Add | OpKind::Sub | OpKind::Mul | OpKind::Div | OpKind::Mod => {
124                    if children.len() != 2 {
125                        return Err(format!("{op:?} operator must have exactly 2 children"));
126                    }
127                    let lhs_idx = children[0]
128                        .resolve(node_idx)
129                        .ok_or_else(|| "Unresolved left child pointer".to_string())?;
130                    let rhs_idx = children[1]
131                        .resolve(node_idx)
132                        .ok_or_else(|| "Unresolved right child pointer".to_string())?;
133                    let lhs =
134                        format_node(projection, lhs_idx, var_registry, fn_registry, var_order)?;
135                    let rhs =
136                        format_node(projection, rhs_idx, var_registry, fn_registry, var_order)?;
137                    let op_str = match op {
138                        OpKind::Add => "+",
139                        OpKind::Sub => "-",
140                        OpKind::Mul => "*",
141                        OpKind::Div => "/",
142                        OpKind::Mod => "%",
143                        _ => unreachable!(),
144                    };
145                    Ok(format!("({lhs} {op_str} {rhs})"))
146                }
147                OpKind::Pow => {
148                    if children.len() != 2 {
149                        return Err("Pow operator must have exactly 2 children".to_string());
150                    }
151                    let lhs_idx = children[0]
152                        .resolve(node_idx)
153                        .ok_or_else(|| "Unresolved left child pointer".to_string())?;
154                    let rhs_idx = children[1]
155                        .resolve(node_idx)
156                        .ok_or_else(|| "Unresolved right child pointer".to_string())?;
157                    let lhs =
158                        format_node(projection, lhs_idx, var_registry, fn_registry, var_order)?;
159                    let rhs =
160                        format_node(projection, rhs_idx, var_registry, fn_registry, var_order)?;
161                    Ok(format!("pow({lhs}, {rhs})"))
162                }
163            }
164        }
165        SymbolKind::Function(fn_id) => {
166            let children = node.children.as_slice_with_pool(&projection.children_pool);
167            let fn_name = fn_registry
168                .name(crate::dag::symbol::SymbolId(fn_id.0))
169                .ok_or_else(|| "Unknown function symbol ID".to_string())?;
170
171            // Map standard transcendentals to their WGSL built-in shader functions.
172            let wgsl_fn = match fn_name {
173                "sin" | "cos" | "tan" | "asin" | "acos" | "atan" | "sinh" | "cosh" | "tanh"
174                | "exp" | "log" | "log2" | "sqrt" | "abs" | "floor" | "ceil" | "round" | "sign" => {
175                    fn_name
176                }
177                _ => return Err(format!("Unsupported function on GPU: '{fn_name}'")),
178            };
179
180            let mut args = Vec::new();
181            for ptr in children {
182                let child_idx = ptr
183                    .resolve(node_idx)
184                    .ok_or_else(|| "Unresolved relative child pointer".to_string())?;
185                args.push(format_node(
186                    projection,
187                    child_idx,
188                    var_registry,
189                    fn_registry,
190                    var_order,
191                )?);
192            }
193            Ok(format!("{}({})", wgsl_fn, args.join(", ")))
194        }
195        SymbolKind::ControlFlow(_) => {
196            Err("Control flow is not supported on the GPU backend".to_string())
197        }
198    }
199}
200
201/// Holds compiled GPU resources together to prevent recreation and layout mismatch overhead.
202pub struct CachedPipeline {
203    pipeline: wgpu::ComputePipeline,
204    bind_group_layout: wgpu::BindGroupLayout,
205}
206
207/// A high-performance WebGPU compute execution context with dynamic buffer pooling and pipeline caching.
208pub struct GpuExecutor {
209    device: wgpu::Device,
210    queue: wgpu::Queue,
211    pipeline_cache: std::collections::HashMap<u64, CachedPipeline, rapidhash::fast::GlobalState>,
212    input_buffers: Vec<wgpu::Buffer>,
213    output_buffer: Option<wgpu::Buffer>,
214    staging_buffer: Option<wgpu::Buffer>,
215    /// Reusable CPU buffer to avoid memory allocations during f64 -> f32 conversions on every batch call.
216    conversion_scratchpad: Vec<f32>,
217    current_capacity: usize,
218}
219
220/// Helper to compute rapidhash of WGSL shader source.
221fn hash_shader_src(src: &str) -> u64 {
222    use std::hash::Hasher;
223    let mut hasher = rapidhash::fast::RapidHasher::default();
224    hasher.write(src.as_bytes());
225    hasher.finish()
226}
227
228impl GpuExecutor {
229    /// Discovers and initialises the default GPU adapter and device.
230    ///
231    /// Returns `None` if no compatible GPU hardware backend is available.
232    #[must_use]
233    pub fn new() -> Option<Self> {
234        let instance = wgpu::Instance::default();
235        let Ok(adapter) =
236            pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
237        else {
238            return None;
239        };
240        let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
241            label: Some("RSSN JIT GPU Device"),
242            required_features: wgpu::Features::empty(),
243            required_limits: wgpu::Limits::default(),
244            memory_hints: wgpu::MemoryHints::default(),
245            experimental_features: wgpu::ExperimentalFeatures::default(),
246            trace: wgpu::Trace::Off,
247        }))
248        .ok()?;
249
250        Some(Self {
251            device,
252            queue,
253            pipeline_cache: std::collections::HashMap::default(),
254            input_buffers: Vec::new(),
255            output_buffer: None,
256            staging_buffer: None,
257            conversion_scratchpad: Vec::new(),
258            current_capacity: 0,
259        })
260    }
261
262    /// Dispatches a high-throughput parallel compute job to the GPU using the compiled WGSL shader source.
263    ///
264    /// Performs zero-allocation float array conversions at the CPU-GPU memory boundary
265    /// using a pre-allocated conversion scratchpad, maintaining optimal cache locality.
266    ///
267    /// # Panics
268    /// This function may panic if:
269    /// - Internal WGPU calls fail unexpectedly (e.g., `unwrap()` on `create_compute_pipeline`).
270    /// - A compute pipeline cannot be created.
271    /// - A bind group cannot be created.
272    /// - A command encoder cannot be created.
273    /// - Submission of commands to the queue fails.
274    /// - `pollster::block_on` encounters an error when polling the device.
275    /// - `bytemuck::cast_slice` fails (indicating incorrect memory layout assumptions).
276    ///
277    /// # Errors
278    /// Returns a `String` describing the error if execution fails due to:
279    /// - Failure to fetch the compute pipeline from the cache.
280    /// - Uninitialized output or staging buffers.
281    /// - Staging buffer mapping fails or the GPU channel disconnects.
282    pub fn execute_batch(
283        &mut self,
284        shader_src: &str,
285        n_rows: usize,
286        vars_cols: &[&[f64]],
287        out: &mut [f64],
288    ) -> Result<(), String> {
289        if n_rows == 0 {
290            return Ok(());
291        }
292
293        // 1. Get or Compile WGSL Shader Module & Compute Pipeline (utilising Fast LRU hash cache)
294        let hash = hash_shader_src(shader_src);
295        if !self.pipeline_cache.contains_key(&hash) {
296            let shader_module = self
297                .device
298                .create_shader_module(wgpu::ShaderModuleDescriptor {
299                    label: Some("RSSN JIT Compute Shader"),
300                    source: wgpu::ShaderSource::Wgsl(shader_src.into()),
301                });
302
303            let compute_pipeline =
304                self.device
305                    .create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
306                        label: Some("RSSN JIT GPU Compute Pipeline"),
307                        layout: None,
308                        module: &shader_module,
309                        entry_point: Some("main"),
310                        compilation_options: wgpu::PipelineCompilationOptions::default(),
311                        cache: None,
312                    });
313
314            // Cache bind group layout along with pipeline to optimize driver-level validation passes
315            let bind_group_layout = compute_pipeline.get_bind_group_layout(0);
316
317            self.pipeline_cache.insert(
318                hash,
319                CachedPipeline {
320                    pipeline: compute_pipeline,
321                    bind_group_layout,
322                },
323            );
324        }
325        let cached = self
326            .pipeline_cache
327            .get(&hash)
328            .ok_or("Failed to fetch compute pipeline")?;
329
330        // 2. Stateful Buffer Pooling: Re-allocate ONLY when row capacity changes
331        let needs_realloc = self.output_buffer.is_none() || self.current_capacity != n_rows;
332        if needs_realloc {
333            self.current_capacity = n_rows;
334            let output_size_bytes = (n_rows * std::mem::size_of::<f32>()) as u64;
335
336            self.output_buffer = Some(self.device.create_buffer(&wgpu::BufferDescriptor {
337                label: Some("GPU Output Storage Buffer"),
338                size: output_size_bytes,
339                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
340                mapped_at_creation: false,
341            }));
342
343            self.staging_buffer = Some(self.device.create_buffer(&wgpu::BufferDescriptor {
344                label: Some("GPU Staging Buffer"),
345                size: output_size_bytes,
346                usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
347                mapped_at_creation: false,
348            }));
349
350            // Clear input buffers to trigger a resize/reallocation
351            self.input_buffers.clear();
352        }
353
354        // Dynamically scale/allocate input buffers if variable column size changes (safely handles up and down scaling)
355        if self.input_buffers.len() != vars_cols.len() {
356            self.input_buffers.clear();
357            let input_size_bytes = (n_rows * std::mem::size_of::<f32>()) as u64;
358            for i in 0..vars_cols.len() {
359                let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
360                    label: Some(&format!("GPU Input Storage Buffer {i}")),
361                    size: input_size_bytes,
362                    usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
363                    mapped_at_creation: false,
364                });
365                self.input_buffers.push(buffer);
366            }
367        }
368
369        // 3. Write input columns into pooled GPU memory buffers (zero-allocation)
370        // Re-use scratchpad space to avoid per-column vec-allocations
371        self.conversion_scratchpad.resize(n_rows, 0.0f32);
372        for (i, col) in vars_cols.iter().enumerate() {
373            // Unroll slightly or optimize via element copying
374            for (dst, &src) in self.conversion_scratchpad.iter_mut().zip(col.iter()) {
375                *dst = src as f32;
376            }
377            self.queue.write_buffer(
378                &self.input_buffers[i],
379                0,
380                bytemuck::cast_slice(&self.conversion_scratchpad),
381            );
382        }
383
384        // 4. Map Bind Group Entries (zero-copy references from stateful buffers)
385        let mut bind_group_entries = Vec::with_capacity(vars_cols.len() + 1);
386        for (i, buffer) in self.input_buffers.iter().enumerate() {
387            bind_group_entries.push(wgpu::BindGroupEntry {
388                binding: i as u32,
389                resource: buffer.as_entire_binding(),
390            });
391        }
392
393        let output_buffer = self
394            .output_buffer
395            .as_ref()
396            .ok_or("Uninitialized output buffer")?;
397        let staging_buffer = self
398            .staging_buffer
399            .as_ref()
400            .ok_or("Uninitialized staging buffer")?;
401
402        // Output binding is placed immediately after input bindings.
403        let out_binding = vars_cols.len() as u32;
404        bind_group_entries.push(wgpu::BindGroupEntry {
405            binding: out_binding,
406            resource: output_buffer.as_entire_binding(),
407        });
408
409        let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
410            label: Some("RSSN JIT GPU Bind Group"),
411            layout: &cached.bind_group_layout,
412            entries: &bind_group_entries,
413        });
414
415        // 5. Command Encoding and Pass Dispatch
416        let mut encoder = self
417            .device
418            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
419                label: Some("RSSN JIT GPU Compute Encoder"),
420            });
421
422        {
423            let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
424                label: Some("RSSN JIT Compute Pass"),
425                timestamp_writes: None,
426            });
427            compute_pass.set_pipeline(&cached.pipeline);
428            compute_pass.set_bind_group(0, &bind_group, &[]);
429
430            // Dispatch in workgroups of size 256.
431            let workgroup_count = n_rows.div_ceil(256);
432            compute_pass.dispatch_workgroups(workgroup_count as u32, 1, 1);
433        }
434
435        let output_size_bytes = (n_rows * std::mem::size_of::<f32>()) as u64;
436        encoder.copy_buffer_to_buffer(output_buffer, 0, staging_buffer, 0, output_size_bytes);
437        self.queue.submit(Some(encoder.finish()));
438
439        // 6. Map staging buffer with explicit bounds and fetch computed results.
440        let buffer_slice = staging_buffer.slice(0..output_size_bytes);
441        let (sender, receiver) = std::sync::mpsc::channel();
442        buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
443            let _ = sender.send(result);
444        });
445
446        // Poll device until mapping completes.
447        self.device
448            .poll(wgpu::PollType::Wait {
449                submission_index: None,
450                timeout: None,
451            })
452            .unwrap();
453        receiver
454            .recv()
455            .map_err(|e| format!("GPU channel disconnect error: {e}"))?
456            .map_err(|e| format!("Staging buffer mapping failed: {e:?}"))?;
457
458        {
459            let data = buffer_slice.get_mapped_range();
460            let f32_out: &[f32] = bytemuck::cast_slice(&data);
461
462            // Convert results back to f64 matching our JIT execution signature.
463            for (dest, &src) in out.iter_mut().zip(f32_out.iter()) {
464                *dest = f64::from(src);
465            }
466        }
467
468        staging_buffer.unmap();
469        Ok(())
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use crate::ast::convert::dag_to_ast;
477    use crate::dag::builder::DagBuilder;
478    use crate::parser::expr::parse_expression;
479
480    #[test]
481    fn test_compile_simple_expression_to_wgsl() {
482        let mut builder = DagBuilder::new();
483        let root = parse_expression("x^2 + 2*x + 1.5", &mut builder).unwrap();
484        let ast = dag_to_ast(builder.arena(), root);
485        let var_order = vec!["x"];
486
487        let shader =
488            compile_to_wgsl(&ast, builder.registry(), builder.fn_registry(), &var_order).unwrap();
489
490        assert!(shader.contains("@group(0) @binding(0) var<storage, read> var_0: array<f32>;"));
491        assert!(
492            shader.contains("@group(0) @binding(1) var<storage, read_write> out_col: array<f32>;")
493        );
494        assert!(shader.contains("let val = "));
495    }
496
497    #[test]
498    fn test_execute_batch_gpu() {
499        let mut executor = match GpuExecutor::new() {
500            Some(e) => e,
501            None => return, // Skip test if no GPU compatible hardware is found
502        };
503
504        let mut builder = DagBuilder::new();
505        let root = parse_expression("x + y + 10.0", &mut builder).unwrap();
506        let ast = dag_to_ast(builder.arena(), root);
507        let var_order = vec!["x", "y"];
508
509        let shader =
510            compile_to_wgsl(&ast, builder.registry(), builder.fn_registry(), &var_order).unwrap();
511
512        let n = 1000;
513        let x_data = vec![1.0; n];
514        let y_data = vec![2.0; n];
515        let mut out = vec![0.0; n];
516
517        // Execute batch multiple times to verify caching and buffer reuse works flawlessly
518        for _ in 0..3 {
519            executor
520                .execute_batch(&shader, n, &[&x_data, &y_data], &mut out)
521                .unwrap();
522            for i in 0..n {
523                assert_eq!(out[i], 13.0); // 1.0 + 2.0 + 10.0
524            }
525        }
526    }
527}