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}