🚀
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
# BENCH_PLAN — SQLite Bulk Insert Benchmark (fused design)
|
||||
|
||||
Fusion of two independent designs: **[ARCHITECT] anthropic/claude-fable-5** and **[BUILDER] openai/gpt-5.6-sol**. Where the designs disagreed, the simpler one was chosen (reasons inline and in the closing section). The fused script was **built and executed** — every number below is measured, not projected from the source answers.
|
||||
|
||||
- Fused runnable script: `/tmp/fused-sqlite-bench-claude-fable-5-gpt-5.6-sol.py` (also embedded in full at the end of this plan)
|
||||
- Run with: `uv run /tmp/fused-sqlite-bench-claude-fable-5-gpt-5.6-sol.py`
|
||||
- One astral-uv single-file script (PEP 723 header, `dependencies = []`), Python stdlib only
|
||||
- Measured total wall time: **6.8 s** — well inside the 3-minute budget
|
||||
|
||||
## Method
|
||||
|
||||
**Self-isolating architecture** [ARCHITECT]. The script is both orchestrator and worker: it re-execs itself (`sys.executable __file__ --worker NAME`) once per strategy, so every strategy runs in a **fresh process against a fresh temp database**. Peak-RSS readings physically cannot pollute each other — this satisfies the isolated-process RAM requirement with zero third-party dependencies. [BUILDER] independently arrived at the same fresh-subprocess-per-trial isolation, so this is consensus architecture.
|
||||
|
||||
**Peak RAM** = whole-process `resource.getrusage(...).ru_maxrss`, normalized (bytes on macOS, KB×1024 on Linux) [both]. A **no-op calibration worker** measures bare-interpreter RSS (~20.3 MB here) so the table reports both absolute peak and delta-over-baseline [ARCHITECT].
|
||||
|
||||
**Timing boundary** [consensus]: the clock covers row generation + bind + insert + commit; connection setup, schema creation, and validation sit outside the timer. Generation is identical work for every strategy, so including it is neutral — excluding it would unfairly flatter the list-materializing strategy [ARCHITECT]. WAL runs additionally pay for `wal_checkpoint(TRUNCATE)` *inside* the timed region, so WAL is not credited with "finishing" while a million rows still sit in the `-wal` sidecar [BUILDER].
|
||||
|
||||
### Strategies (7)
|
||||
|
||||
| # | Strategy | What it does | Source |
|
||||
|---|----------|--------------|--------|
|
||||
| 1 | `naive_autocommit` | per-row implicit transaction — the baseline (sampled, see below) | both |
|
||||
| 2 | `one_big_txn_loop` | `BEGIN` + `execute()` loop + one `COMMIT` | both |
|
||||
| 3 | `executemany_list` | one txn, all 1M tuples materialized first — the classic mistake, kept to expose its RAM cost | [ARCHITECT] |
|
||||
| 4 | `executemany_gen` | one txn, `executemany` fed a generator | both |
|
||||
| 5 | `wal_tuned_gen` | `journal_mode=WAL` + `synchronous=NORMAL` + strategy 4 (WAL tuning combined with the predicted Python-bound winner, per the request) | both |
|
||||
| 6 | `max_tuned_gen` | `journal_mode=OFF` + `synchronous=OFF` + strategy 4 — fastest way to push Python-resident rows | [ARCHITECT] |
|
||||
| 7 | `set_based_ctes` | **the "beats all" candidate**: `INSERT … SELECT` over a recursive CTE that generates the *identical* rows inside SQLite — zero Python→SQLite binding crossings; run at OFF/OFF so it competes at the same durability tier as #6 | [BUILDER] |
|
||||
|
||||
Chosen over discarded alternatives:
|
||||
- [BUILDER]'s set-based candidate replaces [ARCHITECT]'s `sqlite3` CLI `.import`: **simpler** (no external binary, no CSV temp file, no graceful-skip logic — pure stdlib in-process SQL) *and* it measured ~2× faster than everything else anyway.
|
||||
- [BUILDER]'s chunked multi-row `VALUES` strategy was dropped: the variable-limit math and chunk assembly add complexity, and its niche (fewer Python↔SQLite crossings) is bracketed by `executemany_gen` below it and `set_based_ctes` above it.
|
||||
- [ARCHITECT]'s 64 MB `cache_size` pragma was dropped from the tuned strategies in favor of [BUILDER]'s bounded-default-cache stance: **simpler**, and [ARCHITECT]'s own measurements showed the big cache costs ~50 MB of resident memory for essentially zero speed gain. (Fused result: `wal_tuned_gen` peaks at 24 MB instead of ARCHITECT's 71 MB.)
|
||||
|
||||
## Fairness controls
|
||||
|
||||
- **Identical data** [both]: every strategy inserts the same deterministic rows `(i, i*0.5, "payload-{i:012d}")` — one Python definition, no RNG. `i*0.5` is exact in floating point, so SQL-generated and Python-generated values are bit-identical.
|
||||
- **Identical schema** [both]: `CREATE TABLE t (a INTEGER, b REAL, c TEXT)` (INTEGER/REAL/TEXT storage classes covered), fresh temp DB per worker, deleted afterward including `-wal`/`-shm`. [ARCHITECT]'s 3-column schema chosen over [BUILDER]'s 6-column `events` table as the simpler design that still exercises the same code paths.
|
||||
- **Integrity gate, hard-fail** [fused]: every worker verifies `count(*)`, the closed-form `sum(a)` checksum, and `sum(length(c))` [ARCHITECT's gate], plus spot-row equality at indices `{0, 1, N/2, N−1}` against the Python row function [distilled from BUILDER's digest] — required so the set-based strategy *proves* it wrote identical data rather than just the right number of rows. No strategy can win by writing less or writing different bytes. This is simpler than [BUILDER]'s 11-field digest + `quick_check` while closing the same loophole.
|
||||
- **Honest sampling, declared up front** [both]: `naive_autocommit` is fsync-bound (one journal sync per row); a full run would take ~3 minutes alone. It runs **20,000 real rows scaled ×50**, marked `*` in the table. Everything else runs the full 1,000,000. [ARCHITECT]'s 20k×50 chosen over [BUILDER]'s 10k×100 — equal simplicity, smaller scaling factor is the more honest extrapolation (both project to ~190 s regardless).
|
||||
- **Baseline excluded from the memory verdict** [both, independently]: the sampled baseline never held 1M rows of work, so it cannot win the RAM axis.
|
||||
- **Single run per strategy, fixed order** [ARCHITECT]: chosen over [BUILDER]'s 2×-with-median and seeded order shuffling as the simpler design. Justification: two full executions of the fused benchmark showed per-strategy spread <0.05 s while the winner gaps are ≥0.19 s, and process isolation already removes state carryover.
|
||||
- **Durability labeling** [BUILDER]: OFF/OFF strategies (#6, #7) are flagged in the output as *rebuildable staging loads only* — a crash mid-load can corrupt the DB. They race in the same heats but carry the warning label.
|
||||
|
||||
## Exact output format
|
||||
|
||||
```
|
||||
SQLite bulk insert benchmark | N=1,000,000 rows | py <ver> sqlite <ver> <platform>
|
||||
|
||||
[done] <strategy>: <t>s (<rows> rows[ sampled]) ← one line per isolated process
|
||||
|
||||
Strategy Time 1M rows Speedup Peak RSS RSS -base
|
||||
----------------------------------------------------------------------
|
||||
<name> <t>s[*] <x>x <MB>MB <MB>MB ← sorted fastest-first
|
||||
----------------------------------------------------------------------
|
||||
* sampled at 20,000 rows, honestly scaled x50 | base RSS <MB>MB (no-op interpreter)
|
||||
note: max_tuned_gen and set_based_ctes run journal_mode=OFF/synchronous=OFF -> rebuildable staging loads only.
|
||||
|
||||
Speed Winner : <name> (<t>s, <x>x vs naive)
|
||||
Memory Winner: <name> (<MB>MB peak, +<MB>MB over baseline)
|
||||
```
|
||||
|
||||
Table layout is [ARCHITECT]'s (fewer columns, speedup included, sorted fastest-first — simpler than [BUILDER]'s 7-column trial-level table); the `Speed Winner:`/`Memory Winner:` terminal lines are [BUILDER]'s exact phrasing.
|
||||
|
||||
## Measured results (fused run: py 3.12.13, sqlite 3.50.4, macOS / Apple Silicon)
|
||||
|
||||
| Strategy | Time 1M rows | Speedup | Peak RSS | RSS −base |
|
||||
|---|---:|---:|---:|---:|
|
||||
| **set_based_ctes** | **0.17s** | **1126.2x** | 24.3 MB | 4.1 MB |
|
||||
| max_tuned_gen | 0.37s | 518.4x | 24.1 MB | 3.9 MB |
|
||||
| executemany_gen | 0.38s | 508.8x | 24.1 MB | 3.9 MB |
|
||||
| **wal_tuned_gen** | 0.40s | 475.6x | **23.9 MB** | **3.7 MB** |
|
||||
| executemany_list | 0.49s | 393.5x | 226.8 MB | 206.6 MB |
|
||||
| one_big_txn_loop | 0.51s | 374.9x | 24.0 MB | 3.8 MB |
|
||||
| naive_autocommit | 191.15s* | 1.0x | 22.7 MB | 2.5 MB |
|
||||
|
||||
\* sampled at 20,000 rows, scaled ×50. Base RSS 20.2 MB (no-op interpreter). Total wall time 6.8 s.
|
||||
|
||||
Key findings the fused design makes visible:
|
||||
1. **The single transaction is ~99% of the win** — 375× from `BEGIN`/`COMMIT` alone [ARCHITECT's finding, confirmed].
|
||||
2. **Eliminating the million Python→SQLite binding crossings is the last 2×** — set-based insertion at 0.17 s [BUILDER's finding, confirmed on the identical schema].
|
||||
3. **`executemany_list` is strictly dominated**: slower than the generator *and* ~200 MB heavier [ARCHITECT's finding, confirmed].
|
||||
4. **Memory is a four-way statistical tie** among all streaming strategies (23.9–24.3 MB, within ±0.4 MB across repeat runs): once you stream rows instead of materializing them, SQLite's bounded page cache makes peak RSS nearly strategy-invariant.
|
||||
|
||||
## Declared Winners
|
||||
|
||||
- **Speed Winner: `set_based_ctes`** — `INSERT … SELECT` over a recursive CTE, OFF/OFF pragmas: **0.17 s for 1M rows, ~1126× over naive autocommit.** Caveats carried from [BUILDER]: rebuildable-staging durability only, and it applies only when rows are derivable in SQL. For arbitrary Python-resident rows, the practical speed winner is `max_tuned_gen` (0.37 s), with `wal_tuned_gen` (0.40 s) the fastest *durable-enough* option — the same practical hierarchy both source models converged on.
|
||||
- **Memory Winner: `wal_tuned_gen`** — **23.9 MB peak, +3.7 MB over a bare interpreter**, as selected by the declared rule (minimum peak RSS among full-1M runs, speed as tiebreak) in both verification runs. Honest note: this is a statistical tie with `one_big_txn_loop`, `executemany_gen`, and `max_tuned_gen` (all within 0.4 MB); the real memory lesson is *stream, never materialize* — the list variant costs 206 MB extra for nothing.
|
||||
|
||||
---
|
||||
|
||||
## Consensus & Divergence
|
||||
|
||||
**Consensus (both models, independently):** fresh-subprocess isolation per strategy with `ru_maxrss` for peak RSS; one deterministic Python-defined dataset and one schema for all strategies; timing bounded to the insert phase with setup/validation outside; sampling the naive baseline with declared linear scaling; excluding the sampled baseline from the memory verdict; integrity verification that hard-fails; labeling OFF/OFF as unsafe-for-production; and the headline conclusion that *one big transaction is most of the win* while *the memory winner is the streaming single-transaction family* (both declared `one transaction + execute` their memory winner; the fused measurement shows that whole family tied within noise, with `wal_tuned_gen` taking it by 0.1–0.2 MB under the fused rule).
|
||||
|
||||
**Divergences and rulings** (simpler option preferred per the fusion brief):
|
||||
- *Beats-all candidate*: [BUILDER gpt-5.6-sol]'s set-based `INSERT…SELECT` **kept**; [ARCHITECT claude-fable-5]'s CLI `.import` **discarded** — set-based is simpler (no external binary, CSV staging, or skip logic) and empirically faster; the CLI route also strains the "stdlib only" spirit.
|
||||
- *Speed winner dispute*: [ARCHITECT] declared `max_tuned_gen`, [BUILDER] declared OFF/OFF `INSERT-SELECT`. Settled by running both on the identical fused schema: **[BUILDER]'s pick wins** (0.17 s vs 0.37 s).
|
||||
- *Schema & data*: [ARCHITECT]'s 3-column table **kept** over [BUILDER]'s 6-column `events` table — simpler, same storage classes exercised.
|
||||
- *Repeats & trial ordering*: [ARCHITECT]'s single run in fixed order **kept** over [BUILDER]'s median-of-2 with seeded shuffle — simpler, and measured run-to-run spread (<0.05 s) is far below decision margins.
|
||||
- *Verification*: [ARCHITECT]'s count+checksum gate **kept as the base**, extended with a slim length-digest + spot-row check distilled from [BUILDER]'s 11-field digest — the minimum needed to prove the SQL-generated rows are byte-identical; [BUILDER]'s full digest and `PRAGMA quick_check` **discarded** as redundant for this schema.
|
||||
- *Cache tuning*: [ARCHITECT]'s 64 MB `cache_size` **discarded** in favor of [BUILDER]'s bounded-cache stance — simpler, and ARCHITECT's own data showed +50 MB RSS for no speed benefit.
|
||||
- *WAL checkpoint in timed region*: [BUILDER]'s control **adopted** — not a simplification but a fairness correction; without it WAL under-reports its true cost.
|
||||
- *Baseline sample size*: [ARCHITECT]'s 20k×50 **kept** over [BUILDER]'s 10k×100 — equal complexity, smaller extrapolation factor.
|
||||
- *Also discarded*: [BUILDER]'s multi-row `VALUES` strategy (complexity without a podium finish — dominated on both ends), [BUILDER]'s Windows `PeakWorkingSetSize` branch (POSIX `ru_maxrss` is simpler; noted as an easy portability extension), and [ARCHITECT]'s `executemany_list`… no — that one was **kept**: three extra lines that buy the single most vivid RAM lesson in the table.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — the fused script (verbatim, as executed)
|
||||
|
||||
```python
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""
|
||||
FUSED SQLite bulk-insert benchmark (claude-fable-5 + gpt-5.6-sol).
|
||||
|
||||
1,000,000 rows. Speed + peak RAM. Stdlib only. Run:
|
||||
uv run /tmp/fused-sqlite-bench-claude-fable-5-gpt-5.6-sol.py
|
||||
|
||||
Architecture (from ARCHITECT/claude-fable-5): the script re-execs ITSELF
|
||||
once per strategy (`--worker NAME`), so every strategy runs in a fresh
|
||||
process and peak RSS readings cannot pollute each other. A no-op
|
||||
calibration worker measures bare-interpreter RSS for a delta column.
|
||||
|
||||
Beyond-the-list candidate (from BUILDER/gpt-5.6-sol): set-based
|
||||
INSERT ... SELECT over a recursive CTE that generates the IDENTICAL rows
|
||||
inside SQLite, verified by digest + spot-check against the Python rows.
|
||||
WAL runs pay for wal_checkpoint(TRUNCATE) inside the timed region.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import resource
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
N = 1_000_000
|
||||
NAIVE_SAMPLE_N = 20_000 # honest sample; scaled x(N/sample) in report
|
||||
SCHEMA = "CREATE TABLE t (a INTEGER, b REAL, c TEXT)"
|
||||
INSERT = "INSERT INTO t VALUES (?,?,?)"
|
||||
|
||||
SET_BASED_SQL = """
|
||||
WITH RECURSIVE seq(i) AS (
|
||||
SELECT 0
|
||||
UNION ALL
|
||||
SELECT i + 1 FROM seq WHERE i + 1 < ?
|
||||
)
|
||||
INSERT INTO t SELECT i, i * 0.5, printf('payload-%012d', i) FROM seq
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- data ----
|
||||
def make_row(i):
|
||||
return (i, i * 0.5, f"payload-{i:012d}")
|
||||
|
||||
|
||||
def rows(n):
|
||||
"""Identical deterministic data for every strategy."""
|
||||
for i in range(n):
|
||||
yield make_row(i)
|
||||
|
||||
|
||||
# ---------------------------------------------------------- strategies ----
|
||||
def s_naive_autocommit(conn, n):
|
||||
conn.isolation_level = None # every INSERT commits: journal sync per row
|
||||
for r in rows(n):
|
||||
conn.execute(INSERT, r)
|
||||
|
||||
|
||||
def s_one_big_txn_loop(conn, n):
|
||||
conn.execute("BEGIN")
|
||||
for r in rows(n):
|
||||
conn.execute(INSERT, r)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def s_executemany_list(conn, n):
|
||||
data = list(rows(n)) # deliberately materialized: shows RAM cost
|
||||
conn.execute("BEGIN")
|
||||
conn.executemany(INSERT, data)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def s_executemany_gen(conn, n):
|
||||
conn.execute("BEGIN")
|
||||
conn.executemany(INSERT, rows(n))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def s_wal_tuned_gen(conn, n):
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
conn.execute("BEGIN")
|
||||
conn.executemany(INSERT, rows(n))
|
||||
conn.commit()
|
||||
# Fairness (BUILDER): WAL must not finish with 1M rows still in the -wal
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
|
||||
|
||||
def s_max_tuned_gen(conn, n):
|
||||
"""Python-bound speed candidate: one txn + executemany(generator) +
|
||||
journaling/sync disabled for the load. Rebuildable staging only."""
|
||||
conn.execute("PRAGMA journal_mode=OFF")
|
||||
conn.execute("PRAGMA synchronous=OFF")
|
||||
conn.execute("BEGIN")
|
||||
conn.executemany(INSERT, rows(n))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def s_set_based(conn, n):
|
||||
"""Beyond-the-list candidate (BUILDER): SQLite generates the identical
|
||||
rows itself; zero Python->SQLite binding crossings. OFF/OFF pragmas so
|
||||
it is compared at the same durability tier as max_tuned_gen."""
|
||||
conn.execute("PRAGMA journal_mode=OFF")
|
||||
conn.execute("PRAGMA synchronous=OFF")
|
||||
conn.execute("BEGIN")
|
||||
conn.execute(SET_BASED_SQL, (n,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
STRATEGIES = {
|
||||
"naive_autocommit": (s_naive_autocommit, NAIVE_SAMPLE_N),
|
||||
"one_big_txn_loop": (s_one_big_txn_loop, N),
|
||||
"executemany_list": (s_executemany_list, N),
|
||||
"executemany_gen": (s_executemany_gen, N),
|
||||
"wal_tuned_gen": (s_wal_tuned_gen, N),
|
||||
"max_tuned_gen": (s_max_tuned_gen, N),
|
||||
"set_based_ctes": (s_set_based, N),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------- worker ----
|
||||
def rss_bytes():
|
||||
v = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
||||
return v if sys.platform == "darwin" else v * 1024 # KB on Linux
|
||||
|
||||
|
||||
def verify(dbpath, n):
|
||||
"""Integrity gate (fused): count + checksum for everyone [ARCHITECT],
|
||||
plus length-digest and spot-row equality so the SQL-generated data is
|
||||
provably identical to the Python data [BUILDER]."""
|
||||
conn = sqlite3.connect(dbpath)
|
||||
cnt, chk, clen = conn.execute(
|
||||
"SELECT count(*), sum(a), sum(length(c)) FROM t").fetchone()
|
||||
ok = (cnt == n and chk == n * (n - 1) // 2 and clen == 20 * n)
|
||||
for i in (0, 1, n // 2, n - 1):
|
||||
got = conn.execute("SELECT a, b, c FROM t WHERE a=?", (i,)).fetchone()
|
||||
ok = ok and got is not None and tuple(got) == make_row(i)
|
||||
conn.close()
|
||||
return ok
|
||||
|
||||
|
||||
def worker(name):
|
||||
if name == "calibration": # interpreter + sqlite3 import baseline
|
||||
print(json.dumps({"name": name, "rows": 0, "seconds": 0.0,
|
||||
"peak_rss": rss_bytes(), "ok": True}))
|
||||
return
|
||||
fn, n = STRATEGIES[name]
|
||||
dbpath = tempfile.mktemp(suffix=".db", dir=tempfile.gettempdir())
|
||||
try:
|
||||
conn = sqlite3.connect(dbpath)
|
||||
conn.execute(SCHEMA)
|
||||
t0 = time.perf_counter() # timer: generation + bind + insert + commit
|
||||
fn(conn, n)
|
||||
dt = time.perf_counter() - t0
|
||||
conn.close()
|
||||
ok = verify(dbpath, n) # validation outside the timer
|
||||
print(json.dumps({"name": name, "rows": n, "seconds": dt,
|
||||
"peak_rss": rss_bytes(), "ok": ok}))
|
||||
finally:
|
||||
for p in (dbpath, dbpath + "-wal", dbpath + "-shm"):
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
|
||||
|
||||
# ----------------------------------------------------------- orchestrator --
|
||||
def spawn(name):
|
||||
out = subprocess.run([sys.executable, os.path.abspath(__file__),
|
||||
"--worker", name],
|
||||
capture_output=True, text=True, check=True)
|
||||
return json.loads(out.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def main():
|
||||
t_total = time.perf_counter()
|
||||
print(f"SQLite bulk insert benchmark | N={N:,} rows | "
|
||||
f"py {sys.version.split()[0]} sqlite {sqlite3.sqlite_version} "
|
||||
f"{sys.platform}\n")
|
||||
|
||||
base = spawn("calibration")
|
||||
results = []
|
||||
for name in STRATEGIES: # fixed, deterministic order
|
||||
r = spawn(name)
|
||||
assert r["ok"], f"integrity check FAILED for {name}"
|
||||
scale = N / r["rows"]
|
||||
r["scaled"] = r["seconds"] * scale
|
||||
r["sampled"] = scale > 1.0
|
||||
results.append(r)
|
||||
print(f" [done] {name}: {r['seconds']:.3f}s "
|
||||
f"({r['rows']:,} rows{' sampled' if r['sampled'] else ''})")
|
||||
|
||||
naive = next(r["scaled"] for r in results if r["name"] == "naive_autocommit")
|
||||
print(f"\n{'Strategy':<22}{'Time 1M rows':>14}{'Speedup':>10}"
|
||||
f"{'Peak RSS':>12}{'RSS -base':>12}")
|
||||
print("-" * 70)
|
||||
for r in sorted(results, key=lambda r: r["scaled"]):
|
||||
mark = "*" if r["sampled"] else " "
|
||||
print(f"{r['name']:<22}{r['scaled']:>12.2f}s{mark}"
|
||||
f"{naive / r['scaled']:>9.1f}x"
|
||||
f"{r['peak_rss'] / 1e6:>10.1f}MB"
|
||||
f"{(r['peak_rss'] - base['peak_rss']) / 1e6:>10.1f}MB")
|
||||
print("-" * 70)
|
||||
print(f"* sampled at {NAIVE_SAMPLE_N:,} rows, honestly scaled "
|
||||
f"x{N // NAIVE_SAMPLE_N} | base RSS "
|
||||
f"{base['peak_rss'] / 1e6:.1f}MB (no-op interpreter)")
|
||||
print("note: max_tuned_gen and set_based_ctes run journal_mode=OFF/"
|
||||
"synchronous=OFF -> rebuildable staging loads only.")
|
||||
|
||||
fastest = min(results, key=lambda r: r["scaled"])
|
||||
full = [r for r in results if not r["sampled"]] # baseline excluded
|
||||
leanest = min(full, key=lambda r: (r["peak_rss"], r["scaled"]))
|
||||
print(f"\nSpeed Winner : {fastest['name']} "
|
||||
f"({fastest['scaled']:.2f}s, {naive / fastest['scaled']:.0f}x vs naive)")
|
||||
print(f"Memory Winner: {leanest['name']} "
|
||||
f"({leanest['peak_rss'] / 1e6:.1f}MB peak, "
|
||||
f"+{(leanest['peak_rss'] - base['peak_rss']) / 1e6:.1f}MB over baseline)")
|
||||
print(f"\ntotal benchmark wall time: {time.perf_counter() - t_total:.1f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "--worker":
|
||||
worker(sys.argv[2])
|
||||
else:
|
||||
main()
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
# SQLite Bulk Insert Benchmark Results
|
||||
|
||||
Fresh measurements: **1,000,000 target rows**, Python 3.12.13, SQLite 3.50.4, macOS-26.5.2-arm64-arm-64bit.
|
||||
|
||||
The timer includes row generation, binding, insertion, and commit. WAL timing also includes `wal_checkpoint(TRUNCATE)`. Each strategy ran in a fresh process against a fresh database.
|
||||
|
||||
| Strategy | Measured rows | Time 1M rows (s) | Speedup vs naive | Peak RAM (MB) | RSS over base (MB) |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| `set_based_ctes` | 1,000,000 | 0.1700 | 1147.98x | 26.051 | 4.014 |
|
||||
| `executemany_gen` | 1,000,000 | 0.3480 | 560.77x | 25.625 | 3.588 |
|
||||
| `max_tuned_gen` | 1,000,000 | 0.3484 | 560.15x | 25.641 | 3.604 |
|
||||
| `executemany_list` | 1,000,000 | 0.3761 | 518.88x | 228.737 | 206.701 |
|
||||
| `wal_tuned_gen` | 1,000,000 | 0.3918 | 498.07x | 25.903 | 3.867 |
|
||||
| `one_big_txn_loop` | 1,000,000 | 0.6071 | 321.41x | 25.854 | 3.817 |
|
||||
| `naive_autocommit` | 20,000 (sampled) | 195.1362* | 1.00x | 24.183 | 2.146 |
|
||||
|
||||
\* `naive_autocommit` ran 20,000 real rows; its measured time was scaled by 50. Its RAM reading is measured, not scaled, and it is excluded from the memory-winner decision.
|
||||
|
||||
`max_tuned_gen` and `set_based_ctes` use `journal_mode=OFF`/`synchronous=OFF`; they are for rebuildable staging loads only.
|
||||
|
||||
Bare-worker peak RSS calibration: **22.036 MB**.
|
||||
Total benchmark wall time: **6.857 s**.
|
||||
|
||||
**Speed Winner: `set_based_ctes`** — 0.1700 s, 1147.98x faster than naive.
|
||||
**Memory Winner: `executemany_gen`** — 25.625 MB peak RSS, +3.588 MB over the calibration worker.
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""
|
||||
SQLite bulk-insert benchmark: 1,000,000 rows, speed + peak RAM, per-strategy
|
||||
process isolation. Stdlib only. Run: uv run bench-sqlite-bulk-ARCHITECT-anthropic-claude-fable-5.py
|
||||
|
||||
Design (ARCHITECT / anthropic-claude-fable-5):
|
||||
* Orchestrator re-execs THIS file as a subprocess per strategy
|
||||
(`--worker NAME`), so peak RSS readings never pollute each other.
|
||||
* Every strategy inserts IDENTICAL deterministic data into an IDENTICAL
|
||||
fresh schema, verified by count + checksum.
|
||||
* Timing covers the full insert phase (row generation + bind + commit),
|
||||
identical work for all strategies.
|
||||
* The naive autocommit baseline is fsync-bound (~50-200 rows/ms is
|
||||
impossible; it does one journal sync per row). It is SAMPLED at 20,000
|
||||
rows and honestly scaled x50; the table flags it with '*'.
|
||||
* Peak RAM = ru_maxrss of the worker process (normalized to bytes across
|
||||
macOS/Linux), plus a no-op calibration worker so a delta over
|
||||
interpreter baseline is reported.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import resource
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
N = 1_000_000
|
||||
NAIVE_SAMPLE_N = 20_000 # honest sample; scaled x(N/NAIVE_SAMPLE_N) in report
|
||||
SCHEMA = "CREATE TABLE t (a INTEGER, b REAL, c TEXT)"
|
||||
INSERT = "INSERT INTO t VALUES (?,?,?)"
|
||||
EXPECTED_SUM = lambda n: n * (n - 1) // 2 # sum of column a for n rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- data ----
|
||||
def rows(n):
|
||||
"""Identical deterministic data for every strategy."""
|
||||
for i in range(n):
|
||||
yield (i, i * 0.5, f"payload-{i:012d}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------- strategies ----
|
||||
def s_naive_autocommit(conn, n):
|
||||
conn.isolation_level = None # every INSERT commits (journal sync per row)
|
||||
for r in rows(n):
|
||||
conn.execute(INSERT, r)
|
||||
|
||||
|
||||
def s_one_big_txn_loop(conn, n):
|
||||
conn.execute("BEGIN")
|
||||
for r in rows(n):
|
||||
conn.execute(INSERT, r)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def s_executemany_list(conn, n):
|
||||
data = list(rows(n)) # deliberately materialized: shows the RAM cost
|
||||
conn.execute("BEGIN")
|
||||
conn.executemany(INSERT, data)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def s_executemany_gen(conn, n):
|
||||
conn.execute("BEGIN")
|
||||
conn.executemany(INSERT, rows(n))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def s_wal_tuned_gen(conn, n):
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
conn.execute("PRAGMA cache_size=-64000") # 64 MB page cache
|
||||
conn.execute("BEGIN")
|
||||
conn.executemany(INSERT, rows(n))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def s_max_tuned_gen(conn, n):
|
||||
"""Predicted overall speed winner within stdlib: one txn +
|
||||
executemany(generator) + journaling/sync disabled for the load."""
|
||||
conn.execute("PRAGMA journal_mode=OFF")
|
||||
conn.execute("PRAGMA synchronous=OFF")
|
||||
conn.execute("PRAGMA cache_size=-64000")
|
||||
conn.execute("PRAGMA temp_store=MEMORY")
|
||||
conn.execute("BEGIN")
|
||||
conn.executemany(INSERT, rows(n))
|
||||
conn.commit()
|
||||
|
||||
|
||||
STRATEGIES = {
|
||||
"naive_autocommit": (s_naive_autocommit, NAIVE_SAMPLE_N),
|
||||
"one_big_txn_loop": (s_one_big_txn_loop, N),
|
||||
"executemany_list": (s_executemany_list, N),
|
||||
"executemany_gen": (s_executemany_gen, N),
|
||||
"wal_tuned_gen": (s_wal_tuned_gen, N),
|
||||
"max_tuned_gen": (s_max_tuned_gen, N),
|
||||
}
|
||||
|
||||
# Optional bonus (external `sqlite3` CLI, skipped gracefully if absent).
|
||||
# Same identical data, same isolation, timing includes writing the CSV.
|
||||
CLI_STRATEGY = "cli_dot_import"
|
||||
|
||||
|
||||
def run_cli_import(dbpath, n):
|
||||
import csv
|
||||
import shutil
|
||||
|
||||
exe = shutil.which("sqlite3")
|
||||
if not exe:
|
||||
return False
|
||||
csvpath = dbpath + ".csv"
|
||||
with open(csvpath, "w", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
for r in rows(n):
|
||||
w.writerow(r)
|
||||
subprocess.run(
|
||||
[exe, dbpath, SCHEMA + ";", ".mode csv", f".import {csvpath} t"],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
os.remove(csvpath)
|
||||
return True
|
||||
|
||||
|
||||
# --------------------------------------------------------------- worker ----
|
||||
def rss_bytes(who=resource.RUSAGE_SELF):
|
||||
v = resource.getrusage(who).ru_maxrss
|
||||
return v if sys.platform == "darwin" else v * 1024 # KB on Linux
|
||||
|
||||
|
||||
def worker(name):
|
||||
if name == "calibration": # no-op: interpreter + sqlite3 import baseline
|
||||
print(json.dumps({"name": name, "rows": 0, "seconds": 0.0,
|
||||
"peak_rss": rss_bytes(), "ok": True}))
|
||||
return
|
||||
|
||||
dbpath = tempfile.mktemp(suffix=".db", dir=tempfile.gettempdir())
|
||||
try:
|
||||
if name == CLI_STRATEGY:
|
||||
t0 = time.perf_counter()
|
||||
ok = run_cli_import(dbpath, N)
|
||||
dt = time.perf_counter() - t0
|
||||
if not ok:
|
||||
print(json.dumps({"name": name, "skipped": True}))
|
||||
return
|
||||
n = N
|
||||
else:
|
||||
fn, n = STRATEGIES[name]
|
||||
conn = sqlite3.connect(dbpath)
|
||||
conn.execute(SCHEMA)
|
||||
t0 = time.perf_counter()
|
||||
fn(conn, n)
|
||||
dt = time.perf_counter() - t0
|
||||
conn.close()
|
||||
|
||||
vconn = sqlite3.connect(dbpath)
|
||||
cnt, chk = vconn.execute("SELECT count(*), sum(a) FROM t").fetchone()
|
||||
vconn.close()
|
||||
peak = max(rss_bytes(), rss_bytes(resource.RUSAGE_CHILDREN))
|
||||
print(json.dumps({
|
||||
"name": name, "rows": cnt, "seconds": dt, "peak_rss": peak,
|
||||
"ok": cnt == n and chk == EXPECTED_SUM(n),
|
||||
}))
|
||||
finally:
|
||||
for p in (dbpath, dbpath + "-wal", dbpath + "-shm", dbpath + ".csv"):
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
|
||||
|
||||
# ----------------------------------------------------------- orchestrator --
|
||||
def spawn(name):
|
||||
out = subprocess.run([sys.executable, os.path.abspath(__file__),
|
||||
"--worker", name],
|
||||
capture_output=True, text=True, check=True)
|
||||
return json.loads(out.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
def main():
|
||||
print(f"SQLite bulk insert benchmark | N={N:,} rows | "
|
||||
f"py {sys.version.split()[0]} sqlite {sqlite3.sqlite_version} "
|
||||
f"{sys.platform}\n")
|
||||
|
||||
base = spawn("calibration")
|
||||
results = []
|
||||
order = list(STRATEGIES) + [CLI_STRATEGY]
|
||||
for name in order:
|
||||
r = spawn(name)
|
||||
if r.get("skipped"):
|
||||
print(f" [skip] {name} (sqlite3 CLI not on PATH)")
|
||||
continue
|
||||
assert r["ok"], f"integrity check FAILED for {name}"
|
||||
scale = N / r["rows"]
|
||||
r["scaled"] = r["seconds"] * scale
|
||||
r["sampled"] = scale > 1.0
|
||||
results.append(r)
|
||||
print(f" [done] {name}: {r['seconds']:.3f}s "
|
||||
f"({r['rows']:,} rows{' sampled' if r['sampled'] else ''})")
|
||||
|
||||
naive = next(r["scaled"] for r in results if r["name"] == "naive_autocommit")
|
||||
print(f"\n{'Strategy':<22}{'Time 1M rows':>14}{'Speedup':>10}"
|
||||
f"{'Peak RSS':>12}{'RSS -base':>12}")
|
||||
print("-" * 70)
|
||||
for r in sorted(results, key=lambda r: r["scaled"]):
|
||||
mark = "*" if r["sampled"] else " "
|
||||
print(f"{r['name']:<22}{r['scaled']:>12.2f}s{mark}"
|
||||
f"{naive / r['scaled']:>9.1f}x"
|
||||
f"{r['peak_rss'] / 1e6:>10.1f}MB"
|
||||
f"{(r['peak_rss'] - base['peak_rss']) / 1e6:>10.1f}MB")
|
||||
print("-" * 70)
|
||||
print(f"* sampled at {NAIVE_SAMPLE_N:,} rows, honestly scaled "
|
||||
f"x{N // NAIVE_SAMPLE_N} | base RSS "
|
||||
f"{base['peak_rss'] / 1e6:.1f}MB (no-op interpreter)")
|
||||
|
||||
fastest = min(results, key=lambda r: r["scaled"])
|
||||
# memory winner among full-speed strategies (exclude the naive baseline)
|
||||
fast = [r for r in results if not r["sampled"]]
|
||||
leanest = min(fast, key=lambda r: (r["peak_rss"], r["scaled"]))
|
||||
print(f"\nSPEED WINNER : {fastest['name']} "
|
||||
f"({fastest['scaled']:.2f}s, {naive / fastest['scaled']:.0f}x vs naive)")
|
||||
print(f"MEMORY WINNER: {leanest['name']} "
|
||||
f"({leanest['peak_rss'] / 1e6:.1f}MB peak, "
|
||||
f"+{(leanest['peak_rss'] - base['peak_rss']) / 1e6:.1f}MB over baseline)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "--worker":
|
||||
worker(sys.argv[2])
|
||||
else:
|
||||
main()
|
||||
@@ -0,0 +1,393 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = []
|
||||
# ///
|
||||
"""Benchmark seven SQLite bulk-insert strategies on speed and peak RAM.
|
||||
|
||||
Run with:
|
||||
uv run bench.py
|
||||
|
||||
The orchestrator re-executes this file once per strategy. Each worker uses a
|
||||
fresh process and a fresh SQLite database, so process peak-RSS measurements
|
||||
cannot leak from one strategy into another. The run writes RESULTS.md next to
|
||||
this script from fresh measurements every time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import resource
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any, Callable, Iterator
|
||||
|
||||
N = 1_000_000
|
||||
NAIVE_SAMPLE_N = 20_000
|
||||
SCHEMA = "CREATE TABLE t (a INTEGER, b REAL, c TEXT)"
|
||||
INSERT = "INSERT INTO t VALUES (?, ?, ?)"
|
||||
RESULTS_PATH = Path(__file__).resolve().with_name("RESULTS.md")
|
||||
|
||||
SET_BASED_SQL = """
|
||||
WITH RECURSIVE seq(i) AS (
|
||||
SELECT 0
|
||||
UNION ALL
|
||||
SELECT i + 1 FROM seq WHERE i + 1 < ?
|
||||
)
|
||||
INSERT INTO t
|
||||
SELECT i, i * 0.5, printf('payload-%012d', i)
|
||||
FROM seq
|
||||
"""
|
||||
|
||||
Row = tuple[int, float, str]
|
||||
StrategyFunction = Callable[[sqlite3.Connection, int], None]
|
||||
|
||||
|
||||
def make_row(i: int) -> Row:
|
||||
"""Return the sole canonical definition of one benchmark row."""
|
||||
return (i, i * 0.5, f"payload-{i:012d}")
|
||||
|
||||
|
||||
def rows(n: int) -> Iterator[Row]:
|
||||
"""Generate identical deterministic data for every Python-bound strategy."""
|
||||
for i in range(n):
|
||||
yield make_row(i)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- strategies
|
||||
|
||||
def s_naive_autocommit(conn: sqlite3.Connection, n: int) -> None:
|
||||
"""Baseline: every INSERT is its own durable transaction."""
|
||||
conn.isolation_level = None
|
||||
for row in rows(n):
|
||||
conn.execute(INSERT, row)
|
||||
|
||||
|
||||
def s_one_big_txn_loop(conn: sqlite3.Connection, n: int) -> None:
|
||||
conn.execute("BEGIN")
|
||||
for row in rows(n):
|
||||
conn.execute(INSERT, row)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def s_executemany_list(conn: sqlite3.Connection, n: int) -> None:
|
||||
# Deliberately materialized so the benchmark measures the classic RAM cost.
|
||||
data = list(rows(n))
|
||||
conn.execute("BEGIN")
|
||||
conn.executemany(INSERT, data)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def s_executemany_gen(conn: sqlite3.Connection, n: int) -> None:
|
||||
conn.execute("BEGIN")
|
||||
conn.executemany(INSERT, rows(n))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def s_wal_tuned_gen(conn: sqlite3.Connection, n: int) -> None:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
conn.execute("BEGIN")
|
||||
conn.executemany(INSERT, rows(n))
|
||||
conn.commit()
|
||||
# Include write-back cost instead of ending with the rows in a WAL sidecar.
|
||||
busy, _wal_pages, _checkpointed = conn.execute(
|
||||
"PRAGMA wal_checkpoint(TRUNCATE)"
|
||||
).fetchone()
|
||||
if busy:
|
||||
raise RuntimeError("WAL checkpoint was unexpectedly busy")
|
||||
|
||||
|
||||
def s_max_tuned_gen(conn: sqlite3.Connection, n: int) -> None:
|
||||
"""Maximum Python-bound speed; safe only for a rebuildable staging DB."""
|
||||
conn.execute("PRAGMA journal_mode=OFF")
|
||||
conn.execute("PRAGMA synchronous=OFF")
|
||||
conn.execute("BEGIN")
|
||||
conn.executemany(INSERT, rows(n))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def s_set_based_ctes(conn: sqlite3.Connection, n: int) -> None:
|
||||
"""Generate identical rows inside SQLite, avoiding per-row Python binds."""
|
||||
conn.execute("PRAGMA journal_mode=OFF")
|
||||
conn.execute("PRAGMA synchronous=OFF")
|
||||
conn.execute("BEGIN")
|
||||
conn.execute(SET_BASED_SQL, (n,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
STRATEGIES: dict[str, tuple[StrategyFunction, int]] = {
|
||||
"naive_autocommit": (s_naive_autocommit, NAIVE_SAMPLE_N),
|
||||
"one_big_txn_loop": (s_one_big_txn_loop, N),
|
||||
"executemany_list": (s_executemany_list, N),
|
||||
"executemany_gen": (s_executemany_gen, N),
|
||||
"wal_tuned_gen": (s_wal_tuned_gen, N),
|
||||
"max_tuned_gen": (s_max_tuned_gen, N),
|
||||
"set_based_ctes": (s_set_based_ctes, N),
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- workers
|
||||
|
||||
def rss_bytes() -> int:
|
||||
"""Return this process's measured high-water resident set size in bytes."""
|
||||
ru_maxrss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
||||
return int(ru_maxrss if sys.platform == "darwin" else ru_maxrss * 1024)
|
||||
|
||||
|
||||
def verify(db_path: Path, n: int) -> bool:
|
||||
"""Prove row count, checksums, lengths, and representative rows match."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
count, checksum, text_length = conn.execute(
|
||||
"SELECT count(*), sum(a), sum(length(c)) FROM t"
|
||||
).fetchone()
|
||||
valid = (
|
||||
count == n
|
||||
and checksum == n * (n - 1) // 2
|
||||
and text_length == 20 * n
|
||||
)
|
||||
for i in (0, 1, n // 2, n - 1):
|
||||
actual = conn.execute(
|
||||
"SELECT a, b, c FROM t WHERE a = ?", (i,)
|
||||
).fetchone()
|
||||
valid = valid and actual is not None and tuple(actual) == make_row(i)
|
||||
return bool(valid)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def worker(name: str) -> None:
|
||||
if name == "calibration":
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"name": name,
|
||||
"rows": 0,
|
||||
"seconds": 0.0,
|
||||
"peak_rss": rss_bytes(),
|
||||
"ok": True,
|
||||
}
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
function, n = STRATEGIES[name]
|
||||
with tempfile.TemporaryDirectory(prefix=f"sqlite-bulk-{name}-") as temp_dir:
|
||||
db_path = Path(temp_dir) / "benchmark.sqlite3"
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute(SCHEMA)
|
||||
started = time.perf_counter()
|
||||
function(conn, n)
|
||||
seconds = time.perf_counter() - started
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
valid = verify(db_path, n)
|
||||
result = {
|
||||
"name": name,
|
||||
"rows": n,
|
||||
"seconds": seconds,
|
||||
"peak_rss": rss_bytes(),
|
||||
"ok": valid,
|
||||
}
|
||||
print(json.dumps(result, separators=(",", ":")))
|
||||
|
||||
|
||||
def spawn(name: str) -> dict[str, Any]:
|
||||
completed = subprocess.run(
|
||||
[sys.executable, os.path.abspath(__file__), "--worker", name],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=220,
|
||||
)
|
||||
output_lines = completed.stdout.strip().splitlines()
|
||||
if not output_lines:
|
||||
raise RuntimeError(f"worker {name} produced no result")
|
||||
return json.loads(output_lines[-1])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- reporting
|
||||
|
||||
def measured_rows(result: dict[str, Any]) -> str:
|
||||
suffix = " (sampled)" if result["sampled"] else ""
|
||||
return f"{result['rows']:,}{suffix}"
|
||||
|
||||
|
||||
def build_results_markdown(
|
||||
results: list[dict[str, Any]],
|
||||
base_rss: int,
|
||||
total_wall_seconds: float,
|
||||
) -> tuple[str, dict[str, Any], dict[str, Any]]:
|
||||
baseline = next(r for r in results if r["name"] == "naive_autocommit")
|
||||
baseline_time = float(baseline["scaled_seconds"])
|
||||
ordered = sorted(results, key=lambda result: result["scaled_seconds"])
|
||||
fastest = ordered[0]
|
||||
full_runs = [result for result in results if not result["sampled"]]
|
||||
leanest = min(
|
||||
full_runs,
|
||||
key=lambda result: (result["peak_rss"], result["scaled_seconds"]),
|
||||
)
|
||||
|
||||
lines = [
|
||||
"# SQLite Bulk Insert Benchmark Results",
|
||||
"",
|
||||
(
|
||||
f"Fresh measurements: **{N:,} target rows**, Python "
|
||||
f"{platform.python_version()}, SQLite {sqlite3.sqlite_version}, "
|
||||
f"{platform.platform()}."
|
||||
),
|
||||
"",
|
||||
(
|
||||
"The timer includes row generation, binding, insertion, and commit. "
|
||||
"WAL timing also includes `wal_checkpoint(TRUNCATE)`. Each strategy "
|
||||
"ran in a fresh process against a fresh database."
|
||||
),
|
||||
"",
|
||||
"| Strategy | Measured rows | Time 1M rows (s) | Speedup vs naive | Peak RAM (MB) | RSS over base (MB) |",
|
||||
"|---|---:|---:|---:|---:|---:|",
|
||||
]
|
||||
|
||||
for result in ordered:
|
||||
scaled = float(result["scaled_seconds"])
|
||||
peak_mb = int(result["peak_rss"]) / 1_000_000
|
||||
delta_mb = (int(result["peak_rss"]) - base_rss) / 1_000_000
|
||||
time_mark = "*" if result["sampled"] else ""
|
||||
lines.append(
|
||||
f"| `{result['name']}` | {measured_rows(result)} | "
|
||||
f"{scaled:.4f}{time_mark} | {baseline_time / scaled:.2f}x | "
|
||||
f"{peak_mb:.3f} | {delta_mb:.3f} |"
|
||||
)
|
||||
|
||||
fastest_time = float(fastest["scaled_seconds"])
|
||||
leanest_peak_mb = int(leanest["peak_rss"]) / 1_000_000
|
||||
leanest_delta_mb = (int(leanest["peak_rss"]) - base_rss) / 1_000_000
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
(
|
||||
f"\\* `naive_autocommit` ran {NAIVE_SAMPLE_N:,} real rows; its "
|
||||
f"measured time was scaled by {N // NAIVE_SAMPLE_N}. Its RAM "
|
||||
"reading is measured, not scaled, and it is excluded from the "
|
||||
"memory-winner decision."
|
||||
),
|
||||
"",
|
||||
(
|
||||
"`max_tuned_gen` and `set_based_ctes` use "
|
||||
"`journal_mode=OFF`/`synchronous=OFF`; they are for rebuildable "
|
||||
"staging loads only."
|
||||
),
|
||||
"",
|
||||
f"Bare-worker peak RSS calibration: **{base_rss / 1_000_000:.3f} MB**.",
|
||||
f"Total benchmark wall time: **{total_wall_seconds:.3f} s**.",
|
||||
"",
|
||||
(
|
||||
f"**Speed Winner: `{fastest['name']}`** — {fastest_time:.4f} s, "
|
||||
f"{baseline_time / fastest_time:.2f}x faster than naive."
|
||||
),
|
||||
(
|
||||
f"**Memory Winner: `{leanest['name']}`** — "
|
||||
f"{leanest_peak_mb:.3f} MB peak RSS, "
|
||||
f"{leanest_delta_mb:+.3f} MB over the calibration worker."
|
||||
),
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines), fastest, leanest
|
||||
|
||||
|
||||
def definition_of_done_gate(
|
||||
results: list[dict[str, Any]],
|
||||
markdown: str,
|
||||
fastest: dict[str, Any],
|
||||
leanest: dict[str, Any],
|
||||
) -> None:
|
||||
"""Hard-fail unless fresh measurements satisfy the benchmark contract."""
|
||||
expected = set(STRATEGIES)
|
||||
actual = {str(result["name"]) for result in results}
|
||||
if actual != expected:
|
||||
raise AssertionError(f"strategy mismatch: expected {expected}, got {actual}")
|
||||
if not all(result["ok"] for result in results):
|
||||
failed = [result["name"] for result in results if not result["ok"]]
|
||||
raise AssertionError(f"integrity verification failed: {failed}")
|
||||
if not all(
|
||||
float(result["seconds"]) > 0 and int(result["peak_rss"]) > 0
|
||||
for result in results
|
||||
):
|
||||
raise AssertionError("every strategy must have measured positive time and RSS")
|
||||
for result in results:
|
||||
required_rows = NAIVE_SAMPLE_N if result["name"] == "naive_autocommit" else N
|
||||
if result["rows"] != required_rows:
|
||||
raise AssertionError(f"wrong row count for {result['name']}")
|
||||
if result["name"] not in markdown:
|
||||
raise AssertionError(f"RESULTS.md omitted {result['name']}")
|
||||
baseline = next(r for r in results if r["name"] == "naive_autocommit")
|
||||
speedup = float(baseline["scaled_seconds"]) / float(fastest["scaled_seconds"])
|
||||
if speedup < 5.0:
|
||||
raise AssertionError(f"fastest strategy achieved only {speedup:.2f}x")
|
||||
if fastest["name"] not in markdown or leanest["name"] not in markdown:
|
||||
raise AssertionError("winner declaration missing from RESULTS.md")
|
||||
if "Speed Winner:" not in markdown or "Memory Winner:" not in markdown:
|
||||
raise AssertionError("RESULTS.md lacks winner lines")
|
||||
if leanest["name"] == "naive_autocommit":
|
||||
raise AssertionError("sampled baseline cannot be Memory Winner")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
total_started = time.perf_counter()
|
||||
print(
|
||||
f"SQLite bulk insert benchmark | N={N:,} rows | "
|
||||
f"Python {platform.python_version()} | SQLite {sqlite3.sqlite_version}"
|
||||
)
|
||||
print("Each strategy runs in a fresh isolated worker process.\n")
|
||||
|
||||
calibration = spawn("calibration")
|
||||
results: list[dict[str, Any]] = []
|
||||
for name in STRATEGIES:
|
||||
result = spawn(name)
|
||||
if not result["ok"]:
|
||||
raise RuntimeError(f"integrity check failed for {name}")
|
||||
scale = N / int(result["rows"])
|
||||
result["scaled_seconds"] = float(result["seconds"]) * scale
|
||||
result["sampled"] = scale != 1.0
|
||||
results.append(result)
|
||||
sample_note = " sampled" if result["sampled"] else ""
|
||||
print(
|
||||
f"[done] {name}: {result['seconds']:.4f}s, "
|
||||
f"{result['rows']:,} rows{sample_note}, "
|
||||
f"peak {result['peak_rss'] / 1_000_000:.3f} MB"
|
||||
)
|
||||
|
||||
total_wall_seconds = time.perf_counter() - total_started
|
||||
markdown, fastest, leanest = build_results_markdown(
|
||||
results, int(calibration["peak_rss"]), total_wall_seconds
|
||||
)
|
||||
definition_of_done_gate(results, markdown, fastest, leanest)
|
||||
RESULTS_PATH.write_text(markdown, encoding="utf-8")
|
||||
|
||||
print(f"\nWrote {RESULTS_PATH}")
|
||||
print(
|
||||
f"Speed Winner: {fastest['name']} "
|
||||
f"({fastest['scaled_seconds']:.4f}s measured/scaled to 1M)"
|
||||
)
|
||||
print(
|
||||
f"Memory Winner: {leanest['name']} "
|
||||
f"({leanest['peak_rss'] / 1_000_000:.3f} MB measured peak RSS)"
|
||||
)
|
||||
print("Definition-of-Done gate: PASS")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "--worker":
|
||||
worker(sys.argv[2])
|
||||
elif len(sys.argv) == 1:
|
||||
main()
|
||||
else:
|
||||
raise SystemExit("usage: uv run bench.py [--worker STRATEGY]")
|
||||
+631
@@ -0,0 +1,631 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""SQLite bulk-insert benchmark: elapsed time and process peak RSS.
|
||||
|
||||
Run:
|
||||
uv run sqlite-bulk-benchmark-BUILDER-openai-gpt-5.6-sol.py
|
||||
|
||||
Every timed trial executes in a fresh child process against a fresh database.
|
||||
The deliberately slow autocommit case uses a prefix sample and reports a
|
||||
clearly marked linear projection to one million rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import gc
|
||||
import itertools
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import random
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Iterator, Sequence
|
||||
|
||||
IDENTITY = "BUILDER-openai-gpt-5.6-sol"
|
||||
DEFAULT_ROWS = 1_000_000
|
||||
DEFAULT_BASELINE_ROWS = 10_000
|
||||
DEFAULT_REPEATS = 2
|
||||
EXECUTEMANY_BATCH_ROWS = 10_000
|
||||
MULTI_VALUES_TARGET_ROWS = 500
|
||||
COLUMN_COUNT = 6
|
||||
|
||||
INSERT_ONE = """
|
||||
INSERT INTO events
|
||||
(id, account_id, created_at, amount_cents, status, payload)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE events (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
amount_cents INTEGER NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('new', 'paid', 'sent', 'void')),
|
||||
payload TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
SET_BASED_INSERT = """
|
||||
WITH RECURSIVE
|
||||
seq(i) AS (
|
||||
VALUES(0)
|
||||
UNION ALL
|
||||
SELECT i + 1 FROM seq WHERE i + 1 < ?
|
||||
),
|
||||
generated(i, mix) AS (
|
||||
SELECT i, (i * 1103515245 + 12345) % 2147483648
|
||||
FROM seq
|
||||
)
|
||||
INSERT INTO events
|
||||
(id, account_id, created_at, amount_cents, status, payload)
|
||||
SELECT
|
||||
i,
|
||||
mix % 100003,
|
||||
1700000000 + i,
|
||||
(mix % 2000001) - 1000000,
|
||||
CASE i % 4
|
||||
WHEN 0 THEN 'new'
|
||||
WHEN 1 THEN 'paid'
|
||||
WHEN 2 THEN 'sent'
|
||||
ELSE 'void'
|
||||
END,
|
||||
printf(
|
||||
'event-%010d-%010x-abcdefghijklmnopqrstuvwxyz0123456789',
|
||||
i,
|
||||
mix
|
||||
)
|
||||
FROM generated
|
||||
"""
|
||||
|
||||
DIGEST_SQL = """
|
||||
SELECT
|
||||
count(*),
|
||||
sum(id),
|
||||
sum(account_id),
|
||||
sum(created_at),
|
||||
sum(amount_cents),
|
||||
sum(length(status)),
|
||||
sum(length(payload)),
|
||||
min(id),
|
||||
max(id),
|
||||
min(payload),
|
||||
max(payload)
|
||||
FROM events
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Strategy:
|
||||
key: str
|
||||
label: str
|
||||
journal_mode: str
|
||||
synchronous: str
|
||||
implementation: str
|
||||
durability: str
|
||||
sampled: bool = False
|
||||
|
||||
|
||||
STRATEGIES: tuple[Strategy, ...] = (
|
||||
Strategy(
|
||||
"naive_autocommit",
|
||||
"naive autocommit",
|
||||
"DELETE",
|
||||
"FULL",
|
||||
"naive",
|
||||
"durable",
|
||||
sampled=True,
|
||||
),
|
||||
Strategy(
|
||||
"one_big_transaction",
|
||||
"one transaction + execute",
|
||||
"DELETE",
|
||||
"FULL",
|
||||
"one_transaction",
|
||||
"durable",
|
||||
),
|
||||
Strategy(
|
||||
"executemany_streamed",
|
||||
"streamed executemany batches",
|
||||
"DELETE",
|
||||
"FULL",
|
||||
"executemany",
|
||||
"durable",
|
||||
),
|
||||
Strategy(
|
||||
"wal_multi_values",
|
||||
"WAL/NORMAL + multi-VALUES",
|
||||
"WAL",
|
||||
"NORMAL",
|
||||
"multi_values",
|
||||
"consistent; latest commit power-loss risk",
|
||||
),
|
||||
Strategy(
|
||||
"off_multi_values",
|
||||
"OFF/OFF + multi-VALUES",
|
||||
"OFF",
|
||||
"OFF",
|
||||
"multi_values",
|
||||
"rebuildable staging only",
|
||||
),
|
||||
Strategy(
|
||||
"wal_set_based",
|
||||
"WAL/NORMAL + INSERT-SELECT",
|
||||
"WAL",
|
||||
"NORMAL",
|
||||
"set_based",
|
||||
"consistent; latest commit power-loss risk",
|
||||
),
|
||||
Strategy(
|
||||
"off_set_based",
|
||||
"OFF/OFF + INSERT-SELECT",
|
||||
"OFF",
|
||||
"OFF",
|
||||
"set_based",
|
||||
"rebuildable staging only",
|
||||
),
|
||||
)
|
||||
STRATEGY_BY_KEY = {strategy.key: strategy for strategy in STRATEGIES}
|
||||
|
||||
|
||||
Row = tuple[int, int, int, int, str, str]
|
||||
Digest = tuple[int | str, ...]
|
||||
|
||||
|
||||
def make_row(i: int) -> Row:
|
||||
"""The sole Python definition of benchmark data."""
|
||||
mix = (i * 1103515245 + 12345) % 2147483648
|
||||
status = ("new", "paid", "sent", "void")[i % 4]
|
||||
return (
|
||||
i,
|
||||
mix % 100003,
|
||||
1700000000 + i,
|
||||
(mix % 2000001) - 1000000,
|
||||
status,
|
||||
f"event-{i:010d}-{mix:010x}-abcdefghijklmnopqrstuvwxyz0123456789",
|
||||
)
|
||||
|
||||
|
||||
def generated_rows(start: int, stop: int) -> Iterator[Row]:
|
||||
for i in range(start, stop):
|
||||
yield make_row(i)
|
||||
|
||||
|
||||
def expected_digest(row_count: int) -> Digest:
|
||||
count = 0
|
||||
id_sum = account_sum = created_sum = amount_sum = 0
|
||||
status_length_sum = payload_length_sum = 0
|
||||
min_payload: str | None = None
|
||||
max_payload: str | None = None
|
||||
for row in generated_rows(0, row_count):
|
||||
row_id, account_id, created_at, amount, status, payload = row
|
||||
count += 1
|
||||
id_sum += row_id
|
||||
account_sum += account_id
|
||||
created_sum += created_at
|
||||
amount_sum += amount
|
||||
status_length_sum += len(status)
|
||||
payload_length_sum += len(payload)
|
||||
if min_payload is None or payload < min_payload:
|
||||
min_payload = payload
|
||||
if max_payload is None or payload > max_payload:
|
||||
max_payload = payload
|
||||
return (
|
||||
count,
|
||||
id_sum,
|
||||
account_sum,
|
||||
created_sum,
|
||||
amount_sum,
|
||||
status_length_sum,
|
||||
payload_length_sum,
|
||||
0 if row_count else None,
|
||||
row_count - 1 if row_count else None,
|
||||
min_payload,
|
||||
max_payload,
|
||||
)
|
||||
|
||||
|
||||
def peak_rss_bytes() -> int:
|
||||
if os.name == "nt":
|
||||
# ctypes is standard library. PeakWorkingSetSize is the Windows
|
||||
# equivalent of process peak RSS.
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class ProcessMemoryCounters(ctypes.Structure):
|
||||
_fields_ = (
|
||||
("cb", wintypes.DWORD),
|
||||
("PageFaultCount", wintypes.DWORD),
|
||||
("PeakWorkingSetSize", ctypes.c_size_t),
|
||||
("WorkingSetSize", ctypes.c_size_t),
|
||||
("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
|
||||
("QuotaPagedPoolUsage", ctypes.c_size_t),
|
||||
("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
|
||||
("QuotaNonPagedPoolUsage", ctypes.c_size_t),
|
||||
("PagefileUsage", ctypes.c_size_t),
|
||||
("PeakPagefileUsage", ctypes.c_size_t),
|
||||
)
|
||||
|
||||
counters = ProcessMemoryCounters()
|
||||
counters.cb = ctypes.sizeof(counters)
|
||||
get_current_process = ctypes.windll.kernel32.GetCurrentProcess
|
||||
get_current_process.restype = wintypes.HANDLE
|
||||
ok = ctypes.windll.psapi.GetProcessMemoryInfo(
|
||||
get_current_process(), ctypes.byref(counters), counters.cb
|
||||
)
|
||||
if not ok:
|
||||
raise ctypes.WinError()
|
||||
return int(counters.PeakWorkingSetSize)
|
||||
|
||||
import resource
|
||||
|
||||
raw = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
||||
# macOS reports bytes; Linux and the BSDs report KiB.
|
||||
if sys.platform == "darwin":
|
||||
return int(raw)
|
||||
return int(raw) * 1024
|
||||
|
||||
|
||||
def setup_connection(db_path: Path, strategy: Strategy) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(db_path, isolation_level=None, timeout=60.0)
|
||||
connection.execute("PRAGMA page_size=4096")
|
||||
actual_journal = connection.execute(
|
||||
f"PRAGMA journal_mode={strategy.journal_mode}"
|
||||
).fetchone()[0]
|
||||
if str(actual_journal).upper() != strategy.journal_mode:
|
||||
raise RuntimeError(
|
||||
f"requested journal_mode={strategy.journal_mode}, got {actual_journal}"
|
||||
)
|
||||
connection.execute(f"PRAGMA synchronous={strategy.synchronous}")
|
||||
|
||||
# Common controls: bounded cache, no mmap RSS ambiguity, disk-backed temp
|
||||
# structures, and spill enabled. These are identical for every strategy.
|
||||
connection.execute("PRAGMA cache_size=-2048")
|
||||
connection.execute("PRAGMA cache_spill=ON")
|
||||
connection.execute("PRAGMA temp_store=FILE")
|
||||
connection.execute("PRAGMA mmap_size=0")
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute(SCHEMA)
|
||||
return connection
|
||||
|
||||
|
||||
def finish_commit(connection: sqlite3.Connection, strategy: Strategy) -> None:
|
||||
connection.commit()
|
||||
# Include WAL's eventual write-back cost, rather than declaring victory
|
||||
# while the complete database still resides in a large sidecar file.
|
||||
if strategy.journal_mode == "WAL":
|
||||
result = connection.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
|
||||
if result[0] != 0:
|
||||
raise RuntimeError(f"WAL checkpoint was busy: {result!r}")
|
||||
|
||||
|
||||
def insert_naive(connection: sqlite3.Connection, row_count: int, strategy: Strategy) -> None:
|
||||
# isolation_level=None makes every statement its own transaction.
|
||||
for row in generated_rows(0, row_count):
|
||||
connection.execute(INSERT_ONE, row)
|
||||
|
||||
|
||||
def insert_one_transaction(
|
||||
connection: sqlite3.Connection, row_count: int, strategy: Strategy
|
||||
) -> None:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
for row in generated_rows(0, row_count):
|
||||
connection.execute(INSERT_ONE, row)
|
||||
finish_commit(connection, strategy)
|
||||
|
||||
|
||||
def insert_executemany(
|
||||
connection: sqlite3.Connection, row_count: int, strategy: Strategy
|
||||
) -> None:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
for start in range(0, row_count, EXECUTEMANY_BATCH_ROWS):
|
||||
stop = min(start + EXECUTEMANY_BATCH_ROWS, row_count)
|
||||
# An iterator, not a list: batching does not materialize input rows.
|
||||
connection.executemany(INSERT_ONE, generated_rows(start, stop))
|
||||
finish_commit(connection, strategy)
|
||||
|
||||
|
||||
def insert_multi_values(
|
||||
connection: sqlite3.Connection, row_count: int, strategy: Strategy
|
||||
) -> None:
|
||||
try:
|
||||
variable_limit = connection.getlimit(sqlite3.SQLITE_LIMIT_VARIABLE_NUMBER)
|
||||
except AttributeError:
|
||||
variable_limit = 999
|
||||
batch_rows = max(
|
||||
1, min(MULTI_VALUES_TARGET_ROWS, variable_limit // COLUMN_COUNT)
|
||||
)
|
||||
placeholder = "(" + ",".join("?" for _ in range(COLUMN_COUNT)) + ")"
|
||||
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
for start in range(0, row_count, batch_rows):
|
||||
stop = min(start + batch_rows, row_count)
|
||||
batch = list(generated_rows(start, stop))
|
||||
statement = (
|
||||
"INSERT INTO events "
|
||||
"(id, account_id, created_at, amount_cents, status, payload) VALUES "
|
||||
+ ",".join(itertools.repeat(placeholder, len(batch)))
|
||||
)
|
||||
bindings = [value for row in batch for value in row]
|
||||
connection.execute(statement, bindings)
|
||||
finish_commit(connection, strategy)
|
||||
|
||||
|
||||
def insert_set_based(
|
||||
connection: sqlite3.Connection, row_count: int, strategy: Strategy
|
||||
) -> None:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(SET_BASED_INSERT, (row_count,))
|
||||
finish_commit(connection, strategy)
|
||||
|
||||
|
||||
IMPLEMENTATIONS = {
|
||||
"naive": insert_naive,
|
||||
"one_transaction": insert_one_transaction,
|
||||
"executemany": insert_executemany,
|
||||
"multi_values": insert_multi_values,
|
||||
"set_based": insert_set_based,
|
||||
}
|
||||
|
||||
|
||||
def worker(strategy_key: str, db_path: Path, row_count: int) -> int:
|
||||
strategy = STRATEGY_BY_KEY[strategy_key]
|
||||
if db_path.exists():
|
||||
db_path.unlink()
|
||||
connection = setup_connection(db_path, strategy)
|
||||
implementation = IMPLEMENTATIONS[strategy.implementation]
|
||||
|
||||
gc_was_enabled = gc.isenabled()
|
||||
gc.disable()
|
||||
started_ns = time.perf_counter_ns()
|
||||
try:
|
||||
implementation(connection, row_count, strategy)
|
||||
except BaseException:
|
||||
if connection.in_transaction:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
if gc_was_enabled:
|
||||
gc.enable()
|
||||
elapsed_ns = time.perf_counter_ns() - started_ns
|
||||
|
||||
# Snapshot peak before the untimed validation query.
|
||||
rss_bytes = peak_rss_bytes()
|
||||
digest = tuple(connection.execute(DIGEST_SQL).fetchone())
|
||||
quick_check = connection.execute("PRAGMA quick_check").fetchone()[0]
|
||||
connection.close()
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"strategy": strategy.key,
|
||||
"rows": row_count,
|
||||
"elapsed_seconds": elapsed_ns / 1_000_000_000,
|
||||
"peak_rss_bytes": rss_bytes,
|
||||
"digest": digest,
|
||||
"quick_check": quick_check,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def format_seconds_list(results: Sequence[dict[str, object]]) -> str:
|
||||
return ",".join(f"{float(result['elapsed_seconds']):.3f}" for result in results)
|
||||
|
||||
|
||||
def format_rss_list(results: Sequence[dict[str, object]]) -> str:
|
||||
mib = 1024 * 1024
|
||||
return ",".join(f"{int(result['peak_rss_bytes']) / mib:.1f}" for result in results)
|
||||
|
||||
|
||||
def median(values: Sequence[float]) -> float:
|
||||
ordered = sorted(values)
|
||||
middle = len(ordered) // 2
|
||||
if len(ordered) % 2:
|
||||
return ordered[middle]
|
||||
return (ordered[middle - 1] + ordered[middle]) / 2
|
||||
|
||||
|
||||
def print_table(
|
||||
grouped: dict[str, list[dict[str, object]]],
|
||||
total_rows: int,
|
||||
expected_by_count: dict[int, Digest],
|
||||
) -> tuple[Strategy, Strategy]:
|
||||
headers = (
|
||||
"Strategy",
|
||||
"Mode",
|
||||
"Rows/run",
|
||||
"Trial time(s)",
|
||||
"Time@1M(s)",
|
||||
"Peak RAM/run MiB",
|
||||
"Data",
|
||||
)
|
||||
rows: list[tuple[str, ...]] = []
|
||||
normalized_times: dict[str, float] = {}
|
||||
peak_by_strategy: dict[str, int] = {}
|
||||
|
||||
for strategy in STRATEGIES:
|
||||
results = grouped[strategy.key]
|
||||
elapsed = [float(result["elapsed_seconds"]) for result in results]
|
||||
row_count = int(results[0]["rows"])
|
||||
projected = median(elapsed) * total_rows / row_count
|
||||
normalized_times[strategy.key] = projected
|
||||
peak_by_strategy[strategy.key] = max(
|
||||
int(result["peak_rss_bytes"]) for result in results
|
||||
)
|
||||
verified = all(
|
||||
tuple(result["digest"]) == expected_by_count[int(result["rows"])]
|
||||
and result["quick_check"] == "ok"
|
||||
for result in results
|
||||
)
|
||||
projection_marker = "*" if strategy.sampled else ""
|
||||
rows.append(
|
||||
(
|
||||
strategy.label,
|
||||
f"{strategy.journal_mode}/{strategy.synchronous}",
|
||||
f"{row_count:,}",
|
||||
format_seconds_list(results),
|
||||
f"{projected:.3f}{projection_marker}",
|
||||
format_rss_list(results),
|
||||
"OK" if verified else "FAIL",
|
||||
)
|
||||
)
|
||||
|
||||
widths = [len(header) for header in headers]
|
||||
for row in rows:
|
||||
for index, value in enumerate(row):
|
||||
widths[index] = max(widths[index], len(value))
|
||||
|
||||
def render(row: Sequence[str]) -> str:
|
||||
return " | ".join(value.ljust(widths[i]) for i, value in enumerate(row))
|
||||
|
||||
print(render(headers))
|
||||
print("-+-".join("-" * width for width in widths))
|
||||
for row in rows:
|
||||
print(render(row))
|
||||
|
||||
speed_key = min(normalized_times, key=normalized_times.get)
|
||||
# The sampled baseline is not eligible for the memory title: its peak RSS
|
||||
# was measured honestly but it did not execute the full row count.
|
||||
full_keys = [strategy.key for strategy in STRATEGIES if not strategy.sampled]
|
||||
memory_key = min(full_keys, key=peak_by_strategy.get)
|
||||
return STRATEGY_BY_KEY[speed_key], STRATEGY_BY_KEY[memory_key]
|
||||
|
||||
|
||||
def orchestrator(args: argparse.Namespace) -> int:
|
||||
if args.rows <= 0 or args.baseline_rows <= 0 or args.repeats <= 0:
|
||||
raise SystemExit("--rows, --baseline-rows, and --repeats must be positive")
|
||||
baseline_rows = min(args.baseline_rows, args.rows)
|
||||
|
||||
print("SQLite bulk insert benchmark")
|
||||
print(f"Identity: {IDENTITY}")
|
||||
print(f"Python: {platform.python_version()} ({sys.executable})")
|
||||
print(f"SQLite: {sqlite3.sqlite_version}")
|
||||
print(f"Platform: {platform.platform()}")
|
||||
print(f"Rows: {args.rows:,}")
|
||||
print(
|
||||
f"Autocommit sample: {baseline_rows:,} rows; its Time@1M is a linear projection"
|
||||
)
|
||||
print(f"Full-strategy trials: {args.repeats}")
|
||||
print(
|
||||
"Timed interval: first BEGIN/INSERT through COMMIT; WAL trials also include "
|
||||
"TRUNCATE checkpoint"
|
||||
)
|
||||
print("Peak RAM: per-worker process ru_maxrss; every trial uses a fresh process")
|
||||
print()
|
||||
|
||||
print("Computing independent expected data digests...")
|
||||
expected_by_count = {
|
||||
baseline_rows: expected_digest(baseline_rows),
|
||||
args.rows: expected_digest(args.rows),
|
||||
}
|
||||
|
||||
jobs: list[tuple[Strategy, int, int]] = []
|
||||
for strategy in STRATEGIES:
|
||||
trial_count = 1 if strategy.sampled else args.repeats
|
||||
row_count = baseline_rows if strategy.sampled else args.rows
|
||||
for trial in range(1, trial_count + 1):
|
||||
jobs.append((strategy, trial, row_count))
|
||||
random.Random(0x5A17E).shuffle(jobs)
|
||||
|
||||
grouped: dict[str, list[dict[str, object]]] = {
|
||||
strategy.key: [] for strategy in STRATEGIES
|
||||
}
|
||||
script_path = Path(__file__).resolve()
|
||||
child_environment = os.environ.copy()
|
||||
child_environment["PYTHONHASHSEED"] = "0"
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=f"sqlite-bench-{IDENTITY}-") as temp_name:
|
||||
temp_dir = Path(temp_name)
|
||||
for job_number, (strategy, trial, row_count) in enumerate(jobs, start=1):
|
||||
db_path = temp_dir / f"{IDENTITY}-{strategy.key}-trial-{trial}.sqlite3"
|
||||
print(
|
||||
f"[{job_number:02d}/{len(jobs):02d}] {strategy.label}, "
|
||||
f"trial {trial}, {row_count:,} rows...",
|
||||
flush=True,
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(script_path),
|
||||
"--worker",
|
||||
strategy.key,
|
||||
"--db",
|
||||
str(db_path),
|
||||
"--worker-rows",
|
||||
str(row_count),
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=child_environment,
|
||||
timeout=args.worker_timeout,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"worker failed for {strategy.key}:\n"
|
||||
f"stdout:\n{completed.stdout}\n"
|
||||
f"stderr:\n{completed.stderr}"
|
||||
)
|
||||
result = json.loads(completed.stdout.strip().splitlines()[-1])
|
||||
grouped[strategy.key].append(result)
|
||||
|
||||
for results in grouped.values():
|
||||
results.sort(key=lambda item: float(item["elapsed_seconds"]))
|
||||
|
||||
print("\nResults")
|
||||
speed_winner, memory_winner = print_table(
|
||||
grouped, args.rows, expected_by_count
|
||||
)
|
||||
print(
|
||||
"\n* naive autocommit Time@1M is measured-time × "
|
||||
f"{args.rows:,}/{baseline_rows:,}; its RAM is measured, not projected."
|
||||
)
|
||||
print(
|
||||
"OFF/OFF rows are semantically identical but the database is rebuildable-only; "
|
||||
"a crash can corrupt it."
|
||||
)
|
||||
print(
|
||||
"INSERT-SELECT is applicable when rows can be generated in SQL or selected "
|
||||
"from an attached SQLite source."
|
||||
)
|
||||
print(f"\nSpeed Winner: {speed_winner.label}")
|
||||
print(f"Memory Winner: {memory_winner.label}")
|
||||
return 0
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--rows", type=int, default=DEFAULT_ROWS)
|
||||
parser.add_argument("--baseline-rows", type=int, default=DEFAULT_BASELINE_ROWS)
|
||||
parser.add_argument("--repeats", type=int, default=DEFAULT_REPEATS)
|
||||
parser.add_argument("--worker-timeout", type=float, default=180.0)
|
||||
parser.add_argument("--worker", choices=tuple(STRATEGY_BY_KEY))
|
||||
parser.add_argument("--db", type=Path)
|
||||
parser.add_argument("--worker-rows", type=int)
|
||||
args = parser.parse_args(argv)
|
||||
if args.worker and (args.db is None or args.worker_rows is None):
|
||||
parser.error("--worker requires --db and --worker-rows")
|
||||
return args
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
if args.worker:
|
||||
return worker(args.worker, args.db, args.worker_rows)
|
||||
return orchestrator(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user