Recursive AI

Benchmarks

All onelite docs

OneLite's own benchmark suite lives in bench/OneLite.Bench. It compares OneLite with the reference SQLite library at two levels and measures nothing else. The tables below are the first published baseline (commit 7e028d2). Speed is a target, not a release gate: the reference ratios are information, and the first baseline tables below predate the tuning described under "Tuning: prepare and point lookups".

Method

Levels

  • Engine: OneLite's Statement.Step (sync driver) against the native sqlite3_step, called through a raw LibraryImport of libe_sqlite3 3.53.4. The P/Invoke code exists only in the bench project.
  • App: RecursiveAI.OneLite.Data against Microsoft.Data.Sqlite 10.0.14 on SQLitePCLRaw.bundle_e_sqlite3 3.0.5 (SQLite 3.53.4). Both go through one shared driver, so the code above the ADO.NET API is identical.

Settings, identical on both sides. Page size 4096, cache_size = -2000 (2 MB), the same file system (a scratch directory on local disk), the same data generator and seed, the same SQL. journal_mode and synchronous are set per row by PRAGMA on both sides (default WAL/NORMAL; insert_autocommit uses delete/FULL; wal_commit_full uses WAL/FULL). The base table has 100,000 rows. Each side computes a checksum of what the queries returned and the report shows "Results agree".

Measurement. Every row × level × side runs in its own child process, so peak memory is clean (it includes the runtime baseline of roughly 35–50 MB). Each runs one warm-up and 3 timed repetitions; the report gives the median. The allocation column is GC.GetAllocatedBytesForCurrentThread per operation. There is no BenchmarkDotNet; the harness is a plain stopwatch loop, because 60+ process-isolated measurements would not fit the time boxes.

Runs. One run per machine, in two groups (A: write, prepare, open and cold-start rows; B: read and query rows), each run alone on the machine by a queue that does not start anything else beside a timing job. The two groups are merged; the geometric mean covers every row except open_time, which is reported only.

Reproduce with bench/run.sh --rows <a,b|all> --levels engine,app --reps 3 --out <prefix> (--vec points at the sqlite-vec loadable for the vec0_knn reference; --aot at the published bench/OneLite.Bench.Cold for the NativeAOT cold-start row). OneLite.Bench merge combines filtered runs.

Rows. insert_tx_10k and insert_tx_100k (bulk insert in one transaction); insert_autocommit (200 single-row commits); prepare_cold (first prepare of new statement texts); wal_commit_normal and wal_commit_full (1,000 commits); lookup_rowid and lookup_index (200,000 point lookups); scan_filter (unindexed scan with a filter); range_scan; join (two tables); group_by; order_by_sort (sort larger than the cache); fts5_top10 (FTS5 MATCH, top 10 by bm25); vec0_knn (vec0 k-NN, against sqlite-vec 0.1.9's prebuilt extension); prepare_cost; open_time (open, one query, close). Cold start (process start to the first answer, JIT and NativeAOT) is separate.

Read these first

  • fsync against fdatasync. On Linux the pinned libe_sqlite3 issues fsync for each commit sync and OneLite issues fdatasync. The number of syncs is the same (about 2,012 for 2 × 1,000 commits in strace), but fdatasync skips the file-metadata flush, so wal_commit_full (0.28× on x64) and insert_autocommit (0.85×) are faster than native for a reason that is not engine speed. It is a property of the reference build, not a durability gap by count. On macOS the two sides are close (1.9× and 1.1×).
  • Concurrent writers. The BEGIN CONCURRENT throughput row is not part of this baseline. It is pending until the feature's measurements are ready; there is no number for it here.
  • Short rows include JIT. insert_tx_10k runs about 80 ms, and the warm-up is a single run, so tier-0 code is part of the timing. insert_tx_100k shows the steadier ratio (3.9× against 18.7× on x64).
  • Allocation. The cached hot path allocates nothing per row stepped (scan_filter 0.2 B/op, join 0.4 B/op) and, since the tuning below, nothing per statement execution of a point lookup (bind, step, reset: 0 B at engine level). Preparing a statement that was prepared before on the connection allocates about 140 B; a first prepare about 23 KB. fts5_top10 allocates 5.7 MB per query.
  • Mac load. The Mac was not idle by itself: its 1-minute load average sat near 2 before, during and after both groups, with no other queue job running. Treat the arm64 numbers as slightly less quiet than the x64 ones.

x64: AMD Ryzen 9 7950X3D, 32 cores, Linux Mint 22.3

.NET 10.0.13 (SDK 10.0.402), SQLite 3.53.4, Microsoft.Data.Sqlite 10.0.14. Load average (1 min): group A 0.76 before, 1.83 highest, 1.77 after; group B 1.25 before, 1.28 highest, 1.24 after. Group B was run twice. The first run started at a load of 4.54 and could not be shown to have been alone, so it was discarded and re-run; the re-run agrees with it to within a few percent on every row.

Engine level

RowOpsOneLite msNative msRatioOneLite B/opPeak MB (OneLite / native)
insert_tx_10k10,00082.504.4218.65×43562 / 41
insert_tx_100k100,000258.6966.803.87×441116 / 66
insert_autocommit2003204.153791.290.85×1,54557 / 36
wal_commit_normal1,00050.0028.021.78×2,31156 / 33
wal_commit_full1,0001803.986411.150.28×2,43162 / 36
prepare_cost3,000161.198.2619.52×23,315102 / 34
open_time (report only)2001269.8135.1336.15×54,539113 / 59
lookup_rowid200,000709.25481.791.47×600118 / 59
lookup_index200,000898.92367.412.45×600122 / 60
scan_filter100,00020.484.624.44×0.2108 / 58
range_scan2,00072.6016.094.51×610111 / 60
join100,00023.2868.690.34×0.4112 / 60
group_by100,000125.0722.135.65×122115 / 60
order_by_sort100,000101.7431.463.23×273114 / 61
fts5_top10300476.9443.7210.91×5,713,668138 / 52
vec0_knn100123.84266.750.46×76,298123 / 44

Geometric mean, 15 rows: 2.56× (target ≤1.15×; per row ≤1.5×).

App level

RowOneLite.Data msMicrosoft.Data.Sqlite msRatioOneLite B/opPeak MB (OneLite / MDS)
insert_tx_10k125.9815.208.29×21,077109 / 53
insert_tx_100k577.35143.534.02×21,633122 / 96
insert_autocommit3204.843796.810.84×18,89167 / 41
wal_commit_normal72.9728.212.59×22,54480 / 40
wal_commit_full1834.696337.000.29×22,66486 / 43
prepare_cost188.2022.258.46×27,166107 / 50
open_time (report only)1280.5138.7633.04×56,627123 / 90
lookup_rowid1574.73575.422.74×15,288128 / 96
lookup_index1887.94470.064.02×18,648128 / 96
scan_filter20.596.493.17×0.2117 / 87
range_scan116.6431.033.76×20,496119 / 90
join23.7368.640.35×0.4122 / 90
group_by126.3922.555.60×122125 / 91
order_by_sort131.0650.722.58×377124 / 92
fts5_top10482.2742.9511.23×5,730,055143 / 61
vec0_knn151.72261.340.58×92,080129 / 60

Geometric mean, 15 rows: 2.47× (target ≤1.0×; per row ≤1.25×).

Cold start (report only)

OneLiteReference
JIT, Statement.Step against native129 ms29 ms
NativeAOT, OneLite against native via P/Invoke20 ms8 ms
JIT, .Data against Microsoft.Data.Sqlite126 ms47 ms

arm64: Apple M2, 8 cores, macOS 27.0.1

.NET 10.0.11, SQLite 3.53.4, Microsoft.Data.Sqlite 10.0.14. Load average (1 min): group A 2.05 before, 2.05 highest, 1.97 after; group B 2.04 before, 2.21 highest, 2.16 after (see the note above).

Engine level

RowOpsOneLite msNative msRatioOneLite B/opPeak MB (OneLite / native)
insert_tx_10k10,00073.004.6915.56×43571 / 53
insert_tx_100k100,000269.3852.495.13×44184 / 61
insert_autocommit20043.8740.281.09×1,91361 / 48
wal_commit_normal1,00035.587.994.45×2,31164 / 49
wal_commit_full1,00064.6334.461.88×2,43169 / 49
prepare_cost3,000106.776.0917.54×23,31668 / 49
open_time (report only)20069.2156.351.23×55,57486 / 60
lookup_rowid200,000503.89290.811.73×60086 / 60
lookup_index200,000688.78229.753.00×60085 / 63
scan_filter100,00024.524.076.02×0.281 / 60
range_scan2,00084.8113.066.50×61082 / 63
join100,00027.3256.120.49×0.484 / 63
group_by100,000125.6924.385.16×12288 / 63
order_by_sort100,000117.8438.593.05×273104 / 63
fts5_top10300578.4938.4415.05×5,713,686103 / 63
vec0_knn100124.89158.270.79×76,29890 / 60

Geometric mean, 15 rows: 3.62×.

App level

RowOneLite.Data msMicrosoft.Data.Sqlite msRatioOneLite B/opPeak MB (OneLite / MDS)
insert_tx_10k89.8416.915.31×20,98880 / 63
insert_tx_100k507.1687.145.82×21,63389 / 66
insert_autocommit47.9840.701.18×19,26768 / 54
wal_commit_normal50.899.455.38×22,54474 / 54
wal_commit_full80.1436.562.19×22,66474 / 54
prepare_cost154.7918.308.46×27,16773 / 61
open_time (report only)64.9053.361.22×57,78287 / 66
lookup_rowid1170.59357.053.28×15,28990 / 67
lookup_index1439.63285.675.04×18,64991 / 69
scan_filter26.086.064.31×0.288 / 65
range_scan114.9129.503.90×20,49790 / 68
join26.5056.170.47×0.491 / 69
group_by125.4824.425.14×12293 / 69
order_by_sort142.3862.872.26×377107 / 70
fts5_top10591.3839.3015.05×5,730,067115 / 70
vec0_knn247.15159.841.55×92,08494 / 67

Geometric mean, 15 rows: 3.47×.

Cold start (report only)

OneLiteReference
JIT, Statement.Step against native109 ms41 ms
NativeAOT, OneLite against native via P/Invoke24 ms16 ms
JIT, .Data against Microsoft.Data.Sqlite116 ms55 ms

The five worst engine rows, with the suspected cause

No fixes have been made; each is a candidate for its own tuning unit. Ratios are x64 / arm64.

  1. prepare_cost (19.5× / 17.5×): parse, plan and code generation allocate about 23 KB per statement. Fixed for repeated prepares by statement reuse, see "Tuning: prepare and point lookups"; a first prepare of a text still costs this much.
  2. insert_tx_10k (18.7× / 15.6×): the row is short (about 80 ms) so JIT and tier-0 code dominate; the same insert path is 3.9× / 5.1× at 100,000 rows, where the remaining gap is per-row allocation (435 B/op) and the record build.
  3. fts5_top10 (10.9× / 15.1×): 5.7 MB allocated per query, which points at the FTS5 segment and doclist readers copying into fresh buffers instead of reading spans in place.
  4. group_by (5.7× / 5.2×): the aggregation and the grouping sorter or hash path (122 B per row), not the scan.
  5. range_scan (4.5× / 6.5×): each execution re-seeked and allocated about 600 B, and every index entry was followed to the table row. Both fixed, see "Tuning: prepare and point lookups"; the unindexed scan_filter (4.4× / 6.0×) points at the dispatch loop's per-row cost rather than I/O.

Also visible: open_time is 36× on Linux and about 1.2× on macOS, so a Linux-only open or close cost (locks, -shm, recovery on open or the closing checkpoint) is suspected.

Tuning: prepare and point lookups

Three changes, measured on the same machine in one run, before and after: (1) a finalized statement leaves its program, machine and cursors with the connection and the next prepare of the same text takes them (schema, foreign_keys and encoding unchanged; at most 32 texts); (2) a read transaction in WAL mode starts and ends without allocating and, while the wal-index snapshot is unchanged, skips re-reading the database size and page 1; (3) a query on one rowid table that an index answers completely reads the index alone. x64 only (AMD Ryzen 9 7950X3D, 32 cores, Linux Mint 22.3), .NET 10.0.13, SQLite 3.53.4, Microsoft.Data.Sqlite 10.0.14, 3 repetitions after a warm-up, the run alone on the machine (load average 2.2 before the first half and 1.6 before the second, CPU idle when it started). The ratios are against native SQLite; B/op is OneLite's allocation per operation.

prepare_cost prepares the same three texts 1,000 times, so after the first round every prepare is a reuse; native SQLite compiles each time. Read its ratio as the cost of preparing a text again, not of compiling.

Engine level

RowBefore msAfter msNative ms (before / after)Ratio beforeRatio afterB/op beforeB/op after
prepare_cost153.770.657.17 / 7.3521.43×0.09×23,600136
lookup_rowid725.39402.49488.64 / 478.771.48×0.84×6000
lookup_index913.31494.50371.26 / 373.002.46×1.33×6000
range_scan72.9631.4616.59 / 16.084.40×1.96×6100.1

App level (RecursiveAI.OneLite.Data against Microsoft.Data.Sqlite)

RowBefore msAfter msMDS ms (before / after)Ratio beforeRatio afterB/op beforeB/op after
prepare_cost196.5520.9921.71 / 22.249.06×0.94×27,5041,712
lookup_rowid1550.08465.45590.09 / 582.472.63×0.80×15,520376
lookup_index1922.49550.40462.86 / 462.664.15×1.19×18,920272
range_scan115.4236.6330.64 / 30.503.77×1.20×20,768296

Cold prepare (first prepare of a new statement text)

prepare_cold prepares three statements of the prepare_cost shapes 1,000 times, each round with a new literal, so no text has been prepared before and nothing is reused. Read it next to prepare_cost (reuse): reuse is 0.65 ms, a first prepare is about 160 ms, because the reuse cache only helps repeated text. Indicative (a shared heavy queue slot, not a bench slot, 3 repetitions, one x64 Linux machine, 4 cores reserved, load average about 6.5 before and 7.5 after; native SQLite 3.53.4 and Microsoft.Data.Sqlite as in the other rows):

LevelOneLite msReference msRatio
engine prepare_cold158.587.7320.5×
engine prepare_cost (reuse)0.657.640.09×
app prepare_cold193.0321.888.8×
app prepare_cost (reuse)21.8923.030.95×

What is left. lookup_index and range_scan still evaluate the whole WHERE clause on every index entry, as the engine always does (a plan can change speed, never the answer). Every read transaction costs two fcntl calls (taking and releasing the wal-index read mark), as it does in the native library; on this machine starting and ending a read transaction around a trivial query takes about 1.8 µs, most of it those calls.

Where OneLite is faster

join (0.34× / 0.49×; the plan differs and the checksums agree), vec0_knn (0.46× / 0.79× at engine level, from the SIMD kernels), and on x64 wal_commit_full and insert_autocommit (see the fsync note above).