perf(merge_insert): probe the most selective indexed key first and stop early - #8719
Conversation
…op early `MapIndexExec::map_batch` probed every indexed join key as one `ScalarIndexExpr::And` and evaluated the whole expression, so a low-cardinality key materialized a candidate set the size of the table while pruning almost nothing. Probe one key at a time, intersect as we go, and stop once the candidate set is no larger than the source batch. Order the keys by how many distinct values the source batch holds for them, so a composite key written coarse-to-fine does not pay for the coarse probe first. Dedup the `IsIn` lists. Both the ordering and the dedup are gated on having more than one lookup, leaving the single-key path unchanged. Skipping a probe only widens the candidate set, which the downstream full-key join already trims. Extra candidates stay inside index-covered fragments because the restricted deletion mask is still built from every lookup's fragment bitmap. Closes lance-format#8718
|
Benchmarked the sequential-probe path, since that was the open item. The case is constructible on the existing Release builds, same machine, one sitting, swapping only the binary. Median of 5 runs after a discarded warmup. Cold means a fresh dataset handle per iteration, so the index cache is not primed.
The worst case costs 19% cold and 9% warm. The overlap One check that the construction measures what it claims: on I have folded these numbers into the description as well. |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
cc @westonpace @wjones127 @Xuanwo FYI |
|
Agreed on the reading, and worth naming the condition under which the 19% shows up, because it is narrower than "two probes needed". The regression needs the selectivity estimates to tie. The ordering is computed from the source side, so with 100 distinct source values in both key columns the two keys score the same, the stable sort keeps the caller's column order, and whichever key the caller happened to list first gets probed first. In the benchmark that is So the cost is paid only when we had no information to order by and guessed wrong. That is why I left it: on If you would rather not pay it at all, the targeted mitigation is to keep the sequential loop only when the estimates distinguish the keys, and fall back to probing concurrently when they tie. That keeps the early-stop win, since it depends on one key being visibly more selective, and removes the sequential round trip in exactly the case that regressed. Happy to fold that into this PR or file it as a follow-up, whichever you prefer. |
There was a problem hiding this comment.
The latest clarification establishes that the measured 19% regression used a source-distinct-count tie. The implementation still runs probes sequentially whenever the first result exceeds the source batch size, so unequal estimates can also take the no-stop path; a tie-only concurrent fallback would remove the demonstrated regression, but not every possible one. Exactness and fragment coverage remain intact, and the full scan remains available for affected workloads, so this is still a non-blocking performance risk. Maintainers can accept it as-is or request the targeted fallback to eliminate the measured case.
|
Thank you @wjones127 |
What this changes
MapIndexExec::map_batchprobed every indexed join key as oneScalarIndexExpr::Andand evaluated the whole expression, so a low-cardinality key materialized a candidate set the size of the table while pruning almost nothing (#8718).It now probes one key at a time, intersects as it goes, and stops once the candidate set is no larger than the source batch. Keys are ordered by how many distinct values the source batch holds for them, so a caller who writes a composite key coarse-to-fine (
["tenant_id", "row_id"]) does not pay for the coarse probe first.IsInlists are deduplicated. Both the ordering and the dedup are gated on having more than one lookup, so the single-key path behaves as before.Skipping a probe widens the candidate set, which is safe because the downstream join filters on the full composite key; the file already documents that the probe result is a super-set. Extra candidates stay inside index-covered fragments because the restricted deletion mask is still built from every lookup's fragment bitmap. That coupling is now stated in a comment, because relaxing it would feed the same target row into the join twice and the default
SourceDedupeBehavior::Failwould abort the merge.Numbers
Same dataset as the issue. Baseline and patched were measured in one sitting about twenty minutes apart, swapping only the binary: the patch was reverse-applied for the baseline, then re-applied. Release build, warm index cache, median of 5 runs.
on = ["composite_a", "composite_b"]on = ["composite_b", "composite_a"]on = ["composite_a"], single keyconditional_update, single keyid_int, single keyid_uuid4100k, single keycompositev2 hash path (control)id_uuid4v2 hash path (control)The two controls do not touch
MapIndexExecand moved by 3.2% and 3.4%, so the two sides are comparable. The single-key shapes moved by 1.6% to 9.2%, which lands inside this script's run-to-run spread once the control drift is subtracted; that path is meant to be unchanged.What this does not fix
A single low-cardinality key still materializes the whole candidate set. There is no second key to intersect with and no reason for the loop to stop, so
on = ["composite_b"]alone still exhausts the default pool, before and after this change. Bounding it needs a budget inside the index probe, andmerge_insertwould first need to accept a non-exact result, which it currentlytodo!()s. Related: #1983.The distinct count is a source-side proxy for target-side selectivity. A skewed batch, with many distinct values that each match many target rows, can be ordered worse than the caller wrote it; the floor is the old behaviour of probing every key.
Probes now run in sequence where the old
Andran them concurrently throughtry_join!. That is what makes stopping possible. Measured on the case where no probe can be skipped, a 100-row source withon = ["composite_b", "composite_a"]so that the coarse key probes first and returns roughly 976k candidates: 36.6 ms to 43.4 ms on a cold index cache, 10.6 ms to 11.6 ms warm, so 19% and 9%. The same construction with the columns swapped stops after one probe and goes from 37.1 ms to 3.5 ms cold.Test plan
map_index_exec_probes_most_selective_key_firstasserts the emitted candidate count, which is the only observable that reveals which probe ran:IndexMetricshas no per-probe counter and one collector is shared across lookups. Reverting the ordering makes it fail withleft: 2, right: 4.cargo test -p lance --lib merge_insert -- --test-threads=1: 219 pass.cargo fmt --all,cargo clippy --all --tests --benches -- -D warnings.Follow-up note
The route gate comment at
merge_insert.rs:1432says a partially-indexed composite key "under-matches" on the indexed path. The mechanism is the opposite: each column'sIsInis a super-set, uncovered fragments go through the union scan, and the full-key join trims. Left alone to keep this diff to the two files it needs.Closes #8718