bench: go-sqlite comparison harness + first numbers

- mirrors db-bench schema/modes line-for-line; ram + durable (FULL
  sync) flavors; tmpfs-fsync trap re-confirmed (122k/s lie vs 3.1k/s
  ext4)
- wo wins reads x2.6, query x6.4, durable seed x1.4; sqlite wins ram
  writes x1.9, durable mixed writes x1.4 (update-through-query re-probe
  = named optimization target)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
shoney.arickathil 2026-08-22 17:12:16 +02:00
parent b683545d61
commit 5cf979e405
5 changed files with 227 additions and 0 deletions

View file

@ -0,0 +1,40 @@
# go-sqlite — the comparison harness
Go (`database/sql` + mattn/go-sqlite3, cgo) mirroring
`docs/examples/db-bench`'s schema and modes line-for-line, so the
numbers align column-for-column. Not a gate — a reference point;
SQLite is the honest peer (embedded, single-writer, WAL, same
durability knob).
Run: `go build -o go-sqlite . && ./go-sqlite ram 20000` /
`./go-sqlite durable 20000 <ext4-dir>` — a tmpfs dir makes fsync free
and the durable numbers a lie (measured: 122k/s on /tmp vs 3.1k/s on
ext4; the campaign's own trap, re-confirmed).
## Measured 2026-08-22 (N=20k, same machine, ext4, single-shard vs single-conn)
| metric | writeonce | Go+SQLite | ratio |
| --- | --- | --- | --- |
| ram seed inserts/s | 245,188 | 296,965 | sqlite ×1.2 |
| ram read ops/s (p50µs) | 1,097,574 (1) | 429,645 (2) | **wo ×2.6** |
| ram query ops/s | 989,609 | 154,559 | **wo ×6.4** |
| ram write ops/s | 195,465 | 380,069 | sqlite ×1.9 |
| durable seed inserts/s (p50µs) | 4,460 (~220) | 3,113 (241) | **wo ×1.4** |
| durable write ops/s | 2,324 | 3,257 | sqlite ×1.4 |
Readings, honestly:
- **Reads/queries: writeonce wins 2.6–6.4×** — RAM-authoritative rows +
the index probe answer without page decoding or a bytecode/VM ↔ cgo
boundary; SQLite pays B-tree page traversal + the cgo call per op.
- **ram writes: SQLite wins ~1.9×** — writeonce's update path re-runs a
probe per update (update-through-query) and its insert encodes slots
per field; SQLite's page write is tight. A real optimization target,
now measured.
- **durable seed: writeonce wins ~1.4×** (append-only WAL + fdatasync
vs SQLite WAL frame + FULL sync); durable mixed writes flip back to
SQLite ×1.4 — the update's extra probe again.
- Caveats: different languages (Go harness pays ~1µs cgo per op; wo
pays its interpreter), both are the honest end-to-end app-visible
cost of their stack. Single connection vs single shard; no
concurrency comparison here (SQLite has one writer by design).

BIN
bench/compare/go-sqlite/go-sqlite Executable file

Binary file not shown.

View file

@ -0,0 +1,5 @@
module writeonce.bench/go-sqlite
go 1.25
require github.com/mattn/go-sqlite3 v1.14.34

View file

@ -0,0 +1,2 @@
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=

View file

@ -0,0 +1,180 @@
// go-sqlite — the comparison harness for docs/examples/db-bench.
// Mirrors the .wo sample's schema and modes so the lines align
// column-for-column: <op> <count> <ops/sec> <p50us> <p99us>.
//
// Flavors mirror the campaign's: "ram" = :memory:, "durable" = a file
// with synchronous=FULL and per-statement autocommit — an fsync per
// insert, the same ack-after-durable contract writeonce's WAL gives.
//
// Usage: go-sqlite <ram|durable> <N> [dir]
package main
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sort"
"time"
_ "github.com/mattn/go-sqlite3"
)
func pct(d []time.Duration, p int) int64 {
if len(d) == 0 {
return 0
}
s := make([]time.Duration, len(d))
copy(s, d)
sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })
i := len(s) * p / 100
if i >= len(s) {
i = len(s) - 1
}
return s[i].Microseconds()
}
func report(op string, n int, total time.Duration, per []time.Duration) {
us := total.Microseconds()
if us < 1 {
us = 1
}
fmt.Printf("%s %d %d %d %d\n", op, n, int64(n)*1e6/us, pct(per, 50), pct(per, 99))
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "go-sqlite:", err)
os.Exit(1)
}
}
func main() {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: go-sqlite <ram|durable> <N> [dir]")
os.Exit(2)
}
flavor := os.Args[1]
var n int
fmt.Sscanf(os.Args[2], "%d", &n)
dsn := ":memory:"
if flavor == "durable" {
dir := "."
if len(os.Args) > 3 {
dir = os.Args[3]
}
// FULL = fsync before every commit acknowledges — the peer of
// writeonce's per-statement WAL commit
dsn = filepath.Join(dir, "bench.db") + "?_journal_mode=WAL&_synchronous=FULL"
}
db, err := sql.Open("sqlite3", dsn)
must(err)
defer db.Close()
db.SetMaxOpenConns(1) // one writer, like the engine; keeps :memory: coherent
_, err = db.Exec(`
CREATE TABLE buckets (id INTEGER PRIMARY KEY, tag TEXT NOT NULL UNIQUE);
CREATE TABLE items (id INTEGER PRIMARY KEY, k INTEGER NOT NULL,
v INTEGER NOT NULL,
bucket INTEGER NOT NULL REFERENCES buckets(id));
CREATE INDEX items_k ON items(k);
CREATE INDEX items_bucket ON items(bucket);
PRAGMA foreign_keys = ON;`)
must(err)
kmod := n / 10
if kmod < 1 {
kmod = 1
}
itemV := func(i int) int { return (i * 37) % 1000 }
lcg := func(s int) int {
x := s*1103515245 + 12345
if x < 0 {
x = -x
}
return x
}
// seed: one bucket per 100 children, per-statement autocommit —
// mirror of the .wo sample's ack-per-insert shape
insB, err := db.Prepare("INSERT INTO buckets(tag) VALUES(?)")
must(err)
insI, err := db.Prepare("INSERT INTO items(k, v, bucket) VALUES(?, ?, ?)")
must(err)
per := make([]time.Duration, 0, n)
t0 := time.Now()
var bref int64
for i, b := 1, 0; i <= n; b++ {
r, err := insB.Exec(fmt.Sprintf("b%d", b))
must(err)
bref, _ = r.LastInsertId()
for j := 0; j < 100 && i <= n; j, i = j+1, i+1 {
o0 := time.Now()
_, err = insI.Exec(i%kmod, itemV(i), bref)
must(err)
per = append(per, time.Since(o0))
}
}
report("seed", n, time.Since(t0), per)
// read: indexed point lookups, LIMIT 1 — the .wo take-1 shape
rd, err := db.Prepare("SELECT v FROM items WHERE k = ? LIMIT 1")
must(err)
per = per[:0]
sink, s := 0, 42
nr := n / 2
t0 = time.Now()
for i := 0; i < nr; i++ {
s = lcg(s)
o0 := time.Now()
var v int
if err := rd.QueryRow(s % kmod).Scan(&v); err == nil {
sink += v
}
per = append(per, time.Since(o0))
}
report("read", nr, time.Since(t0), per)
// query: full equality probes (~10 rows each), materialized + counted
qr, err := db.Prepare("SELECT v FROM items WHERE k = ?")
must(err)
per = per[:0]
rows, s := 0, 7
nq := n / 10
t0 = time.Now()
for i := 0; i < nq; i++ {
s = lcg(s)
o0 := time.Now()
rs, err := qr.Query(s % kmod)
must(err)
for rs.Next() {
rows++
}
rs.Close()
per = append(per, time.Since(o0))
}
report("query", nq, time.Since(t0), per)
fmt.Printf("query rows %d\n", rows)
// write: alternating inserts (disjoint k) and update-through-query
up, err := db.Prepare(
"UPDATE items SET v = v + 1 WHERE id = (SELECT id FROM items WHERE k = ? LIMIT 1)")
must(err)
per = per[:0]
s = 99
nw := n / 2
t0 = time.Now()
for i := 0; i < nw; i++ {
o0 := time.Now()
if i%2 == 0 {
_, err = insI.Exec(2000000+i, itemV(i), bref)
} else {
s = lcg(s)
_, err = up.Exec(s % kmod)
}
must(err)
per = append(per, time.Since(o0))
}
report("write", nw, time.Since(t0), per)
_ = sink
}