Skip to main content

tensorlogic_scirs_backend/
parallel_executor.rs

1//! Parallel executor implementation using Rayon for multi-threaded execution.
2//!
3//! This module provides a parallel implementation of the TensorLogic executor
4//! that can execute independent operations concurrently using thread pools.
5//!
6//! ## Key Features
7//!
8//! - **Level-by-level execution**: Operations are grouped by execution level
9//! - **Rayon thread pools**: Configurable thread pool for parallel execution
10//! - **Automatic dependency handling**: Uses DependencyAnalysis for safe parallelization
11//! - **Performance monitoring**: Tracks parallel vs sequential execution times
12//!
13//! ## Example
14//!
15//! ```rust,ignore
16//! use tensorlogic_scirs_backend::ParallelScirs2Exec;
17//! use tensorlogic_infer::TlAutodiff;
18//!
19//! let mut executor = ParallelScirs2Exec::new();
20//! executor.set_num_threads(4); // Use 4 threads
21//!
22//! let result = executor.forward(&graph)?;
23//! ```
24
25#[cfg(feature = "parallel")]
26use scirs2_core::parallel_ops::*;
27
28#[cfg(feature = "parallel")]
29use std::sync::{Arc, Mutex};
30use tensorlogic_infer::{ElemOp, ExecutorError, ReduceOp, TlAutodiff, TlExecutor};
31#[cfg(not(feature = "parallel"))]
32use tensorlogic_ir::EinsumGraph;
33#[cfg(feature = "parallel")]
34use tensorlogic_ir::{EinsumGraph, OpType};
35
36use crate::autodiff::ForwardTape;
37#[cfg(feature = "parallel")]
38use crate::dependency_analyzer::DependencyAnalysis;
39#[cfg(feature = "parallel")]
40use crate::ops::{parse_elem_op, parse_reduce_op};
41#[cfg(feature = "parallel")]
42use crate::temporal_ops;
43use crate::Scirs2Tensor;
44
45/// Configuration for parallel execution.
46#[derive(Debug, Clone)]
47pub struct ParallelConfig {
48    /// Number of threads to use (None = use all available cores)
49    pub num_threads: Option<usize>,
50    /// Minimum number of operations per level to enable parallelization
51    /// (levels with fewer ops run sequentially to avoid overhead)
52    pub min_parallel_ops: usize,
53    /// Enable memory pooling for tensor reuse
54    pub enable_pooling: bool,
55}
56
57impl Default for ParallelConfig {
58    fn default() -> Self {
59        Self {
60            num_threads: None, // Use all available cores
61            min_parallel_ops: 2,
62            enable_pooling: true,
63        }
64    }
65}
66
67/// Statistics about parallel execution.
68#[derive(Debug, Clone)]
69pub struct ParallelStats {
70    /// Number of execution levels
71    pub num_levels: usize,
72    /// Number of operations executed in parallel
73    pub parallel_ops: usize,
74    /// Number of operations executed sequentially
75    pub sequential_ops: usize,
76    /// Maximum number of concurrent operations in any level
77    pub max_parallelism: usize,
78    /// Estimated speedup from parallelization
79    pub estimated_speedup: f64,
80}
81
82/// Parallel executor using Rayon for multi-threaded execution.
83pub struct ParallelScirs2Exec {
84    /// Base executor for sequential operations
85    pub(crate) base: crate::executor::Scirs2Exec,
86    /// Configuration for parallel execution
87    pub config: ParallelConfig,
88    /// Statistics from last execution
89    pub stats: Option<ParallelStats>,
90}
91
92impl ParallelScirs2Exec {
93    /// Create a new parallel executor with default configuration.
94    pub fn new() -> Self {
95        Self {
96            base: crate::executor::Scirs2Exec::new(),
97            config: ParallelConfig::default(),
98            stats: None,
99        }
100    }
101
102    /// Create a parallel executor with custom configuration.
103    pub fn with_config(config: ParallelConfig) -> Self {
104        let base = if config.enable_pooling {
105            crate::executor::Scirs2Exec::with_memory_pool()
106        } else {
107            crate::executor::Scirs2Exec::new()
108        };
109
110        Self {
111            base,
112            config,
113            stats: None,
114        }
115    }
116
117    /// Set the number of threads to use.
118    pub fn set_num_threads(&mut self, num_threads: usize) {
119        self.config.num_threads = Some(num_threads);
120    }
121
122    /// Get the number of threads configured (returns actual thread count).
123    #[cfg(feature = "parallel")]
124    pub fn num_threads(&self) -> usize {
125        self.config.num_threads.unwrap_or_else(current_num_threads)
126    }
127
128    #[cfg(not(feature = "parallel"))]
129    pub fn num_threads(&self) -> usize {
130        self.config.num_threads.unwrap_or(1)
131    }
132
133    /// Enable or disable memory pooling.
134    pub fn set_pooling(&mut self, enable: bool) {
135        self.config.enable_pooling = enable;
136        if enable {
137            self.base.enable_pooling();
138        } else {
139            self.base.disable_pooling();
140        }
141    }
142
143    /// Get pool statistics if pooling is enabled.
144    pub fn pool_stats(&self) -> Option<crate::memory_pool::PoolStats> {
145        self.base.pool_stats()
146    }
147
148    /// Get statistics from the last execution.
149    pub fn execution_stats(&self) -> Option<&ParallelStats> {
150        self.stats.as_ref()
151    }
152
153    /// Add a named tensor to the executor.
154    pub fn add_tensor(&mut self, name: impl Into<String>, tensor: Scirs2Tensor) {
155        self.base.add_tensor(name, tensor);
156    }
157
158    /// Get a tensor by name.
159    pub fn get_tensor(&self, name: &str) -> Option<&Scirs2Tensor> {
160        self.base.get_tensor(name)
161    }
162
163    /// Execute a single operation (helper function).
164    #[cfg(feature = "parallel")]
165    fn execute_operation(
166        &self,
167        node: &tensorlogic_ir::EinsumNode,
168        input_tensors: &[Scirs2Tensor],
169    ) -> Result<Scirs2Tensor, ExecutorError> {
170        // Dispatch based on operation type
171        match &node.op {
172            OpType::Einsum { spec } => {
173                // Need to use a mutable executor for einsum
174                // For now, we'll use the sequential path through self.base
175                // In a real parallel implementation, we'd need to handle this differently
176                let views: Vec<_> = input_tensors.iter().map(|t| t.view()).collect();
177                let view_refs: Vec<_> = views.iter().collect();
178                scirs2_linalg::einsum(spec, &view_refs)
179                    .map_err(|e| ExecutorError::InvalidEinsumSpec(format!("Einsum error: {}", e)))
180            }
181            OpType::ElemUnary { op } => {
182                if input_tensors.len() != 1 {
183                    return Err(ExecutorError::InvalidEinsumSpec(format!(
184                        "Unary operation requires 1 input, got {}",
185                        input_tensors.len()
186                    )));
187                }
188                // Intercept temporal Next.
189                if let Some(top_result) = temporal_ops::parse_temporal_op(op) {
190                    let top = top_result.map_err(|e| {
191                        ExecutorError::InvalidEinsumSpec(format!("Temporal op parse error: {}", e))
192                    })?;
193                    return match top {
194                        temporal_ops::TemporalOp::Next { axis } => {
195                            Ok(temporal_ops::shift_next(&input_tensors[0].view(), axis))
196                        }
197                        temporal_ops::TemporalOp::Binary { .. } => {
198                            Err(ExecutorError::InvalidEinsumSpec(
199                                "temporal binary op (until/weakuntil/release/strongrelease) is a binary op, not unary".to_string(),
200                            ))
201                        }
202                    };
203                }
204                let elem_op = parse_elem_op(op)?;
205                match elem_op {
206                    ElemOp::Relu => Ok(input_tensors[0].mapv(|v| v.max(0.0))),
207                    ElemOp::Sigmoid => Ok(input_tensors[0].mapv(|v| 1.0 / (1.0 + (-v).exp()))),
208                    ElemOp::OneMinus => Ok(input_tensors[0].mapv(|v| 1.0 - v)),
209                    _ => Err(ExecutorError::UnsupportedOperation(format!(
210                        "Unary operation {:?} not supported",
211                        elem_op
212                    ))),
213                }
214            }
215            OpType::ElemBinary { op } => {
216                if input_tensors.len() != 2 {
217                    return Err(ExecutorError::InvalidEinsumSpec(format!(
218                        "Binary operation requires 2 inputs, got {}",
219                        input_tensors.len()
220                    )));
221                }
222                // Intercept temporal Until.
223                if let Some(top_result) = temporal_ops::parse_temporal_op(op) {
224                    let top = top_result.map_err(|e| {
225                        ExecutorError::InvalidEinsumSpec(format!("Temporal op parse error: {}", e))
226                    })?;
227                    return match top {
228                        temporal_ops::TemporalOp::Binary { axis, sem, form } => {
229                            Ok(temporal_ops::temporal_binary_scan(
230                                &input_tensors[0].view(),
231                                &input_tensors[1].view(),
232                                axis,
233                                form,
234                                sem,
235                            ))
236                        }
237                        temporal_ops::TemporalOp::Next { .. } => {
238                            Err(ExecutorError::InvalidEinsumSpec(
239                                "temporal_next is a unary op, not binary".to_string(),
240                            ))
241                        }
242                    };
243                }
244                let elem_op = parse_elem_op(op)?;
245                let x = &input_tensors[0];
246                let y = &input_tensors[1];
247
248                // Handle scalar broadcasting
249                let x_is_scalar = x.ndim() == 0;
250                let y_is_scalar = y.ndim() == 0;
251
252                let (x_broadcast, y_broadcast);
253                let (x_ref, y_ref) = if x_is_scalar && !y_is_scalar {
254                    let scalar_value = x
255                        .iter()
256                        .next()
257                        .expect("scalar ndim==0 tensor always has one element");
258                    x_broadcast =
259                        scirs2_core::ndarray::Array::from_elem(y.raw_dim(), *scalar_value);
260                    (&x_broadcast.view(), &y.view())
261                } else if y_is_scalar && !x_is_scalar {
262                    let scalar_value = y
263                        .iter()
264                        .next()
265                        .expect("scalar ndim==0 tensor always has one element");
266                    y_broadcast =
267                        scirs2_core::ndarray::Array::from_elem(x.raw_dim(), *scalar_value);
268                    (&x.view(), &y_broadcast.view())
269                } else if x.shape() != y.shape() {
270                    return Err(ExecutorError::ShapeMismatch(format!(
271                        "Shape mismatch: {:?} vs {:?}",
272                        x.shape(),
273                        y.shape()
274                    )));
275                } else {
276                    (&x.view(), &y.view())
277                };
278
279                let result = match elem_op {
280                    ElemOp::Add => x_ref + y_ref,
281                    ElemOp::Subtract => x_ref - y_ref,
282                    ElemOp::Multiply => x_ref * y_ref,
283                    ElemOp::Divide => x_ref / y_ref,
284                    ElemOp::Min => scirs2_core::ndarray::Zip::from(x_ref)
285                        .and(y_ref)
286                        .map_collect(|&a, &b| a.min(b)),
287                    ElemOp::Max => scirs2_core::ndarray::Zip::from(x_ref)
288                        .and(y_ref)
289                        .map_collect(|&a, &b| a.max(b)),
290                    ElemOp::OrMax => scirs2_core::ndarray::Zip::from(x_ref)
291                        .and(y_ref)
292                        .map_collect(|&a, &b| a.max(b)),
293                    ElemOp::OrProbSum => scirs2_core::ndarray::Zip::from(x_ref)
294                        .and(y_ref)
295                        .map_collect(|&a, &b| a + b - a * b),
296                    ElemOp::Nand => scirs2_core::ndarray::Zip::from(x_ref)
297                        .and(y_ref)
298                        .map_collect(|&a, &b| 1.0 - (a * b)),
299                    ElemOp::Nor => scirs2_core::ndarray::Zip::from(x_ref)
300                        .and(y_ref)
301                        .map_collect(|&a, &b| 1.0 - a.max(b)),
302                    ElemOp::Xor => scirs2_core::ndarray::Zip::from(x_ref)
303                        .and(y_ref)
304                        .map_collect(|&a, &b| a + b - 2.0 * a * b),
305                    _ => {
306                        return Err(ExecutorError::UnsupportedOperation(format!(
307                            "Binary operation {:?} not supported",
308                            elem_op
309                        )))
310                    }
311                };
312                Ok(result)
313            }
314            OpType::Reduce { op, axes } => {
315                if input_tensors.len() != 1 {
316                    return Err(ExecutorError::InvalidEinsumSpec(format!(
317                        "Reduce operation requires 1 input, got {}",
318                        input_tensors.len()
319                    )));
320                }
321                let reduce_op = parse_reduce_op(op)?;
322                let x = &input_tensors[0];
323
324                use scirs2_core::ndarray::Axis;
325                let mut result = x.clone();
326                for &axis in axes.iter().rev() {
327                    result = match reduce_op {
328                        ReduceOp::Sum => result.sum_axis(Axis(axis)),
329                        ReduceOp::Max => result.map_axis(Axis(axis), |view| {
330                            view.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b))
331                        }),
332                        ReduceOp::Min => result.map_axis(Axis(axis), |view| {
333                            view.iter().fold(f64::INFINITY, |a, &b| a.min(b))
334                        }),
335                        ReduceOp::Mean => {
336                            let sum = result.sum_axis(Axis(axis));
337                            let count = result.len_of(Axis(axis)) as f64;
338                            sum / count
339                        }
340                        ReduceOp::Product => {
341                            result.map_axis(Axis(axis), |view| view.iter().product())
342                        }
343                    };
344                }
345                Ok(result)
346            }
347        }
348    }
349}
350
351impl Default for ParallelScirs2Exec {
352    fn default() -> Self {
353        Self::new()
354    }
355}
356
357// Delegate basic TlExecutor methods to the base executor
358impl TlExecutor for ParallelScirs2Exec {
359    type Tensor = Scirs2Tensor;
360    type Error = ExecutorError;
361
362    fn einsum(&mut self, spec: &str, inputs: &[Self::Tensor]) -> Result<Self::Tensor, Self::Error> {
363        self.base.einsum(spec, inputs)
364    }
365
366    fn elem_op(&mut self, op: ElemOp, x: &Self::Tensor) -> Result<Self::Tensor, Self::Error> {
367        self.base.elem_op(op, x)
368    }
369
370    fn elem_op_binary(
371        &mut self,
372        op: ElemOp,
373        x: &Self::Tensor,
374        y: &Self::Tensor,
375    ) -> Result<Self::Tensor, Self::Error> {
376        self.base.elem_op_binary(op, x, y)
377    }
378
379    fn reduce(
380        &mut self,
381        op: ReduceOp,
382        x: &Self::Tensor,
383        axes: &[usize],
384    ) -> Result<Self::Tensor, Self::Error> {
385        self.base.reduce(op, x, axes)
386    }
387}
388
389#[cfg(feature = "parallel")]
390impl TlAutodiff for ParallelScirs2Exec {
391    type Tape = ForwardTape;
392
393    fn forward(&mut self, graph: &EinsumGraph) -> Result<Self::Tensor, Self::Error> {
394        if graph.is_empty() {
395            return Err(ExecutorError::InvalidEinsumSpec(
396                "Empty graph provided".to_string(),
397            ));
398        }
399
400        if graph.outputs.is_empty() {
401            return Err(ExecutorError::InvalidEinsumSpec(
402                "No output tensors specified".to_string(),
403            ));
404        }
405
406        // Analyze dependencies
407        let analysis = DependencyAnalysis::analyze(graph);
408
409        // Initialize tensor storage
410        let computed_tensors: Arc<Mutex<Vec<Option<Scirs2Tensor>>>> =
411            Arc::new(Mutex::new(vec![None; graph.tensors.len()]));
412
413        let node_inputs: Arc<Mutex<Vec<Vec<Scirs2Tensor>>>> =
414            Arc::new(Mutex::new(Vec::with_capacity(graph.nodes.len())));
415
416        // Initialize input tensors from our stored tensors
417        {
418            let mut storage = computed_tensors
419                .lock()
420                .expect("lock should not be poisoned");
421            for (idx, tensor_name) in graph.tensors.iter().enumerate() {
422                if let Some(tensor) = self.base.tensors.get(tensor_name) {
423                    storage[idx] = Some(tensor.clone());
424                } else {
425                    // Handle tensors with axes notation (e.g., "age[a]" -> "age")
426                    let base_name = tensor_name.split('[').next().unwrap_or(tensor_name);
427                    if let Some(tensor) = self.base.tensors.get(base_name) {
428                        storage[idx] = Some(tensor.clone());
429                    } else if tensor_name.starts_with("const_") || base_name.starts_with("const_") {
430                        // Handle constant tensors
431                        let const_name = if tensor_name.starts_with("const_") {
432                            tensor_name
433                        } else {
434                            base_name
435                        };
436                        if let Some(value_str) = const_name.strip_prefix("const_") {
437                            if let Ok(value) = value_str.parse::<f64>() {
438                                use scirs2_core::ndarray::arr0;
439                                storage[idx] = Some(arr0(value).into_dyn());
440                            }
441                        }
442                    }
443                }
444            }
445        }
446
447        // Track statistics
448        let mut parallel_ops = 0;
449        let mut sequential_ops = 0;
450
451        // Execute operations level by level
452        for level_ops in &analysis.execution_levels {
453            let should_parallelize = level_ops.len() >= self.config.min_parallel_ops;
454
455            if should_parallelize {
456                // Parallel execution for this level
457                parallel_ops += level_ops.len();
458
459                // Execute all operations in this level in parallel
460                let results: Vec<_> = level_ops
461                    .par_iter()
462                    .map(|&op_idx| {
463                        let node = &graph.nodes[op_idx];
464
465                        // Read inputs from shared storage
466                        let inputs: Result<Vec<_>, _> = {
467                            let storage = computed_tensors
468                                .lock()
469                                .expect("lock should not be poisoned");
470                            node.inputs
471                                .iter()
472                                .map(|&idx| {
473                                    storage
474                                        .get(idx)
475                                        .and_then(|t| t.as_ref())
476                                        .cloned()
477                                        .ok_or_else(|| {
478                                            ExecutorError::TensorNotFound(format!(
479                                                "Tensor at index {} not found",
480                                                idx
481                                            ))
482                                        })
483                                })
484                                .collect()
485                        };
486
487                        let input_tensors = inputs?;
488                        let result = self.execute_operation(node, &input_tensors)?;
489
490                        Ok((op_idx, node.outputs.clone(), input_tensors, result))
491                    })
492                    .collect::<Result<Vec<_>, ExecutorError>>()?;
493
494                // Store results
495                {
496                    let mut storage = computed_tensors
497                        .lock()
498                        .expect("lock should not be poisoned");
499                    let mut inputs_vec = node_inputs.lock().expect("lock should not be poisoned");
500
501                    // Ensure node_inputs has enough capacity
502                    while inputs_vec.len()
503                        <= results.iter().map(|(idx, _, _, _)| *idx).max().unwrap_or(0)
504                    {
505                        inputs_vec.push(Vec::new());
506                    }
507
508                    for (op_idx, outputs, inputs, tensor) in results {
509                        // Store in tensor storage
510                        if let Some(&output_idx) = outputs.first() {
511                            storage[output_idx] = Some(tensor);
512                        }
513
514                        // Store inputs for backward pass
515                        inputs_vec[op_idx] = inputs;
516                    }
517                }
518            } else {
519                // Sequential execution for this level
520                sequential_ops += level_ops.len();
521
522                let mut storage = computed_tensors
523                    .lock()
524                    .expect("lock should not be poisoned");
525                let mut inputs_vec = node_inputs.lock().expect("lock should not be poisoned");
526
527                for &op_idx in level_ops {
528                    let node = &graph.nodes[op_idx];
529
530                    let inputs: Result<Vec<_>, _> = node
531                        .inputs
532                        .iter()
533                        .map(|&idx| {
534                            storage
535                                .get(idx)
536                                .and_then(|t| t.as_ref())
537                                .cloned()
538                                .ok_or_else(|| {
539                                    ExecutorError::TensorNotFound(format!(
540                                        "Tensor at index {} not found",
541                                        idx
542                                    ))
543                                })
544                        })
545                        .collect();
546
547                    let input_tensors = inputs?;
548                    let result = self.execute_operation(node, &input_tensors)?;
549
550                    // Store result
551                    if let Some(&output_idx) = node.outputs.first() {
552                        storage[output_idx] = Some(result);
553                    }
554
555                    // Store inputs for backward pass
556                    while inputs_vec.len() <= op_idx {
557                        inputs_vec.push(Vec::new());
558                    }
559                    inputs_vec[op_idx] = input_tensors;
560                }
561            }
562        }
563
564        // Store tape for backward pass
565        let final_tensors = Arc::try_unwrap(computed_tensors)
566            .expect("all threads finished, Arc has single owner")
567            .into_inner()
568            .expect("Mutex should not be poisoned");
569        let final_inputs = Arc::try_unwrap(node_inputs)
570            .expect("all threads finished, Arc has single owner")
571            .into_inner()
572            .expect("Mutex should not be poisoned");
573
574        self.base.tape = Some(ForwardTape {
575            tensors: final_tensors.clone(),
576            node_inputs: final_inputs,
577        });
578
579        // Store statistics
580        self.stats = Some(ParallelStats {
581            num_levels: analysis.num_levels,
582            parallel_ops,
583            sequential_ops,
584            max_parallelism: analysis.max_parallelism,
585            estimated_speedup: analysis.estimated_speedup(),
586        });
587
588        // Return the output tensor
589        let output_idx = graph.outputs[0];
590        final_tensors
591            .get(output_idx)
592            .and_then(|t| t.clone())
593            .ok_or_else(|| ExecutorError::TensorNotFound("Output tensor not computed".to_string()))
594    }
595
596    fn backward(
597        &mut self,
598        graph: &EinsumGraph,
599        loss_grad: &Self::Tensor,
600    ) -> Result<Self::Tape, Self::Error> {
601        // Use the base executor's backward implementation
602        // (backward pass is typically more sequential due to dependency chains)
603        self.base.backward(graph, loss_grad)
604    }
605}
606
607// If parallel feature is not enabled, provide a non-parallel implementation
608#[cfg(not(feature = "parallel"))]
609impl TlAutodiff for ParallelScirs2Exec {
610    type Tape = ForwardTape;
611
612    fn forward(&mut self, graph: &EinsumGraph) -> Result<Self::Tensor, Self::Error> {
613        // Fall back to sequential execution
614        self.base.forward(graph)
615    }
616
617    fn backward(
618        &mut self,
619        graph: &EinsumGraph,
620        loss_grad: &Self::Tensor,
621    ) -> Result<Self::Tape, Self::Error> {
622        self.base.backward(graph, loss_grad)
623    }
624}
625
626#[cfg(test)]
627#[cfg(feature = "parallel")]
628mod tests {
629    use super::*;
630    use scirs2_core::ndarray::array;
631    use tensorlogic_ir::EinsumNode;
632
633    fn create_parallel_test_graph() -> EinsumGraph {
634        // Create a graph with parallelizable operations:
635        // Tensors: 0=a, 1=b, 2=c, 3=d, 4=e, 5=f
636        // Op0: c = relu(a)    (level 0, independent)
637        // Op1: d = sigmoid(b) (level 0, independent)
638        // Op2: e = c + d      (level 1, depends on Op0, Op1)
639        // Op3: f = relu(e)    (level 2, depends on Op2)
640
641        let mut graph = EinsumGraph::new();
642
643        let a_idx = graph.add_tensor("a"); // 0
644        let b_idx = graph.add_tensor("b"); // 1
645        let c_idx = graph.add_tensor("c"); // 2
646        let d_idx = graph.add_tensor("d"); // 3
647        let e_idx = graph.add_tensor("e"); // 4
648        let f_idx = graph.add_tensor("f"); // 5
649
650        graph.add_input(a_idx).expect("unwrap");
651        graph.add_input(b_idx).expect("unwrap");
652
653        // Op0: c = relu(a)
654        graph
655            .add_node(EinsumNode {
656                op: OpType::ElemUnary {
657                    op: "relu".to_string(),
658                },
659                inputs: vec![a_idx],
660                outputs: vec![c_idx],
661                metadata: None,
662            })
663            .expect("unwrap");
664
665        // Op1: d = sigmoid(b)
666        graph
667            .add_node(EinsumNode {
668                op: OpType::ElemUnary {
669                    op: "sigmoid".to_string(),
670                },
671                inputs: vec![b_idx],
672                outputs: vec![d_idx],
673                metadata: None,
674            })
675            .expect("unwrap");
676
677        // Op2: e = c + d
678        graph
679            .add_node(EinsumNode {
680                op: OpType::ElemBinary {
681                    op: "add".to_string(),
682                },
683                inputs: vec![c_idx, d_idx],
684                outputs: vec![e_idx],
685                metadata: None,
686            })
687            .expect("unwrap");
688
689        // Op3: f = relu(e)
690        graph
691            .add_node(EinsumNode {
692                op: OpType::ElemUnary {
693                    op: "relu".to_string(),
694                },
695                inputs: vec![e_idx],
696                outputs: vec![f_idx],
697                metadata: None,
698            })
699            .expect("unwrap");
700
701        graph.add_output(f_idx).expect("unwrap");
702
703        graph
704    }
705
706    #[test]
707    fn test_parallel_executor_creation() {
708        let executor = ParallelScirs2Exec::new();
709        assert_eq!(executor.config.min_parallel_ops, 2);
710        assert!(executor.config.enable_pooling);
711    }
712
713    #[test]
714    fn test_set_num_threads() {
715        let mut executor = ParallelScirs2Exec::new();
716        executor.set_num_threads(4);
717        assert_eq!(executor.config.num_threads, Some(4));
718    }
719
720    #[test]
721    fn test_parallel_forward_pass() {
722        let graph = create_parallel_test_graph();
723        let mut executor = ParallelScirs2Exec::new();
724
725        executor.add_tensor("a", array![-1.0, 2.0, -3.0].into_dyn());
726        executor.add_tensor("b", array![0.0, 1.0, 2.0].into_dyn());
727
728        let result = executor.forward(&graph).expect("unwrap");
729
730        // Verify output shape
731        assert_eq!(result.shape(), &[3]);
732
733        // Verify statistics
734        let stats = executor.execution_stats().expect("unwrap");
735        assert_eq!(stats.num_levels, 3);
736        assert!(stats.parallel_ops >= 2); // Op0 and Op1 should run in parallel
737    }
738
739    #[test]
740    fn test_parallel_vs_sequential_correctness() {
741        let graph = create_parallel_test_graph();
742
743        // Execute with parallel executor
744        let mut parallel_exec = ParallelScirs2Exec::new();
745        parallel_exec.add_tensor("a", array![-1.0, 2.0, -3.0].into_dyn());
746        parallel_exec.add_tensor("b", array![0.0, 1.0, 2.0].into_dyn());
747        let parallel_result = parallel_exec.forward(&graph).expect("unwrap");
748
749        // Execute with sequential executor
750        let mut sequential_exec = crate::executor::Scirs2Exec::new();
751        sequential_exec.add_tensor("a", array![-1.0, 2.0, -3.0].into_dyn());
752        sequential_exec.add_tensor("b", array![0.0, 1.0, 2.0].into_dyn());
753        let sequential_result = sequential_exec.forward(&graph).expect("unwrap");
754
755        // Results should match
756        assert_eq!(parallel_result.shape(), sequential_result.shape());
757
758        for (p, s) in parallel_result.iter().zip(sequential_result.iter()) {
759            assert!((p - s).abs() < 1e-10);
760        }
761    }
762
763    #[test]
764    fn test_parallel_stats() {
765        let graph = create_parallel_test_graph();
766        let mut executor = ParallelScirs2Exec::new();
767
768        executor.add_tensor("a", array![1.0, 2.0].into_dyn());
769        executor.add_tensor("b", array![3.0, 4.0].into_dyn());
770
771        executor.forward(&graph).expect("unwrap");
772
773        let stats = executor.execution_stats().expect("unwrap");
774        assert_eq!(stats.num_levels, 3);
775        assert!(stats.max_parallelism >= 2);
776        assert!(stats.estimated_speedup > 1.0);
777    }
778
779    #[test]
780    fn test_pooling_integration() {
781        let graph = create_parallel_test_graph();
782        let mut executor = ParallelScirs2Exec::new();
783        executor.set_pooling(true);
784
785        executor.add_tensor("a", array![1.0, 2.0].into_dyn());
786        executor.add_tensor("b", array![3.0, 4.0].into_dyn());
787
788        executor.forward(&graph).expect("unwrap");
789
790        // Pool should have some statistics (if pooling is used)
791        let _pool_stats = executor.pool_stats();
792        // Note: pool might not be used in forward pass, so this is optional
793    }
794
795    #[test]
796    fn test_min_parallel_ops_threshold() {
797        // Create a graph with only 1 independent operation
798        let mut graph = EinsumGraph::new();
799
800        let a_idx = graph.add_tensor("a");
801        let b_idx = graph.add_tensor("b");
802
803        graph.add_input(a_idx).expect("unwrap");
804
805        // Single operation
806        graph
807            .add_node(EinsumNode {
808                op: OpType::ElemUnary {
809                    op: "relu".to_string(),
810                },
811                inputs: vec![a_idx],
812                outputs: vec![b_idx],
813                metadata: None,
814            })
815            .expect("unwrap");
816
817        graph.add_output(b_idx).expect("unwrap");
818
819        let mut executor = ParallelScirs2Exec::new();
820        executor.add_tensor("a", array![1.0, 2.0, 3.0].into_dyn());
821
822        executor.forward(&graph).expect("unwrap");
823
824        let stats = executor.execution_stats().expect("unwrap");
825        // Since there's only 1 op, it should run sequentially
826        assert_eq!(stats.sequential_ops, 1);
827        assert_eq!(stats.parallel_ops, 0);
828    }
829
830    #[test]
831    fn test_backward_pass_with_parallel() {
832        let graph = create_parallel_test_graph();
833        let mut executor = ParallelScirs2Exec::new();
834
835        executor.add_tensor("a", array![1.0, 2.0, 3.0].into_dyn());
836        executor.add_tensor("b", array![0.5, 1.0, 1.5].into_dyn());
837
838        executor.forward(&graph).expect("unwrap");
839
840        // Backward pass
841        let loss_grad = array![1.0, 1.0, 1.0].into_dyn();
842
843        let tape = executor.backward(&graph, &loss_grad).expect("unwrap");
844
845        // Should have gradients for inputs
846        assert!(!tape.is_empty());
847    }
848}