Skip to main content

vortex_array/expr/
optimize.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use vortex_error::VortexResult;
5
6use crate::expr::BoundExpression;
7use crate::scalar_fn::ExpressionReduceNode;
8
9impl BoundExpression {
10    /// Optimize the root expression node only, iterating to convergence.
11    ///
12    /// This applies optimization rules repeatedly until no more changes occur:
13    /// 1. `simplify` - scalar-function simplifications over the bound node
14    /// 2. `reduce` - abstract reduction rules via `ReduceNode`
15    pub fn optimize(&self) -> VortexResult<BoundExpression> {
16        Ok(self.try_optimize()?.unwrap_or_else(|| self.clone()))
17    }
18
19    /// Apply this node's own simplification rule, if it has one.
20    ///
21    /// Non-scalar nodes carry no rules, so they never simplify.
22    fn simplify_node(&self) -> VortexResult<Option<BoundExpression>> {
23        match self {
24            BoundExpression::Scalar { scalar_fn, .. } => scalar_fn.simplify(self),
25            BoundExpression::Root { .. } => Ok(None),
26        }
27    }
28
29    /// Apply this node's own abstract reduction rule, if it has one.
30    fn reduce_node<'a>(
31        &self,
32        node: &ExpressionReduceNode<'a>,
33    ) -> VortexResult<Option<ExpressionReduceNode<'a>>> {
34        match self {
35            BoundExpression::Scalar { scalar_fn, .. } => scalar_fn.reduce_expression(node),
36            BoundExpression::Root { .. } => Ok(None),
37        }
38    }
39
40    /// Try to optimize the root expression node only, returning None if no optimizations applied.
41    fn try_optimize(&self) -> VortexResult<Option<BoundExpression>> {
42        // Copy-on-write: `current` stays None until a rule fires, so unchanged nodes (the common
43        // case) are never cloned.
44        let mut current: Option<BoundExpression> = None;
45        let mut loop_counter = 0;
46
47        loop {
48            if loop_counter > 100 {
49                vortex_error::vortex_bail!(
50                    "Exceeded maximum optimization iterations (possible infinite loop)"
51                );
52            }
53            loop_counter += 1;
54
55            let expr = current.as_ref().unwrap_or(self);
56            let mut changed = false;
57
58            if let Some(simplified) = expr.simplify_node()? {
59                current = Some(simplified);
60                changed = true;
61            }
62
63            // Try reduce via ReduceNode. The node borrows the expression, so constructing it is
64            // free; the block scopes the borrow so `current` can be updated.
65            let reduced = {
66                let expr = current.as_ref().unwrap_or(self);
67                expr.reduce_node(&ExpressionReduceNode::new(expr))?
68                    .map(ExpressionReduceNode::into_expression)
69            };
70            if let Some(reduced_expr) = reduced {
71                current = Some(reduced_expr);
72                changed = true;
73            }
74
75            if !changed {
76                break;
77            }
78        }
79
80        Ok(current)
81    }
82
83    /// Optimize the entire expression tree recursively.
84    ///
85    /// Optimizes children first (bottom-up), then optimizes the root.
86    pub fn optimize_recursive(&self) -> VortexResult<BoundExpression> {
87        Ok(self
88            .try_optimize_recursive()?
89            .unwrap_or_else(|| self.clone()))
90    }
91
92    /// Try to optimize the entire expression tree recursively.
93    pub fn try_optimize_recursive(&self) -> VortexResult<Option<BoundExpression>> {
94        // First optimize the root
95        let mut current = self.try_optimize()?;
96
97        // Then recursively optimize children. The new children vector is only allocated once a
98        // child actually changes, so fully-optimized subtrees cost no allocations.
99        let expr = current.as_ref().unwrap_or(self);
100        let children = expr.children();
101        let mut new_children: Option<Vec<BoundExpression>> = None;
102        for (idx, child) in children.iter().enumerate() {
103            if let Some(optimized) = child.try_optimize_recursive()? {
104                new_children
105                    .get_or_insert_with(|| children[..idx].to_vec())
106                    .push(optimized);
107            } else if let Some(new_children) = new_children.as_mut() {
108                new_children.push(child.clone());
109            }
110        }
111
112        if let Some(new_children) = new_children {
113            let updated = expr.clone().with_children(new_children)?;
114
115            // After updating children, try to optimize root again
116            current = Some(updated.try_optimize()?.unwrap_or(updated));
117        }
118
119        Ok(current)
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use rstest::rstest;
126    use vortex_buffer::buffer;
127    use vortex_error::VortexResult;
128    use vortex_error::vortex_err;
129
130    use crate::IntoArray;
131    use crate::VortexSessionExecute;
132    use crate::array_session;
133    use crate::arrays::BoolArray;
134    use crate::arrays::StructArray;
135    use crate::assert_arrays_eq;
136    use crate::dtype::DType;
137    use crate::dtype::FieldName;
138    use crate::dtype::Nullability;
139    use crate::dtype::PType;
140    use crate::dtype::StructFields;
141    use crate::expr::Expression;
142    use crate::expr::and;
143    use crate::expr::cast;
144    use crate::expr::eq;
145    use crate::expr::get_item;
146    use crate::expr::lit;
147    use crate::expr::lt_eq;
148    use crate::expr::merge;
149    use crate::expr::or;
150    use crate::expr::pack;
151    use crate::expr::root;
152    use crate::expr::select;
153    use crate::expr::transform::replace;
154    use crate::expr::zip_expr;
155    use crate::scalar::Scalar;
156    use crate::scalar_fn::fns::literal::Literal;
157
158    #[test]
159    fn optimize_or_chain_correctness() -> VortexResult<()> {
160        let expr = or(
161            eq(get_item("x", root()), lit(1i32)),
162            eq(get_item("x", root()), lit(2i32)),
163        );
164        let scope = DType::Struct(
165            StructFields::new(
166                ["x"].into(),
167                vec![DType::Primitive(PType::I32, Nullability::NonNullable)],
168            ),
169            Nullability::NonNullable,
170        );
171        let optimized = expr.bind(&scope)?.optimize_recursive()?;
172
173        let data =
174            StructArray::from_fields(&[("x", buffer![0i32, 1, 2, 3].into_array())])?.into_array();
175        let actual = data.apply_bound(&optimized)?;
176        assert_arrays_eq!(
177            actual,
178            BoolArray::from_iter([false, true, true, false]),
179            &mut array_session().create_execution_ctx()
180        );
181        Ok(())
182    }
183
184    #[test]
185    fn optimize_folds_cast_of_literal_in_comparison() -> VortexResult<()> {
186        let expr = lt_eq(
187            get_item("x", root()),
188            cast(
189                lit(3i32),
190                DType::Primitive(PType::F64, Nullability::NonNullable),
191            ),
192        );
193        let scope = DType::Struct(
194            StructFields::new(
195                ["x"].into(),
196                vec![DType::Primitive(PType::F64, Nullability::NonNullable)],
197            ),
198            Nullability::NonNullable,
199        );
200        let optimized = expr.bind(&scope)?.optimize_recursive()?;
201
202        // Prune rules pattern-match a bare Literal on the comparison RHS; a cast wrapper
203        // silently disables pruning.
204        let rhs = optimized
205            .child(1)
206            .as_opt::<Literal>()
207            .ok_or_else(|| vortex_err!("expected a bare literal RHS, got {optimized}"))?;
208        assert_eq!(rhs, &Scalar::primitive(3.0f64, Nullability::NonNullable));
209        Ok(())
210    }
211
212    #[rstest]
213    #[case::and_annihilator(and(root(), lit(false)), DType::Bool(Nullability::Nullable))]
214    #[case::or_annihilator(or(root(), lit(true)), DType::Bool(Nullability::Nullable))]
215    #[case::and_nullable_identity(
216        and(root(), lit(Scalar::from(Some(true)))),
217        DType::Bool(Nullability::NonNullable)
218    )]
219    #[case::or_nullable_identity(
220        or(root(), lit(Scalar::from(Some(false)))),
221        DType::Bool(Nullability::NonNullable)
222    )]
223    #[case::zip_true(
224        zip_expr(lit(true), lit(1i32), root()),
225        DType::Primitive(PType::I32, Nullability::Nullable)
226    )]
227    #[case::zip_false(
228        zip_expr(lit(false), root(), lit(1i32)),
229        DType::Primitive(PType::I32, Nullability::Nullable)
230    )]
231    fn literal_simplification_preserves_result_dtype(
232        #[case] expr: Expression,
233        #[case] scope: DType,
234    ) -> VortexResult<()> {
235        let original = expr.bind(&scope)?;
236        let optimized = expr.bind(&scope)?.optimize_recursive()?;
237        assert_eq!(optimized.dtype(), original.dtype());
238
239        Ok(())
240    }
241
242    /// `Select` over a `pack` rewrites to a `pack` of the selected child fields. It must read
243    /// those fields out of the child `pack` itself, because the optimizer visits children before
244    /// their parent and so never revisits the children a rule introduces.
245    #[test]
246    fn optimize_select_of_pack_reads_pack_fields() -> VortexResult<()> {
247        let field_names = (0..4)
248            .map(|idx| FieldName::from(format!("field_{idx}")))
249            .collect::<Vec<_>>();
250        let fields = StructFields::new(
251            field_names.clone().into(),
252            vec![DType::Primitive(PType::U64, Nullability::NonNullable); field_names.len()],
253        );
254        let scope = DType::Struct(fields, Nullability::NonNullable);
255
256        let expr = select(
257            field_names
258                .iter()
259                .cloned()
260                .chain(["input_row_idx".into(), "input_file_idx".into()])
261                .collect::<Vec<_>>(),
262            merge([
263                root(),
264                pack(
265                    [
266                        ("input_row_idx", lit(0_u64)),
267                        ("input_file_idx", lit(1_u64)),
268                    ],
269                    Nullability::NonNullable,
270                ),
271            ]),
272        );
273        let root_pack = pack(
274            field_names
275                .iter()
276                .map(|name| (name.clone(), get_item(name.clone(), root())))
277                .collect::<Vec<_>>(),
278            Nullability::NonNullable,
279        );
280        let expr = replace(expr, &root(), root_pack);
281
282        let expected = pack(
283            field_names
284                .into_iter()
285                .map(|name| (name.clone(), get_item(name, root())))
286                .chain([
287                    ("input_row_idx".into(), lit(0_u64)),
288                    ("input_file_idx".into(), lit(1_u64)),
289                ])
290                .collect::<Vec<_>>(),
291            Nullability::NonNullable,
292        );
293
294        let expected = expected.bind(&scope)?;
295
296        let optimized = expr.bind(&scope)?.optimize_recursive()?;
297        assert_eq!(optimized, expected, "got {optimized}");
298        assert_eq!(optimized.optimize_recursive()?, expected);
299
300        Ok(())
301    }
302
303    /// `Merge` reduces to a `pack` that reads each field out of its children, so it must also
304    /// read straight out of a `pack` child.
305    #[test]
306    fn optimize_merge_of_packs_reads_pack_fields() -> VortexResult<()> {
307        let scope = DType::Struct(StructFields::empty(), Nullability::NonNullable);
308        let expr = merge([
309            pack([("a", lit(0_u64))], Nullability::NonNullable),
310            pack([("b", lit(1_u64))], Nullability::NonNullable),
311        ]);
312
313        let expected = pack(
314            [("a", lit(0_u64)), ("b", lit(1_u64))],
315            Nullability::NonNullable,
316        );
317
318        let expected = expected.bind(&scope)?;
319
320        let optimized = expr.bind(&scope)?.optimize_recursive()?;
321        assert_eq!(optimized, expected, "got {optimized}");
322        assert_eq!(optimized.optimize_recursive()?, expected);
323
324        Ok(())
325    }
326}