Skip to main content

earthsci_ast/
performance.rs

1//! Experimental performance utilities (benchmark support).
2//!
3//! Opt-in via the `parallel` / `simd` / `zero_copy` / `custom_alloc` feature
4//! flags (or `performance` for all four). NOTE: nothing in the production
5//! simulate paths uses this module — `simulate` / `simulate_array` have their
6//! own evaluators and deliberately use rustc-hash + reused scratch buffers
7//! (see Cargo.toml). These utilities exist for the `benchmarks` bench target,
8//! the feature-gated tests in `tests/performance.rs`, and downstream
9//! experimentation:
10//! - Zero-copy JSON parsing with simd-json (`fast_parse`)
11//! - Parallel expression batch evaluation with rayon (`ParallelEvaluator`,
12//!   `parallel` feature)
13//! - SIMD vector math (`simd_math`)
14//! - A bump-allocator arena for large models (`ModelAllocator`,
15//!   `custom_alloc` feature)
16//! - A stack-based postfix expression evaluator ([`CompactExpr`])
17
18use crate::{EsmFile, Expr};
19use std::collections::HashMap;
20
21#[cfg(feature = "zero_copy")]
22use simd_json;
23
24#[cfg(feature = "parallel")]
25use rayon::prelude::*;
26
27#[cfg(feature = "simd")]
28use wide::f64x4;
29
30#[cfg(feature = "custom_alloc")]
31use bumpalo::Bump;
32
33/// Error type for performance operations
34#[derive(Debug, thiserror::Error)]
35pub enum PerformanceError {
36    /// JSON parsing failed (either backend of [`fast_parse`]).
37    #[error("parse error: {0}")]
38    ParseError(String),
39    #[error("Parallel evaluation error: {0}")]
40    ParallelError(String),
41    /// Stack-machine evaluation failed ([`CompactExpr::evaluate_fast`]):
42    /// operand underflow, unbound variable, division by zero, or an operator
43    /// outside the compact instruction set.
44    #[error("expression evaluation error: {0}")]
45    EvalError(String),
46    #[error("SIMD operation error: {0}")]
47    SimdError(String),
48    #[error("Memory allocation error: {0}")]
49    AllocError(String),
50}
51
52/// JSON parser that uses simd-json when the `zero_copy` feature is enabled
53/// and serde_json otherwise. One signature for both backends, so callers do
54/// not need their own cfg fork (simd-json needs a mutable buffer, which this
55/// function owns internally).
56pub fn fast_parse(json_str: &str) -> Result<EsmFile, PerformanceError> {
57    #[cfg(feature = "zero_copy")]
58    {
59        let mut bytes = json_str.as_bytes().to_vec();
60        simd_json::serde::from_slice(&mut bytes)
61            .map_err(|e| PerformanceError::ParseError(e.to_string()))
62    }
63    #[cfg(not(feature = "zero_copy"))]
64    {
65        serde_json::from_str(json_str).map_err(|e| PerformanceError::ParseError(e.to_string()))
66    }
67}
68
69/// Parallel evaluation context for expressions
70#[cfg(feature = "parallel")]
71pub struct ParallelEvaluator {
72    thread_pool: rayon::ThreadPool,
73}
74
75#[cfg(feature = "parallel")]
76impl ParallelEvaluator {
77    /// Create a new parallel evaluator with specified number of threads
78    pub fn new(num_threads: Option<usize>) -> Result<Self, PerformanceError> {
79        let pool = if let Some(n) = num_threads {
80            rayon::ThreadPoolBuilder::new()
81                .num_threads(n)
82                .build()
83                .map_err(|e| PerformanceError::ParallelError(e.to_string()))?
84        } else {
85            rayon::ThreadPoolBuilder::new()
86                .build()
87                .map_err(|e| PerformanceError::ParallelError(e.to_string()))?
88        };
89
90        Ok(Self { thread_pool: pool })
91    }
92
93    /// Evaluate multiple expressions in parallel
94    pub fn evaluate_batch(
95        &self,
96        expressions: &[Expr],
97        variables: &HashMap<String, f64>,
98    ) -> Result<Vec<f64>, PerformanceError> {
99        self.thread_pool.install(|| {
100            expressions
101                .par_iter()
102                .map(|expr| {
103                    crate::expression::evaluate(expr, variables)
104                        .map_err(|e| PerformanceError::ParallelError(format!("{e:?}")))
105                })
106                .collect()
107        })
108    }
109
110    /// Parallel stoichiometric matrix computation
111    pub fn compute_stoichiometric_matrix_parallel(
112        &self,
113        system: &crate::ReactionSystem,
114    ) -> Result<Vec<Vec<f64>>, PerformanceError> {
115        let num_species = system.species.len();
116        let num_reactions = system.reactions.len();
117
118        // Declaration order, matching `stoichiometric_matrix` in src/reactions.rs
119        // (API_SPEC.md §5.10). `species` is an `IndexMap`, so key iteration is
120        // both deterministic and the authored order; the `.sort()` this used to
121        // do made the parallel matrix disagree with every other binding's rows.
122        let species_index: HashMap<String, usize> = system
123            .species
124            .keys()
125            .enumerate()
126            .map(|(idx, name)| (name.clone(), idx))
127            .collect();
128
129        self.thread_pool.install(|| {
130            // Initialize matrix with parallel vectors
131            let mut matrix: Vec<Vec<f64>> = (0..num_species)
132                .into_par_iter()
133                .map(|_| vec![0.0; num_reactions])
134                .collect();
135
136            // Process reactions in parallel
137            let reaction_contributions: Vec<Vec<(usize, f64)>> = system
138                .reactions
139                .par_iter()
140                .enumerate()
141                .map(|(_reaction_idx, reaction)| {
142                    let mut contributions = Vec::new();
143
144                    // Process substrates (negative coefficients)
145                    for substrate in reaction.substrates.iter().flatten() {
146                        if let Some(&species_idx) = species_index.get(&substrate.species) {
147                            contributions.push((species_idx, -substrate.coefficient));
148                        }
149                    }
150
151                    // Process products (positive coefficients)
152                    for product in reaction.products.iter().flatten() {
153                        if let Some(&species_idx) = species_index.get(&product.species) {
154                            contributions.push((species_idx, product.coefficient));
155                        }
156                    }
157
158                    contributions
159                })
160                .collect();
161
162            // Apply contributions to matrix
163            for (reaction_idx, contributions) in reaction_contributions.into_iter().enumerate() {
164                for (species_idx, coeff) in contributions {
165                    matrix[species_idx][reaction_idx] += coeff;
166                }
167            }
168
169            Ok(matrix)
170        })
171    }
172}
173
174/// SIMD-optimized mathematical operations for expression evaluation
175#[cfg(feature = "simd")]
176pub mod simd_math {
177    use super::*;
178
179    /// Apply an element-wise binary op over two vectors, four lanes at a time.
180    /// `simd_op` handles full `f64x4` chunks; `scalar_op` handles the tail.
181    fn elementwise_simd(
182        a: &[f64],
183        b: &[f64],
184        result: &mut [f64],
185        simd_op: impl Fn(f64x4, f64x4) -> f64x4,
186        scalar_op: impl Fn(f64, f64) -> f64,
187    ) -> Result<(), PerformanceError> {
188        if a.len() != b.len() || a.len() != result.len() {
189            return Err(PerformanceError::SimdError(
190                "Vector length mismatch".to_string(),
191            ));
192        }
193
194        let chunks = a.len() / 4;
195
196        // Process 4 elements at a time with SIMD
197        for i in 0..chunks {
198            let idx = i * 4;
199            let va = f64x4::from([a[idx], a[idx + 1], a[idx + 2], a[idx + 3]]);
200            let vb = f64x4::from([b[idx], b[idx + 1], b[idx + 2], b[idx + 3]]);
201            let vr = simd_op(va, vb);
202
203            result[idx..idx + 4].copy_from_slice(&vr.to_array());
204        }
205
206        // Handle remaining elements
207        for i in (chunks * 4)..a.len() {
208            result[i] = scalar_op(a[i], b[i]);
209        }
210
211        Ok(())
212    }
213
214    /// SIMD-optimized vector addition
215    pub fn add_vectors_simd(
216        a: &[f64],
217        b: &[f64],
218        result: &mut [f64],
219    ) -> Result<(), PerformanceError> {
220        elementwise_simd(a, b, result, |va, vb| va + vb, |x, y| x + y)
221    }
222
223    /// SIMD-optimized element-wise multiplication
224    pub fn multiply_vectors_simd(
225        a: &[f64],
226        b: &[f64],
227        result: &mut [f64],
228    ) -> Result<(), PerformanceError> {
229        elementwise_simd(a, b, result, |va, vb| va * vb, |x, y| x * y)
230    }
231
232    /// SIMD-optimized dot product
233    pub fn dot_product_simd(a: &[f64], b: &[f64]) -> Result<f64, PerformanceError> {
234        if a.len() != b.len() {
235            return Err(PerformanceError::SimdError(
236                "Vector length mismatch".to_string(),
237            ));
238        }
239
240        let chunks = a.len() / 4;
241        let mut sum = f64x4::from([0.0, 0.0, 0.0, 0.0]);
242
243        // Process 4 elements at a time with SIMD
244        for i in 0..chunks {
245            let idx = i * 4;
246            let va = f64x4::from([a[idx], a[idx + 1], a[idx + 2], a[idx + 3]]);
247            let vb = f64x4::from([b[idx], b[idx + 1], b[idx + 2], b[idx + 3]]);
248            sum += va * vb;
249        }
250
251        let mut result = sum.to_array().iter().sum::<f64>();
252
253        // Handle remaining elements
254        for i in (chunks * 4)..a.len() {
255            result += a[i] * b[i];
256        }
257
258        Ok(result)
259    }
260}
261
262/// Memory pool allocator for large model processing
263#[cfg(feature = "custom_alloc")]
264pub struct ModelAllocator {
265    bump: Bump,
266}
267
268#[cfg(feature = "custom_alloc")]
269impl Default for ModelAllocator {
270    fn default() -> Self {
271        Self::new()
272    }
273}
274
275#[cfg(feature = "custom_alloc")]
276impl ModelAllocator {
277    /// Create a new model allocator with specified capacity
278    pub fn new() -> Self {
279        Self { bump: Bump::new() }
280    }
281
282    /// Create allocator with pre-allocated capacity
283    pub fn with_capacity(capacity: usize) -> Self {
284        Self {
285            bump: Bump::with_capacity(capacity),
286        }
287    }
288
289    /// Allocate a slice for storing intermediate results
290    pub fn alloc_slice<T>(&self, len: usize) -> &mut [T]
291    where
292        T: Default + Clone,
293    {
294        self.bump.alloc_slice_fill_default(len)
295    }
296
297    /// Reset the allocator for reuse
298    pub fn reset(&mut self) {
299        self.bump.reset();
300    }
301
302    /// Get current allocated bytes
303    pub fn allocated_bytes(&self) -> usize {
304        self.bump.allocated_bytes()
305    }
306}
307
308/// Compact node representation for stack-based evaluation
309#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
310pub enum CompactNode {
311    /// Numeric constant
312    Number(f64),
313    /// Variable reference
314    Variable(String),
315    /// Operator (operands implicit via postfix notation)
316    Operator(String),
317}
318
319/// High-performance expression tree with compact representation
320#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
321pub struct CompactExpr {
322    /// Flattened expression tree using small vectors for cache efficiency
323    pub nodes: smallvec::SmallVec<[CompactNode; 8]>,
324    /// Variable name → first-seen index, populated at compile time by
325    /// [`CompactExpr::from_expr`]. Currently unused by [`CompactExpr::evaluate_fast`]
326    /// (which resolves each `Variable` node by name against the supplied map), but
327    /// kept because it is part of the serialized / WASM-exposed surface.
328    pub var_cache: HashMap<String, usize>,
329}
330
331impl CompactExpr {
332    /// Create a compact expression from a standard expression
333    pub fn from_expr(expr: &Expr) -> Self {
334        let mut nodes = smallvec::SmallVec::new();
335        let mut var_cache = HashMap::new();
336
337        Self::flatten_expr(expr, &mut nodes, &mut var_cache);
338
339        Self { nodes, var_cache }
340    }
341
342    /// Flatten expression tree into linear representation
343    fn flatten_expr(
344        expr: &Expr,
345        nodes: &mut smallvec::SmallVec<[CompactNode; 8]>,
346        var_cache: &mut HashMap<String, usize>,
347    ) {
348        match expr {
349            Expr::Number(n) => {
350                nodes.push(CompactNode::Number(*n));
351            }
352            Expr::Integer(n) => {
353                // Compact representation currently stores float only; promote
354                // per §5.4.1 at the perf-path boundary (evaluate semantics).
355                nodes.push(CompactNode::Number(*n as f64));
356            }
357            Expr::Variable(name) => {
358                let index = var_cache.len();
359                var_cache.entry(name.clone()).or_insert(index);
360                nodes.push(CompactNode::Variable(name.clone()));
361            }
362            Expr::Operator(expr_node) => {
363                // Recursively flatten operands first
364                for arg in &expr_node.args {
365                    Self::flatten_expr(arg, nodes, var_cache);
366                }
367                nodes.push(CompactNode::Operator(expr_node.op.clone()));
368            }
369        }
370    }
371
372    /// Fast evaluation using stack-based postfix evaluation. Purely
373    /// sequential — available regardless of the `parallel` feature.
374    pub fn evaluate_fast(&self, variables: &HashMap<String, f64>) -> Result<f64, PerformanceError> {
375        type Stack = smallvec::SmallVec<[f64; 16]>;
376
377        fn underflow() -> PerformanceError {
378            PerformanceError::EvalError("Stack underflow".to_string())
379        }
380
381        // Pop the two operands of a binary op, returning `(a, b)` in
382        // left-to-right order (`b` was on top of the stack).
383        fn pop2(stack: &mut Stack) -> Result<(f64, f64), PerformanceError> {
384            if stack.len() < 2 {
385                return Err(underflow());
386            }
387            let b = stack.pop().unwrap();
388            let a = stack.pop().unwrap();
389            Ok((a, b))
390        }
391
392        // Pop the single operand of a unary op.
393        fn pop1(stack: &mut Stack) -> Result<f64, PerformanceError> {
394            stack.pop().ok_or_else(underflow)
395        }
396
397        let mut stack = Stack::new();
398
399        for node in &self.nodes {
400            match node {
401                CompactNode::Number(n) => stack.push(*n),
402                CompactNode::Variable(name) => {
403                    let value = variables.get(name).ok_or_else(|| {
404                        PerformanceError::EvalError(format!("Undefined variable: {name}"))
405                    })?;
406                    stack.push(*value);
407                }
408                CompactNode::Operator(op) => match op.as_str() {
409                    "+" => {
410                        let (a, b) = pop2(&mut stack)?;
411                        stack.push(a + b);
412                    }
413                    "-" => {
414                        let (a, b) = pop2(&mut stack)?;
415                        stack.push(a - b);
416                    }
417                    "*" => {
418                        let (a, b) = pop2(&mut stack)?;
419                        stack.push(a * b);
420                    }
421                    "/" => {
422                        let (a, b) = pop2(&mut stack)?;
423                        if b == 0.0 {
424                            return Err(PerformanceError::EvalError(
425                                "Division by zero".to_string(),
426                            ));
427                        }
428                        stack.push(a / b);
429                    }
430                    "^" => {
431                        let (a, b) = pop2(&mut stack)?;
432                        stack.push(a.powf(b));
433                    }
434                    "sin" => {
435                        let a = pop1(&mut stack)?;
436                        stack.push(a.sin());
437                    }
438                    "cos" => {
439                        let a = pop1(&mut stack)?;
440                        stack.push(a.cos());
441                    }
442                    "exp" => {
443                        let a = pop1(&mut stack)?;
444                        stack.push(a.exp());
445                    }
446                    "log" => {
447                        let a = pop1(&mut stack)?;
448                        if a <= 0.0 {
449                            return Err(PerformanceError::EvalError(
450                                "Invalid log argument".to_string(),
451                            ));
452                        }
453                        stack.push(a.ln());
454                    }
455                    _ => {
456                        return Err(PerformanceError::EvalError(format!(
457                            "Unsupported operator: {op}"
458                        )));
459                    }
460                },
461            }
462        }
463
464        if stack.len() != 1 {
465            return Err(PerformanceError::EvalError(
466                "Invalid expression".to_string(),
467            ));
468        }
469
470        Ok(stack[0])
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use crate::types::{Expr, ExpressionNode};
478
479    #[test]
480    fn test_compact_expr_creation() {
481        let expr = Expr::operator(ExpressionNode {
482            op: "+".to_string(),
483            args: vec![Expr::Variable("x".to_string()), Expr::Number(1.0)],
484            wrt: None,
485            dim: None,
486            ..Default::default()
487        });
488
489        let compact = CompactExpr::from_expr(&expr);
490        assert_eq!(compact.nodes.len(), 3);
491        assert_eq!(compact.var_cache.len(), 1);
492        assert!(compact.var_cache.contains_key("x"));
493    }
494
495    #[cfg(feature = "parallel")]
496    #[test]
497    fn test_fast_evaluation() {
498        let expr = Expr::operator(ExpressionNode {
499            op: "+".to_string(),
500            args: vec![Expr::Variable("x".to_string()), Expr::Number(1.0)],
501            wrt: None,
502            dim: None,
503            ..Default::default()
504        });
505
506        let compact = CompactExpr::from_expr(&expr);
507        let mut variables = HashMap::new();
508        variables.insert("x".to_string(), 5.0);
509
510        let result = compact.evaluate_fast(&variables).unwrap();
511        assert_eq!(result, 6.0);
512    }
513
514    #[cfg(feature = "simd")]
515    #[test]
516    fn test_simd_operations() {
517        let a = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
518        let b = vec![2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
519        let mut result = vec![0.0; 8];
520
521        simd_math::add_vectors_simd(&a, &b, &mut result).unwrap();
522
523        assert_eq!(result, vec![3.0, 5.0, 7.0, 9.0, 11.0, 13.0, 15.0, 17.0]);
524
525        let dot = simd_math::dot_product_simd(&a, &b).unwrap();
526        assert_eq!(dot, 240.0); // 1*2 + 2*3 + ... + 8*9 = 240.0
527    }
528}