fix(datafusion): treat signed zeros as equal in array_has - #9326
jonasdedden wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
The signed-zero correction is sound for stable haystacks, but the current OR lowering violates DataFusion’s volatile-expression contract by evaluating the haystack twice and can silently drop rows. Preserve one haystack evaluation; a single array_has_any(haystack, [-0.0, 0.0]) probe verified the intended semantics, or the equality kernel can normalize signed zeros directly.
| let matches_positive = Expr::ScalarFunction(ScalarFunction { | ||
| func: func.func.clone(), | ||
| args: vec![ | ||
| haystack.clone(), |
There was a problem hiding this comment.
Cloning the haystack into both OR arms changes the semantics of supported volatile expressions: DataFusion permits a Volatility::Volatile function to return a different value on each evaluation. I ran a volatile list UDF that returns [+0.0] on its first call and [-0.0] on its second. Optimizing array_has(volatile_zeros(), 0.0) produced the two calls shown below and returned false for all 10,000 rows even though every UDF result contained a zero. Preserve a single haystack evaluation—for example, lower this to one array_has_any(haystack, [-0.0, 0.0]) probe (the same experiment then made one call and returned all 10,000 rows), or normalize equality in the kernel.
Reproducer run on this head
let schema = Arc::new(Schema::empty());
let planner = Planner::new(schema.clone());
let calls = Arc::new(AtomicUsize::new(0));
let calls_in_udf = Arc::clone(&calls);
let list_type = DataType::List(Arc::new(Field::new("item", DataType::Float64, true)));
let volatile_zeros = create_udf(
"volatile_zeros",
vec![],
list_type,
Volatility::Volatile,
Arc::new(move |_| {
let value = if calls_in_udf.fetch_add(1, Ordering::SeqCst) % 2 == 0 {
0.0
} else {
-0.0
};
let list = ListArray::from_iter_primitive::<Float64Type, _, _>(
[Some(vec![Some(value)])],
);
Ok(ColumnarValue::Scalar(ScalarValue::List(Arc::new(list))))
}),
);
let optimized = planner.optimize_expr(array_has(volatile_zeros.call(vec![]), lit(0.0)))?;
let batch = RecordBatch::try_new_with_options(
schema,
vec![],
&RecordBatchOptions::new().with_row_count(Some(10_000)),
)?;
let values = planner
.create_physical_expr(&optimized)?
.evaluate(&batch)?
.into_array(10_000)?;
let values = values.as_any().downcast_ref::<BooleanArray>().unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1); // observed: 2
assert_eq!(values.true_count(), 10_000); // observed: 0Observed optimized expression:
array_has(volatile_zeros(), Float64(-0)) OR array_has(volatile_zeros(), Float64(0))
calls=2 true=0 false=10000
|
Closing in favor of #9323. |
Problem
#6236 makes filters treat
-0.0and0.0as one value when one side is a zero literal, butarray_hasstill compares by encoding (second part of #9316):array_hasuses Arrow's total-ordereqkernel (compare_with_eq), so the two encodings compare unequal. This affectsFloat32/Float64(andFloat16via the same helper), nested fields (s.x), computed haystacks (array_append(l, 1.0)), computed needles (-1.0 * 0.0), and integer needles against float lists (which coerce to float). Aliaseslist_has/array_contains/list_containsresolve to the same kernel.Chosen rule
Extend the existing
signed_zeroplanner rewrite, the same way0.0 IN (a, b)is already handled:Float16/32/64, either encoding). Integer, NULL, NaN, and non-literal needles are left alone.optimize_exprdeduplicates rather than growing (same fixed-point pattern as the existingIN-list dedup).normalize_zero_comparisonsfoldsarray_hasoperands first, so computed zeros (-1.0 * 0.0,1.0 - 1.0) present as literals before the rewrite; otherwise the simplifier would fold the whole probe by total order first.NULLsemantics preserved:array_has(NULL, 0.0)rewrites toNULL OR NULL=NULL; a null element never matches, soORof two falses stays false.Alternatives considered
array_has/eqkernel to IEEE equality): would also fix column needles (array_has(l, m[0])), but that is the column-to-column case owned separately, and it diverges from fix: make float filters treat -0.0 and 0.0 as the same value #6236's planner-rewrite approach which deliberately preserves bit-pattern index keying.array_hasnever uses a scalar index anyway (BTREE/BITMAP reject list columns; plans stay as plain filters), so theORkeeps the same plan shape.ORis the only uniform spelling.array_has_any/array_has_all: left out (see limitations).Compatibility
array_has(int_list, 0)never presents as a floating zero post-coercion, so it stays a single probe.array_has(float_list, 0)coerces0to float and correctly becomes theOR.array_hasstays a plain filter (full_filter/refine_filter); no new index path. Verified list columns cannot take BTREE/BITMAP indices.Test results
cargo test -p lance-datafusion --lib: 189 passed (76signed_zero, including 7 newarray_hastests).cargo clippy -p lance-datafusion --lib: clean.cargo fmt --check -p lance-datafusion: clean.pylance 13.0.0b4(before fix):array_has(l, 0.0)->[1, 5],array_has(l, -0.0)->[0, 5]; the rewrite outputarray_has(l, -0.0) OR array_has(l, 0.0)->[0, 1, 5](correct) forFloat64/Float32/nested,NOT (...)correctly excludes the null-list row, non-zero needles unchanged. This validates the emitted form on the current engine; the new unit tests validate the planner emits exactly that form (Float64/32 via coercion, nested/computed/aliases, folded needles, idempotent re-optimization).Remaining limitations
-0.0 = 0.0is separate).array_has(l, m)) stay sign-sensitive; owned by the column-column fix.array_has_any/array_has_allwith zero-containing needle lists are not rewritten.ANYcould widen its needle list;ALLneeds anOR-per-zero expansion and is not a simple widening. Left as follow-up.