feat(vortex-geo): null-propagating scalar functions#8803
feat(vortex-geo): null-propagating scalar functions#8803HarukiMoriarty wants to merge 4 commits into
Conversation
Make the binary geo scalar functions (ST_Distance, ST_Intersects, ST_Contains) null-propagating, matching SQL/OGC semantics and the other Vortex binary kernels: a nullable geometry operand is now allowed, and any row whose geometry input is null yields a null result (output nullable iff an operand is nullable). A geo kernel decodes each operand into a geo_types geometry, and a null row has no geometry to decode, so it can't compute over every row and mask the nulls afterwards the way numeric kernels do. The shared execute_null_propagating dispatch in scalar_fn/mod.rs instead filters the null rows out up front, computes over the rows valid in both operands, and scatters the results back under the combined null mask. - validate_geometry_operands no longer rejects nullable operands - return_dtype mirrors operand nullability - tests for nullable columns, constant-null operands, and column/column nulls Signed-off-by: Nemo Yu <zyu379@wisc.edu>
Polar Signals Profiling ResultsLatest Run
Previous Runs (4)
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 queriesVerdict: No clear signal (low confidence) How to read Verdict and Engines
datafusion / vortex-file-compressed (1.043x ➖, 0↑ 0↓)
datafusion / parquet (0.973x ➖, 0↑ 0↓)
duckdb / vortex-file-compressed (1.066x ➖, 0↑ 0↓)
duckdb / parquet (1.019x ➖, 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 —
|
- Add validity() + is_null_sensitive()=false vtable overrides to the three geo kernels so the planner can derive the output null mask symbolically (mask pushdown), matching vortex-tensor's binary functions. Shared via a binary_result_validity helper. is_fallible is left at the default since geometry decoding can genuinely fail. - Cover the two-column paths for the asymmetric ST_Contains: nulls on either side, plus the all-null-column and empty-combined-mask early returns. - Drop the unreachable AllOr::None arm in build_array; callers short-circuit the fully-null case to all_null_array. - Document that operands may differ in geometry type, and that the nullable test column's validity mirrors its storage. Signed-off-by: Nemo Yu <zyu379@wisc.edu>
9a50270 to
0716c2c
Compare
| pub(crate) fn binary_result_validity(expression: &Expression) -> VortexResult<Option<Expression>> { | ||
| Ok(Some(and( | ||
| expression.child(0).validity()?, | ||
| expression.child(1).validity()?, | ||
| ))) | ||
| } |
There was a problem hiding this comment.
I feel like this would be a good helper function for all of our arrays to make use of. I bet there is a lot of duplication in our vtable impls in general.
|
@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
Address PR review round 2: - Move the shared null-propagating dispatch out of scalar_fn/mod.rs into scalar_fn/null_propagate.rs, keeping mod.rs to module-tree wiring. - Bring ST_Distance and ST_Intersects null-path coverage to parity with ST_Contains: all-null-column and empty-combined-mask early returns, plus the two-column path for ST_Intersects. Signed-off-by: Nemo Yu <zyu379@wisc.edu>
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.
Promote the "result is null iff any operand is null" validity — previously geo's binary_result_validity plus four inline copies in vortex-tensor — into a shared helper, vortex_array::expr::null_propagating_validity. It conjoins every child's validity, so it covers unary (l2_norm) and binary kernels alike. - vortex-array: add expr::null_propagating_validity - vortex-geo: GeoDistance / GeoContains / GeoIntersects use it; drop the local binary_result_validity - vortex-tensor: InnerProduct / CosineSimilarity / L2Denorm / L2Norm use it instead of inlining and(child validities) Signed-off-by: Nemo Yu <zyu379@wisc.edu>
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.