Skip to content

perf: cover scans with primary keys and estimate key prefixes for TPC-C - #397

Merged
KKould merged 9 commits into
mainfrom
perf/tpcc-payment-delivery
Nov 2, 2026
Merged

KKould merged 9 commits into
mainfrom
perf/tpcc-payment-delivery

Conversation

@KKould

@KKould KKould commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #396, closing more of the TPC-C gap with SQLite on LMDB.

Changes

  • perf(execution): flatten hash keys and reuse accumulators.
  • feat(range): compute parameter arithmetic as index range bounds. Range is no longer generic: its bounds are RangeValue, a value or a ComputedValue (left op right over parameters and constants for + - * / %). ol_o_id >= ($4 - 20) in Stock-Level becomes an index range with no residual Filter; IndexScan computes the bound when it binds the range. A NULL bound empties the range, and <=> no longer takes a parameter as a bound. Selectivity estimation walks the bounds' fields, so different computed bounds are no longer estimated as a point.
  • feat(index): cover scans with primary key fields of secondary indexes. A secondary index entry holds the primary key, so scans reading only index columns and primary key fields skip the row lookup. Payment and Order-Status read customers by c_last this way.
  • feat(statistics): estimate composite key prefixes with their own NDV. ANALYZE counts the NDV of every key prefix; prefix ranges are estimated from it instead of spreading the whole key's NDV evenly over the fields. Prepared Delivery statements on order_line now pick the primary key prefix over fkey_order_line_1 plus a row lookup each. This changes the stored statistics format: existing databases must ANALYZE again.
  • refactor(stream-agg): carry the row that closes a group to the next call. The closed group's output overwrites the row that closed it, so the executor kept a second set of accumulators to fold that row first. It now writes the closed group in order and keeps only the row's aggregate arguments, taken from the bytes pool, to open the next group on the next call.
  • refactor(index): replace covered deserializers with a covered flag. A covered scan decodes the memcomparable index key from the index's value type, so IndexInfo::covered_deserializers was only checked for presence; it is now covered: bool.
  • test(storage) wraps the wasm-only memory storage test's range bounds in RangeValue, and refactor(range) renames RangeValue::fields to iter.

TPC-C (720 s, 1 warehouse, pinned to one P-core, p90 µs)

Measured on the PR head; README and tpcc/README.md carry the same numbers.

Backend TpmC New-Order Payment Order-Status Delivery Stock-Level
KiteSQL LMDB (#396 README) 187161 274 68 40 454 278
KiteSQL LMDB (this PR) 209840 254 50 28 345 202
KiteSQL RocksDB (this PR) 48049 354 85 146 14511 562
SQLite (same run) 192435 270 40 33 344 265

LMDB is now at or below SQLite on every transaction except Payment (50 vs 40 µs); Delivery matches it (345 vs 344 µs) since prepared statements read order_line by its primary key prefix.

Testing

make clippy, make test, make test-slt and make test-slt-sqlite pass on the first three commits. The later commits were checked with make clippy, the lib tests and make test-slt; make test-wasm, make miri and make native-examples pass after the wasm fix.

Hash operators now keep their keys and rows in flat buffers, with the hash
table holding offsets or indexes, so new keys no longer allocate.

- HashAgg evaluates each key straight into one buffer and stores
  accumulators back to back; the table holds the group index.
- StreamAgg keeps the open group's keys and the incoming row's keys in one
  buffer, and swaps two accumulator sets instead of building new ones.
- HashJoin keeps distinct keys in one buffer and build rows in one Vec,
  chaining the rows of a key through `next`. This drops the bump arena and
  its lifetime transmutes, and the duplicated no-match branches of the
  join kinds.
- EXCEPT/INTERSECT store right rows flat instead of taking each tuple, and
  compare values only, no longer the primary key.
- Accumulators gain `reset` and `take_result`, so SimpleAgg and StreamAgg
  reuse them across groups and executions.
- Execution hash tables and DISTINCT accumulator sets use an FxHash-style
  hasher instead of SipHash.
`Range` is no longer generic: its bounds are `RangeValue`, either a value or a
`ComputedValue` (`left op right` over parameters and constants for + - * / %).
Predicates such as `k >= $3 - 20` now become index ranges with no residual
filter; the bound is computed in place when IndexScan binds the range.

- A NULL parameter or computed bound empties the range, and `<=>` no longer
  takes a parameter as a range bound.
- Selectivity estimation walks the bounds' fields instead of building stand-in
  values, so different computed bounds are no longer estimated as a point.
- `ParamArena::bind` clones each parameter once.
A secondary index entry holds the primary key, so a scan reading only index
columns and primary key fields no longer looks the row up by primary key.
`index_covering` gives primary key fields the slots after the index columns,
and `decode_index_into` fills them from the entry's value. TPC-C Payment and
Order-Status read customers by `c_last` this way.

Also add `DataValue::is_tuple`, `as_slice` and `as_mut_slice` for the places
that matched tuples by hand.
Index statistics kept only the whole key's NDV, so parameterized ranges on a
key prefix spread it evenly over the fields. On TPC-C order_line that
overestimated the primary key prefix (w, d, o) at ~23 rows instead of 10, and
Delivery read order lines through fkey_order_line_1 plus a row lookup each.

ANALYZE now counts the NDV of every key prefix with one HyperLogLog each;
`HistogramMeta::number_of_distinct_values` replaces the single NDV, its last
entry being the whole key's. A prefix range is estimated as one over its
prefix NDV, falling back to the even spread when the statistics have none.

This changes the stored statistics format: existing databases must ANALYZE
again.
The wasm-only memory storage test still built Range bounds from DataValue,
which no longer compiles since Range holds RangeValue.
@codecov

codecov Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.39651% with 69 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.01%. Comparing base (943033a) to head (ae9fc9c).

Files with missing lines Patch % Lines
src/expression/range_detacher.rs 89.33% 24 Missing ⚠️
src/execution/dql/aggregate/avg.rs 36.36% 7 Missing ⚠️
src/execution/dql/aggregate/stream_agg.rs 85.36% 6 Missing ⚠️
src/execution/dql/aggregate/group_concat.rs 44.44% 5 Missing ⚠️
src/execution/dql/aggregate/mod.rs 88.37% 5 Missing ⚠️
src/execution/dql/aggregate/total.rs 44.44% 5 Missing ⚠️
src/execution/dql/aggregate/count.rs 75.00% 3 Missing ⚠️
src/execution/dql/aggregate/sum.rs 75.00% 3 Missing ⚠️
src/execution/hash.rs 90.62% 3 Missing ⚠️
src/optimizer/core/histogram.rs 96.70% 3 Missing ⚠️
... and 3 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #397      +/-   ##
==========================================
- Coverage   93.08%   93.01%   -0.08%     
==========================================
  Files         272      273       +1     
  Lines       38711    39012     +301     
==========================================
+ Hits        36036    36286     +250     
- Misses       2675     2726      +51     
Flag Coverage Δ
rust 93.01% <91.39%> (-0.08%) ⬇️

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

Files with missing lines Coverage Δ
src/db/program.rs 92.59% <ø> (ø)
src/execution/dml/analyze.rs 90.32% <ø> (ø)
src/execution/dql/aggregate/simple_agg.rs 100.00% <100.00%> (+8.92%) ⬆️
src/execution/dql/index_scan.rs 97.24% <100.00%> (ø)
src/execution/dql/join/hash/full_join.rs 100.00% <100.00%> (+5.12%) ⬆️
src/execution/dql/join/hash/inner_join.rs 100.00% <100.00%> (ø)
src/execution/dql/join/hash/left_join.rs 100.00% <100.00%> (ø)
src/execution/dql/join/hash/mod.rs 98.57% <100.00%> (ø)
src/execution/dql/join/hash/right_join.rs 100.00% <100.00%> (+7.14%) ⬆️
src/execution/dql/join/hash_join.rs 90.65% <100.00%> (+0.65%) ⬆️
... and 34 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The closed group's output overwrites the row that closed it, so the executor
kept a second set of accumulators to fold that row before writing the output.
It now writes the closed group in order and keeps only the row's aggregate
arguments, taken from the bytes pool, to open the next group on the next
call. `aggregate_arg` is factored out of `update_accumulators` for this.
It returns an iterator, not a slice: a computed value follows its prefix.
A covered scan decodes the memcomparable index key from the index's value
type, never with row deserializers, so `IndexInfo::covered_deserializers` was
only ever checked for presence. It is now `covered: bool`, and
`index_covering` returns just the scan-to-index mapping.
@KKould
KKould merged commit 6a2b30a into main Nov 2, 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