Skip to content

free join: use galloping cursors instead of per-probe binary search on sorted column indexes #950

Description

@yihozhang

Opportunity

SortedColumnIndex::get_subset (core-relations/src/sorted_index.rs) runs a full binary search over the keys array on every probe. But in the `JoinStage::Intersect` arms of core-relations/src/free_join/execute.rs, probes are driven by another sorted index's for_each, which emits keys in ascending order:

  • two-scan arm (~line 1180): smaller.for_each(|val, ...| ... larger.get_subset(val))
  • multi-scan rest arm (~line 1292): probers[smallest].for_each(...) probing each probers[i].get_subset(key)

When the outer prober is a sorted variant (DynamicColumn/SparseColumn), the probe sequence into each inner index is monotonically increasing, so each probe can resume from a caller-held cursor with galloping/exponential search (amortized O(log gap), O(1) for adjacent keys) instead of O(log n) from scratch. SortedOffsetSlice::scan_for_offset (core-relations/src/offsets/mod.rs) already implements exactly this shape for sparse-sparse intersection and can be adapted to the keys: Vec<(Value, u32)> layout.

Sketch

Add SortedColumnIndex::get_subset_from(&self, cursor: &mut usize, key: Value): gallop forward when keys[*cursor].0 <= key, else fall back to a plain binary search and reset the cursor. The fallback makes it safe to plumb through Prober unconditionally — hash-driven outer loops (Cached*/Dynamic iterate in insertion order) just degrade to today's cost plus one comparison. Cursors must live in the caller (the index is a shared Arc snapshot), scoped to one for_each.

When both sides are DynamicColumn, a further step is to replace for_each + probe with a direct merge-join over the two keys arrays.

Smaller related wins

  • intersect_with_dense_ref (execute.rs ~line 235) does two binary_search_by_id calls; the second can start from the first's result via binary_search_from, mirroring what Subset::intersect's Sparse∩Dense arm already does in offsets/mod.rs.
  • The incremental-rebuild loops (core-relations/src/table/rebuild.rs ~lines 140, 165) probe SortedRebuildIndex with distinct, order-independent ids: sorting the ids first turns the loop into a linear merge against the index.

Expected payoff is low single digits end-to-end (probe machinery is a few percent of runtime on the transformer benchmarks), but the change is small and self-contained. Verify with scripts/bench.py.

follow up of #948

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions