feat(vortex-geo): null-propagating scalar functions#8803
feat(vortex-geo): null-propagating scalar functions#8803HarukiMoriarty wants to merge 4 commits into
Conversation
Polar Signals Profiling ResultsLatest Run
Previous Runs (8)
Powered by Polar Signals Cloud |
Merging this PR will improve performance by 11.17%
Performance Changes
Tip Curious why this is faster? Comment Comparing Footnotes
|
Benchmarks: Vortex queries 📖Verdict: Likely regression (medium confidence) How to read Verdict and Engines
datafusion / vortex-file-compressed (1.057x ➖, 0↑ 0↓)
datafusion / parquet (0.999x ➖, 0↑ 0↓)
duckdb / vortex-file-compressed (1.261x ❌, 0↑ 2↓)
duckdb / parquet (1.025x ➖, 0↑ 0↓)
No file size changes detected. |
|
@claude review this PR, and review it also with the context of |
|
Claude finished @connortsui20's task in 3m 58s —— View job Claude PR Review —
|
9a50270 to
0716c2c
Compare
|
@claude review again, again check for consistency within vortex-geo and between vortex-tensor, and generally look for null semantics bugs |
|
Claude finished @connortsui20's task in 3m 16s —— View job Claude PR Review — round 2 (consistency + null semantics)Re-reviewed the updated diff against
Verdict: The round-1 feedback landed cleanly — Null-semantics audit (no bugs found)Traced every path in
1. Test-coverage inconsistency —
|
| null path | distance |
contains |
intersects |
|---|---|---|---|
| col-vs-const null rows | ✅ | ✅ | ✅ |
| constant-null → all null | ✅ | ✅ | ✅ |
two-col pair nulls (eval_column_pair) |
✅ | ✅ | ❌ |
all-null column (valid.all_false() in eval_column) |
❌ | ✅ | ❌ |
two-col all-null (valid.all_false() in eval_column_pair) |
❌ | ✅ | ❌ |
ST_Intersects never runs eval_column_pair with nulls at all — the entire two-nullable-column path is untested for it. Since intersection is symmetric it's cheap to add, and it's the highest-value gap. Adding the all-false early-return tests to distance/intersects (mirroring contains_all_null_column_is_all_null / contains_column_pair_all_null) would bring all three to parity. Fix this →
2. is_fallible divergence from vortex-tensor is correct — leave as-is
vortex-tensor sets is_fallible = false; the geo functions keep the true default. This is the right call and should not be copied: geometry decoding (geometries → WKB/geoarrow decode) can genuinely fail on malformed storage, whereas the tensor math kernels cannot. Flagging only to confirm the asymmetry is intentional, not an oversight.
3. Minor — binary_result_validity and the dead AllOr::None arms
- The shared
binary_result_validityhelper is a nice consolidation, and @connortsui20's inline note (mod.rs:142) about promoting it to a general array-level helper is a reasonable follow-up — many vtablevalidity()impls compute exactlyand(child validities). Out of scope for this PR, but worth a tracking issue. - The
unreachable!on theAllOr::Nonearms in bothbuild_arrayimpls is now clearly documented as guarded by theall_false()short-circuits — good. The earlierBitBuffer::from_iter(vec![false; len])concern is gone since that arm no longer allocates. ✅
Consistency with vortex-tensor — summary
| aspect | tensor | geo (this PR) | consistent? |
|---|---|---|---|
return_dtype nullability |
any operand nullable |
any operand nullable |
✅ |
validity() |
and(child0, child1) |
and(child0, child1) via helper |
✅ |
is_null_sensitive |
false |
false |
✅ |
is_fallible |
false |
true (default) |
✅ justified (decode can fail) |
| operand type check | eq_ignore_nullability (same type) |
each is some geometry | ✅ justified (cross-type geo ops are valid) |
I ran no build/test commands (read-only review); the coverage table was derived by reading the test modules. Nice, well-documented change overall.
· branch nemo/geo-null-propagation
connortsui20
left a comment
There was a problem hiding this comment.
Must fix
1. A null geometry literal now breaks zone pruning. Before this PR a nullable operand was rejected at type-check, so the prune rules never saw a null literal. Now ST_Intersects(geom, NULL) executes fine (all-null result, tested), but query_aabb (prune/mod.rs:84-89) calls single_geometry on the constant with no is_null check, hits "geo: null geometry is not supported", and the error propagates out of the stats rewrite driver (vortex-array/src/stats/rewrite.rs:148), failing the whole rewrite. Minimal fix: decline on scalar.is_null() (return Ok(None)); optimal: rewrite to lit(true) since an all-NULL predicate matches nothing — cf. how the null radius literal is already declined in prune/distance.rs:68-73. Needs a prune test either way.
2. Zero-length inputs with non-nullable operands produce the wrong dtype. Mask::AllTrue(0) has true_count() == 0, so all_false() is vacuously true, and eval_column / eval_column_pair check all_false() before all_true() (scalar_fn/mod.rs:220-223, 252-254). A len-0 non-nullable execution therefore returns the Nullable all_null_array while return_dtype declared NonNullable — tripping the debug-build result-dtype assertion (vortex-array/src/scalar_fn/typed.rs:149-158), silently mismatched in release. Untested; worth a test.
Suggested simplification (also fixes bug 2)
GeoOutput can drop from 3 methods to 2: make build_array handle AllOr::None by returning an all-invalid array instead of unreachable! (scalar_fn/mod.rs:87, 122). Then null_dtype(), all_null_array, and the two all_false() early-returns all become unnecessary — the const-null paths can route through T::build_array(len, &Mask::new_false(len), vec![], Nullable). That removes the panic and its cross-function invariant, and the len-0 case falls out correct for free (AllTrue(0) → indices() is All → correctly-typed empty array).
Also minor: the if q.scalar().is_null() { return all_null } block is repeated 3× in execute_null_propagating (mod.rs:170-191) — one loop over both operands before the match does it once.
Worth a note in this PR
Nullable columns now silently degrade AABB pruning: GeometryAabb::accumulate reads the unmasked coordinate buffers (aggregate_fn/aabb.rs:207-216), so null-row placeholder coords (e.g. (0,0)) get unioned into the zone box. Safe direction — only ever under-prunes — but a placeholder at the origin drags a distant chunk's box across the plane. Deserves a comment at accumulate, and there's no test covering a nullable column through the aggregate or pruning end-to-end.
|
| let valid = array.validity()?.execute_mask(array.len(), ctx)?; | ||
| let array = if valid.all_true() { | ||
| array | ||
| } else { | ||
| array.filter(valid)? | ||
| }; |
There was a problem hiding this comment.
I remember (maybe incorrectly) that it used to be the case you didnt need this if statement, .filter should do this check for you. @joseph-isaacs is this still true? tracing through the code its hard for me to tell
There was a problem hiding this comment.
Confirmed. .filter collapses an all-true mask: Filter's reduce rule (filter/rules.rs:43) returns the child, and ArrayRef::filter runs .optimize(). So the if all_true guard was redundant; removed it here.
| // Drop null rows before reading coordinates: a null geometry's storage holds placeholder | ||
| // coordinates (e.g. `(0, 0)`) that would otherwise widen the zone box and drag it toward | ||
| // the origin. |
There was a problem hiding this comment.
so do any of the functions below need a precondition that it probably shouldnt take nulls? I also think that converting nulls to (0, 0) might be a footgun in general
There was a problem hiding this comment.
Yes, both functions have an implicit "no nulls" precondition, now documented. I think we should never converts nulls to (0, 0).
| .execute::<PrimitiveArray>(ctx)?; | ||
| if let Some(rect) = aabb_of(xs.as_slice::<f64>(), ys.as_slice::<f64>()) { |
There was a problem hiding this comment.
Basically it would be good to add some comments here explaining that because nulls have been removed we are fine to use all the values.
But that brings up a separate question: did you look at the perf of computing this merge on very sparsely null data? for example, if there is only a single null value, it would be way faster to not execute the filter above and instead just merge if the value is not null.
There was a problem hiding this comment.
Commented. I think it is fine, it's uniform across all geometry types, and the hot/common path (non-nullable) already costs nothing. The sparse-null compaction is an acceptable tradeoff for a write-time stat; if profiling ever flags nullable-geometry stat writes, revisit with a coordinate-validity-aware min/max.
Add expr::union_child_validities — the conjunction of an expression's child validities (result null iff any operand is null), the common ScalarFnVTable validity() for null-propagating kernels. Migrate vortex-tensor's four scalar functions (InnerProduct, CosineSimilarity, L2Denorm, L2Norm) onto it, replacing their inline and(child validities). Signed-off-by: Nemo Yu <zyu379@wisc.edu>
Make the binary geo scalar functions (ST_Distance, ST_Intersects, ST_Contains) null-propagating: a nullable geometry operand is allowed, and any row whose geometry input is null yields a null result (null in -> null out), matching SQL/OGC and the other Vortex binary kernels. - validate_geometry_operands no longer rejects nullable operands - return_dtype mirrors operand nullability; validity() uses the shared vortex_array::expr::union_child_validities (#8829); is_null_sensitive = false - shared execute module filters null rows before decoding, computes over the rows valid in both operands, and scatters results back under the combined mask - prune: query_aabb declines a null geometry literal instead of erroring - GeometryAabb::accumulate skips null rows so placeholders don't widen the box - tests for nullable columns, constant-null, column/column, empty input Signed-off-by: Nemo Yu <zyu379@wisc.edu>
cb2af19 to
45f0a48
Compare
ArrayRef::filter collapses an all-true mask back to the input (Filter's reduce rule + optimize), so the if valid.all_true() guards in GeometryAabb::accumulate and the execute helpers were redundant. Filter unconditionally; all-valid inputs pass through unchanged. Signed-off-by: Nemo Yu <zyu379@wisc.edu>
connortsui20
left a comment
There was a problem hiding this comment.
I think this looks good, but I would like if someone else has eyes on this as well. @joseph-isaacs?
What
Make the binary geo scalar functions (
ST_Distance,ST_Intersects,ST_Contains) null-propagating. A nullable geometry operand is now allowed, and any row whose geometry input is null yields aNULLresult.Why
Spiral needs the geo predicates to accept nullable inputs to run SpatialBench.
How
A geo kernel decodes each operand into a
geo_typesgeometry, and a null row has no geometry to decode, so it can't compute over every row and mask the nulls afterwards the way the numeric kernels do.Instead, the shared
execute_null_propagatingdispatch drops the null rows, computes only the rows valid in both operands, and scatters the results back under the combined null mask.return_dtypeis nullable iff an operand is, andvalidate_geometry_operandsno longer rejects nullable operands.