From 9ad099da1248b3465bec54b11a1c338c67bcb53e Mon Sep 17 00:00:00 2001 From: Ilja Heitlager Date: Sun, 30 Aug 2026 12:52:36 +0200 Subject: [PATCH 1/2] perf: build IdxInsert keys from live registers instead of re-seeking (#663) INSERT and UPDATE codegen already hold the new row's column values in col_regs/rowid_reg before writing it; re-seeking the table cursor and re-reading those same values via Column/Rowid to build the secondary index keys was a wasted btree seek + page touch per indexed write. emit_index_key_ops_from_regs builds the key via Opcode::Copy from the existing registers instead. IdxDelete paths (removing a different, already-on-disk row's entries) are untouched. spend: matched estimate (medium) --- src/codegen/index_maintenance.rs | 99 ++++++++++++++++++++++++++++---- src/codegen/stmt/insert.rs | 29 ++-------- src/codegen/stmt/update.rs | 23 +++----- 3 files changed, 101 insertions(+), 50 deletions(-) diff --git a/src/codegen/index_maintenance.rs b/src/codegen/index_maintenance.rs index edee3100..04fcd7aa 100644 --- a/src/codegen/index_maintenance.rs +++ b/src/codegen/index_maintenance.rs @@ -5,16 +5,17 @@ //! cursor, and emit the `IdxInsert`/`IdxDelete` pair for a row's index //! entries. //! -//! Index keys are read back from the table cursor's *current* row via -//! ordinary `Opcode::Column`/`Opcode::Rowid` (rowid last, matching the -//! on-disk index key convention `btree::index::insert`/`index::delete` -//! use) rather than reusing already-computed value registers — there is -//! no register-copy opcode in the frozen V2 set -//! (`tools/opcodes-v2.json`), so a fresh contiguous register run is -//! rebuilt by reading from a cursor every time one is needed. For a -//! freshly-inserted/updated row, the caller must position the table -//! cursor on it first (`SeekRowid`) since `Opcode::Insert` does not -//! reposition the cursor itself. +//! For a row whose values are only available from disk (the *old* row +//! of an `UPDATE`'s rebuild, or a row displaced by an `INSERT OR +//! REPLACE` conflict), index keys are read back from the table cursor's +//! *current* row via ordinary `Opcode::Column`/`Opcode::Rowid` (rowid +//! last, matching the on-disk index key convention +//! `btree::index::insert`/`index::delete` use) — see +//! [`emit_index_key_ops`]. For a row whose values are already sitting in +//! registers (a freshly-inserted/updated row, before it's written), +//! [`emit_index_key_ops_from_regs`] builds the same key layout via +//! `Opcode::Copy` from those registers instead, with no cursor re-seek +//! or re-read. //! //! `DESC` index columns are rejected (`CodegenError::Unsupported`) //! rather than silently mis-keyed: no index b-tree comparator in this @@ -146,3 +147,81 @@ pub(crate) fn emit_index_key_ops( } Ok(()) } + +/// Like [`emit_index_key_ops`], but for a row whose column values are +/// already sitting in `col_regs` (one register per `schema.columns` +/// entry, in order — the same layout `INSERT`/`UPDATE` codegen builds +/// for `MakeRecord`) and whose rowid is already in `rowid_reg`. Builds +/// each index's key via `Opcode::Copy` from those registers into a +/// fresh contiguous run instead of `Opcode::Column`/`Opcode::Rowid` +/// against a cursor — so callers don't need to `SeekRowid` back onto +/// the row first. Always emits `IdxInsert`: the only caller that needs +/// `IdxDelete` (removing a *different*, already-on-disk row's stale +/// entries) has no such register run to reuse and stays on +/// [`emit_index_key_ops`]. +pub(crate) fn emit_index_key_ops_from_regs( + em: &mut Emitter, + reg: &mut RegAlloc, + schema: &TableSchema, + col_regs: &[i32], + rowid_reg: i32, + first_index_cursor: i32, +) -> Result<(), CodegenError> { + for (i, index) in schema.indexes.iter().enumerate() { + let index_cursor = first_index_cursor.saturating_add(i32::try_from(i).unwrap_or(0)); + let mut start = None; + for col in &index.columns { + if col.desc { + return Err(CodegenError::Unsupported { + reason: format!( + "index {} has a DESC column ({}); descending index keys aren't supported yet", + index.name, col.name + ), + }); + } + let col_idx = + column_index(schema, &col.name).ok_or_else(|| CodegenError::Unsupported { + reason: format!( + "index {} references a column or expression this codegen can't resolve: {}", + index.name, col.name + ), + })?; + // The rowid-alias column's own register holds NULL (readers + // substitute the cursor's actual rowid instead — see + // `emit_column_read`), so its live value is `rowid_reg`, not + // `col_regs[col_idx]`. + let src = if Some(col_idx) == schema.rowid_alias { + rowid_reg + } else { + *col_regs + .get(col_idx) + .ok_or_else(|| CodegenError::Unsupported { + reason: format!( + "index {} references column {} outside the row's register run", + index.name, col.name + ), + })? + }; + let r = reg.alloc(); + if start.is_none() { + start = Some(r); + } + em.emit(Instruction::new(Opcode::Copy, src, r, 0)); + } + let key_rowid_reg = reg.alloc(); + if start.is_none() { + start = Some(key_rowid_reg); + } + em.emit(Instruction::new(Opcode::Copy, rowid_reg, key_rowid_reg, 0)); + + let count = i32::try_from(index.columns.len().saturating_add(1)).unwrap_or(0); + em.emit(Instruction::with_p4( + Opcode::IdxInsert, + index_cursor, + start.unwrap_or(key_rowid_reg), + 0, + P4::Int(i64::from(count)), + )); + } + Ok(()) +} diff --git a/src/codegen/stmt/insert.rs b/src/codegen/stmt/insert.rs index 4446cf85..a9f239a9 100644 --- a/src/codegen/stmt/insert.rs +++ b/src/codegen/stmt/insert.rs @@ -81,7 +81,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use crate::codegen::expr::{column_index, compile_cond, compile_value}; use crate::codegen::index_maintenance::{ - emit_index_key_ops, open_index_cursors, valid_table_root_page, + emit_index_key_ops, emit_index_key_ops_from_regs, open_index_cursors, valid_table_root_page, }; use crate::codegen::select::{ compile_select_joined_scan, compile_select_scan, select_result_column_count, @@ -794,29 +794,10 @@ fn compile_row( )); if !schema.indexes.is_empty() { - // `Insert` doesn't reposition `TABLE_CURSOR` onto the row it - // just wrote, but the index-key registers are read back via - // `Opcode::Column`/`Opcode::Rowid` against the cursor's current - // row (see `index_maintenance`), so seek onto it first. A - // not-found jump target is required by `SeekRowid`'s shape but - // should be unreachable — the row was just inserted. - let seek_ok = em.new_label(); - let seek_addr = em.emit(Instruction::new( - Opcode::SeekRowid, - TABLE_CURSOR, - 0, - rowid_reg, - )); - em.patch_p2(seek_addr, seek_ok); - emit_index_key_ops( - em, - reg, - schema, - TABLE_CURSOR, - FIRST_INDEX_CURSOR, - Opcode::IdxInsert, - )?; - em.place(seek_ok); + // The new row's values are already sitting in `col_regs`/ + // `rowid_reg` — build index keys from those directly instead of + // seeking `TABLE_CURSOR` back onto the just-written row. + emit_index_key_ops_from_regs(em, reg, schema, &col_regs, rowid_reg, FIRST_INDEX_CURSOR)?; } em.place(row_skip); diff --git a/src/codegen/stmt/update.rs b/src/codegen/stmt/update.rs index f63fd9f7..6e9ab321 100644 --- a/src/codegen/stmt/update.rs +++ b/src/codegen/stmt/update.rs @@ -40,7 +40,7 @@ use crate::codegen::expr::{column_index, compile_cond, compile_value, emit_column_read}; use crate::codegen::index_maintenance::{ - emit_index_key_ops, open_index_cursors, valid_table_root_page, + emit_index_key_ops, emit_index_key_ops_from_regs, open_index_cursors, valid_table_root_page, }; use crate::codegen::select::{is_rowid_reference, top_level_equality_operands, CodegenError}; use crate::codegen::stmt::insert::{ @@ -344,26 +344,17 @@ pub fn compile_update_with_catalog( )); if !schema.indexes.is_empty() { - // Same "`Insert` doesn't reposition the cursor" caveat as - // `insert.rs` — seek back onto the row just written before - // reading its new index-key values. - let seek_ok = em.new_label(); - let seek_addr = em.emit(Instruction::new( - Opcode::SeekRowid, - TABLE_CURSOR, - 0, - rowid_reg, - )); - em.patch_p2(seek_addr, seek_ok); - emit_index_key_ops( + // The new row's values are already sitting in `col_regs`/ + // `rowid_reg` — build index keys from those directly instead of + // seeking `TABLE_CURSOR` back onto the just-written row. + emit_index_key_ops_from_regs( &mut em, &mut reg, schema, - TABLE_CURSOR, + &col_regs, + rowid_reg, FIRST_INDEX_CURSOR, - Opcode::IdxInsert, )?; - em.place(seek_ok); } if let Some(loop_start) = loop_start { From 188d79911b941a4e4f133be17879183fece9e81f Mon Sep 17 00:00:00 2001 From: Ilja Heitlager Date: Sun, 30 Aug 2026 12:55:55 +0200 Subject: [PATCH 2/2] chore: release v0.18.10 Also backfills the 0.18.9 changelog entry missing for #660 (SorterInsert register-source read), which was merged before 0.18.9's release commit but never got its own changelog line. --- CHANGELOG.md | 23 +++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afbc0ccc..94fb35ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to sqlite-rs. Format follows [Keep a Changelog](https://keep **Versioning policy:** one minor version per completed plan phase — the version number tells the plan's story, sub-steps stay inside a phase. V1 (READ CORE) = 0.1.0 through 0.4.0. *(History note: internal iterations briefly numbered 0.4.0–0.6.0 were renumbered into the phase scheme on 14 Aug 2026, before any tag or publication of those versions existed.)* +## [0.18.10] - 2026-08-30 + +### Fixed + +- INSERT and UPDATE codegen re-`SeekRowid`'d back onto the row they had + just written, then re-read every index column via `Opcode::Column`/ + `Opcode::Rowid` to build `IdxInsert` keys — even though those same + values were still sitting in `col_regs`/`rowid_reg` from just before + the write. `emit_index_key_ops_from_regs` builds the key via + `Opcode::Copy` from those registers instead, dropping the seek and + re-read on every indexed INSERT/UPDATE. `IdxDelete` paths (removing a + *different*, already-on-disk row's stale entries) are unaffected — + they have no such register run to reuse (#663). + ## [0.18.9] - 2026-08-30 ### Fixed @@ -17,6 +31,15 @@ All notable changes to sqlite-rs. Format follows [Keep a Changelog](https://keep to the index-cursor side. Measured ~13.8% faster on an indexed range scan and ~7% faster on an indexed join (#661). +- `SorterInsert` always re-decoded its sort-key columns out of the + record blob it had just been handed, even though those same values + were still sitting in registers moments earlier, before `MakeRecord` + encoded them. It now gains an optional source-register run (honored + when `p5` is nonzero), which `compile_grouped_scan`'s `GROUP BY` path + opts into; every other `SorterInsert` emitter keeps the original + decode-from-blob behavior. Measured ~3.5-4% faster on `group_by_agg` + (#660). + ## [0.18.8] - 2026-08-29 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 4f6369c4..494140ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -781,7 +781,7 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "sqlite-rs" -version = "0.18.9" +version = "0.18.10" dependencies = [ "criterion", "md-5", diff --git a/Cargo.toml b/Cargo.toml index 75ad8ade..40e704c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlite-rs" -version = "0.18.9" +version = "0.18.10" edition = "2021" publish = false license = "Apache-2.0" diff --git a/README.md b/README.md index 599e9f2c..6dc37f6f 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ See [.openspec/plan.md](.openspec/plan.md) for the full breakdown and [.openspec ## Status -**Version 0.18.9** — see [CHANGELOG.md](CHANGELOG.md). One minor version per completed plan phase. +**Version 0.18.10** — see [CHANGELOG.md](CHANGELOG.md). One minor version per completed plan phase. | Phase | Version | Status | |-------|---------|--------|