Skip to content

perf: skip UPDATE range-seek's two-pass plan when SET doesn't touch the scanned index (#675) - #681

Merged
iheitlager merged 1 commit into
mainfrom
fix/675-update-range-seek-single-pass
Aug 31, 2026
Merged

perf: skip UPDATE range-seek's two-pass plan when SET doesn't touch the scanned index (#675)#681
iheitlager merged 1 commit into
mainfrom
fix/675-update-range-seek-single-pass

Conversation

@iheitlager

Copy link
Copy Markdown
Member

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/BETWEEN against 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 via SeekRowid — regardless of whether the SET clause actually touched the scanned index. That two-pass detour is only needed when it does (the index cursor doing the IdxNext walk has no protection against the walk mutating its own b-tree mid-scan).
  • Adds an explicit overlap check (range_seek_index_position, factored out of select/range_scan.rs's existing find_range_seek_detail dispatch) and applies the update directly inside the index walk when the SET columns 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).
  • Found and fixed a pre-existing, unrelated correctness bug while adding coverage for the two-pass fallback (SET val = val + 1 WHERE val > 15, i.e. SET on the scanned column itself): emit_update_row_body's col_regs assumed compile_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, so MakeRecord's contiguous-run assumption silently read the wrong registers. A SET col = <expr> update on a non-last column with a following SET, or with any prior unassigned column, could silently write the wrong value (verified: it did, on main, for this exact shape). Fixed the same way insert.rs's compile_column_source already does ([V4] fix: two computed result columns collide — needs a Copy/SCopy opcode #141/fix: harvest Copy opcode, unblock computed result columns (#141) #261): Copy each column's value into a freshly bump-allocated, back-to-back register right before MakeRecord.
  • Checked DELETE's equivalent shape per this ticket's acceptance criteria: delete.rs has 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-equality SeekRowid case) — range predicates fall back to plain Rewind/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 no OpenEphemeral when SET doesn't touch the scanned index) and range_predicate_update_on_indexed_column_keeps_two_pass_plan (asserts OpenEphemeral is 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 lint clean
  • Manual repro from the ticket: UPDATE bench_data SET n = n + 1 WHERE x > 50000 on bench_1mb.db/bench_50mb.db via sqlite-rs exec

spend: within estimate (medium) — the overlap-check fix plus the register-contiguity bug it surfaced along the way.

Closes #675


🤖 Analysis by Claude

…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
iheitlager force-pushed the fix/675-update-range-seek-single-pass branch from ac7a29b to 1ba58b0 Compare August 31, 2026 12:32
@iheitlager
iheitlager merged commit e04330e into main Aug 31, 2026
6 checks passed
@iheitlager
iheitlager deleted the fix/675-update-range-seek-single-pass branch August 31, 2026 12:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: UPDATE range-seek always pays for a two-pass deferred-rowid plan it usually doesn't need

1 participant