Skip to main content

optirs_tpu/xla/
execution.rs

1//! CPU reference executor for the XLA IR.
2//!
3//! This module evaluates an [`XLAComputation`] directly on the CPU using
4//! `ndarray`. It exists so that computations built with
5//! [`ComputationGraphBuilder`](super::frontend::ComputationGraphBuilder) and
6//! rewritten by the optimization pipeline can actually be *run* and checked,
7//! rather than merely being constructed.
8//!
9//! # What this is and is not
10//!
11//! There is no TPU in this environment. This executor is a **reference
12//! implementation**: it defines the semantics that a TPU backend would have to
13//! match, and it makes the optimization passes testable end-to-end (a pass is
14//! correct exactly when it preserves this evaluator's results). It makes no
15//! claim to TPU-like performance and performs no device execution.
16//!
17//! Operations that are not implemented return
18//! [`NotImplementedError`](scirs2_core::error::CoreError::NotImplementedError)
19//! naming the operation, never a fabricated result.
20//!
21//! # Example
22//!
23//! ```
24//! use optirs_tpu::xla::execution::ReferenceExecutor;
25//! use optirs_tpu::xla::frontend::{
26//!     ComputationGraphBuilder, ConstantValue, OperationType, TensorShape,
27//! };
28//!
29//! # fn main() -> optirs_tpu::error::Result<()> {
30//! let mut builder: ComputationGraphBuilder<f32> = ComputationGraphBuilder::new();
31//! let mut computation = builder.create_computation("two_plus_three");
32//!
33//! let scalar = TensorShape {
34//!     dimensions: Vec::new(),
35//!     dynamic_dimensions: Vec::new(),
36//!     element_count: 1,
37//!     tuple_shapes: Vec::new(),
38//! };
39//!
40//! let two = builder.add_operation(
41//!     &mut computation,
42//!     OperationType::Constant(ConstantValue::scalar(2.0)),
43//!     vec![],
44//!     scalar.clone(),
45//! )?;
46//! let two = computation
47//!     .operation_output(two)
48//!     .ok_or_else(|| optirs_tpu::error::OptimError::from("missing operand".to_string()))?;
49//!
50//! let three = builder.add_operation(
51//!     &mut computation,
52//!     OperationType::Constant(ConstantValue::scalar(3.0)),
53//!     vec![],
54//!     scalar.clone(),
55//! )?;
56//! let three = computation
57//!     .operation_output(three)
58//!     .ok_or_else(|| optirs_tpu::error::OptimError::from("missing operand".to_string()))?;
59//!
60//! builder.add_operation(
61//!     &mut computation,
62//!     OperationType::Add,
63//!     vec![two, three],
64//!     scalar,
65//! )?;
66//! builder.mark_terminal_operands_as_outputs(&mut computation)?;
67//!
68//! let outputs = ReferenceExecutor::new().execute(&computation, Default::default())?;
69//! assert_eq!(outputs[0].iter().copied().collect::<Vec<f64>>(), vec![5.0]);
70//! # Ok(())
71//! # }
72//! ```
73
74use std::collections::HashMap;
75use std::fmt::Debug;
76
77use scirs2_core::error::ErrorContext;
78use scirs2_core::ndarray::{Array2, ArrayD, IxDyn};
79use scirs2_core::numeric::Float;
80
81use super::frontend::{
82    ConstantValue, OperandId, OperationId, OperationType, ReduceOperation, ReductionFunction,
83    TensorShape, XLAComputation, XLAOperation,
84};
85use crate::error::{OptimError, Result};
86
87/// Values bound to operands during evaluation, keyed by operand.
88///
89/// All arithmetic is performed in `f64` regardless of the graph's element type,
90/// matching [`ConstantValue`]'s storage.
91pub type ValueMap = HashMap<OperandId, ArrayD<f64>>;
92
93/// CPU reference evaluator for [`XLAComputation`] graphs.
94#[derive(Debug, Default, Clone)]
95pub struct ReferenceExecutor {
96    /// Number of operations evaluated by the most recent run.
97    evaluated_operations: usize,
98}
99
100impl ReferenceExecutor {
101    /// Create a new executor.
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Number of operations evaluated by the most recent [`Self::execute`] call.
107    pub fn evaluated_operations(&self) -> usize {
108        self.evaluated_operations
109    }
110
111    /// Evaluate `computation` and return one array per declared output.
112    ///
113    /// `inputs` supplies values for `Parameter` operations, keyed by the
114    /// operand each parameter defines. A missing parameter is an error rather
115    /// than an implicit zero.
116    pub fn execute<T>(
117        &mut self,
118        computation: &XLAComputation<T>,
119        inputs: ValueMap,
120    ) -> Result<Vec<ArrayD<f64>>>
121    where
122        T: Float + Debug + Default + Clone + Send + Sync + 'static,
123    {
124        let mut values = self.evaluate_all(computation, inputs)?;
125
126        if computation.outputs.is_empty() {
127            return Err(OptimError::ValidationError(ErrorContext::new(format!(
128                "computation '{}' declares no outputs; nothing to return",
129                computation.metadata.name
130            ))));
131        }
132
133        let mut results = Vec::with_capacity(computation.outputs.len());
134        for output in &computation.outputs {
135            let value = values.remove(&output.operand).ok_or_else(|| {
136                OptimError::ComputationError(ErrorContext::new(format!(
137                    "output {} of computation '{}' references operand {:?}, which was never \
138                     produced",
139                    output.index, computation.metadata.name, output.operand
140                )))
141            })?;
142            results.push(value);
143        }
144
145        Ok(results)
146    }
147
148    /// Evaluate every operation and return the full operand environment.
149    ///
150    /// Useful for inspecting intermediates in tests.
151    pub fn evaluate_all<T>(
152        &mut self,
153        computation: &XLAComputation<T>,
154        inputs: ValueMap,
155    ) -> Result<ValueMap>
156    where
157        T: Float + Debug + Default + Clone + Send + Sync + 'static,
158    {
159        self.evaluated_operations = 0;
160        let mut values: ValueMap = inputs;
161
162        for operation in Self::execution_order(computation)? {
163            // A `Parameter` is defined by the caller, not computed. Anything
164            // else whose operand is already bound was supplied as a pre-set
165            // value and is likewise left alone.
166            if values.contains_key(&operation.output) {
167                continue;
168            }
169            let result = self.evaluate_operation(computation, operation, &values)?;
170            values.insert(operation.output, result);
171            self.evaluated_operations += 1;
172        }
173
174        Ok(values)
175    }
176
177    /// Operations in an order where every operand is defined before it is read.
178    fn execution_order<T>(computation: &XLAComputation<T>) -> Result<Vec<&XLAOperation<T>>>
179    where
180        T: Float + Debug + Default + Clone + Send + Sync + 'static,
181    {
182        let by_id: HashMap<OperationId, &XLAOperation<T>> = computation
183            .operations
184            .iter()
185            .map(|op| (op.id, op))
186            .collect();
187
188        // Kahn's algorithm over the operand dependencies, which is independent
189        // of the (possibly stale) `dependencies` map.
190        let producers: HashMap<OperandId, OperationId> = computation
191            .operations
192            .iter()
193            .map(|op| (op.output, op.id))
194            .collect();
195
196        let mut in_degree: HashMap<OperationId, usize> = HashMap::new();
197        let mut dependents: HashMap<OperationId, Vec<OperationId>> = HashMap::new();
198
199        for operation in &computation.operations {
200            in_degree.entry(operation.id).or_insert(0);
201            for input in &operation.inputs {
202                if let Some(&producer) = producers.get(input) {
203                    dependents.entry(producer).or_default().push(operation.id);
204                    *in_degree.entry(operation.id).or_insert(0) += 1;
205                }
206            }
207        }
208
209        let mut ready: Vec<OperationId> = computation
210            .operations
211            .iter()
212            .filter(|op| in_degree.get(&op.id) == Some(&0))
213            .map(|op| op.id)
214            .collect();
215
216        let mut order: Vec<&XLAOperation<T>> = Vec::with_capacity(computation.operations.len());
217        while let Some(op_id) = ready.pop() {
218            if let Some(&operation) = by_id.get(&op_id) {
219                order.push(operation);
220            }
221            for next in dependents.get(&op_id).cloned().unwrap_or_default() {
222                if let Some(degree) = in_degree.get_mut(&next) {
223                    *degree = degree.saturating_sub(1);
224                    if *degree == 0 {
225                        ready.push(next);
226                    }
227                }
228            }
229        }
230
231        if order.len() != computation.operations.len() {
232            return Err(OptimError::ValidationError(ErrorContext::new(format!(
233                "computation '{}' contains a dependency cycle and cannot be executed",
234                computation.metadata.name
235            ))));
236        }
237
238        Ok(order)
239    }
240
241    /// Evaluate a single operation against the current environment.
242    fn evaluate_operation<T>(
243        &self,
244        computation: &XLAComputation<T>,
245        operation: &XLAOperation<T>,
246        values: &ValueMap,
247    ) -> Result<ArrayD<f64>>
248    where
249        T: Float + Debug + Default + Clone + Send + Sync + 'static,
250    {
251        let output_shape = computation
252            .operands
253            .get(&operation.output)
254            .map(|operand| operand.shape.clone())
255            .unwrap_or_default();
256
257        // Resolve operand values up front so each arm can pattern-match.
258        let mut operands: Vec<&ArrayD<f64>> = Vec::with_capacity(operation.inputs.len());
259        for input in &operation.inputs {
260            let value = values.get(input).ok_or_else(|| {
261                OptimError::ComputationError(ErrorContext::new(format!(
262                    "operation {:?} ({:?}) reads operand {:?} before it is defined",
263                    operation.id, operation.op_type, input
264                )))
265            })?;
266            operands.push(value);
267        }
268
269        match (&operation.op_type, operands.as_slice()) {
270            (OperationType::Constant(value), _) => Ok(constant_to_array(value)),
271
272            (OperationType::Parameter, _) => {
273                Err(OptimError::InvalidInput(ErrorContext::new(format!(
274                    "no input value was supplied for parameter operand {:?} of computation '{}'",
275                    operation.output, computation.metadata.name
276                ))))
277            }
278
279            // Binary elementwise operations, with rank-0 scalar broadcasting.
280            (OperationType::Add, [a, b]) => binary(a, b, |x, y| x + y),
281            (OperationType::Subtract, [a, b]) => binary(a, b, |x, y| x - y),
282            (OperationType::Multiply, [a, b]) => binary(a, b, |x, y| x * y),
283            (OperationType::Divide, [a, b]) => binary(a, b, |x, y| x / y),
284            (OperationType::Maximum, [a, b]) => binary(a, b, f64::max),
285            (OperationType::Minimum, [a, b]) => binary(a, b, f64::min),
286            (OperationType::And, [a, b]) => binary(a, b, |x, y| bool_to_f64(x != 0.0 && y != 0.0)),
287            (OperationType::Or, [a, b]) => binary(a, b, |x, y| bool_to_f64(x != 0.0 || y != 0.0)),
288            (OperationType::Xor, [a, b]) => {
289                binary(a, b, |x, y| bool_to_f64((x != 0.0) != (y != 0.0)))
290            }
291            (OperationType::Equal, [a, b]) => binary(a, b, |x, y| bool_to_f64(x == y)),
292            (OperationType::NotEqual, [a, b]) => binary(a, b, |x, y| bool_to_f64(x != y)),
293            (OperationType::Less, [a, b]) => binary(a, b, |x, y| bool_to_f64(x < y)),
294            (OperationType::LessEqual, [a, b]) => binary(a, b, |x, y| bool_to_f64(x <= y)),
295            (OperationType::Greater, [a, b]) => binary(a, b, |x, y| bool_to_f64(x > y)),
296            (OperationType::GreaterEqual, [a, b]) => binary(a, b, |x, y| bool_to_f64(x >= y)),
297
298            // Unary elementwise operations.
299            (OperationType::Negate, [a]) => Ok(a.mapv(|x| -x)),
300            (OperationType::Abs, [a]) => Ok(a.mapv(f64::abs)),
301            (OperationType::Square, [a]) => Ok(a.mapv(|x| x * x)),
302            (OperationType::Sqrt, [a]) => Ok(a.mapv(f64::sqrt)),
303            (OperationType::Rsqrt, [a]) => Ok(a.mapv(|x| 1.0 / x.sqrt())),
304            (OperationType::Exp, [a]) => Ok(a.mapv(f64::exp)),
305            (OperationType::Log, [a]) => Ok(a.mapv(f64::ln)),
306            (OperationType::Sin, [a]) => Ok(a.mapv(f64::sin)),
307            (OperationType::Cos, [a]) => Ok(a.mapv(f64::cos)),
308            (OperationType::Tanh, [a]) => Ok(a.mapv(f64::tanh)),
309            (OperationType::Ceil, [a]) => Ok(a.mapv(f64::ceil)),
310            (OperationType::Floor, [a]) => Ok(a.mapv(f64::floor)),
311            (OperationType::Round, [a]) => Ok(a.mapv(f64::round)),
312            (OperationType::Sign, [a]) => Ok(a.mapv(|x| {
313                if x > 0.0 {
314                    1.0
315                } else if x < 0.0 {
316                    -1.0
317                } else {
318                    0.0
319                }
320            })),
321            (OperationType::Not, [a]) => Ok(a.mapv(|x| bool_to_f64(x == 0.0))),
322            (OperationType::Copy, [a]) => Ok((*a).clone()),
323
324            // Matrix products.
325            (OperationType::Dot, [a, b])
326            | (OperationType::DotGeneral, [a, b])
327            | (OperationType::MatMul, [a, b]) => matmul(a, b),
328
329            // Shape manipulation.
330            (OperationType::Reshape, [a]) => reshape(a, &output_shape),
331            (OperationType::Transpose, [a]) => Ok(a.t().to_owned()),
332            (OperationType::Broadcast, [a]) => broadcast_to(a, &output_shape),
333
334            (OperationType::Reduce(reduce_op), [a]) => reduce(a, reduce_op),
335
336            (OperationType::Concatenate, values) if !values.is_empty() => {
337                concatenate(values, &operation.attributes)
338            }
339
340            // A tuple has no single array representation in this value model.
341            (OperationType::Tuple, _) | (OperationType::GetTupleElement, _) => {
342                Err(OptimError::NotImplementedError(ErrorContext::new(format!(
343                    "the CPU reference executor does not model tuple values, so {:?} \
344                     (operation {:?}) cannot be evaluated",
345                    operation.op_type, operation.id
346                ))))
347            }
348
349            // Everything else: say what is missing rather than invent a value.
350            (op_type, operands) => {
351                Err(OptimError::NotImplementedError(ErrorContext::new(format!(
352                    "the CPU reference executor does not implement {:?} with {} operand(s) \
353                     (operation {:?} in computation '{}')",
354                    op_type,
355                    operands.len(),
356                    operation.id,
357                    computation.metadata.name
358                ))))
359            }
360        }
361    }
362}
363
364/// Convert a literal into an array.
365fn constant_to_array(value: &ConstantValue) -> ArrayD<f64> {
366    let shape = IxDyn(&value.dims);
367    ArrayD::from_shape_vec(shape, value.data.clone()).unwrap_or_else(|_| {
368        // `ConstantValue::new` enforces the invariant, but a hand-built literal
369        // could violate it; fall back to a flat view rather than panicking.
370        ArrayD::from_shape_vec(IxDyn(&[value.data.len()]), value.data.clone())
371            .unwrap_or_else(|_| ArrayD::zeros(IxDyn(&[0])))
372    })
373}
374
375fn bool_to_f64(value: bool) -> f64 {
376    if value {
377        1.0
378    } else {
379        0.0
380    }
381}
382
383/// Element-wise binary application with rank-0 scalar broadcasting.
384fn binary(
385    lhs: &ArrayD<f64>,
386    rhs: &ArrayD<f64>,
387    op: impl Fn(f64, f64) -> f64,
388) -> Result<ArrayD<f64>> {
389    if lhs.shape() == rhs.shape() {
390        let mut out = lhs.clone();
391        for (slot, &value) in out.iter_mut().zip(rhs.iter()) {
392            *slot = op(*slot, value);
393        }
394        return Ok(out);
395    }
396
397    if lhs.len() == 1 {
398        let scalar = lhs.iter().next().copied().unwrap_or(0.0);
399        return Ok(rhs.mapv(|value| op(scalar, value)));
400    }
401
402    if rhs.len() == 1 {
403        let scalar = rhs.iter().next().copied().unwrap_or(0.0);
404        return Ok(lhs.mapv(|value| op(value, scalar)));
405    }
406
407    Err(OptimError::ShapeError(ErrorContext::new(format!(
408        "cannot apply an element-wise operation to shapes {:?} and {:?}; only rank-0 \
409         broadcasting is modelled",
410        lhs.shape(),
411        rhs.shape()
412    ))))
413}
414
415/// Two-dimensional matrix product `[M, K] x [K, N] -> [M, N]`.
416fn matmul(lhs: &ArrayD<f64>, rhs: &ArrayD<f64>) -> Result<ArrayD<f64>> {
417    let lhs_2d: Array2<f64> = lhs.clone().into_dimensionality().map_err(|_| {
418        OptimError::ShapeError(ErrorContext::new(format!(
419            "matrix product requires a rank-2 left operand, got shape {:?}",
420            lhs.shape()
421        )))
422    })?;
423    let rhs_2d: Array2<f64> = rhs.clone().into_dimensionality().map_err(|_| {
424        OptimError::ShapeError(ErrorContext::new(format!(
425            "matrix product requires a rank-2 right operand, got shape {:?}",
426            rhs.shape()
427        )))
428    })?;
429
430    if lhs_2d.ncols() != rhs_2d.nrows() {
431        return Err(OptimError::ShapeError(ErrorContext::new(format!(
432            "matrix product contraction mismatch: {:?} x {:?}",
433            lhs_2d.shape(),
434            rhs_2d.shape()
435        ))));
436    }
437
438    Ok(lhs_2d.dot(&rhs_2d).into_dyn())
439}
440
441/// Reinterpret an array under a new shape with the same element count.
442fn reshape(value: &ArrayD<f64>, target: &TensorShape) -> Result<ArrayD<f64>> {
443    let expected: usize = if target.dimensions.is_empty() {
444        1
445    } else {
446        target.dimensions.iter().product()
447    };
448
449    if expected != value.len() {
450        return Err(OptimError::ShapeError(ErrorContext::new(format!(
451            "reshape changes the element count: {} elements cannot become shape {:?}",
452            value.len(),
453            target.dimensions
454        ))));
455    }
456
457    let flat: Vec<f64> = value.iter().copied().collect();
458    ArrayD::from_shape_vec(IxDyn(&target.dimensions), flat).map_err(|error| {
459        OptimError::ShapeError(ErrorContext::new(format!("reshape failed: {error}")))
460    })
461}
462
463/// Broadcast a scalar (or already-matching array) to the target shape.
464fn broadcast_to(value: &ArrayD<f64>, target: &TensorShape) -> Result<ArrayD<f64>> {
465    let count: usize = if target.dimensions.is_empty() {
466        1
467    } else {
468        target.dimensions.iter().product()
469    };
470
471    if value.len() == count {
472        return reshape(value, target);
473    }
474
475    if value.len() == 1 {
476        let scalar = value.iter().next().copied().unwrap_or(0.0);
477        return Ok(ArrayD::from_elem(IxDyn(&target.dimensions), scalar));
478    }
479
480    Err(OptimError::NotImplementedError(ErrorContext::new(format!(
481        "the CPU reference executor only broadcasts rank-0 scalars; cannot broadcast shape \
482         {:?} to {:?}",
483        value.shape(),
484        target.dimensions
485    ))))
486}
487
488/// Reduce over the requested dimensions.
489fn reduce(value: &ArrayD<f64>, reduce_op: &ReduceOperation) -> Result<ArrayD<f64>> {
490    let (init, combine): (f64, fn(f64, f64) -> f64) = match reduce_op.function {
491        ReductionFunction::Add => (0.0, |a, b| a + b),
492        ReductionFunction::Multiply => (1.0, |a, b| a * b),
493        ReductionFunction::Max => (f64::NEG_INFINITY, f64::max),
494        ReductionFunction::Min => (f64::INFINITY, f64::min),
495        ReductionFunction::And => (1.0, |a, b| bool_to_f64(a != 0.0 && b != 0.0)),
496        ReductionFunction::Or => (0.0, |a, b| bool_to_f64(a != 0.0 || b != 0.0)),
497        ReductionFunction::Xor => (0.0, |a, b| bool_to_f64((a != 0.0) != (b != 0.0))),
498    };
499
500    // Reducing every dimension (or an unspecified set) collapses to a scalar.
501    let reduces_all = reduce_op.dimensions.is_empty() || reduce_op.dimensions.len() == value.ndim();
502
503    if reduces_all {
504        let total = value.iter().copied().fold(init, combine);
505        return ArrayD::from_shape_vec(IxDyn(&[]), vec![total]).map_err(|error| {
506            OptimError::ShapeError(ErrorContext::new(format!("reduce failed: {error}")))
507        });
508    }
509
510    let mut current = value.clone();
511    // Remove axes from the highest index down so earlier indices stay valid.
512    let mut axes = reduce_op.dimensions.clone();
513    axes.sort_unstable();
514    axes.dedup();
515
516    for &axis in axes.iter().rev() {
517        if axis >= current.ndim() {
518            return Err(OptimError::ShapeError(ErrorContext::new(format!(
519                "reduce dimension {axis} is out of range for a rank-{} operand",
520                current.ndim()
521            ))));
522        }
523        current = current.fold_axis(scirs2_core::ndarray::Axis(axis), init, |&a, &b| {
524            combine(a, b)
525        });
526    }
527
528    Ok(current)
529}
530
531/// Concatenate operands along the axis named by the `dimension` attribute.
532fn concatenate(
533    values: &[&ArrayD<f64>],
534    attributes: &super::frontend::OperationAttributes,
535) -> Result<ArrayD<f64>> {
536    use super::frontend::AttributeValue;
537
538    let axis = match attributes.attributes.get("dimension") {
539        Some(AttributeValue::Int(value)) if *value >= 0 => *value as usize,
540        Some(other) => {
541            return Err(OptimError::InvalidInput(ErrorContext::new(format!(
542                "concatenate `dimension` attribute must be a non-negative Int, got {other:?}"
543            ))))
544        }
545        None => 0,
546    };
547
548    let views: Vec<_> = values.iter().map(|value| value.view()).collect();
549    scirs2_core::ndarray::concatenate(scirs2_core::ndarray::Axis(axis), &views).map_err(|error| {
550        OptimError::ShapeError(ErrorContext::new(format!("concatenate failed: {error}")))
551    })
552}
553
554#[cfg(test)]
555mod tests {
556    use super::super::frontend::graph_capture::test_support::{add_op, scalar_shape, shape};
557    use super::super::frontend::ComputationGraphBuilder;
558    use super::*;
559
560    /// The headline capability: build a graph, execute it, get a number.
561    #[test]
562    fn executes_a_three_operation_graph() {
563        let mut builder: ComputationGraphBuilder<f32> = ComputationGraphBuilder::new();
564        let mut comp = builder.create_computation("add");
565
566        let two = add_op(
567            &mut builder,
568            &mut comp,
569            OperationType::Constant(ConstantValue::scalar(2.0)),
570            vec![],
571            scalar_shape(),
572        );
573        let three = add_op(
574            &mut builder,
575            &mut comp,
576            OperationType::Constant(ConstantValue::scalar(3.0)),
577            vec![],
578            scalar_shape(),
579        );
580        let _sum = add_op(
581            &mut builder,
582            &mut comp,
583            OperationType::Add,
584            vec![two, three],
585            scalar_shape(),
586        );
587        builder
588            .mark_terminal_operands_as_outputs(&mut comp)
589            .expect("outputs must be inferable");
590
591        let outputs = ReferenceExecutor::new()
592            .execute(&comp, ValueMap::new())
593            .expect("execution must succeed");
594
595        assert_eq!(outputs.len(), 1);
596        assert_eq!(outputs[0].iter().copied().collect::<Vec<_>>(), vec![5.0]);
597    }
598
599    /// Parameters are bound from the caller's input map.
600    #[test]
601    fn executes_with_parameter_inputs() {
602        let mut builder: ComputationGraphBuilder<f32> = ComputationGraphBuilder::new();
603        let mut comp = builder.create_computation("scale");
604
605        let x = add_op(
606            &mut builder,
607            &mut comp,
608            OperationType::Parameter,
609            vec![],
610            shape(&[3]),
611        );
612        let factor = add_op(
613            &mut builder,
614            &mut comp,
615            OperationType::Constant(ConstantValue::scalar(10.0)),
616            vec![],
617            scalar_shape(),
618        );
619        let _scaled = add_op(
620            &mut builder,
621            &mut comp,
622            OperationType::Multiply,
623            vec![x, factor],
624            shape(&[3]),
625        );
626        builder
627            .mark_terminal_operands_as_outputs(&mut comp)
628            .expect("outputs must be inferable");
629
630        let mut inputs = ValueMap::new();
631        inputs.insert(
632            x,
633            ArrayD::from_shape_vec(IxDyn(&[3]), vec![1.0, 2.0, 3.0])
634                .expect("input array must build"),
635        );
636
637        let outputs = ReferenceExecutor::new()
638            .execute(&comp, inputs)
639            .expect("execution must succeed");
640
641        assert_eq!(
642            outputs[0].iter().copied().collect::<Vec<_>>(),
643            vec![10.0, 20.0, 30.0]
644        );
645    }
646
647    /// A missing parameter binding is an error, not an implicit zero.
648    #[test]
649    fn missing_parameter_input_is_an_error() {
650        let mut builder: ComputationGraphBuilder<f32> = ComputationGraphBuilder::new();
651        let mut comp = builder.create_computation("unbound");
652
653        let x = add_op(
654            &mut builder,
655            &mut comp,
656            OperationType::Parameter,
657            vec![],
658            shape(&[2]),
659        );
660        let _out = add_op(
661            &mut builder,
662            &mut comp,
663            OperationType::Abs,
664            vec![x],
665            shape(&[2]),
666        );
667        builder
668            .mark_terminal_operands_as_outputs(&mut comp)
669            .expect("outputs must be inferable");
670
671        assert!(ReferenceExecutor::new()
672            .execute(&comp, ValueMap::new())
673            .is_err());
674    }
675
676    /// Matrix multiplication produces the mathematically correct product.
677    #[test]
678    fn executes_matrix_multiplication() {
679        let mut builder: ComputationGraphBuilder<f32> = ComputationGraphBuilder::new();
680        let mut comp = builder.create_computation("matmul");
681
682        let a = add_op(
683            &mut builder,
684            &mut comp,
685            OperationType::Constant(
686                ConstantValue::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2])
687                    .expect("constant must be well formed"),
688            ),
689            vec![],
690            shape(&[2, 2]),
691        );
692        let b = add_op(
693            &mut builder,
694            &mut comp,
695            OperationType::Constant(
696                ConstantValue::new(vec![5.0, 6.0, 7.0, 8.0], vec![2, 2])
697                    .expect("constant must be well formed"),
698            ),
699            vec![],
700            shape(&[2, 2]),
701        );
702        let _product = add_op(
703            &mut builder,
704            &mut comp,
705            OperationType::MatMul,
706            vec![a, b],
707            shape(&[2, 2]),
708        );
709        builder
710            .mark_terminal_operands_as_outputs(&mut comp)
711            .expect("outputs must be inferable");
712
713        let outputs = ReferenceExecutor::new()
714            .execute(&comp, ValueMap::new())
715            .expect("execution must succeed");
716
717        // [[1,2],[3,4]] x [[5,6],[7,8]] = [[19,22],[43,50]]
718        assert_eq!(
719            outputs[0].iter().copied().collect::<Vec<_>>(),
720            vec![19.0, 22.0, 43.0, 50.0]
721        );
722    }
723
724    /// Reshape preserves the element count and reorders nothing.
725    #[test]
726    fn executes_reshape() {
727        let mut builder: ComputationGraphBuilder<f32> = ComputationGraphBuilder::new();
728        let mut comp = builder.create_computation("reshape");
729
730        let source = add_op(
731            &mut builder,
732            &mut comp,
733            OperationType::Constant(
734                ConstantValue::new(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], vec![2, 3])
735                    .expect("constant must be well formed"),
736            ),
737            vec![],
738            shape(&[2, 3]),
739        );
740        let _reshaped = add_op(
741            &mut builder,
742            &mut comp,
743            OperationType::Reshape,
744            vec![source],
745            shape(&[3, 2]),
746        );
747        builder
748            .mark_terminal_operands_as_outputs(&mut comp)
749            .expect("outputs must be inferable");
750
751        let outputs = ReferenceExecutor::new()
752            .execute(&comp, ValueMap::new())
753            .expect("execution must succeed");
754
755        assert_eq!(outputs[0].shape(), &[3, 2]);
756        assert_eq!(
757            outputs[0].iter().copied().collect::<Vec<_>>(),
758            vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
759        );
760    }
761
762    /// Reductions collapse the requested axes.
763    #[test]
764    fn executes_sum_reduction() {
765        let mut builder: ComputationGraphBuilder<f32> = ComputationGraphBuilder::new();
766        let mut comp = builder.create_computation("reduce");
767
768        let source = add_op(
769            &mut builder,
770            &mut comp,
771            OperationType::Constant(
772                ConstantValue::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2])
773                    .expect("constant must be well formed"),
774            ),
775            vec![],
776            shape(&[2, 2]),
777        );
778        let _total = add_op(
779            &mut builder,
780            &mut comp,
781            OperationType::Reduce(ReduceOperation {
782                function: ReductionFunction::Add,
783                dimensions: vec![0, 1],
784                init_value: None,
785            }),
786            vec![source],
787            scalar_shape(),
788        );
789        builder
790            .mark_terminal_operands_as_outputs(&mut comp)
791            .expect("outputs must be inferable");
792
793        let outputs = ReferenceExecutor::new()
794            .execute(&comp, ValueMap::new())
795            .expect("execution must succeed");
796
797        assert_eq!(outputs[0].iter().copied().collect::<Vec<_>>(), vec![10.0]);
798    }
799
800    /// An unimplemented operation reports itself rather than returning a value.
801    #[test]
802    fn unimplemented_operation_reports_itself() {
803        let mut builder: ComputationGraphBuilder<f32> = ComputationGraphBuilder::new();
804        let mut comp = builder.create_computation("unsupported");
805
806        let source = add_op(
807            &mut builder,
808            &mut comp,
809            OperationType::Constant(ConstantValue::scalar(1.0)),
810            vec![],
811            scalar_shape(),
812        );
813        let _gathered = add_op(
814            &mut builder,
815            &mut comp,
816            OperationType::Gather,
817            vec![source],
818            scalar_shape(),
819        );
820        builder
821            .mark_terminal_operands_as_outputs(&mut comp)
822            .expect("outputs must be inferable");
823
824        let error = ReferenceExecutor::new()
825            .execute(&comp, ValueMap::new())
826            .expect_err("Gather is not implemented");
827        let message = format!("{error}");
828        assert!(
829            message.contains("Gather"),
830            "the error must name the operation: {message}"
831        );
832    }
833
834    /// The optimization pipeline must preserve the executor's results.
835    ///
836    /// This is the property that makes the passes trustworthy: whatever
837    /// constant folding, algebraic simplification, CSE and DCE do, the numbers
838    /// coming out must not change.
839    #[test]
840    fn optimization_preserves_execution_results() {
841        use super::super::optimization::OptimizationPipeline;
842        use super::super::XLACompilerConfig;
843
844        let build = || {
845            let mut builder: ComputationGraphBuilder<f32> = ComputationGraphBuilder::new();
846            let mut comp = builder.create_computation("preserved");
847
848            let x = add_op(
849                &mut builder,
850                &mut comp,
851                OperationType::Constant(ConstantValue::scalar(4.0)),
852                vec![],
853                scalar_shape(),
854            );
855            let one = add_op(
856                &mut builder,
857                &mut comp,
858                OperationType::Constant(ConstantValue::scalar(1.0)),
859                vec![],
860                scalar_shape(),
861            );
862            let zero = add_op(
863                &mut builder,
864                &mut comp,
865                OperationType::Constant(ConstantValue::scalar(0.0)),
866                vec![],
867                scalar_shape(),
868            );
869            // (x * 1) + 0, plus a duplicated subexpression for CSE to find.
870            let scaled = add_op(
871                &mut builder,
872                &mut comp,
873                OperationType::Multiply,
874                vec![x, one],
875                scalar_shape(),
876            );
877            let shifted = add_op(
878                &mut builder,
879                &mut comp,
880                OperationType::Add,
881                vec![scaled, zero],
882                scalar_shape(),
883            );
884            let squared_a = add_op(
885                &mut builder,
886                &mut comp,
887                OperationType::Square,
888                vec![shifted],
889                scalar_shape(),
890            );
891            let squared_b = add_op(
892                &mut builder,
893                &mut comp,
894                OperationType::Square,
895                vec![shifted],
896                scalar_shape(),
897            );
898            let _total = add_op(
899                &mut builder,
900                &mut comp,
901                OperationType::Add,
902                vec![squared_a, squared_b],
903                scalar_shape(),
904            );
905            builder
906                .mark_terminal_operands_as_outputs(&mut comp)
907                .expect("outputs must be inferable");
908            comp
909        };
910
911        let before = ReferenceExecutor::new()
912            .execute(&build(), ValueMap::new())
913            .expect("the unoptimized graph must execute");
914
915        let config = XLACompilerConfig::default();
916        let mut pipeline: OptimizationPipeline<f32> = OptimizationPipeline::new(&config);
917        let optimized = pipeline
918            .optimize(build())
919            .expect("optimization must succeed");
920
921        let after = ReferenceExecutor::new()
922            .execute(&optimized, ValueMap::new())
923            .expect("the optimized graph must execute");
924
925        // (4*1 + 0)^2 * 2 = 32
926        assert_eq!(before[0].iter().copied().collect::<Vec<_>>(), vec![32.0]);
927        assert_eq!(
928            before[0].iter().copied().collect::<Vec<_>>(),
929            after[0].iter().copied().collect::<Vec<_>>(),
930            "optimization must not change the computed result"
931        );
932    }
933}