Skip to content

perf: borrow LMDB strings without copying and reuse execution buffers - #399

Merged
KKould merged 14 commits into
mainfrom
feat/zero_copy
Nov 6, 2026
Merged

KKould merged 14 commits into
mainfrom
feat/zero_copy

Conversation

@KKould

@KKould KKould commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

What problem does this PR solve?

LMDB reads copied every string and binary out of the memory map, and reused executors reallocated their buffers on every execution.

Issue link:

What is changed and how it works?

  • Strings and binaries read in LMDB read-only transactions point into the memory map; values leaving the transaction are copied. Materializing operators keep them mapped until reset.
  • Bound keys, parameters, scan ranges and iterator slots reuse their buffers across executions; string parameters bind without allocating.
  • Row reads skip columns nothing reads, and projections move plain columns instead of cloning them.
  • Fix: MarkApply no longer reuses its materialized RHS from a previous execution.
  • TPC-C (720s): KiteSQL LMDB TpmC 209840 -> 225720 (SQLite 192110).

Breaking: Utf8 / Binary value types, ResultRow in next_tuple / FromQueryRow, Transaction::fetch returns TxnOrmIter, Program::execute takes (usize, Param).

Code changes

  • Has Rust code change
  • Has CI related scripts change

Check List

Tests

  • Unit test
  • Integration test
  • Manual test (add detailed scripts or steps below)
  • No code

Side effects

  • Performance regression: Consumes more CPU
  • Performance regression: Consumes more Memory
  • Breaking backward compatibility

Note for reviewer

KKould added 11 commits November 6, 2026 18:36
A reused executor, such as a Program's, resets its tree before every
execution, and MarkApply kept its materialized RHS through that reset,
so an IN/ANY subquery answered with the rows of an earlier execution,
possibly from another transaction. Executor::start now marks the reset
as a restart, and MarkApply keeps its cache only when a parent rescans
it within one execution.
…ctions

DataValue::Utf8 and DataValue::Binary now hold Utf8Str and BinaryBytes,
which are owned or point into the reads of a MappedScope. An LMDB
read-only transaction holds one, so the Char, Varchar and Varbinary
columns it decodes point into its memory map instead of being copied.

Mapped values never leave a transaction as is: cloning one copies it,
and result rows (ResultRow) read without copying but hand values out
copied, or lent for 'txn. TransactionIter and ProgramIter borrow their
transaction, so their next_tuple lends values until it ends; other
iterators lend them only within the callback.

The ORM decodes through FromTxnRow and FromTxnValue on those iterators
(txn_orm, Transaction::get and Transaction::fetch), and the Model and
Projection derives accept one lifetime, so Cow<'txn, str> fields borrow
from the transaction.

Breaking: the value types of Utf8 and Binary, next_tuple callbacks and
FromQueryRow take a ResultRow, and Transaction::fetch returns a
TxnOrmIter.
- StorageTransaction::range/range_rev and remove_range take a BytesPool;
  LMDB and memory iterators copy their bounds into pooled buffers and
  InnerIter::recycle hands them back.
- TableCodec range-bound helpers hand their BytesPool to the callback.
- IndexIter takes its encode buffers from the pool lazily, and finished
  executions recycle every storage iterator's buffers.
- DataValue implements clone_from to reuse string, binary and tuple
  buffers; ParamArena::bind overwrites the previous values with it.
- IndexScan keeps its ranges in ScanRanges: the plan's static range, or an
  owned range that parameters bind into with Range::bind_from, so reruns
  reuse its buffers. IndexIter no longer holds ranges; each call lends them
  with the cover mapping through IndexScanView.
- The cover mapping is computed once per executor (TupleMapping::cover)
  instead of on every read_by_index.
- Bound keys encode into ExecBuffer::bound_buffers, and StorageIter holds
  IndexIter unboxed (416 -> 200 bytes on LMDB).
- DataValue/RangeValue/Range bind_from replace bind_parameters, writing a
  bound template over the previous value in place; ParamArena::bind
  matches the slot directly.
- Range::as_slice; next_scan_tuple and next_tuple_for_test take a closure.
- Executions reuse the allocation of their storage iterator slots: an
  emptied slot Vec goes back to ExecBuffer type-erased (SpareIters) and
  comes back when the slot layout matches. New slots reserve exactly.
- A ProgramIter dropped before its rows run out still hands its iterator
  buffers and slots back to the program.
- Program::execute takes `(usize, Param)` pairs. Param::Str and
  Param::Bytes borrow, and binding copies them into the buffers of the
  previous execution, so string parameters bind without allocating.
  tpcc's DbParam follows.
- Fixed-size integers decode straight from the row slice.
- SkipFixed and SkipVariable carry `null`: with it the column's bytes are
  skipped but it keeps its slot as a NULL. SkipResidualFilterColumns uses
  it for table-row reads (looked-up index rows, primary key and sequential
  scans), so a column only the eliminated index predicate read is no
  longer decoded. Covered reads keep using the cover mapping.
- ProjectOperator::exprs holds `(Option<usize>, ExprRef)`. The last
  optimizer rule, MapProjectColumns, sets the input position of each plain
  column no other projected expression reads, and rewrite moves that
  value out instead of evaluating and cloning it.
- ProjectOperator::new takes an iterator of expressions.
…ons read-only

Strings decoded from stored rows are no longer validated as UTF-8 outside
debug builds: every stored string was encoded from a `str`. Order-Status and
Stock-Level now run in read-only transactions, as TPC-C allows; SQLite starts
them with a deferred BEGIN.
Operators that keep values while they run cloned them, and cloning a
mapped string copies it out of the read transaction. They now copy with
`DataValue::clone_in_txn`, which leaves a mapped value pointing into its
read: grouping, distinct, sort, window and join keys, joined rows of hash
and nested-loop joins, MIN/MAX and DISTINCT aggregate state, IN-subquery
keys, index probes and set-operation rows. Each drops what it kept when
`reset` runs before the next execution. `Clone` still copies, so values
that leave the engine stay owned.
alloc_trace runs one prepared, index-driven query on KiteSQL (LMDB) and
on SQLite, with and without mmap, for notes from 64 B to 256 KiB. It
reports latency, malloc and lookaside counts, live heap, and where the
returned note pointers point. A multi-op query passes the same rows
through grouping, a window and a sort.

borrowed_parse keeps a whole result set after the query, as raw rows
and parsed from JSON and CSV, borrowing every string on KiteSQL and
owning them on SQLite.

Both count allocations by interposing glibc malloc, so they run on
Linux with glibc only.
@codecov

codecov Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.75945% with 149 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.03%. Comparing base (4861378) to head (7ee7729).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/types/mapped.rs 85.62% 24 Missing ⚠️
src/types/value.rs 86.00% 21 Missing ⚠️
src/orm/mod.rs 73.23% 19 Missing ⚠️
src/db/program.rs 40.00% 18 Missing ⚠️
src/db.rs 84.25% 17 Missing ⚠️
src/planner/arena.rs 53.12% 15 Missing ⚠️
src/types/serialize.rs 92.52% 8 Missing ⚠️
src/storage/mod.rs 96.55% 7 Missing ⚠️
src/expression/range_detacher.rs 93.40% 6 Missing ⚠️
src/types/tuple.rs 70.58% 5 Missing ⚠️
... and 6 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #399      +/-   ##
==========================================
- Coverage   93.07%   93.03%   -0.04%     
==========================================
  Files         273      274       +1     
  Lines       39175    39998     +823     
==========================================
+ Hits        36461    37214     +753     
- Misses       2714     2784      +70     
Flag Coverage Δ
rust 93.03% <89.75%> (-0.04%) ⬇️

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

Files with missing lines Coverage Δ
src/binder/expr.rs 92.78% <100.00%> (ø)
src/binder/parser.rs 90.83% <100.00%> (-0.01%) ⬇️
src/binder/select.rs 95.90% <100.00%> (+0.01%) ⬆️
src/execution/dml/analyze.rs 90.32% <100.00%> (ø)
src/execution/dql/aggregate/avg.rs 83.63% <100.00%> (ø)
src/execution/dql/aggregate/count.rs 89.13% <100.00%> (+0.24%) ⬆️
src/execution/dql/aggregate/group_concat.rs 75.00% <100.00%> (+0.58%) ⬆️
src/execution/dql/aggregate/hash_agg.rs 97.72% <100.00%> (-0.08%) ⬇️
src/execution/dql/aggregate/min_max.rs 96.77% <100.00%> (+9.67%) ⬆️
src/execution/dql/aggregate/stream_agg.rs 83.72% <100.00%> (-0.55%) ⬇️
... and 71 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

IndexScan borrows the plan's static range or owns one that parameters bind into, so the ScanRanges enum and its range() accessor go away.
Fixes clippy's missing_safety_doc and drops the doc comment of Tuple::deserialize_from_into.
@KKould
KKould merged commit 07d968a into main Nov 6, 2026
12 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