fix: treat -0.0 and 0.0 as equal between float columns - #9332
jonasdedden wants to merge 1 commit into
Conversation
Comparisons between two non-literal float values still compare by encoding: a = b misses opposite-zero pairs and a < b admits -0.0 < 0.0. The lance-format#6236 literal rewrite cannot reach them because there is no literal to retarget. Rewrite each float-float comparison to name both encodings on each side: =, <=, >= gain OR (a IN (-0,0) AND b IN (-0,0)); !=, <, > gain AND NOT (...); IS [NOT] DISTINCT FROM uses IS [NOT] TRUE so NULL rows stay decided. Each side lists its own Float16/32/64 zeros; non-floats and literal sides are left to the existing arms. BETWEEN needs no arm: the simplifier expands non-constant bounds before this runs. Part of lance-format#9316; array_has is a separate fix.
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
The signed-zero correction is sound for stable operands, but the rewrite must not evaluate a computed operand more than once. Preserve single-evaluation semantics by normalizing each operand once, or leave volatile expressions on the pre-existing path; the current cloning changes supported filter results.
| } | ||
| let left_type = float_type_of(left, df_schema)?; | ||
| let right_type = float_type_of(right, df_schema)?; | ||
| let left_in = zero_in_list_for((**left).clone(), &left_type)?; |
There was a problem hiding this comment.
Cloning each computed operand into the zero guard evaluates volatile expressions a second time, so the guard can describe a different value from the comparison and change existing query results. Lance supports random() in filters: with one million z = +0.0 rows, the regression below selected 750,540 rows on this head; one evaluation of round(random()) should match about half. The optimized expression contains two independent round(random()) calls. Please either preserve single evaluation (for example, normalize each operand once) or decline this rewrite for expressions containing volatile functions, and cover the case.
Reproducer
#[test]
fn volatile_operand_is_evaluated_once() {
use std::sync::Arc;
use arrow_array::{ArrayRef, BooleanArray, Float64Array, RecordBatch};
let row_count = 1_000_000;
let batch = RecordBatch::try_from_iter(vec![(
"z",
Arc::new(Float64Array::from(vec![0.0; row_count])) as ArrayRef,
)])
.unwrap();
let planner = crate::planner::Planner::new(batch.schema());
let optimized = planner
.optimize_expr(planner.parse_filter("round(random()) = z").unwrap())
.unwrap();
let values = planner
.create_physical_expr(&optimized)
.unwrap()
.evaluate(&batch)
.unwrap()
.into_array(row_count)
.unwrap();
let matches = values
.as_any()
.downcast_ref::<BooleanArray>()
.unwrap()
.iter()
.filter(|value| *value == Some(true))
.count();
assert!(
(450_000..=550_000).contains(&matches),
"matches={matches}, expression={optimized}"
);
}cargo test -p lance-datafusion --lib volatile_operand_is_evaluated_once -- --nocapture
Observed: matches=750540, expression=round(random()) = z OR round(random()) IN (...) AND z IN (...).
|
Closing in favor of #9323. |
Drop the test asserting the scalar index is not used and the asserts pinning known float total-order behavior (lance-format#9332, lance-format#9315); cover integers, strings and both zero spellings in the existing query tests instead.
Problem
Part of #9316. #6236 (fixing #5868) rewrites a zero literal to the encoding that answers correctly, so
a = 0.0matches both zeros. Comparisons between two float values have no literal to retarget and still compare by encoding:Full truth table on
main(pylance 13.0.0b4):a = bmisses both opposite-zero pairs,a != badmits them,a < badmits-0 < +0,a > badmits+0 > -0,a <= bmisses+0 <= -0,a >= bmisses-0 >= +0. Nested fields (s.x = s.y) and computed operands (a * 1.0 = b) fail the same way.Chosen rule
Rewrite each comparison between two non-literal float values to name both zero encodings on each side, in each side's own float width:
a = b,a <= b,a >= b(pred) OR (a IN (-0, 0) AND b IN (-0, 0))a != b,a < b,a > b(pred) AND NOT (a IN (-0, 0) AND b IN (-0, 0))a IS NOT DISTINCT FROM b(pred) OR ((both zero) IS TRUE)a IS DISTINCT FROM b(pred) AND ((both zero) IS NOT TRUE)Float-ness comes from
Expr::get_type(&DFSchema), so bare columns, struct fields,array_element, casts, and arithmetic all participate when they infer toFloat16/Float32/Float64. Non-float pairs and any comparison with a literal side are declined (literals belong to the existing arms).NULLrows stayNULLfor the six value operators (NULL IN ..isNULL); the distinctness pair stays decided throughIS [NOT] TRUE.BETWEENneeds no arm: the simplifier expands non-constant bounds into>=/<=before this runs. TheOR/ANDdedup now also covers the emittedboth-zeroshapes, so the output remains a fixed point ofoptimize_expr(the scan path optimizes twice).Alternatives considered
total_cmp, bloom hashes bits). Rejected as invasive for this issue.a = -0 AND b = +0): needs bit-exact tests that the existingIN (-0, 0)spelling cannot express after fix: make float filters treat -0.0 and 0.0 as the same value #6236 (it matches both encodings). The broaderboth zeroguard gives the same truth table for all six operators with the existing index-friendlyINshape, so the simpler rule wins.BETWEEN-with-column-bounds arm: unnecessary; only fully constantBETWEENsurvives unexpanded, and that arm already exists.Compatibility
signed_zerois crate-private;Planner::optimize_exprsignature unchanged).NULLinputs still filter toNULL(distinctness stays decided), non-float comparisons byte-identical.explain_planshows noScalarIndexQuery); single-column zero-literal index pushdown is unchanged and still asserted.Test plan
cargo test -p lance-datafusion --lib: 199 passed (86 insigned_zero, including 6 new column shape/idempotence/width/computed cases).cargo test -p lance --test integration_tests --features slow_tests query::primitives::test_query_float*: 10 passed across Float16/32/64 — existingtest_query_float_special_valuesplus newtest_query_float_column_zero_comparison(12 rows: ±0 pairs both directions and same-encoding, ±1, ±inf, null/null, null/zero, zero/null, 1/-1; bare, swapped, struct-field, and* 1.0computed forms;NOTvariants; indexed via BTree/Bitmap/ZoneMap plus unindexed viaassert_filter_ids, with a pinned no-ScalarIndexQueryplan check) andtest_query_float_column_zero_with_nans_pinned(same-NaN equal, opposite-sign NaN ordered, zeros fixed alongside).cargo test -p lance --test integration_tests --features slow_tests query::primitives::test_float_zero_predicate_uses_scalar_index: passed (literal index use preserved).cargo test -p lance-index --lib scalar: 1084 passed.cargo clippy --all --tests --benches -- -D warningsclean,cargo fmt --all --checkclean.Limitations / follow-up
array_has(list, 0.0)is intentionally untouched here; it is the sibling half of bug: -0.0 and 0.0 still compare unequal between columns and in array_has #9316.a IN (b, ...)with column list elements still compares by encoding.GROUP BY/DISTINCT/ordering,merge_insertjoin keys (DataFusion hashes bits), and KNNdistance_rangeare unchanged.IN), so they evaluate twice; avoiding that needs planner-level CSE, not a local rewrite.