Uh oh!
There was an error while loading. Please reload this page.
fix(datafusion): coerce numeric literals to and from Float16 - #8847
fix(datafusion): coerce numeric literals to and from Float16#8847LuciferYang wants to merge 9 commits into
Conversation
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
## Problem Fixes#5868. IEEE 754 and SQL treat `-0.0` and `0.0` as one number. Arrow and DataFusion 54 order floats by total order, which ranks `-0.0` strictly below `0.0`, and test equality on `to_bits()`. A filter over a float column therefore answers by encoding rather than by value. With this data: ``` value: +0.0 -0.0 +inf -inf NaN 1.0 -1.0 MIN MAX NULL id: 0 1 2 3 4 5 6 7 8 9 ``` `value < 0.0` returns ids 1, 3, 6, 7, and id 1 is `-0.0`, which is not less than zero. `value = 0.0` returns id 0 and misses id 1. ## Approach Rewrite the literal at the end of `Planner::optimize_expr`, replacing a zero with whichever encoding answers the operator correctly: | predicate | rewritten to | |---|---| | `value < 0.0`, `value >= 0.0` | compare against `-0.0` | | `value <= 0.0`, `value > 0.0` | compare against `+0.0` | | `value = 0.0` | `value IN (-0.0, 0.0)` | | `value != 0.0` | `value NOT IN (-0.0, 0.0)` | | `value IN (0.0, 1.0)` | `value IN (-0.0, 0.0, 1.0)` | | `0.0 IN (a, b)` | `a IN (-0.0, 0.0) OR b IN (-0.0, 0.0)` | | `value IS NOT DISTINCT FROM 0.0` | `(value IN (-0.0, 0.0)) IS TRUE` | | `value IS DISTINCT FROM 0.0` | `(value IN (-0.0, 0.0)) IS NOT TRUE` | The ordered comparisons stay a single literal, so they cost the same as before. An equality against zero becomes a two-value lookup. The rewrite needs no recheck and changes no index code: the scalar indices and the in-place filter both keep their existing behaviour, and they now agree with each other. `IS [NOT] DISTINCT FROM` is reachable from a hand-built `Expr` but not from SQL, which rejects it in `planner.rs`. The `IS TRUE` pairing is what keeps the null case decided while naming the operand once: `NULL IN (..)` is NULL, and `NULL IS TRUE` is false, which is what distinctness means for a null against a non-null literal. An earlier revision guarded that arm to a bare column so it could write `IS NOT NULL AND IN (..)`, which names the operand twice; bailing out on anything else left `filter_expr` answering computed operands like `value * 2.0` on Arrow's order, so the guard bought a rare double evaluation at the price of wrong rows. The rewrite runs in two places: `normalize_zero_comparisons` before simplification and `rewrite_signed_zero_comparisons` after it. Coercion has to precede both, or `value = 0` keeps an integer literal and never reaches the float arm. `normalize_zero_comparisons` exists because a rewrite that only runs on the finished expression cannot reach a comparison whose zero does not exist yet. `ExprSimplifier::simplify` folds an operand and everything above it in one pass, so `-1.0 * 0.0 < (1.0 - 1.0)` went straight to a boolean decided by Arrow's total order, answering `true` where IEEE says `false`. It walks bottom-up and folds only the operands of the node in hand, so by the time any container is folded, every comparison inside it already carries the corrected literal. That traversal deliberately does not enumerate which containers may sit above a comparison. An earlier revision listed `AND`, `OR` and `NOT`, which left `IS TRUE`, `IS FALSE`, `= TRUE`, `CAST(.. AS BOOLEAN)` and `IN (TRUE)` folding the inner comparison under the old semantics, and any such list would keep missing the next spelling. An operand that is already a literal skips the simplifier, because a literal cannot fold further and an `IN` list can hold hundreds of them. Without that, planning a large list roughly doubled in cost. Paired measurement, same binary with only the short-circuit toggled, 20 optimizations each: a 2048-element integer list went from 18.62ms to 6.89ms, a 256-element one from 2.10ms to 0.90ms, and a 257-element float list, which still has to be widened, from 2.34ms to 1.03ms. The pass after `simplify` catches what only becomes visible later: `simplify` is what expands `BETWEEN` into two comparisons and folds the casts coercion inserts. `folded_constant_comparisons_use_ieee_semantics` covers 25 shapes across both, and the `between` case of the fixed-point test covers the last. One consequence worth flagging for review: `BETWEEN` needed its own arm in the rewrite. It normally arrives already expanded into `>=` and `<=`, which is why it had none, but a fully constant `BETWEEN` never arrives expanded because `simplify` expands and folds it in the same pass. The arm gives `low` the `-0.0` encoding and `high` the `+0.0` one, matching what the expanded operators would take. The hook is `optimize_expr`, not `create_filter_plan`, because the latter is not the only entry point. `projection.rs` and the memtable scanner's `plan_full_scan` family call `optimize_expr` and then build a physical expression directly, so hooking the filter plan would let one predicate get two different answers inside a single result set. The output has to be a fixed point of `optimize_expr`, not of the rewrite alone. DataFusion's `ShortenInListSimplifier` expands an `IN` list of at most three elements over a bare column back into an `OR` chain, and the scan path optimizes twice, so the second pass can reprocess the rewrite's own output. That showed up as `value = 0.0` planning to `value IN ([-0,0]) OR value IN ([-0,0])` and searching the index twice. `rewrite_node` therefore dedupes equivalent `OR` and `AND` operands, and `optimizing_twice_changes_nothing` drives a real `Planner` rather than the rewrite by itself. That property is now load-bearing twice over: with two passes inside `optimize_expr` and the scan path calling it twice, a zero predicate goes through the rewrite four times. ## Behaviour change `value = 0` now matches rows holding `-0.0`, in Rust and through the Python and Java bindings. ## The test that pinned the bug `test_query_float_special_values` checked `value > 0.0`, `value < 0.0` and `value = 0.0` with `test_filter`, which compares Lance against the same DataFusion release. That makes the reference implementation the source of the bug, so the test passed on the wrong answer. Those three cases now assert row ids, and the case list now also covers both spellings of the literal, a literal on the left, `BETWEEN`, `IN`, and composition with `IS NULL`. `assert_filter_ids` runs every predicate twice, once through the dataset's index and once with scalar indices turned off, because `DatasetTestCases` never generates the no-index variant on its own. ## Out of scope None of these turn on a zero literal, so the rewrite leaves them where they are: - NaN ordering. Arrow sorts NaN above every value, so it survives `>` and `>=`. The new assertions pin this rather than change it. - Column against column comparisons, GROUP BY, DISTINCT, ordering, and the `array_has` family. There is no literal to rewrite. - merge_insert join keys. DataFusion 54 hashes join keys on raw bits, so the two zeros land in different buckets. `test_merge_insert_on_float_zero_key` now runs with and without an index to pin current behaviour. - Float16 columns. `safe_coerce_scalar` has no Float16 arm, so no numeric literal resolves against a Float16 column at all. Tracked in #8846 and fixed by #8847, which makes the Float16 branch of this rewrite reachable; it is unreachable dead code until that lands. - KNN `distance_range`. Its bounds do not pass through `Planner`. ## Test plan - `cargo test -p lance-datafusion` (142 passed), including 69 tests in the new `signed_zero` module - `cargo test -p lance-index` (1173 passed) - `cargo test -p lance --lib -- --test-threads=1` (3179 passed) - `cargo test -p lance --test integration_tests --features slow_tests -- --test-threads=1` (51 passed) - `cargo clippy --all --tests --benches -- -D warnings` clean, `cargo fmt --all` applied - All three lockfiles refreshed for the new `half` dependency in `lance-datafusion` --------- Co-authored-by: Xuanwo <github@xuanwo.io>
safe_coerce_scalar had no Float16 arm in either direction, so a Float16 column could not be filtered at all, and the index layer silently dropped the predicate to a refine filter.
Drive the index test through the production coercion order, pin the f16 boundary in bits, use the target-independent conversion, and stop the doc from claiming rounding is harmless.
The previous wording said the truncation was unreachable from a written literal. It is reachable: = 2049.001 matches rows holding 2048.
Four review rounds found a wrong clause in this one comment, each time in the sentence explaining the mechanism rather than the one stating it. The prose now stops at what it can defend and points at the cases.
from_f64 is correctly rounded on aarch64 with fp16, so the claim is only true of the const form.
Neither half entry point is correctly rounded: the software path truncates the low 32 mantissa bits before rounding (half-rs#151) and the x86 path goes through f32 (half-rs#116). Pick the true nearest among half's answer and its two neighbours, and take the overflow decision off half entirely.
The Float16 case was left out of test_query_float_special_values because literal coercion could not reach the type. This PR supplies that arm, so the case runs now.
6e0ed01 to
b229edbCompare
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
LuciferYang
commented
Sep 1, 2026
cc @Xuanwo FYI |
This comment was marked as outdated.
This comment was marked as outdated.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
LuciferYang
commented
Sep 2, 2026
The red |
Problem
Fixes#8846.
A
Float16scalar column can be written and scanned, but no numeric literal ever reaches it, so it cannot be filtered at all:safe_coerce_scalarhad noFloat16arm in either direction, and the second direction fails silently. Adding theDataType::Float16targets alone makes filters work, butmaybe_scalarthen callssafe_coerce_scalaron a literal the planner has already coerced toFloat16, so without aScalarValue::Float16source arm the predicate drops to a refine filter. Rows come back correct, so only the plan shows it. On a Float16 column with a BTree index, at 6 rows and at 5000:A
Float32column of the same shape and query already planned the second form, which is the control that says this is a Float16 gap and not a row-count heuristic.What this changes
DataType::Float16is now a target in all ten numeric source arms, andScalarValue::Float16is a new source arm reaching Float16, Float32 and Float64.Out-of-range literals are rejected, not saturated. This differs from the
Float64toFloat32arm next to it.f16overflows at 65520, which an ordinary literal passes, andsafe_coerce_scalarcannot see the operator, so it cannot saturate only where saturation is harmless: collapsing1e-30to zero would makevalue = 1e-30match real zeros. The cost is thatvalue < 100000now errors where saturating would have returned every finite row. I took the error over the wrong row set. TheFloat32arm is left alone because reaching its boundary takes a literal above 1e38.Rounding is done by neighbour comparison, because
halfrounds incorrectly in both of its paths: the software one truncates the low 32 bits of thef64mantissa before rounding (half-rs#151), and the x86 hardware one rounds throughf32first (half-rs#116).nearest_finite_f16starts from the software answer, which at least does not vary by target, then picks the true nearest among it and its two bit-neighbours, tie to even. Wideningf16back tof64is exact, so those three comparisons decide it.= 2049.001now coerces to 2050 wherehalfgives 2048.Overflow is decided by
value.abs() >= 65520.0rather than byhalf. 65520 is the exact IEEE tie above the largest finitef16and is exactly representable inf64, so the check does not inherit the platform-dependent boundary.The
f16grid itself stays inexact: past 2048 it is coarser than the integers, so= 2049matches rows holding 2048, the same way theFloat32andFloat64arms are inexact on finer grids.Out of scope
IndexType::BloomFilterrejectsFloat16outright, so the Float16 cases oftest_query_floatandtest_query_float_special_valuesrun BTree, Bitmap and ZoneMap only.test_bloom_filter_rejects_float16pins that refusal so the skip cannot outlive it.Test plan
cargo test -p lance-datafusion(176 and 5 passed). 24 new cases oversafe_coerce_scalar: every numeric literal type reaching Float16, the range and tie edges pinned as raw bits, non-finite passthrough, both zeros keeping their sign.test_f16_rounds_to_nearest_even_across_the_whole_rangeis the one to read: for each of the 31743 adjacent finitef16pairs it asserts the midpoint and the twof64values either side, with expectations stated rather than recomputed by the code under test. Stubbing the correction back out tofrom_f64_constmakes it fail.cargo test -p lance-index(1187 and 9 passed).test_float16_column_reaches_its_indexdrivesPlanner::parse_filterandcreate_filter_planso it runs the production coercion order; deleting theScalarValue::Float16source arm turns all four cases red. Built oncheck_with_schemait would not have, because that helper skips type coercion and so exercises the target arm instead.cargo test -p lance --lib(3300 passed)cargo test -p lance --test integration_tests --features slow_tests -- --test-threads=1(54 passed).--features slow_testsis required:mod queryis behind that gate, so without it the new tests match nothing and cargo still exits 0. The Float16 case oftest_query_float_special_valuesis new; fix: make float filters treat -0.0 and 0.0 as the same value #6236 already carried the Float16 branch of its signed-zero rewrite, and this is the first coverage that reaches it. Revertingexpr.rsto main makes that case fail with the original coercion error while Float32 and Float64 stay green.cargo clippy --all --tests --benches --features slow_tests -- -D warningsclean,cargo fmt --all -- --checkclean