perf: skip UPDATE range-seek's two-pass plan when SET doesn't touch the scanned index (#675) - #681
Merged
Merged
Conversation
…he scanned index (#675) update.rs's #666 range-seek fast path ran an unconditional two-pass plan (ephemeral-rowid capture, then a from-root replay seek) for any WHERE-clause range predicate, even when no assigned column intersected the scanned index and the self-mutation hazard the two-pass plan guards against couldn't occur. Adds an explicit overlap check (range_seek_index_position, factored out of select/range_scan.rs's find_range_seek_detail) and applies the update directly inside the index walk when it's safe, falling back to the original two-pass plan otherwise. update_filtered_range's 50mb-fixture scaling improves from ~85x (50x rows) to ~34x, and wall-clock time from 14.67s to ~4.7s. While adding correctness coverage for the two-pass fallback (SET on the scanned column itself), found and fixed a pre-existing, unrelated bug in emit_update_row_body: col_regs assumed compile_value's returned register was always contiguous with the previous column's, which breaks for any non-trivial expression (temp registers land in between). A binary `SET col = col + 1`-shaped update on a non-first column silently wrote the wrong value. Fixed the same way insert.rs's compile_column_source does (#141/#261): Copy each column's value into a freshly bump-allocated, back-to-back register before MakeRecord. DELETE's WHERE-clause range predicates have no equivalent two-pass shape to begin with (no index-seek fast path at all currently -- they fall back to Rewind/Next, which is safe since DELETE never re-walks an index cursor mid-mutation), so no follow-up is needed there per this ticket's acceptance criteria. spend: within estimate (medium) -- the overlap-check fix plus the register-contiguity bug it surfaced. Refs: #675 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
iheitlager
force-pushed
the
fix/675-update-range-seek-single-pass
branch
from
August 31, 2026 12:32
ac7a29b to
1ba58b0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
update.rs's perf: range scans check bounds via per-row comparison opcode instead of baking bound into seek #666 range-seek fast path (WHERE col >/>=/</<= lit/BETWEENagainst a leading-indexed column) ran an unconditional two-pass plan — pass 1 walks the index and stashes matched rowids into an ephemeral b-tree, pass 2 replays them viaSeekRowid— regardless of whether theSETclause actually touched the scanned index. That two-pass detour is only needed when it does (the index cursor doing theIdxNextwalk has no protection against the walk mutating its own b-tree mid-scan).range_seek_index_position, factored out ofselect/range_scan.rs's existingfind_range_seek_detaildispatch) and applies the update directly inside the index walk when theSETcolumns don't intersect the scanned index, keeping the original two-pass plan when they do.update_filtered_range(UPDATE bench_data SET n = n + 1 WHERE x > 50000) on the 50MB fixture: 14.67s → ~4.7s; scaling from 1MB→50MB (50x rows) improves from ~85x time to ~34x — closer to linear, though a real remaining gap vs oracle is tracked separately (perf: INSERT/UPDATE codegen re-seeks table cursor to rebuild index keys instead of reusing registers #663, per-index re-seek register reuse).SET val = val + 1 WHERE val > 15, i.e.SETon the scanned column itself):emit_update_row_body'scol_regsassumedcompile_value's returned register was always contiguous with the previous column's. That's false for any non-trivial expression (e.g.col + 1) — its temp registers land in between, soMakeRecord's contiguous-run assumption silently read the wrong registers. ASET col = <expr>update on a non-last column with a followingSET, or with any prior unassigned column, could silently write the wrong value (verified: it did, onmain, for this exact shape). Fixed the same wayinsert.rs'scompile_column_sourcealready does ([V4] fix: two computed result columns collide — needs a Copy/SCopy opcode #141/fix: harvest Copy opcode, unblock computed result columns (#141) #261):Copyeach column's value into a freshly bump-allocated, back-to-back register right beforeMakeRecord.delete.rshas no index-seek fast path for range predicates at all currently (only the perf: UPDATE/DELETE rowid/index-equality seek fast path (mirror #137 for writes) #336 rowid-equalitySeekRowidcase) — range predicates fall back to plainRewind/Next, which is safe since DELETE never re-walks an index cursor mid-mutation. No two-pass hazard exists there, so no follow-up ticket is needed.Test plan
cargo test --test codegen_update— 14 tests, including 2 new:range_predicate_update_without_indexed_set_uses_single_pass(asserts noOpenEphemeralwhenSETdoesn't touch the scanned index) andrange_predicate_update_on_indexed_column_keeps_two_pass_plan(assertsOpenEphemeralis still emitted, and end-to-end correctness, when it does)cargo test— full suite green (979+ lib tests, all integration suites)cargo test --test corpus(380 passed),--test parity(19 passed, 5 pre-existing ignores),--test sqllogictest(15 passed)make lintcleanUPDATE bench_data SET n = n + 1 WHERE x > 50000onbench_1mb.db/bench_50mb.dbviasqlite-rs execspend: within estimate (medium) — the overlap-check fix plus the register-contiguity bug it surfaced along the way.
Closes #675
🤖 Analysis by Claude