Skip to content

Commit 4e37c51

Browse files
authored
Fold and/or constants on Arrays during .optimize() (#9997)
We have two ways of optimizing and/or: we have an optimizer of expressions, including simplify_untyped, and we have a boolean kernel which folds constants with regards to Kleene logic. However, sometimes we can fold constants without executing the array. This change implements constant reduction for and/or with an array reduce rule which applies when you call array's optimize(): and(x, const), and(const, x) folds to x or ConstantArray(constant), same for or(). and(const, const), or(const, const) folds to a ConstantArray as well. Kleene NULLs are not handled. This work is delegated to boolean kernel to keep existing code clean. As a result, .optimize() on array, and, consequently, .reduce() can change reduced array's dtype to a constant and dtype's nullability from nullable to non-nullable. This also brings in an invariant arguments for and/or, if constant, must be booleans. I've fixed the test where and(..., lit(true)) was optimized, which isn't a valid expression anyway. --------- Signed-off-by: Mikhail Kot <mikhail@spiraldb.com>
1 parent 53f6dbb commit 4e37c51

7 files changed

Lines changed: 191 additions & 50 deletions

File tree

‎vortex-array/src/array/mod.rs‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -427,11 +427,16 @@ impl<V: VTable> DynArrayData for ArrayData<V> {
427427
this.encoding_id(),
428428
reduced.encoding_id()
429429
);
430+
// Rules may reduce to a non-nullable constant
430431
vortex_ensure!(
431-
reduced.dtype() == this.dtype(),
432-
"Reduced array dtype mismatch from {} to {}",
432+
reduced.dtype() == this.dtype()
433+
|| (reduced.dtype().eq_ignore_nullability(this.dtype())
434+
&& !reduced.dtype().is_nullable()),
435+
"Reduced array dtype mismatch from {} ({}) to {} ({})",
433436
this.encoding_id(),
434-
reduced.encoding_id()
437+
this.dtype(),
438+
reduced.encoding_id(),
439+
reduced.dtype()
435440
);
436441
Ok(Some(reduced))
437442
}

‎vortex-array/src/arrays/dict/compute/rules.rs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -369,8 +369,8 @@ mod tests {
369369
let expr = Binary::try_new(dict, const_false, Operator::And)?.into_array();
370370

371371
let mut ctx = array_session().create_execution_ctx();
372-
// Kleene AND: null AND false == false, so all three rows must be valid `false`.
373-
let expected = expr.clone().execute::<BoolArray>(&mut ctx)?.into_array();
372+
// Kleene AND: null AND false == false
373+
let expected = BoolArray::from_iter([false, false, false]).into_array();
374374
let optimized = expr.optimize()?;
375375
assert_arrays_eq!(optimized, expected, &mut ctx);
376376
Ok(())
@@ -386,8 +386,8 @@ mod tests {
386386
let expr = Binary::try_new(dict, const_true, Operator::Or)?.into_array();
387387

388388
let mut ctx = array_session().create_execution_ctx();
389-
// Kleene OR: null OR true == true, so all three rows must be valid `true`.
390-
let expected = expr.clone().execute::<BoolArray>(&mut ctx)?.into_array();
389+
// Kleene OR: null OR true == true
390+
let expected = BoolArray::from_iter([true, true, true]).into_array();
391391
let optimized = expr.optimize()?;
392392
assert_arrays_eq!(optimized, expected, &mut ctx);
393393
Ok(())

‎vortex-array/src/arrays/scalar_fn/rules.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ use crate::validity::Validity;
2929

3030
pub(super) const RULES: ReduceRuleSet<ScalarFn> = ReduceRuleSet::new(&[
3131
&ScalarFnPackToStructRule,
32-
&ScalarFnAbstractReduceRule,
3332
&IsNullReduceRule,
33+
// Ordering is important. ScalarFn::reduce() must be called after all other
34+
// optimizations
35+
&ScalarFnAbstractReduceRule,
3436
]);
3537

3638
pub(super) const PARENT_RULES: ParentRuleSet<ScalarFn> = ParentRuleSet::new(&[

‎vortex-array/src/expr/transform/bound_partition.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ mod tests {
312312
use crate::dtype::PType::I32;
313313
use crate::dtype::StructFields;
314314
use crate::expr::analysis::make_bound_free_field_annotator;
315-
use crate::expr::and;
315+
use crate::expr::checked_add;
316316
use crate::expr::col;
317317
use crate::expr::get_item;
318318
use crate::expr::lit;
@@ -409,7 +409,7 @@ mod tests {
409409

410410
#[rstest]
411411
fn test_expr_top_level_ref_get_item_add(dtype: DType) {
412-
let expr = and(get_item("y", get_item("a", root())), lit(1));
412+
let expr = checked_add(get_item("y", get_item("a", root())), lit(1));
413413
let partitioned = partition_by_field(expr.bind(&dtype).unwrap(), &dtype).unwrap();
414414

415415
// Whole expr is a single split
@@ -418,7 +418,7 @@ mod tests {
418418

419419
#[rstest]
420420
fn test_expr_top_level_ref_get_item_add_cannot_split(dtype: DType) {
421-
let expr = and(get_item("y", get_item("a", root())), get_item("b", root()));
421+
let expr = checked_add(get_item("y", get_item("a", root())), get_item("b", root()));
422422
let partitioned = partition_by_field(expr.bind(&dtype).unwrap(), &dtype).unwrap();
423423

424424
// One for id.a and id.b

‎vortex-array/src/expr/transform/partition.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ mod tests {
311311
fn test_expr_top_level_ref_get_item_add(dtype: DType) {
312312
let fields = dtype.as_struct_fields_opt().unwrap();
313313

314-
let expr = and(get_item("y", get_item("a", root())), lit(1));
314+
let expr = and(get_item("y", get_item("a", root())), lit(true));
315315
let partitioned = partition(expr, &dtype, make_free_field_annotator(fields)).unwrap();
316316

317317
// Whole expr is a single split

‎vortex-array/src/scalar_fn/fns/binary/mod.rs‎

Lines changed: 138 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ use crate::proto::expr as pb;
2727
use crate::scalar_fn::Arity;
2828
use crate::scalar_fn::ChildName;
2929
use crate::scalar_fn::ExecutionArgs;
30+
use crate::scalar_fn::ReduceNode;
3031
use crate::scalar_fn::ScalarFnId;
3132
use crate::scalar_fn::ScalarFnVTable;
3233
use crate::scalar_fn::ScalarFnVTableExt;
@@ -68,6 +69,22 @@ impl Binary {
6869
}
6970
}
7071

72+
/// Kleene and/or lookup table where both arguments are constant and non-NULL.
73+
/// is_and, left, right
74+
const KLEENE_LUT: [[[bool; 2]; 2]; 2] = [
75+
[[false, true], [true, true]], // or
76+
[[false, false], [false, true]], // and
77+
];
78+
79+
/// Kleene and/or reduction where one argument is constant non-NULL and other
80+
/// is non-constant.
81+
fn kleene_one_const<T: ReduceNode>(node: T, constant: bool, is_and: bool) -> T {
82+
match (is_and, constant) {
83+
(true, true) | (false, false) => node,
84+
(is_and, constant) => node.new_constant((constant && !is_and).into()),
85+
}
86+
}
87+
7188
impl ScalarFnVTable for Binary {
7289
type Options = Operator;
7390

@@ -152,6 +169,12 @@ impl ScalarFnVTable for Binary {
152169
vortex_bail!("Cannot compare different DTypes {} and {}", lhs, rhs);
153170
}
154171

172+
if matches!(operator, Operator::And | Operator::Or)
173+
&& !(lhs.is_boolean() && rhs.is_boolean())
174+
{
175+
vortex_bail!("'{operator}' requires bool operands, got {lhs} and {rhs}",);
176+
}
177+
155178
Ok(DType::Bool((lhs.is_nullable() || rhs.is_nullable()).into()))
156179
}
157180

@@ -254,18 +277,43 @@ impl ScalarFnVTable for Binary {
254277
operator: &Operator,
255278
expression: &Expression,
256279
) -> VortexResult<Option<Expression>> {
280+
if matches!(operator, Operator::And | Operator::Or) {
281+
return Ok(None); // AND and OR are kleene logic
282+
}
257283
let lhs = expression.child(0).validity()?;
258284
let rhs = expression.child(1).validity()?;
285+
Ok(Some(and(lhs, rhs)))
286+
}
259287

260-
Ok(match operator {
261-
// AND and OR are kleene logic.
262-
Operator::And => None,
263-
Operator::Or => None,
264-
_ => {
265-
// All other binary operators are null if either side is null.
266-
Some(and(lhs, rhs))
267-
}
268-
})
288+
fn reduce<T: ReduceNode>(&self, operator: &Operator, node: &T) -> VortexResult<Option<T>> {
289+
if !matches!(operator, Operator::And | Operator::Or) {
290+
return Ok(None);
291+
}
292+
let left = node.child(0);
293+
let right = node.child(1);
294+
295+
let left_const = left.as_constant();
296+
let right_const = right.as_constant();
297+
298+
// We don't handle Kleene NULL reduction here (.value() returns None
299+
// for NULL). This will be reduced in the boolean kernel during
300+
// execution. Not handling the case keeps the code much simpler.
301+
let left_const = left_const
302+
.and_then(|s| s.value().cloned())
303+
.map(|v| v.as_bool());
304+
let right_const = right_const
305+
.and_then(|s| s.value().cloned())
306+
.map(|v| v.as_bool());
307+
let is_and = *operator == Operator::And;
308+
309+
Ok(Some(match (left_const, right_const) {
310+
(None, None) => return Ok(None),
311+
(Some(left_const), Some(right_const)) => left.new_constant(
312+
KLEENE_LUT[is_and as usize][left_const as usize][right_const as usize].into(),
313+
),
314+
(Some(constant), None) => kleene_one_const(right, constant, is_and),
315+
(None, Some(constant)) => kleene_one_const(left, constant, is_and),
316+
}))
269317
}
270318

271319
fn is_strict(&self, operator: &Operator) -> bool {
@@ -296,8 +344,13 @@ mod tests {
296344
use vortex_error::VortexResult;
297345

298346
use super::*;
347+
use crate::IntoArray;
299348
use crate::VortexSessionExecute;
300349
use crate::array_session;
350+
use crate::arrays::Bool;
351+
use crate::arrays::BoolArray;
352+
use crate::arrays::ConstantArray;
353+
use crate::arrays::PrimitiveArray;
301354
use crate::assert_arrays_eq;
302355
use crate::builtins::ArrayBuiltins;
303356
use crate::dtype::DType;
@@ -316,7 +369,9 @@ mod tests {
316369
use crate::expr::or;
317370
use crate::expr::or_collect;
318371
use crate::expr::test_harness;
372+
use crate::optimizer::ArrayOptimizer;
319373
use crate::scalar::Scalar;
374+
320375
#[test]
321376
fn and_collect_balanced() {
322377
let values = vec![lit(1), lit(2), lit(3), lit(4), lit(5)];
@@ -472,40 +527,22 @@ mod tests {
472527

473528
// Create a struct array with one element for testing.
474529
let lhs_struct = StructArray::from_fields(&[
475-
(
476-
"a",
477-
crate::arrays::PrimitiveArray::from_iter([1i32]).into_array(),
478-
),
479-
(
480-
"b",
481-
crate::arrays::PrimitiveArray::from_iter([3i32]).into_array(),
482-
),
530+
("a", PrimitiveArray::from_iter([1i32]).into_array()),
531+
("b", PrimitiveArray::from_iter([3i32]).into_array()),
483532
])
484533
.unwrap()
485534
.into_array();
486535

487536
let rhs_struct_equal = StructArray::from_fields(&[
488-
(
489-
"a",
490-
crate::arrays::PrimitiveArray::from_iter([1i32]).into_array(),
491-
),
492-
(
493-
"b",
494-
crate::arrays::PrimitiveArray::from_iter([3i32]).into_array(),
495-
),
537+
("a", PrimitiveArray::from_iter([1i32]).into_array()),
538+
("b", PrimitiveArray::from_iter([3i32]).into_array()),
496539
])
497540
.unwrap()
498541
.into_array();
499542

500543
let rhs_struct_different = StructArray::from_fields(&[
501-
(
502-
"a",
503-
crate::arrays::PrimitiveArray::from_iter([1i32]).into_array(),
504-
),
505-
(
506-
"b",
507-
crate::arrays::PrimitiveArray::from_iter([4i32]).into_array(),
508-
),
544+
("a", PrimitiveArray::from_iter([1i32]).into_array()),
545+
("b", PrimitiveArray::from_iter([4i32]).into_array()),
509546
])
510547
.unwrap()
511548
.into_array();
@@ -535,8 +572,6 @@ mod tests {
535572
#[test]
536573
fn test_or_kleene_validity() {
537574
let mut ctx = array_session().create_execution_ctx();
538-
use crate::IntoArray;
539-
use crate::arrays::BoolArray;
540575
use crate::arrays::StructArray;
541576
use crate::expr::col;
542577

@@ -630,14 +665,79 @@ mod tests {
630665
fn test_scalar_subtract_float_underflow_is_ok() {
631666
use vortex_buffer::buffer;
632667

633-
use crate::IntoArray;
634-
use crate::arrays::ConstantArray;
635-
636668
let values = buffer![f32::MIN, 2.0, 3.0].into_array();
637669
let rhs1 = ConstantArray::new(Scalar::from(1.0f32), 3).into_array();
638670
let _results = values.binary(rhs1, Operator::Sub).unwrap();
639671
let values = buffer![f32::MIN, 2.0, 3.0].into_array();
640672
let rhs2 = ConstantArray::new(Scalar::from(f32::MAX), 3).into_array();
641673
let _results = values.binary(rhs2, Operator::Sub).unwrap();
642674
}
675+
676+
#[test]
677+
fn test_and_reduce() -> VortexResult<()> {
678+
let mut ctx = array_session().create_execution_ctx();
679+
let left = BoolArray::from_iter([true, true, false]).into_array();
680+
let right = ConstantArray::new(true, 3).into_array();
681+
682+
let array = Binary::try_new(left.clone(), right.clone(), Operator::And)?
683+
.into_array()
684+
.optimize()?; // calls reduce()
685+
assert!(array.is::<Bool>()); // and(left, const) -> left
686+
assert_arrays_eq!(array, left, &mut ctx);
687+
688+
let array = Binary::try_new(right.clone(), left.clone(), Operator::And)?
689+
.into_array()
690+
.optimize()?;
691+
assert!(array.is::<Bool>()); // and(const, left) -> left
692+
assert_arrays_eq!(array, left, &mut ctx);
693+
694+
let array = Binary::try_new(left.clone(), right.clone(), Operator::Or)?
695+
.into_array()
696+
.optimize()?;
697+
assert_eq!(array.as_constant(), Some(true.into())); // or(left, const) -> true
698+
699+
let array = Binary::try_new(right.clone(), left, Operator::Or)?
700+
.into_array()
701+
.optimize()?;
702+
assert_eq!(array.as_constant(), Some(true.into())); // or(const, left) -> true
703+
704+
let array = Binary::try_new(right.clone(), right.clone(), Operator::Or)?
705+
.into_array()
706+
.optimize()?;
707+
assert_eq!(array.as_constant(), Some(true.into())); // or(const, const) -> const
708+
709+
let left_false = ConstantArray::new(false, 3).into_array();
710+
711+
let array = Binary::try_new(right.clone(), left_false.clone(), Operator::Or)?
712+
.into_array()
713+
.optimize()?;
714+
assert_eq!(array.as_constant(), Some(true.into()));
715+
let array = Binary::try_new(left_false, right, Operator::And)?
716+
.into_array()
717+
.optimize()?;
718+
assert_eq!(array.as_constant(), Some(false.into()));
719+
720+
Ok(())
721+
}
722+
723+
#[test]
724+
fn test_and_reduce_nullable() -> VortexResult<()> {
725+
let mut ctx = array_session().create_execution_ctx();
726+
let nullable_true =
727+
ConstantArray::new(Scalar::bool(true, Nullability::Nullable), 3).into_array();
728+
let right = BoolArray::from_iter([true, false, true]).into_array();
729+
730+
let array = Binary::try_new(nullable_true, right.clone(), Operator::And)?
731+
.into_array()
732+
.optimize()?;
733+
assert_arrays_eq!(array, right, &mut ctx);
734+
Ok(())
735+
}
736+
737+
#[test]
738+
fn test_and_reject_non_bool() {
739+
let lhs = ConstantArray::new(7i32, 3).into_array();
740+
let rhs = PrimitiveArray::from_iter([1i32, 2, 3]).into_array();
741+
assert!(Binary::try_new(lhs, rhs, Operator::And).is_err());
742+
}
643743
}

0 commit comments

Comments
 (0)