Skip to content

feat(vortex-geo): null-propagating scalar functions#8803

Merged
HarukiMoriarty merged 5 commits into
developfrom
nemo/geo-null-propagation
Jul 20, 2026
Merged

feat(vortex-geo): null-propagating scalar functions#8803
HarukiMoriarty merged 5 commits into
developfrom
nemo/geo-null-propagation

Conversation

@HarukiMoriarty

@HarukiMoriarty HarukiMoriarty commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

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 a NULL result.

Why

Spiral needs the geo predicates to accept nullable inputs to run SpatialBench.

How

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 the numeric kernels do.

Instead, the shared execute_null_propagating dispatch drops the null rows, computes only the rows valid in both operands, and scatters the results back under the combined null mask. return_dtype is nullable iff an operand is, and validate_geometry_operands no longer rejects nullable operands.

@HarukiMoriarty HarukiMoriarty added the changelog/feature A new feature label Jul 16, 2026
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Polar Signals Profiling Results

Latest Run

Status Commit Job Attempt Link
🟢 Done 57a1eb3 1 Explore Profiling Data
Previous Runs (9)
Status Commit Job Attempt Link
🟢 Done 8fe6c0e 1 Explore Profiling Data
🟢 Done eea991e 1 Explore Profiling Data
🟢 Done cb2af19 1 Explore Profiling Data
🟢 Done 6ff78a8 1 Explore Profiling Data
🟢 Done 610a8c3 1 Explore Profiling Data
🟢 Done 9ad9376 1 Explore Profiling Data
🟢 Done 0716c2c 1 Explore Profiling Data
🟢 Done 9a50270 1 Explore Profiling Data
🟢 Done 90e4548 1 Explore Profiling Data

Powered by Polar Signals Cloud

@codspeed-hq

codspeed-hq Bot commented Jul 16, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 11.17%

⚡ 1 improved benchmark
✅ 1669 untouched benchmarks
⏩ 44 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation true_count_vortex_buffer[128] 580.6 ns 522.2 ns +11.17%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing nemo/geo-null-propagation (57a1eb3) with develop (00cb75d)2

Open in CodSpeed

Footnotes

  1. 44 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on develop (05c7c2f) during the generation of this report, so 00cb75d was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Benchmarks: Vortex queries 📖

Verdict: No clear signal (low confidence)
Attributed Vortex impact: +3.6%
Engines: DataFusion No clear signal (+7.4%, low confidence) · DuckDB No clear signal (+0.1%, low confidence)
Vortex (geomean): 1.005x ➖
Parquet (geomean): 0.987x ➖
Shifts: Parquet (control) -1.3% · Median polish -0.4%

How to read Verdict and Engines
  • Verdict: Overall PR-level signal after subtracting baseline drift estimated from Parquet control rows. It can be Likely improvement, Likely regression, or No clear signal.
  • Engines: Per-engine attribution. DataFusion is compared against DataFusion/Parquet controls; DuckDB is compared against DuckDB/Parquet controls. This answers whether each engine improved or regressed independently.
  • Confidence: Based on directional consistency, share of rows above the noise floor, and control-run noise.

datafusion / vortex-file-compressed (1.050x ➖, 0↑ 0↓)
name PR 57a1eb3 (ns) base 05c7c2f (ns) ratio (PR/base)
vortex_q00/datafusion:vortex-file-compressed 10181528 9447028 1.08
vortex_q01/datafusion:vortex-file-compressed 6270211 6130391 1.02
datafusion / parquet (0.978x ➖, 0↑ 0↓)
name PR 57a1eb3 (ns) base 05c7c2f (ns) ratio (PR/base)
vortex_q00/datafusion:parquet 20555347 20931306 0.98
vortex_q01/datafusion:parquet 4685060 4811001 0.97
duckdb / vortex-file-compressed (0.997x ➖, 0↑ 0↓)
name PR 57a1eb3 (ns) base 05c7c2f (ns) ratio (PR/base)
vortex_q00/duckdb:vortex-file-compressed 10347996 10304025 1.00
vortex_q01/duckdb:vortex-file-compressed 6157287 6223625 0.99
duckdb / parquet (0.996x ➖, 0↑ 0↓)
name PR 57a1eb3 (ns) base 05c7c2f (ns) ratio (PR/base)
vortex_q00/duckdb:parquet 23599270 23586269 1.00
vortex_q01/duckdb:parquet 9381302 9459430 0.99

No file size changes detected.

@connortsui20

Copy link
Copy Markdown
Member

@claude review this PR, and review it also with the context of vortex-tensor, which has similar types and functions (extension types and null handling). Make sure things are consistent in the vortex-geo crate as well (so null semantics are handled correctly and tested well).

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Claude finished @connortsui20's task in 3m 58s —— View job


Claude PR Review — feat(vortex-geo): null-propagating scalar functions

Reviewed against origin/develop and cross-checked against vortex-tensor's binary scalar functions (InnerProduct, CosineSimilarity, L2Norm), which are the closest analog (extension types + null propagation).

  • Read PR diff (vortex-geo)
  • Study vortex-tensor null handling & extension types
  • Assess null semantics correctness
  • Assess test coverage
  • Post review

Verdict: Solid, well-documented change. The shared execute_null_propagating dispatch is a clean abstraction and the "drop nulls → decode → scatter back" strategy is the right call for kernels that can't decode a null row. return_dtype nullability now matches vortex-tensor exactly (any operand nullable). My main feedback is a consistency gap with vortex-tensor around the vtable's null-related optimization hooks, plus a couple of test-coverage and minor cleanup notes. No correctness bugs found.


1. Missing vtable overrides that vortex-tensor provides (main consistency gap)

vortex-tensor's binary functions override three null-related vtable methods. After this PR the geo functions match on return_dtype but still inherit the conservative trait defaults for the other two.

vortex-tensor/src/scalar_fns/inner_product.rs (161–179):

fn validity(&self, _options, expression) -> VortexResult<Option<Expression>> {
    // The result is null if either input tensor is null.
    let lhs_validity = expression.child(0).validity()?;
    let rhs_validity = expression.child(1).validity()?;
    Ok(Some(and(lhs_validity, rhs_validity)))
}
fn is_null_sensitive(&self, _options) -> bool { false }

The geo functions define neither, so they fall back to vtable.rs defaults:

  • validity()Ok(None) — the planner can't derive output validity symbolically and must execute the whole kernel to get the mask.
  • is_null_sensitive()true (conservative) — but the geo kernels are not null-sensitive. They propagate nulls and never inspect a null value, so e(mask(a, m)) == mask(e(a), m) holds — exactly the property is_null_sensitive documents. This defaults to disabling mask-pushdown optimizations for geo predicates.

Since the whole point of this PR is null propagation, and vortex-tensor establishes the pattern for the identical shape of function, I'd add both to each of GeoDistance / GeoContains / GeoIntersects:

fn validity(&self, _: &Self::Options, expression: &Expression) -> VortexResult<Option<Expression>> {
    // Null iff either geometry operand is null.
    Ok(Some(and(expression.child(0).validity()?, expression.child(1).validity()?)))
}
fn is_null_sensitive(&self, _: &Self::Options) -> bool { false }

These are optimization/consistency improvements, not correctness fixes — behavior is already correct without them. Fix this →

Note: vortex-tensor also sets is_fallible = false. For geo I'd leave the true default — geometry decoding (geometries) genuinely can fail on malformed storage, unlike the tensor math kernels. Worth a conscious decision rather than copying blindly.

2. Test coverage: two-column path is only exercised by distance

eval_column_pair (the (None, None) two-nullable-columns branch) is only covered by distance_propagates_column_pair_nulls. contains and intersects only test a nullable column against a constant. Because ST_Contains is asymmetric (a contains b), a both-columns-nullable test there is the most valuable one to add — it's the case where a null on either side, in either role, must null the row. Fix this →

Also uncovered: an all-null column (the valid.all_false() early-return in eval_column / eval_column_pair). Only the all-null constant path is tested. A cheap addition worth having since it's a distinct code path.

3. Minor: dead AllOr::None arms in build_array

Both GeoOutput::build_array impls handle AllOr::None (all rows null), but every caller (eval_column, eval_column_pair, and the constant paths) already short-circuits valid.all_false() to all_null_array before calling build_array. So those arms are effectively unreachable defensive code. Fine to keep as a safety net, but a short comment noting "callers short-circuit the all-null case" would prevent a future reader from thinking it's a live path. Additionally BitBuffer::from_iter(vec![false; len]) allocates a throwaway Vec<bool>; if you keep the arm, prefer a direct all-false BitBuffer constructor.

4. Consistency nits vs. vortex-tensor

  • Operand-equality check. vortex-tensor validates binary operands with eq_ignore_nullability so the two inputs must be the same underlying type. validate_geometry_operands only checks each operand is some native geometry, allowing e.g. Point vs Polygon — which is legitimate here (distance/contains/intersects across geometry types is meaningful), so this is a justified divergence, not a bug. Just flagging that the asymmetry with tensor is intentional.
  • nullable_point_column placeholder coords. Nice touch storing 0.0 placeholders for null rows and relying on the filter-before-decode to never touch them — this genuinely tests that null rows are never decoded. It does assume the ExtensionArray's validity mirrors its storage StructArray validity; that assumption holds today but is worth a one-line comment since the whole null-propagation contract rests on column.validity() reflecting the null rows.

Correctness review (no issues found)

Walked the branches in execute_null_propagating:

  • Null constant on either side → all_null_array with a Nullable dtype — consistent with return_dtype. ✅
  • nullability derived once from operand dtypes; nulls can only originate from nullable dtypes, so a NonNullable output can never need a null slot. ✅
  • AllOr::Some(rows) scatter zips valid row indices with filtered values in order — indices and values stay aligned because filter(valid) preserves order. ✅
  • Uses bulk validity()?.execute_mask(len, ctx)? and filter, not per-element is_valid(i) in a loop — aligned with the repo's hot-loop guidance. ✅
  • Length mismatch is guarded in the two-column path; constant/column paths inherit the column length. ✅

I ran no build/test commands (read-only review). CI (codspeed/benchmarks) is green on the PR.
· branch nemo/geo-null-propagation

@HarukiMoriarty
HarukiMoriarty force-pushed the nemo/geo-null-propagation branch from 9a50270 to 0716c2c Compare July 16, 2026 20:08
Comment thread vortex-geo/src/scalar_fn/mod.rs Outdated
@connortsui20

Copy link
Copy Markdown
Member

@claude review again, again check for consistency within vortex-geo and between vortex-tensor, and generally look for null semantics bugs

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Claude finished @connortsui20's task in 3m 16s —— View job


Claude PR Review — round 2 (consistency + null semantics)

Re-reviewed the updated diff against origin/develop, cross-checked vortex-tensor's binary kernels (InnerProduct etc.), and traced the null paths end-to-end.

  • Read updated mod.rs, distance.rs, contains.rs, intersects.rs, extension/mod.rs, test_harness.rs
  • Cross-check vortex-tensor (validity, is_null_sensitive, is_fallible, dtype nullability)
  • Audit null semantics (dispatch arms, scatter/gather alignment, all-null paths, ExtensionArray::validity)
  • Post findings

Verdict: The round-1 feedback landed cleanly — validity() (via the shared binary_result_validity) and is_null_sensitive = false are now on all three functions, and they match vortex-tensor exactly. I found no correctness or null-semantics bugs. The remaining items are a test-coverage inconsistency across the three functions and a couple of confirmations.


Null-semantics audit (no bugs found)

Traced every path in execute_null_propagating:

  • Operand order preserved in all dispatch arms. For the asymmetric ST_Contains, (Some(qa), None)eval_column(b, |g| compute(&ga, g)) = a_const.contains(b_row), and (None, Some(qb))eval_column(a, |g| compute(g, &gb)) = a_row.contains(b_const). Container/contained roles stay correct. ✅
  • Scatter alignment. column.filter(valid) preserves row order and valid.indices() is ascending, so rows.iter().zip(values) in build_array maps each computed value to the right row. ✅
  • nullability vs return_dtype. Both derive from any operand nullable; a null scalar always carries a nullable dtype, so all_null_array (always nullable) can never contradict a NonNullable output. A NonNullable output only occurs when both operands are non-nullable, in which case the combined mask is all-true and Validity::from_mask(.., NonNullable) is never handed a partial mask. ✅
  • validity() is exactly right here (not just conservative): distance/contains/intersects always return a non-null value for two valid geometries, so output-null ⟺ input-null, which is precisely and(child0.validity, child1.validity). (This would be wrong for a kernel that can produce null from valid inputs, e.g. division — but that's not the case here.) ✅
  • The contract's foundation holds: ExtensionArray's ValidityChild delegates to storage_array() (vortex-array/src/arrays/extension/vtable/validity.rs:11), so column.validity()? reflects the storage StructArray's validity that nullable_point_column sets. The placeholder 0.0 coords in null rows are never decoded because the filter drops them first. ✅

1. Test-coverage inconsistency — intersects never exercises the two-column null path (main item)

The three functions have diverged in coverage of the very paths this PR adds. contains is now the model; the other two have gaps:

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_validity helper 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 vtable validity() impls compute exactly and(child validities). Out of scope for this PR, but worth a tracking issue.
  • The unreachable! on the AllOr::None arms in both build_array impls is now clearly documented as guarded by the all_false() short-circuits — good. The earlier BitBuffer::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 connortsui20 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@HarukiMoriarty

Copy link
Copy Markdown
Contributor Author
  1. Null literal prune, query_aabb declines on a null constant; prune tests added for both distance & intersects.
  2. len-0 dtype, fixed by the simplification below, plus a regression test.
  3. Simplification, GeoOutput down to 2 methods; build_array handles the empty mask, const-null routes through it, and the is_null checks are one loop.
  4. AABB nulls went past the note: accumulate now skips null rows (matching min/max) + a test.

@HarukiMoriarty
HarukiMoriarty enabled auto-merge (squash) July 17, 2026 14:40
Comment thread vortex-geo/src/aggregate_fn/aabb.rs Outdated
Comment on lines +208 to +213
let valid = array.validity()?.execute_mask(array.len(), ctx)?;
let array = if valid.all_true() {
array
} else {
array.filter(valid)?
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread vortex-geo/src/aggregate_fn/aabb.rs Outdated
Comment on lines +205 to +207
// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, both functions have an implicit "no nulls" precondition, now documented. I think we should never converts nulls to (0, 0).

Comment on lines 224 to 225
.execute::<PrimitiveArray>(ctx)?;
if let Some(rect) = aabb_of(xs.as_slice::<f64>(), ys.as_slice::<f64>()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread vortex-geo/src/scalar_fn/execute.rs
Comment thread vortex-array/src/expr/exprs.rs Outdated
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>
@HarukiMoriarty
HarukiMoriarty changed the base branch from develop to nemo/expr-union-child-validities July 17, 2026 17:43
@HarukiMoriarty
HarukiMoriarty force-pushed the nemo/geo-null-propagation branch from cb2af19 to 45f0a48 Compare July 17, 2026 17:53
Base automatically changed from nemo/expr-union-child-validities to develop July 17, 2026 17:57
HarukiMoriarty and others added 2 commits July 17, 2026 14:00
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>
@HarukiMoriarty
HarukiMoriarty enabled auto-merge (squash) July 17, 2026 18:13

@connortsui20 connortsui20 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this looks good, but I would like if someone else has eyes on this as well. @joseph-isaacs?

// the origin. `filter` collapses an all-true mask back to the input, so a null-free batch
// passes through unchanged.
let valid = array.validity()?.execute_mask(array.len(), ctx)?;
let array = array.filter(valid)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be expensive.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filter(valid) you flagged is in the AABB zone-stat accumulate, so it runs once at write time building the zone map, not on the read path.

On null-free columns (all of SpatialBench, and the common case) filter(all_true) is a no-op; with actual nulls it's a one-time bulk compaction amortized across all reads, I add a TODO here.

Read-side, base-vs-PR is identical across SF 1/3/10 — the per-row kernel isn't even hit here, since DuckDB evaluates ST_* and Vortex only prunes via the stored box.

@joseph-isaacs joseph-isaacs Jul 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shall we add a null in the points too? With a min/max geo

// Null rows are gone, so every coordinate below belongs to a present geometry — the
// `unmasked_field_by_name` reads are therefore safe. Min/max the raw x/y buffers directly:
// cheap, and avoids `to_geometry`'s panic on empty points (which decoding would hit).
let coords = flatten_coordinates(&array, ctx)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This memory copy looks slow too. Might be worth nothing with a todo this is likely slow and should be fixed in needed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, I think the memory copy is the filter(valid) line (one above), not flatten_coordinates. flatten_coordinates just structurally unwraps the extension to its Struct<x,y>.

The filter only copies when nulls are actually present (null-free = no-op), it's a one-time write-side cost.

Comment on lines +179 to +222
fn eval_column<T, F>(
column: &ArrayRef,
f: F,
nullability: Nullability,
ctx: &mut ExecutionCtx,
) -> VortexResult<ArrayRef>
where
T: GeoOutput,
F: Fn(&Geometry<f64>) -> T,
{
let len = column.len();
let valid = column.validity()?.execute_mask(len, ctx)?;
// Drop the null rows before decoding, since a null row has no geometry to decode. `filter`
// collapses an all-true mask, so an all-valid column passes through unchanged.
let decoded = geometries(&column.filter(valid.clone())?, ctx)?;
let values = decoded.iter().map(f).collect();
Ok(T::build_array(len, &valid, values, nullability))
}

/// Evaluate `compute` over each row where both geometry columns are valid, propagating the nulls
/// of either column.
fn eval_column_pair<T, F>(
a: &ArrayRef,
b: &ArrayRef,
compute: F,
nullability: Nullability,
ctx: &mut ExecutionCtx,
) -> VortexResult<ArrayRef>
where
T: GeoOutput,
F: Fn(&Geometry<f64>, &Geometry<f64>) -> T,
{
let len = a.len();
let a_present = a.validity()?.execute_mask(len, ctx)?;
let b_present = b.validity()?.execute_mask(len, ctx)?;
// A row survives only where both columns are present.
let valid = &a_present & &b_present;
// Keep only the rows valid in both columns, so decoding never sees a null geometry. `filter`
// collapses an all-true mask, so all-valid columns pass through unchanged.
let ag = geometries(&a.filter(valid.clone())?, ctx)?;
let bg = geometries(&b.filter(valid.clone())?, ctx)?;
let values = ag.iter().zip(&bg).map(|(x, y)| compute(x, y)).collect();
Ok(T::build_array(len, &valid, values, nullability))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you benchmarked this code at all (its on the read path correct?).

geometries looks slow due to both memory copies and usage of a enum

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally speaking I think a lot of things in the geo crate are kind of slow / not optimized because we are prioritizing features instead of speed here... And the fact that we store things via columns instead of rows is already huge.

So imo it would be better to merge things and then if we see perf is slow in e2e benchmarks we can optimize.

That being said... yes it would be good to see some numbers @HarukiMoriarty

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SF = 1

Query parquet base parquet PR vortex base vortex PR geo-native base geo-native PR
1 45.6 44.4 13.1 13.6 7.0 7.0
2 176.1 161.4 40.4 42.3 54.0 56.7
3 77.0 75.9 35.7 25.0 6.4 9.4
4 630.0 639.0 80.4 61.3 139.3 129.6
5 434.5 374.4 408.7 297.1 310.9 315.7
6 737.1 735.3 126.7 113.3 169.6 150.6
7 186.9 205.2 86.7 157.4 102.5 102.3
8 159.8 160.6 62.0 71.7 69.0 79.3
9 19.3 22.6 18.5 19.0 19.9 22.1

SF = 3

Query parquet base parquet PR vortex base vortex PR geo-native base geo-native PR
1 50.3 52.6 45.8 32.6 11.1 12.7
2 177.7 186.7 67.1 65.5 112.2 139.5
3 119.4 131.4 82.1 73.5 10.3 13.3
4 583.0 622.4 71.8 76.8 114.9 121.7
5 1120.0 1140.0 962.1 999.8 941.8 985.0
6 743.5 775.7 134.2 125.4 230.2 223.2
7 469.5 387.7 385.7 469.1 414.5 384.4
8 200.2 263.6 237.0 198.1 232.7 301.8
9 30.9 34.0 30.1 36.4 28.3 42.2

SF = 10

Query parquet base parquet PR vortex base vortex PR geo-native base geo-native PR
1 176.0 174.3 107.4 105.0 22.7 21.5
2 362.6 350.3 190.7 184.9 319.6 307.0
3 289.2 287.5 230.9 248.2 24.4 23.6
4 911.3 891.1 108.4 106.3 144.2 140.3
5 3660.0 3280.0 3120.0 3000.0 3120.0 3000.0
6 1250.0 1230.0 330.9 282.6 437.0 426.2
7 1290.0 1270.0 1100.0 1080.0 946.0 930.9
8 709.5 722.1 542.0 526.1 666.2 649.6
9 32.1 32.7 31.1 31.9 41.3 40.0

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q1 Q3 and Q6 actually fire the code you mentioned, and all within run-to-tun noise.

When would this path actually cost anything?

  • the column actually contains nulls, otherwise filter(all_true) is a no-op and build_array takes the all-valid fast path, i.e. zero added work;
  • the nulls are sparse. That's the worst case: we pay the filter-compaction + scatter over ~all rows but only skip decoding a handful of null rows. (With dense nulls you filter most rows away and save decode time; with no nulls it's free.)

That regime is uncommon: geometry columns are almost always fully populated (null geometries are unusual and typically dropped at ingest, so SpatialBench's zero-null case is representative).

I think it should be fine?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure what this shows?

…umulate

The GeometryAabb accumulate filters out null rows before min/max-ing the raw
coordinate buffers, which compacts the whole column even for a single null.
Record a validity-aware min/max over the raw x/y buffers as the future
optimization; the common non-nullable case is already a no-op.

Signed-off-by: Nemo Yu <zyu379@wisc.edu>
@HarukiMoriarty
HarukiMoriarty merged commit 2ea51cb into develop Jul 20, 2026
80 checks passed
@HarukiMoriarty
HarukiMoriarty deleted the nemo/geo-null-propagation branch July 20, 2026 19:28
connortsui20 added a commit that referenced this pull request Jul 20, 2026
## Rationale for this change

- Closes: #8807

Three benchmarks stayed flaky after #8742, flipping between the same two
values on PRs that can't affect them. Same root causes and fixes as
#8742:

| Benchmark | Seen flaky on | Why | Fix |
| --- | --- | --- | --- |
| `true_count_vortex_buffer[128]` | ±11.17% on 9 unrelated PRs (#8805,
#8811, #8812, #8820, #8843, #8803, …) | a 128-bit popcount measures
harness overhead and code layout, not the count | drop the 128 size |
| runend `compress[(100000, 4)]` | ±11.9% on #8805, #8750, #8856 |
allocates in the timed region; glibc malloc differs across runner images
| mimalloc as global allocator |
| `cast_decimal` `copy_*[65536]` | identical flags on #8838 and #8724 |
same glibc-malloc cause (512 KB alloc per iteration) | mimalloc as
global allocator |

Left alone: `compact_sliced[(4096, 90)]` (single sighting) and the CUDA
walltime benches (hosted-runner walltime noise, a runner config issue).

The allocator swap shifts every benchmark in the two touched binaries
once — see the comment below. Needs a one-time CodSpeed acknowledgment,
like #8742.

## What changes are included in this PR?

One commit per benchmark; bench files only. Ran `cargo check` + `clippy`
on the three bench targets, smoke-ran the binaries, `cargo +nightly
fmt`.

---------

Signed-off-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog/feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants