Repository navigation
fix: ignore zero and NaN sign bits in filters - #9628
jonasdedden wants to merge 11 commits into
Conversation
|
@Xuanwo, @LuciferYang @westonpace this is a somewhat larger PR, but it fixes two issues in one go. Would be great if it received some reviews! :) |
|
Fair point on read cost, so I benchmarked it: 4M Column-to-column
The normalizer copied both columns, and it got slower the more Scans, 2M rows
What remains is on one-sided How to reproduceSave this as cargo run --release -p lance --example float_filter_benchThe // Minimal benchmark for float filters (lance-format/lance#9628).
// Save as rust/lance/examples/float_filter_bench.rs, then run it on this branch
// and on its merge-base with main:
// cargo run --release -p lance --example float_filter_bench
use std::{sync::Arc, time::Instant};
use arrow_array::{
ArrayRef, Float64Array, RecordBatch, RecordBatchIterator, cast::AsArray, types::Float64Type,
};
use lance::{Dataset, index::DatasetIndexExt};
use lance_datafusion::planner::Planner;
use lance_index::{IndexType, scalar::ScalarIndexParams};
/// `n` values uniform in [-1, 1), with 1% `-0.0` and 0.05% `-NaN`.
fn column(n: usize, mut seed: u64) -> ArrayRef {
Arc::new(Float64Array::from_iter_values((0..n).map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let p = (seed >> 11) as f64 / (1u64 << 53) as f64;
match p {
p if p < 0.0005 => -f64::NAN,
p if p < 0.0105 => -0.0,
p => p * 2.0 - 1.0,
}
})))
}
fn batch(n: usize) -> RecordBatch {
RecordBatch::try_from_iter([("x", column(n, 1)), ("y", column(n, 2))]).unwrap()
}
/// Median of 15 runs, in milliseconds.
fn median_ms(mut f: impl FnMut()) -> f64 {
f();
let mut ms: Vec<f64> = (0..15)
.map(|_| {
let start = Instant::now();
f();
start.elapsed().as_secs_f64() * 1e3
})
.collect();
ms.sort_by(f64::total_cmp);
ms[7]
}
#[tokio::main]
async fn main() {
// Filter evaluation on one in-memory batch, relative to Arrow's plain
// total-order `x < y` kernel measured in the same process.
let data = batch(4_000_000);
let planner = Planner::new(data.schema());
let (x, y) = (
data["x"].as_primitive::<Float64Type>(),
data["y"].as_primitive::<Float64Type>(),
);
let reference = median_ms(|| drop(arrow_ord::cmp::lt(x, y).unwrap()));
println!("total-order x < y kernel: {reference:.2} ms");
for filter in ["x < y", "x = y", "x > 0.5", "x >= 0.1 AND x <= 0.2"] {
let expr = planner.parse_filter(filter).unwrap();
let physical = planner
.create_physical_expr(&planner.optimize_expr(expr).unwrap())
.unwrap();
let ms = median_ms(|| drop(physical.evaluate(&data).unwrap()));
println!("eval {filter:24} {ms:6.2} ms {:.2}x", ms / reference);
}
// Scans of an in-memory dataset with a BTree index on `x`.
let data = batch(2_000_000);
let reader = RecordBatchIterator::new([Ok(data.clone())], data.schema());
let mut dataset = Dataset::write(reader, "memory://", None).await.unwrap();
let params = ScalarIndexParams::for_builtin(IndexType::BTree.try_into().unwrap());
dataset
.create_index_builder(&["x"], IndexType::BTree, ¶ms)
.await
.unwrap();
for (filter, use_index) in [
("x < y", false),
("x > 0.5", true),
("x >= 0.1 AND x <= 0.2", true),
("x > 0.5", false),
] {
let mut ms = Vec::new();
for _ in 0..15 {
let start = Instant::now();
let mut scanner = dataset.scan();
scanner.filter(filter).unwrap().use_scalar_index(use_index);
scanner.count_rows().await.unwrap();
ms.push(start.elapsed().as_secs_f64() * 1e3);
}
ms.sort_by(f64::total_cmp);
let index = if use_index { "btree" } else { "no index" };
println!("scan {filter:24} {index:8} {:6.2} ms", ms[7]);
}
} |
Build both sign encodings with copysign, share one generic body for the NaN-normalizing UDF, merge the literal comparison rewrites, and treat any NaN-bearing zone as a candidate for a range without a lower bound in the zone map. Adds coverage for the UDF's scalar path and for returning the input array unchanged when it holds no negative NaN.
The NaN sign rewrite turned `x >= a` into `x >= a OR x < -inf`, so `x BETWEEN a AND b` could no longer be merged into one index range and searched every row above `a`. A lower bound already excludes negative NaNs, so when an AND chain also holds the upper bound's `x >= -inf` range, both extra ranges are dropped.
Extend the float sign normalization UDF to clear the sign bit of -0.0, lower array_has with a zero or NaN needle to array_has_any over both encodings, and register the UDF for Substrait decoding.
Dropping redundant negative-NaN guards unwrapped each lower bound before removing the guards, so an unwrapped `x >= -inf` was removed along with them. This hit explicit -inf lower bounds and every upper bound that the scanner optimizes again after a Substrait round trip.
Replace the sign-normalizing UDF with a fused comparison UDF that clears the sign bit of zeros and NaNs in registers while comparing, so column comparisons no longer copy either operand. Merge the duplicated operator lists and fold two tests into their parameterized siblings.
The negative-NaN range names the operand a second time, so a volatile operand such as random() was drawn twice and the two comparisons could disagree. Compare such operands through the sign-agnostic comparison UDF instead, as NaN bounds already are.
…g and narrow ranges Assert the rows of zero, NaN and range filters through the memtable B-tree path that now plans via the scalar expression pass, decode the comparison UDF through the aggregate Substrait consumer, and pin that a two-sided Float16 bound stays a single index range.
Indexing the operand slice inside the scalar loop kept a bounds check in it, which stopped most of the loop from vectorizing and left it branching on each value's sign. Bounding the slice first: a NaN bound over 4M unsorted f64 rows drops from 5.6 ms to 3.5 ms.
530132c to
9eb23ff
Compare
|
Pushed some changes, these include: Rebased on current
New fixes
New tests (d59b0f3), ported from #9324
|
There was a problem hiding this comment.
The follow-up fixes repeated evaluation of volatile operands. Local checks confirm signed-zero/NaN results through the shared memtable planner merged in #9804, narrow index ranges, and scalar/aggregate Substrait decoding. The earlier required-lower-bound fix remains intact; no blocking findings remain.
The accepted remaining cost is one-sided >/>=: an extra negative-NaN index search or comparison. The fused kernel avoids copying its operands. Reported benchmarks cover in-memory f64 workloads; performance-sensitive adopters should measure their own workloads.
Fixes #9315. Fixes #9316. Continues #9324 and supersedes #9323.
This builds on the bot PR #9324 and keeps its approach. On top of it, it simplifies the implementation, fixes an index regression the original PR introduced, and folds in the signed-zero fix from #9323 using the same mechanism.
NaN order in filters (#9315)
Arrow compares floats by total order. A NaN with its sign bit set sorts below
-inf, sox > 1.0missed negative NaNs andx < 1.0returned them. #9324 fixes this by rewriting comparisons:x > cbecomesx > c OR x < -inf(the range below-infholds exactly the negative NaNs),x < cbecomesx < c AND x >= -inf, and column-to-column comparisons clear the NaN sign with an internal UDF.Total order also keeps
-0.0and+0.0apart. #6236 fixed that for comparisons against a zero literal, buta = b,a < bandarray_has(l, 0.0)still compared by encoding (#9316).Signed zero (#9316)
Instead of adding a second UDF as #9323 does, column-to-column comparisons (and NaN bounds) go through one internal comparison UDF,
_lance_compare_floats(a, b, op), which ignores the sign of both zeros and NaNs. It replaces #9324's NaN normalizer and clears the sign bits in registers while comparing, so neither operand is copied. A column comparison costs about 1.2× the unfixed kernel; see the benchmark.array_has(l, v)for a zero or NaNvbecomesarray_has_any(l, [-v, +v]): one probe, so a volatile haystack is still evaluated once, and LabelList indices still apply. This covers NaN needles as well, which #9323 did not.Additional fix:
BETWEENlost its single index rangeWith #9324,
x BETWEEN a AND bbecomes(x >= a OR x < -inf) AND x <= b AND x >= -inf. The index optimizer can't merge ranges across anOR, so this ran three index searches, one of them for every row abovea.A lower bound already excludes negative NaNs. When an
ANDchain bounds the same operand from both sides, the addedx < -infandx >= -infcancel out, so the rewrite now drops both.BETWEENandx > a AND x < bare one index range again.NOT BETWEENand one-sided bounds are unchanged. The guards are dropped before the lower bounds are unwrapped, sox BETWEEN -inf AND bkeepsx >= -inf, also when the scanner re-optimizes a filter decoded from Substrait.Additional fix: volatile operands are evaluated once
The range form names its operand twice, so
random() < 0.5drew two values and the two comparisons could disagree. A volatile operand now goes through the comparison UDF instead, like a NaN bound, which evaluates it once. Such operands can't use an index anyway.Additional fix: Substrait round-trip of the internal UDF
The comparison UDF is part of the optimized filter, but the Substrait decoder resolves functions by name from the caller's
SessionState. A column-to-column float filter or a NaN range therefore failed to decode withUnsupported function name. Scalar and aggregate decoding now register the UDF in a copy of the caller's state, and round-trip tests with a plain DataFusion state pin both decoders.Differences to bot PRs
Additional infos respective to both previous bot PRs
Simplification
copysigninstead of bit maskstake_function_argsfor argument checksINlist for equality once-infrange. In the full query test suite, no other range with a missing lower bound ever reaches the zone map, so pruning is unchangedReview follow-up
The points raised by the bot reviews on #9324 still hold:
>and>=remains as accepted. Two-sided bounds no longer pay it.main.The points raised by the bot reviews on #9323 are covered too:
SessionContextstate.array_hasstays a single probe, is stable across repeated optimization, and is tested against LabelList-indexed scans with NULL items, empty lists and NULL lists, plus a Substrait round-trip.