1#[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#[derive(Debug, Clone)]
47pub struct ParallelConfig {
48 pub num_threads: Option<usize>,
50 pub min_parallel_ops: usize,
53 pub enable_pooling: bool,
55}
56
57impl Default for ParallelConfig {
58 fn default() -> Self {
59 Self {
60 num_threads: None, min_parallel_ops: 2,
62 enable_pooling: true,
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct ParallelStats {
70 pub num_levels: usize,
72 pub parallel_ops: usize,
74 pub sequential_ops: usize,
76 pub max_parallelism: usize,
78 pub estimated_speedup: f64,
80}
81
82pub struct ParallelScirs2Exec {
84 pub(crate) base: crate::executor::Scirs2Exec,
86 pub config: ParallelConfig,
88 pub stats: Option<ParallelStats>,
90}
91
92impl ParallelScirs2Exec {
93 pub fn new() -> Self {
95 Self {
96 base: crate::executor::Scirs2Exec::new(),
97 config: ParallelConfig::default(),
98 stats: None,
99 }
100 }
101
102 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 pub fn set_num_threads(&mut self, num_threads: usize) {
119 self.config.num_threads = Some(num_threads);
120 }
121
122 #[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 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 pub fn pool_stats(&self) -> Option<crate::memory_pool::PoolStats> {
145 self.base.pool_stats()
146 }
147
148 pub fn execution_stats(&self) -> Option<&ParallelStats> {
150 self.stats.as_ref()
151 }
152
153 pub fn add_tensor(&mut self, name: impl Into<String>, tensor: Scirs2Tensor) {
155 self.base.add_tensor(name, tensor);
156 }
157
158 pub fn get_tensor(&self, name: &str) -> Option<&Scirs2Tensor> {
160 self.base.get_tensor(name)
161 }
162
163 #[cfg(feature = "parallel")]
165 fn execute_operation(
166 &self,
167 node: &tensorlogic_ir::EinsumNode,
168 input_tensors: &[Scirs2Tensor],
169 ) -> Result<Scirs2Tensor, ExecutorError> {
170 match &node.op {
172 OpType::Einsum { spec } => {
173 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 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 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 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
357impl 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 let analysis = DependencyAnalysis::analyze(graph);
408
409 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 {
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 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 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 let mut parallel_ops = 0;
449 let mut sequential_ops = 0;
450
451 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_ops += level_ops.len();
458
459 let results: Vec<_> = level_ops
461 .par_iter()
462 .map(|&op_idx| {
463 let node = &graph.nodes[op_idx];
464
465 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 {
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 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 if let Some(&output_idx) = outputs.first() {
511 storage[output_idx] = Some(tensor);
512 }
513
514 inputs_vec[op_idx] = inputs;
516 }
517 }
518 } else {
519 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 if let Some(&output_idx) = node.outputs.first() {
552 storage[output_idx] = Some(result);
553 }
554
555 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 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 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 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 self.base.backward(graph, loss_grad)
604 }
605}
606
607#[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 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 let mut graph = EinsumGraph::new();
642
643 let a_idx = graph.add_tensor("a"); let b_idx = graph.add_tensor("b"); let c_idx = graph.add_tensor("c"); let d_idx = graph.add_tensor("d"); let e_idx = graph.add_tensor("e"); let f_idx = graph.add_tensor("f"); graph.add_input(a_idx).expect("unwrap");
651 graph.add_input(b_idx).expect("unwrap");
652
653 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 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 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 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 assert_eq!(result.shape(), &[3]);
732
733 let stats = executor.execution_stats().expect("unwrap");
735 assert_eq!(stats.num_levels, 3);
736 assert!(stats.parallel_ops >= 2); }
738
739 #[test]
740 fn test_parallel_vs_sequential_correctness() {
741 let graph = create_parallel_test_graph();
742
743 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 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 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 let _pool_stats = executor.pool_stats();
792 }
794
795 #[test]
796 fn test_min_parallel_ops_threshold() {
797 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 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 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 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 assert!(!tape.is_empty());
847 }
848}