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:
parent
39b035b513
commit
302a074125
5 changed files with 227 additions and 0 deletions
40
bench/compare/go-sqlite/README.md
Normal file
40
bench/compare/go-sqlite/README.md
Normal 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
BIN
bench/compare/go-sqlite/go-sqlite
Executable file
Binary file not shown.
5
bench/compare/go-sqlite/go.mod
Normal file
5
bench/compare/go-sqlite/go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module writeonce.bench/go-sqlite
|
||||
|
||||
go 1.25
|
||||
|
||||
require github.com/mattn/go-sqlite3 v1.14.34
|
||||
2
bench/compare/go-sqlite/go.sum
Normal file
2
bench/compare/go-sqlite/go.sum
Normal 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=
|
||||
180
bench/compare/go-sqlite/main.go
Normal file
180
bench/compare/go-sqlite/main.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue