Skip to content

perf: reuse executors and resolve tables by id to close the TPC-C gap with SQLite - #396

Merged
KKould merged 20 commits into
mainfrom
feat/tpcc-threads
Nov 1, 2026
Merged

KKould merged 20 commits into
mainfrom
feat/tpcc-threads

Conversation

@KKould

@KKould KKould commented Oct 31, 2026

Copy link
Copy Markdown
Member

Cuts the compute-layer overhead on the TPC-C path. In the refreshed 720-second single-thread run (cpu8, no fsync), KiteSQL LMDB reaches 187161 TpmC against 179039 for SQLite in the same run; the previous README had 165956 against 191484.

Changes

  • Executor reuse: executor nodes no longer depend on the transaction lifetime, parent nodes own their children, and an executor is kept across executions. Program replaces PreparedPlan, runs in place, and ExecuteError is gone. Execution buffers survive across runs.
  • DML at bind time: Insert and Update work out their writes when bound (DmlTable), and UPDATE follows standard semantics: every assignment reads the old row, and assigning a column twice is rejected.
  • Table ids: table keys are prefixed with an allocated table id instead of a name hash. Planning, execution and serialization use table ids; names are resolved at bind time and only shown for display. Other FROM items carry a source id only and show as #id.column in EXPLAIN.
  • Buffer reuse: string and binary values reuse their buffers through a BytesPool in TableCodec, and projection swaps its output with a spare vector instead of appending to the input row.
  • RocksDB: prevents lost updates and drops read committed.
  • tpcc: --threads runs multiple terminals.

Compatibility

  • The on-disk format is incompatible with earlier versions (table id prefixes).
  • PreparedPlan and ExecuteError are removed in favor of Program.

Results (2026-10-31)

Backend TpmC New-Order Payment Order-Status Delivery Stock-Level
KiteSQL LMDB 187161 274 68 40 454 278
KiteSQL RocksDB 46291 374 184 201 14463 651
SQLite 179039 291 43 38 368 320

Latencies are p90 in µs. These are single runs; SQLite itself came in about 7% below the previous run, so the machine noise is of that order. RocksDB dropped from 52931 to 46291 TpmC and needs a rerun to tell a regression from noise.

Testing

make test, make test-wasm, make test-slt, make test-python and make clippy pass. EXPLAIN expectations in slt match source ids with <slt:ignore>, since the ids depend on the views loaded earlier in the run.

Versions: kite_sql 0.6.1, kite_sql_serde_macros 0.2.6.

KKould added 19 commits October 27, 2026 09:29
- RocksDB transactions (pessimistic and optimistic) now set a transaction
  snapshot, so a write to a key committed by another transaction after this
  one began conflicts instead of silently overwriting it.
- Remove the ReadCommitted isolation level: RocksDB could not reset the
  conflict snapshot per statement, so read committed allowed lost updates.
  TransactionIsolationLevel keeps RepeatableRead as the only level.
- Remove docs/transaction-isolation.md and its RC-specific tests.
- Each terminal runs the existing measuring loop on its own connection
  (shared Database for KiteSQL, a new connection for SQLite); counters and
  latency histograms are merged at the end. Only the first terminal drives
  the progress bar.
- Retry commit failures, which is where optimistic conflicts surface.
- Let other_ware pick the last warehouse; with two warehouses it spun forever.
- ExecNode was 712 bytes (RecursiveCte, IndexScan 472, HashJoin 456), so a
  3-node statement allocated a ~2KB node Vec per execution and hit glibc's
  large-bin path (malloc_consolidate). Boxing every variant makes the enum
  pointer-sized; dispatch is unchanged via deref coercion.
- Add exec_node_stays_pointer_sized to keep it that way.

TPCC LMDB 60s on cpu8, alternating runs: 183052/188373 -> 188003/194744 TpmC;
Transaction::execute drops from ~8.4% to ~5.3% of CPU samples.
…etime

- ExecArena gains a transaction lifetime 't. Scan iterators (tuple, index,
  table, view) live in arena-owned StorageIter slots and nodes keep an
  IterSlot, so ExecNode only borrows the plan and no longer carries T.
- ExecArena::new takes the execution context and transaction; init_context
  and the Option-wrapped context are gone.
- RecursiveCte's recursive arena is hosted by the outer arena, keeping the
  node free of transaction-bound state while reusing it across steps.
- ExecNodes::push only appends; the overwrite path and seek are removed.
- Executor nodes hold child ExecNodes directly and call child.next_tuple /
  child.reset. The arena-owned node array, ExecId, RefCell slots, the raw
  slot pointer and the executing counter are gone; ExecNode::next_tuple now
  clears the status, dispatches, and reports Continue/End as a bool.
- MarkApply owns its right root; RightInput keeps only the scan state.
  NestedLoopJoin no longer copies the right input id into its states.
- RecursiveCte builds each step into the outer arena and truncates the
  step's iterator slots afterwards, replacing the hosted sub-arena. The
  step's scans now carry the statement stamp like the rest of the tree.
- Build no longer threads an execution context or transaction: ExecArena
  stores the table cache, the only catalog state executors read, and
  ExecutionContext plus LogicalPlan::exec_capacity_hint are removed.
TableScanOperator replaces `columns` with `TupleDeserializers`: one
entry per table column in table order, either decoded or skipped, cut
after the last read column. It is built from the catalog at bind time
and column pruning narrows it in place, so executors pass it straight
to the storage reader instead of rebuilding deserializers on every
execution.

- storage: `read`/`read_by_index` take `&TupleDeserializers`; the
  column-based wrappers and `create_deserializers` are removed.
- scans returning primary keys keep their primary key columns read.
- `index_covering`, `is_index_covers` and `fmt_explain_list` accept
  iterators; `index_covering` resolves coverage in a single pass.
- the persisted view plan format changes with TableScanOperator.
An executor no longer carries its transaction: it holds the built tree,
its iterator slot count and output schema, and each execution supplies
its own ExecArena. A finished iterator hands it back with
`into_executor`, reset and ready to run again, possibly in another
transaction:

    let exec = tx1.execute(&prepared, params)?.into_executor()?;
    tx1.commit()?;
    let iter = tx2.execute(exec, other_params)?;

- `Database::execute` and `Transaction::execute` take `impl IntoExecutor`,
  implemented for prepared plans and executors.
- `TransactionIter` gains a plan lifetime `'p`, separate from the
  transaction borrow, so a returned executor outlives the transaction.
- Building a tree no longer needs a transaction: build functions take an
  `ExecBuilder`, which counts slots ahead of execution or allocates them
  in the arena while a recursive CTE runs.
- `ExecutorNode::reset` is required; write nodes keep their input and
  track whether they ran instead of taking it.
- `BindSource::execute` builds return an executor; `Executor::from_plan`
  covers the SQL paths.
A Program is a bound and optimized plan together with its executor tree.
Each execution binds new parameters and reuses the tree instead of
rebuilding it, in any transaction of the database that created it.

- `Database::program_sql` / `Transaction::program_sql` and the ORM
  `program` replace `prepare_sql` / `prepare`.
- `Program::execute(&mut tx, params)` returns a `ProgramIter`, which puts
  the program back when the execution ends, even if it fails.
- Remove `PreparedPlan`, `Database::execute` and `Transaction::execute`;
  prepared statements now run inside a transaction.
- `ParamArena` owns its plan arena, and `PlanInput` is gone since plans
  are no longer borrowed.
- TPCC keeps one program per statement; add the `program` example.
An execution now borrows its program instead of moving it out: the
executor and the metadata arena stay inside the `Program`, and
`ProgramIter` holds the transaction guard plus `&mut Program`.

- Remove `ExecuteError`, `Reclaim` and `try_with!`; `BindSource` returns
  `DatabaseError` again, with a `PlanArena` instead of a boxed arena.
- Remove the unsafe move out and write back (`ptr::read`/`ptr::write`,
  `AbortOnUnwind`, `ManuallyDrop`) and `TransactionIter::into_program`.
- Split `begin_execution` out of `start_execution` to start an executor
  in place; `TransactionGuard::next_tuple` serves both iterators.
- `ParamArena::bind` overwrites the bound values in place instead of
  rebuilding every parameter expression.
…E semantics

Insert and Update no longer look up the table or rebuild a snapshot of it
on every execution.

- `TableCatalog::dml_table` builds a `DmlTable` once at bind time: the
  columns, serializers, primary key positions and the key expressions of
  the secondary indexes the write maintains, chosen by a `keep_index`
  closure. `InsertOperator` and `UpdateOperator` carry it.
- The binder hands Insert full rows in table order, filling in column
  defaults, so the executor no longer maps input columns or evaluates
  defaults; `is_mapping_by_name` is gone.
- `UpdateOperator.value_exprs` holds table positions. Every assignment
  reads the old row, and assigning a column twice is rejected.
- `get_column_by_id` returns the column position too, which removes the
  linear scans that looked it up again.
- Add `TupleImpl::clear`; Insert reuses one tuple for its rows.
Every execution used to build a fresh `ExecArena`, so its result row and the
`TableCodec` key and value buffers started empty and grew again on each run.

- Split the arena: `ExecBuffer` holds what outlives an execution (the result,
  the table codec and the runtime probe stack) and is owned by the `Executor`;
  `ExecArena` keeps the transaction, the storage iterators and the other
  per-execution state. Nodes get both through `ExecContext`.
- `ExecBuffer::reset` drops what an unfinished execution left behind.
- Size the key buffers once with `DataValue::memcomparable_len_hint`, and the
  tuple and index values with their length hints plus the stamp.
Replace the 8-byte table-name hash prefix with a monotonically allocated
table id, so keys no longer depend on names and hash collisions are gone.

- Root is keyed by the raw name and maps to TableMeta { name, id, is_view },
  shared by tables and views as the name directory.
- NextTableId holds the next id; tables and views draw from it.
- Column, index meta, statistics, index and tuple keys use the big-endian id;
  views are stored under View{id}.
- Drop the Hash marker keys and DuplicateSourceHash.
- Codec and storage write paths take TableId; DML gets it from DmlTable or
  the catalog.

The on-disk format is incompatible with earlier versions.
Table names are resolved once at bind time; planning, execution and
serialization work with table ids, and names only come back for display.

- The table arena owns the catalog: `NameMapping`, `TableCache` keyed by
  `TableId` and `ViewCache`. `MetaArena::table(id)` looks a table up, and
  `table_name` derives from it, replacing the separate name registry.
- `ColumnRelation` splits into `Table { table_id, column_id }` and
  `Source { source_id, column_id, is_temp }`. Other FROM items only carry an
  id; the source name list is gone and explain shows them as `#id.column`.
- The binder matches columns to sources by relation instead of names, and
  `BoundSource` borrows its name from the catalog.
- Drop `table_aliases`: a FROM item names a real table, not an earlier
  alias. `with_pk` holds a table id.
- Scans, DML and DDL operators carry the table id; DDL executors take the
  name from the cached catalog, and binder and storage entry points take
  `&str` instead of owned names.
- Column ref decoding finds catalog columns through the arena, so the
  decode context no longer holds a transaction.
Decoding a string or binary column allocated a fresh buffer for every
value, and projection appended its output to the input row, so the row
grew and was cloned value by value on each row.

- `BytesPool` in `TableCodec` keeps the buffers of dropped string and
  binary values. Decoding takes from it, and the row buffer gives its old
  values back before the next decode. It holds at most 64 buffers of up to
  4096 bytes.
- Char, Varchar and Varbinary decode from the input slice instead of a
  zeroed vector; CHAR trims the padding before copying.
- `TupleValueSerializable::filling_value` takes the pool and is the only
  decode method; `from_raw` is gone.
- `with_tuple_bound` and `with_tuple_unchecked` hand the pool to their
  closure, since the codec stays borrowed while it runs.
- `rewrite` evaluates into a spare vector and swaps it with the row,
  instead of appending and rotating; borrowed values are copied through
  the pool.
- Primary key lookups clone the tuple id only when they hit and keep it.
- tpcc: `explain_tpcc` names columns through `SchemaView::full_name`.
- macros-test: drop `UserOrderSummary`, unused since the ORM refactor.
- db: `test_prepare_statment` expects derived table columns as `#id.column`.
- slt: explain plans match source ids with `<slt:ignore>`, since the ids
  depend on the views loaded before; `show views` lists views by id.
Rerun of the 720-second single-thread matrix on cpu8 (2026-10-31).
KiteSQL LMDB reaches 187161 TpmC against 179039 for SQLite in the same
run.
The SQLite corpus expects `UPDATE t1 SET x=3, x=4, x=5` to keep the
rightmost assignment; standard SQL rejects a column assigned more than
once, as KiteSQL now does. Skip that statement and the queries that check
its effect, listed in `NON_STANDARD` with the reason.
@codecov

codecov Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.87370% with 211 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.08%. Comparing base (0ac2fe7) to head (adaea82).

Files with missing lines Patch % Lines
src/execution/mod.rs 94.11% 12 Missing ⚠️
src/planner/arena.rs 86.81% 12 Missing ⚠️
src/execution/dml/analyze.rs 74.28% 9 Missing ⚠️
src/execution/ddl/drop_column.rs 75.00% 8 Missing ⚠️
src/execution/dml/copy_to_file.rs 57.89% 8 Missing ⚠️
src/execution/dml/delete.rs 72.41% 8 Missing ⚠️
src/execution/dml/insert.rs 86.44% 8 Missing ⚠️
src/binder/parser.rs 93.00% 7 Missing ⚠️
src/db/program.rs 91.02% 7 Missing ⚠️
src/execution/ddl/add_column.rs 74.07% 7 Missing ⚠️
... and 33 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #396      +/-   ##
==========================================
- Coverage   93.39%   93.08%   -0.31%     
==========================================
  Files         272      272              
  Lines       39089    38711     -378     
==========================================
- Hits        36508    36036     -472     
- Misses       2581     2675      +94     
Flag Coverage Δ
rust 93.08% <90.87%> (-0.31%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/binder/aggregate.rs 97.22% <ø> (ø)
src/binder/alter_table.rs 100.00% <100.00%> (ø)
src/binder/analyze.rs 96.29% <100.00%> (ø)
src/binder/copy.rs 72.72% <100.00%> (+0.98%) ⬆️
src/binder/create_index.rs 89.47% <100.00%> (-1.01%) ⬇️
src/binder/create_table.rs 76.66% <ø> (ø)
src/binder/create_view.rs 100.00% <100.00%> (ø)
src/binder/delete.rs 100.00% <100.00%> (ø)
src/binder/describe.rs 100.00% <100.00%> (ø)
src/binder/distinct.rs 98.66% <ø> (ø)
... and 142 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

- AliasType::Expr(ExprRef) becomes AliasType::Column(ColumnRef): the
  target only carries the output column identity, so the binder no longer
  allocates a column expression to wrap it.
- BinderContext::table_id resolves the name mapping without loading the
  table catalog; truncate, delete and the ORM use it.
- Source names borrow from the catalog, and INSERT without a column list
  borrows the table schema instead of copying it.
- INSERT maps targets to table columns in one pass instead of scanning the
  targets for every column.
- Drop begin_statement_scope/end_statement_scope: no backend implements
  them, and several error paths never ended the scope. TransactionGuard
  no longer needs a raw transaction pointer.
- ALTER TABLE builds its old and new serializers once per statement
  instead of once per row.
- memcomparable_len_hint lists every DataValue variant with its encoded
  length instead of a 16-byte fallback.
- Rename begin_execution to start_execution_inner and the table arena
  locals from catalog to arena.
- Add an UPDATE case where every SET expression reads the old row.
@KKould
KKould merged commit 943033a into main Nov 1, 2026
14 checks passed
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.

1 participant