scirs2_integrate/ode/utils/jacobian/
parallel.rs

1//! Parallel Jacobian computation for ODE systems
2//!
3//! This module provides functions for computing Jacobian matrices in parallel,
4//! which can significantly speed up derivative calculations for large systems.
5//!
6//! To enable parallel Jacobian computation, activate the "parallel_jacobian" feature:
7//! ```toml
8//! [dependencies]
9//! scirs2-integrate = { version = "0.1.0-beta.4", features = ["parallel_jacobian"] }
10//! ```
11//!
12//! Parallel computation is especially beneficial for:
13//! - Large ODE systems (dimension > 20)
14//! - Computationally expensive right-hand side functions
15//! - Multi-core systems where serial computation would be a bottleneck
16
17use crate::common::IntegrateFloat;
18use crate::error::IntegrateResult;
19use scirs2_core::ndarray::{Array1, Array2, ArrayView1};
20use scirs2_core::num_threads;
21
22#[cfg(feature = "parallel_jacobian")]
23use scirs2_core::parallel_ops::*;
24
25/// Compute Jacobian matrix using parallel finite differences
26///
27/// This function divides the work of computing each column of the Jacobian
28/// across multiple threads, which can provide significant speedup for large systems.
29///
30/// # Arguments
31///
32/// * `f` - Function to differentiate
33/// * `t` - Time value
34/// * `y` - State vector
35/// * `f_current` - Current function evaluation at (t, y)
36/// * `perturbation_scale` - Scaling factor for perturbation size
37///
38/// # Returns
39///
40/// Jacobian matrix (∂f/∂y)
41///
42/// # Features
43///
44/// Requires the "parallel_jacobian" feature to be enabled for actual parallel execution.
45/// Falls back to serial execution if the feature is not enabled.
46#[allow(dead_code)]
47pub fn parallel_finite_difference_jacobian<F, Func>(
48    f: &Func,
49    t: F,
50    y: &Array1<F>,
51    f_current: &Array1<F>,
52    perturbation_scale: F,
53) -> IntegrateResult<Array2<F>>
54where
55    F: IntegrateFloat + Send + Sync,
56    Func: Fn(F, ArrayView1<F>) -> Array1<F> + Sync,
57{
58    let n_dim = y.len();
59    let mut jacobian = Array2::<F>::zeros((n_dim, n_dim));
60
61    // Calculate base perturbation size
62    let eps_base = F::from_f64(1e-8).unwrap() * perturbation_scale;
63
64    #[cfg(feature = "parallel_jacobian")]
65    {
66        // Compute columns in parallel using rayon
67        let columns: Vec<(usize, Array1<F>)> = (0..n_dim)
68            .into_par_iter()
69            .map(|j| {
70                // Scale perturbation by variable magnitude
71                let eps = eps_base * (F::one() + y[j].abs()).max(F::one());
72
73                // Perturb the j-th component
74                let mut y_perturbed = y.clone();
75                y_perturbed[j] += eps;
76
77                // Evaluate function at perturbed point
78                let f_perturbed = f(t, y_perturbed.view());
79
80                // Calculate the j-th column using finite differences
81                let mut column = Array1::<F>::zeros(n_dim);
82                for i in 0..n_dim {
83                    column[i] = (f_perturbed[i] - f_current[i]) / eps;
84                }
85
86                (j, column)
87            })
88            .collect();
89
90        // Assemble the Jacobian from columns
91        for (j, column) in columns {
92            for i in 0..n_dim {
93                jacobian[[i, j]] = column[i];
94            }
95        }
96    }
97
98    #[cfg(not(feature = "parallel_jacobian"))]
99    {
100        // Fallback to serial implementation when feature is not enabled
101        for j in 0..n_dim {
102            // Scale perturbation by variable magnitude
103            let eps = eps_base * (F::one() + y[j].abs()).max(F::one());
104
105            // Perturb the j-th component
106            let mut y_perturbed = y.clone();
107            y_perturbed[j] += eps;
108
109            // Evaluate function at perturbed point
110            let f_perturbed = f(t, y_perturbed.view());
111
112            // Calculate the j-th column using finite differences
113            for i in 0..n_dim {
114                jacobian[[i, j]] = (f_perturbed[i] - f_current[i]) / eps;
115            }
116        }
117    }
118
119    Ok(jacobian)
120}
121
122/// Compute sparse Jacobian matrix in parallel using coloring
123///
124/// This function uses graph coloring to identify independent columns of the
125/// Jacobian that can be computed simultaneously, reducing the number of
126/// function evaluations needed for sparse Jacobians.
127///
128/// # Arguments
129///
130/// * `f` - Function to differentiate
131/// * `t` - Time value
132/// * `y` - State vector
133/// * `f_current` - Current function evaluation at (t, y)
134/// * `sparsity_pattern` - Optional sparsity pattern (true for non-zero entries)
135/// * `perturbation_scale` - Scaling factor for perturbation size
136///
137/// # Returns
138///
139/// Jacobian matrix (∂f/∂y)
140///
141/// # Features
142///
143/// Requires the "parallel_jacobian" feature to be enabled for actual parallel execution.
144/// Falls back to a serial implementation if the feature is not enabled.
145#[allow(dead_code)]
146pub fn parallel_sparse_jacobian<F, Func>(
147    f: &Func,
148    t: F,
149    y: &Array1<F>,
150    f_current: &Array1<F>,
151    sparsity_pattern: Option<&Array2<bool>>,
152    perturbation_scale: F,
153) -> IntegrateResult<Array2<F>>
154where
155    F: IntegrateFloat + Send + Sync,
156    Func: Fn(F, ArrayView1<F>) -> Array1<F> + Sync,
157{
158    let n_dim = y.len();
159    let mut jacobian = Array2::<F>::zeros((n_dim, n_dim));
160
161    // Determine sparsity _pattern and coloring
162    let (pattern, colors) = if let Some(_pattern) = sparsity_pattern {
163        // Use provided sparsity _pattern
164        (_pattern.clone(), greedy_coloring(_pattern))
165    } else {
166        // Assume dense Jacobian
167        let dense_pattern = Array2::<bool>::from_elem((n_dim, n_dim), true);
168        // For dense matrices, each column needs its own color
169        let dense_colors = (0..n_dim).collect::<Vec<_>>();
170        (dense_pattern, dense_colors)
171    };
172
173    // Find maximum color
174    let max_color = colors.iter().max().cloned().unwrap_or(0);
175
176    // Base perturbation size
177    let eps_base = F::from_f64(1e-8).unwrap() * perturbation_scale;
178
179    #[cfg(feature = "parallel_jacobian")]
180    {
181        // Process each color group in parallel
182        let color_results: Vec<_> = (0..=max_color)
183            .into_par_iter()
184            .map(|color| {
185                // Find all columns with this color
186                let columns_with_color: Vec<usize> = colors
187                    .iter()
188                    .enumerate()
189                    .filter_map(|(j, &c)| if c == color { Some(j) } else { None })
190                    .collect();
191
192                if columns_with_color.is_empty() {
193                    return Vec::new();
194                }
195
196                // Create a perturbation vector that perturbs all columns of this color
197                let mut y_perturbed = y.clone();
198                let mut perturbation_sizes = Vec::with_capacity(columns_with_color.len());
199
200                for &j in &columns_with_color {
201                    let eps = eps_base * (F::one() + y[j].abs()).max(F::one());
202                    y_perturbed[j] += eps;
203                    perturbation_sizes.push(eps);
204                }
205
206                // Evaluate function with perturbed values
207                let f_perturbed = f(t, y_perturbed.view());
208
209                // Extract columns for this color
210                let mut color_columns = Vec::with_capacity(columns_with_color.len());
211
212                for (idx, &j) in columns_with_color.iter().enumerate() {
213                    let eps = perturbation_sizes[idx];
214
215                    // Extract non-zero elements for this column based on sparsity _pattern
216                    let mut col_indices = Vec::new();
217                    for i in 0..n_dim {
218                        if pattern[[i, j]] {
219                            col_indices.push(i);
220                        }
221                    }
222
223                    // For each non-zero element, compute its value
224                    let mut column_values = Vec::with_capacity(col_indices.len());
225                    for &i in &col_indices {
226                        let df = f_perturbed[i] - f_current[i];
227                        column_values.push((i, j, df / eps));
228                    }
229
230                    color_columns.push(column_values);
231                }
232
233                // Flatten all columns for this color
234                color_columns.into_iter().flatten().collect::<Vec<_>>()
235            })
236            .collect();
237
238        // Combine all results into the Jacobian matrix
239        for color_column in color_results {
240            for (i, j, value) in color_column {
241                jacobian[[i, j]] = value;
242            }
243        }
244    }
245
246    #[cfg(not(feature = "parallel_jacobian"))]
247    {
248        // Serial implementation of the coloring algorithm
249        for color in 0..=max_color {
250            // Find all columns with this color
251            let columns_with_color: Vec<usize> = colors
252                .iter()
253                .enumerate()
254                .filter_map(|(j, &c)| if c == color { Some(j) } else { None })
255                .collect();
256
257            if columns_with_color.is_empty() {
258                continue;
259            }
260
261            // Create a perturbation vector that perturbs all columns of this color
262            let mut y_perturbed = y.clone();
263            let mut perturbation_sizes = Vec::with_capacity(columns_with_color.len());
264
265            for &j in &columns_with_color {
266                let eps = eps_base * (F::one() + y[j].abs()).max(F::one());
267                y_perturbed[j] += eps;
268                perturbation_sizes.push(eps);
269            }
270
271            // Evaluate function with perturbed values
272            let f_perturbed = f(t, y_perturbed.view());
273
274            // Process columns for this color
275            for (idx, &j) in columns_with_color.iter().enumerate() {
276                let eps = perturbation_sizes[idx];
277
278                // Compute values for non-zero elements
279                for i in 0..n_dim {
280                    if pattern[[i, j]] {
281                        let df = f_perturbed[i] - f_current[i];
282                        jacobian[[i, j]] = df / eps;
283                    }
284                }
285            }
286        }
287    }
288
289    Ok(jacobian)
290}
291
292/// Graph coloring algorithm for parallel Jacobian computation
293///
294/// This function implements a greedy coloring algorithm to color the
295/// columns of the Jacobian matrix such that no two columns with non-zero
296/// elements in the same row share the same color.
297///
298/// # Arguments
299///
300/// * `sparsity_pattern` - Sparsity pattern of the Jacobian
301///
302/// # Returns
303///
304/// Vector of colors for each column
305#[allow(dead_code)]
306fn greedy_coloring(_sparsitypattern: &Array2<bool>) -> Vec<usize> {
307    let (n_rows, n_cols) = _sparsitypattern.dim();
308
309    // Initialize colors for all columns
310    let mut colors = vec![usize::MAX; n_cols];
311
312    // Process each column
313    for j in 0..n_cols {
314        // Find non-zero entries in this column
315        let mut non_zero_rows = Vec::new();
316        for i in 0..n_rows {
317            if _sparsitypattern[[i, j]] {
318                non_zero_rows.push(i);
319            }
320        }
321
322        // Find colors used by conflicting columns
323        let mut used_colors = Vec::new();
324        for &row in &non_zero_rows {
325            for k in 0..j {
326                if _sparsitypattern[[row, k]] && colors[k] != usize::MAX {
327                    used_colors.push(colors[k]);
328                }
329            }
330        }
331
332        // Find lowest unused color
333        let mut color = 0;
334        while used_colors.contains(&color) {
335            color += 1;
336        }
337
338        // Assign color to this column
339        colors[j] = color;
340    }
341
342    colors
343}
344
345/// Determine if parallel Jacobian computation should be used
346///
347/// This function uses heuristics to decide whether it's worth using
348/// parallel computation for the Jacobian based on system size, sparsity, etc.
349///
350/// # Arguments
351///
352/// * `n_dim` - Dimension of the system
353/// * `is_sparse` - Whether the Jacobian is known to be sparse
354/// * `num_threads` - Number of available threads
355///
356/// # Returns
357///
358/// True if parallel computation is likely beneficial
359#[allow(dead_code)]
360pub fn should_use_parallel_jacobian(n_dim: usize, is_sparse: bool, numthreads: usize) -> bool {
361    // Check if parallel_jacobian feature is enabled
362    #[cfg(not(feature = "parallel_jacobian"))]
363    {
364        let _ = n_dim;
365        let _ = is_sparse;
366        let _ = numthreads;
367        false // Parallel computation not available without the feature
368    }
369
370    #[cfg(feature = "parallel_jacobian")]
371    {
372        // Don't parallelize if only 1 thread available
373        if num_threads() <= 1 {
374            return false;
375        }
376
377        // For small systems, overhead of parallelization likely exceeds benefits
378        if n_dim < 20 {
379            return false;
380        }
381
382        // For _sparse systems, need to look at the specific sparsity pattern
383        // to determine if parallelization will help
384        if is_sparse {
385            // If very large, parallelize even if _sparse
386            if n_dim > 100 {
387                return true;
388            }
389            // For medium sized _sparse systems, need more information
390            return false;
391        }
392
393        // For dense systems, parallelize if large enough
394        n_dim >= 20
395    }
396}
397
398/// Struct to manage parallel Jacobian computation strategy
399pub struct ParallelJacobianStrategy {
400    /// Whether to use parallel computation
401    pub use_parallel: bool,
402    /// Whether the Jacobian is sparse
403    pub is_sparse: bool,
404    /// Sparsity pattern if known
405    pub sparsity_pattern: Option<Array2<bool>>,
406    /// Last computed Jacobian
407    pub jacobian: Option<Array2<f64>>,
408    /// Number of threads to use
409    pub num_threads: usize,
410}
411
412impl ParallelJacobianStrategy {
413    /// Create a new strategy object
414    pub fn new(_n_dim: usize, issparse: bool) -> Self {
415        // Determine if parallel computation is available and beneficial
416        #[cfg(feature = "parallel_jacobian")]
417        let (use_parallel, num_threads) = {
418            // Get number of available threads from parallel_ops
419            let threads = num_threads();
420            // Check if parallel computation is worthwhile
421            (
422                should_use_parallel_jacobian(_n_dim, issparse, threads),
423                threads,
424            )
425        };
426
427        #[cfg(not(feature = "parallel_jacobian"))]
428        let (use_parallel, num_threads) = {
429            let _ = _n_dim;
430            (false, 1)
431        };
432
433        ParallelJacobianStrategy {
434            use_parallel,
435            is_sparse: issparse,
436            sparsity_pattern: None,
437            jacobian: None,
438            num_threads,
439        }
440    }
441
442    /// Set the sparsity pattern
443    pub fn set_sparsity_pattern(&mut self, pattern: Array2<bool>) {
444        self.sparsity_pattern = Some(pattern);
445        self.is_sparse = true;
446    }
447
448    /// Compute the Jacobian matrix
449    pub fn compute_jacobian<F, Func>(
450        &mut self,
451        f: &Func,
452        t: F,
453        y: &Array1<F>,
454        f_current: &Array1<F>,
455        perturbation_scale: F,
456    ) -> IntegrateResult<Array2<F>>
457    where
458        F: IntegrateFloat + Send + Sync,
459        Func: Fn(F, ArrayView1<F>) -> Array1<F> + Sync,
460    {
461        // Decide which algorithm to use
462        let jacobian = if !self.use_parallel {
463            // Sequential computation
464            crate::ode::utils::common::finite_difference_jacobian(
465                f,
466                t,
467                y,
468                f_current,
469                perturbation_scale,
470            )
471        } else {
472            // We know the parallel_jacobian feature is enabled if we get here
473            if self.is_sparse && self.sparsity_pattern.is_some() {
474                // Parallel sparse computation
475                parallel_sparse_jacobian(
476                    f,
477                    t,
478                    y,
479                    f_current,
480                    self.sparsity_pattern.as_ref(),
481                    perturbation_scale,
482                )?
483            } else {
484                // Parallel dense computation
485                parallel_finite_difference_jacobian(f, t, y, f_current, perturbation_scale)?
486            }
487        };
488
489        // Store result
490        let jacobian_f64 = jacobian.mapv(|x| x.to_f64().unwrap_or(0.0));
491        self.jacobian = Some(jacobian_f64);
492
493        // Return the jacobian
494        Ok(jacobian)
495    }
496
497    /// Check if parallel computation is available and enabled
498    pub fn is_parallel_available(&self) -> bool {
499        #[cfg(feature = "parallel_jacobian")]
500        {
501            true
502        }
503
504        #[cfg(not(feature = "parallel_jacobian"))]
505        {
506            false
507        }
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::greedy_coloring;
514    use crate::ode::utils::{parallel_finite_difference_jacobian, parallel_sparse_jacobian};
515    use scirs2_core::ndarray::{array, Array1, Array2, ArrayView1};
516
517    // Test function for Jacobian computation
518    fn test_func(t: f64, y: ArrayView1<f64>) -> Array1<f64> {
519        array![y[0] * y[0] + y[1], y[0] * y[1] + y[2], y[2] * y[2] - y[0]]
520    }
521
522    // Analytic Jacobian for test function
523    fn analytic_jacobian(y: &[f64]) -> Array2<f64> {
524        array![
525            [2.0 * y[0], 1.0, 0.0],
526            [y[1], y[0], 1.0],
527            [-1.0, 0.0, 2.0 * y[2]]
528        ]
529    }
530
531    #[test]
532    fn test_parallel_jacobian() {
533        let y = array![1.0, 2.0, 3.0];
534        let t = 0.0;
535        let f_current = test_func(t, y.view());
536
537        // Compute parallel Jacobian
538        let numerical_jac =
539            parallel_finite_difference_jacobian(&test_func, t, &y, &f_current, 1.0).unwrap();
540
541        // Compute analytic Jacobian
542        let analytic_jac = analytic_jacobian(&[1.0, 2.0, 3.0]);
543
544        // Check that they match within tolerance
545        for i in 0..3 {
546            for j in 0..3 {
547                assert!((numerical_jac[[i, j]] - analytic_jac[[i, j]]).abs() < 1e-5);
548            }
549        }
550    }
551
552    #[test]
553    fn test_sparse_jacobian() {
554        let y = array![1.0, 2.0, 3.0];
555        let t = 0.0;
556        let f_current = test_func(t, y.view());
557
558        // Create sparsity pattern
559        let sparsity_pattern = array![[true, true, false], [true, true, true], [true, false, true]];
560
561        // Compute sparse Jacobian
562        let numerical_jac =
563            parallel_sparse_jacobian(&test_func, t, &y, &f_current, Some(&sparsity_pattern), 1.0)
564                .unwrap();
565
566        // Compute analytic Jacobian
567        let analytic_jac = analytic_jacobian(&[1.0, 2.0, 3.0]);
568
569        // Check that non-zero entries match within tolerance
570        for i in 0..3 {
571            for j in 0..3 {
572                if sparsity_pattern[[i, j]] {
573                    assert!((numerical_jac[[i, j]] - analytic_jac[[i, j]]).abs() < 1e-5);
574                } else {
575                    assert_eq!(numerical_jac[[i, j]], 0.0);
576                }
577            }
578        }
579    }
580
581    #[test]
582    fn test_greedy_coloring() {
583        // Simple test case
584        let sparsity_pattern = array![
585            [true, true, false, false],
586            [true, false, true, false],
587            [false, true, true, true]
588        ];
589
590        let colors = greedy_coloring(&sparsity_pattern);
591
592        // Verify that no adjacent columns have the same color
593        for i in 0..sparsity_pattern.nrows() {
594            for j1 in 0..sparsity_pattern.ncols() {
595                if !sparsity_pattern[[i, j1]] {
596                    continue;
597                }
598
599                for j2 in (j1 + 1)..sparsity_pattern.ncols() {
600                    if sparsity_pattern[[i, j2]] {
601                        assert_ne!(colors[j1], colors[j2]);
602                    }
603                }
604            }
605        }
606    }
607}