Contents
DuckDB extension performance: connection pool and per-call overhead
Branch duckdb-perf on top of master a377c4d7 (stores without secondary
indexes). Same host, datasets and drivers as baseline.md: a 32-vCPU Xeon
8488C, Debian 12, release extension, DuckDB v1.5.6 Python client, gen.py
xs (~0.1M datoms) and s (~1M). perf.sh measures single-client p50s
(bench.py run, median of 3 reps), single-datom transacts and conc.py
throughput (Python threads, each with its own duckdb cursor on ONE
database). writers.py measures concurrent writes and ab.sh + loop.py
interleave two builds call by call.
Columns:
- before: a377c4d7, the index-free layout (
before-noidx/). Thebaseline.mdnumbers were taken on the indexed 1.14.0 layout. The two differ by less than 0.2 ms per read and ~5 tx/s, so the before/after below uses the index-free run. - stmt: 9e43b7b7 + 63c86ce0, statement-overhead changes (
m2-fixed/). - pool: 42094d96, connection pool + per-store write lock (
m3/).
Concurrency (ops/s at 1 / 8 / 32 client threads)
| workload | scale | before | stmt | pool |
|---|---|---|---|---|
| point_lookup | xs | 280 / 321 / 315 | 280 / 322 / 317 | 285 / 1510 / 1574 |
| mixed reads (point, ref, pull) | xs | 154 / 167 / 166 | 154 / 171 / 171 | 160 / 980 / 1039 |
| point_lookup | s | 254 / 294 / 295 | 259 / 299 / 293 | 258 / 1310 / 1262 |
| mixed reads | s | 125 / 132 / 128 | 121 / 132 / 130 | 120 / 598 / 606 |
Point-lookup p50 at 32 threads: xs 101 -> 20 ms, s 108 -> 25 ms. Before the
pool, throughput was flat from 1 to 32 threads (one connection, one global
lock). With the default pool of 8 connections it scales about 5x and then
flattens. Past 8 concurrent calls, the extra calls wait for a connection.
MENTAT_DUCKDB_POOL raises the limit.
Writes (m3/writers.txt, single-datom edn_t, tx/s):
| layout | threads | stmt (serialized) | pool |
|---|---|---|---|
| one store | 1 | 71 | 72 |
| one store | 8 | 69 | 73 |
| a store per thread | 8 | 65 | 291 |
Writes to one store take turns by design (see “Writes” below); writes to different stores now run in parallel.
Single client (p50 ms)
| scenario | xs before | xs pool | s before | s pool |
|---|---|---|---|---|
| point_lookup | 3.47 | 3.43 | 3.80 | 3.79 |
| pull | 3.52 | 3.46 | 3.85 | 3.80 |
| input_bindings | 5.64 | 5.58 | 6.37 | 6.18 |
| ref_traversal | 11.04 | 10.91 | 15.54 | 14.77 |
| aggregate | 4.99 | 4.93 | 6.40 | 6.10 |
| as_of | 24.16 | 24.12 | 28.21 | 28.60 |
| since | 2.40 | 2.40 | 2.82 | 2.80 |
| single-datom transact (tx/s) | 55 | 58 | 50 | 54 |
Interleaved per-call A/B on xs (m2-fixed/ab-xs.log, 3 rounds): point
lookup 3.50 -> 3.47 ms, pull 3.54 -> 3.54, transact 18.50 -> 17.22 ms.
Single-client latency barely moved, and that is what the profile predicts. Of a
2.8 ms in-store point lookup, 2.2 ms is DuckDB executing the query’s SQL: two
filtered scans of datoms and a hash join. Only ~0.13 ms of that is
preparing it, and mentat’s Rust is ~0.2 ms. In perf record of a loop of
extension point lookups, mentat’s own code is 1.4% of the samples.
What changed
- Connection pool (
crates/duckdb/src/lib.rs). The entrypoint opensMENTAT_DUCKDB_POOLconnections (default one per core, at most 8; the database handle is only valid during the entrypoint, so they can’t be opened later).try_clone()repeated N times inside the entrypoint works. Each call leases one and returns it on drop; with all leased, a call waits on a condvar. - Writes: a per-store lock (
DuckStore::transact, store creation, the legacy-index drop). Without it, the harness’s 8 concurrent writers fail on their first transaction with “TransactionContext Error: Conflict on update!”: every transaction updatesknown_parts, and DuckDB’s optimistic concurrency lets one of two concurrent updaters win. Retrying would also work. The lock is simpler, and a single store’s writes can’t overlap anyway. It is per process; another process can’t open the database for writing. - Schema cache is per thread, revalidated against
meta.generationon every call. It doesn’t depend on which connection a call gets, so it is unchanged. - One generation read fewer per transact (
openreturns the generation it validated;transactcompares against it inside the transaction), andknown_partsis updated only for partitions whose index moved (1 UPDATE instead of 3 for an ordinary transaction). - Prepared-statement cache, opt-in.
SqlConn::query_cached/execute_cachedkeep a statement prepared. The store uses them only for single-table statements on its own bookkeeping tables, with every varying value bound: the generation read, theknown_partsread and theknown_partsUPDATE. Engine queries, joins and the transact DML are prepared fresh.
The prepared-statement bug (and why engine queries aren’t cached)
9e43b7b7 cached every statement. That broke queries: with two stores on one
connection, a count on the second store kept returning 0 after it had
datoms. On DuckDB 1.5.x a reused prepared plan keeps the column statistics it
was planned with. A filter with a constant outside them (the engine inlines
entids: datoms.a = 65536 before that attribute has datoms) keeps matching
nothing after rows arrive. repro-stale-prepared.sql shows it in plain SQL.
A bound parameter (a = $1) is not affected there; the join-statistics
variant is. Upstream: duckdb/duckdb#23704 / #21207, fixed by PR #24696
(merged to main 2026-08-14, in no 1.5.x release); joins: #24182. 63c86ce0
restricts caching as above. Two harness tests fail on 9e43b7b7 and pass
since. Caching engine queries can be reconsidered once the pinned DuckDB
includes #24696 and #24182; it would save ~0.1-0.2 ms per call.
Not done
- Faster single-client reads. The cost is DuckDB scanning
datoms. Things tried and measured:- A sorted copy of
datomscut the join from 2.4 to 1.8 ms (probes/layout.py). - Pre-filtering the transact search join by entity made it slower
(2.4 -> 3.4 ms,
probes/search.py). - Disabling statistics propagation changed nothing (
probes/statcost.py).
Clustering
datomsby(a, e)on checkpoint is the most promising lead. - A sorted copy of
- Transact cost (17 ms) is mostly DuckDB too: COMMIT 3.5 ms, the search
INSERT ... SELECT3.4 ms, and recreating three temp tables per transaction (0.9 ms). Reusing the temp tables withDELETEmeasured the same (probes/cine.py). - The WAL replay index bug (duckdb/duckdb#26828) was found here and is
handled on master (a377c4d7);
repro5.shandrepro-mentat.shreproduce it.
Gate (on 42094d96, the perf box)
| check | result |
|---|---|
cargo fmt --all --check (+ tests-harness) |
pass |
cargo clippy -p mentat_duckdb -p mentat_duckdb_store -- -D warnings |
pass, 0 warnings |
cargo test --manifest-path crates/duckdb/store/tests-harness/Cargo.toml |
pass: differential (0 disagreements vs SQLite), store 10, script_model 15, concurrency 1 |
make debug && DUCKDB=~/duckdb bash test/smoke.sh |
PASS |
python test/concurrency.py build/debug/mentat.duckdb_extension |
OK |
SQLLogicTest (duckdb==1.5.6, duckdb-sqllogictest-python@2ac8dbc) |
SUCCESS, 1/1 |
workspace cargo test (default members) |
pass: 1769 passed, 0 failed, 30 ignored |