Pinned the canonical shard count at M = 4 based on real-Wikipedia
ingest + query benchmark (bench/shard_count_sweep.py). Captured the
"when does SQLite stop being the right substrate" decision tree so
future operators know what bench would justify a fork or replacement.
Bench numbers (Wikipedia 2003 cur dump, 2000 docs, 4 cells of
M ∈ {1, 2, 4, 8}, 50 FTS queries per cell):
M chunks/s q_p50_ms q_p99_ms attach_ms
1 3,648 0.05 0.20 1.61
2 5,649 0.03 0.19 3.24
4 6,405 0.07 0.30 9.00
8 6,959 0.03 0.28 9.65
Key observations:
- M=1→M=2 is the biggest ingest win (+55%). Most gain happens there.
- M=2→M=4 is +13%. M=4→M=8 is only +9% — diminishing returns.
- Real wikitext canonicalization is per-worker Python CPU bound, not
SQLite-writer-lock bound. More shards don't unlock more CPU.
- Query p50/p99 is flat across M within noise (50 queries small).
- ATTACH cost grows linearly: 1.6 / 3.2 / 9.0 / 9.7 ms.
Why M=4 specifically:
- Captures 92% of peak ingest throughput (6,405 / 6,959).
- 6 ATTACH slots free under SQLite's 10 ceiling for aux DBs
(qa.db, snapshots.db, selfmodel-chain.db, crawl_*.db, future
mesh_*.db) — comfortable headroom. M=8 leaves only 2 slots.
- Mobile-tolerable: phone NAND attach is 5-10x slower than NVMe;
M=4 = 45-90 ms cold start (instant), M=8 = 50-100 ms (sluggish
with no headroom).
- Matches fox's current 4-shard layout = cheapest migration.
Decision tree for when SQLite stops being right (full text in
ticket §"When the SQLite-default substrate stops being right"):
A. ATTACH ceiling pressure (auxiliary DBs grow past 5) → bench
forked SQLite with SQLITE_MAX_ATTACHED=125, M ∈ {16, 32, 64};
if attach cost stays linear past M=10, fork viable but pays
permanent "no longer stock sqlite3" tax.
B. Ingest hits >10k chunks/s sustained ceiling → first tune
page_size / WAL checkpoint / mmap_size / synchronous. If
tuning gets 2-5x, stay on SQLite. If still ceiling-limited,
candidates: DuckDB (columnar, MVCC, FTS), libmdbx (B+tree no
FTS; we'd build it). In-house DB rejected without specific
failure of those.
C. Federation needs multi-writer-same-shard → SQLite writer-lock
serializes peers, becomes federation bottleneck. First try
leader-election (single-writer-per-shard with WAL replication
to followers). If true multi-writer required, SQLite is wrong;
candidates: FoundationDB, CRDT-on-KV-store. DuckDB does NOT
solve this — its MVCC is single-process.
Honest verdict: for current arborist workload (single-writer-per-
shard, read-mostly federation), stock python3 sqlite3 is the right
substrate. None of A/B/C are close to firing. The bench discipline
exists to know what to measure when something changes.
bench/results/shard-count-sweep-2026-05-26T16-20-48Z.csv (synthetic
baseline) + 2026-05-26T16-31-34Z.csv (real Wikipedia) committed as
the load-bearing measurement for the M=4 choice.
370 lines
13 KiB
Python
370 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Shard-count sweep — measure ingest + query cost as a function of M.
|
|
|
|
Variables:
|
|
M ∈ {1, 2, 4, 8} shard count for that run
|
|
N = M ingest workers (one per shard, no WAL contention)
|
|
|
|
Fixed:
|
|
corpus = N_DOCS synthetic deterministic-content documents
|
|
queries = N_QUERIES fixed FTS5 queries (head + tail tokens)
|
|
|
|
Output:
|
|
- CSV at bench/results/shard-count-sweep-<ts>.csv
|
|
- Console summary table
|
|
|
|
The script wipes ~/.arborist/bench-shards/ between runs so it never
|
|
touches the live corpus at ~/.arborist/shards/.
|
|
|
|
This benchmark exists to answer one question for #000065:
|
|
**at what M does ingest throughput stop improving, and how does query
|
|
latency change?** Answer drives the canonical-M choice.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import random
|
|
import shutil
|
|
import statistics
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
from pathlib import Path
|
|
|
|
# Make arborist importable from the repo.
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from arborist.document import Document, canonicalize # noqa: E402
|
|
from arborist.ingest import ingest_source # noqa: E402
|
|
from arborist.merkle import MerkleTree, hash_leaf # noqa: E402
|
|
from arborist.source import Source # noqa: E402
|
|
from arborist.sources.wikipedia import WikipediaCurDump # noqa: E402
|
|
from arborist.store import connect, connect_query, discover_shards # noqa: E402
|
|
|
|
BENCH_DIR = Path.home() / ".arborist" / "bench-shards"
|
|
RESULTS_DIR = ROOT / "bench" / "results"
|
|
WIKI_CUR_PATH = ROOT / "data" / "20030516_cur_tablesql.bz2"
|
|
|
|
# Compact for time budget. Each cell of the M-sweep re-ingests N_DOCS,
|
|
# and Wikipedia ingest does real wikitext canonicalization + edge
|
|
# extraction so per-doc cost is realistic.
|
|
N_DOCS = 2000
|
|
N_QUERIES = 50
|
|
SHARD_COUNTS = [1, 2, 4, 8]
|
|
USE_WIKIPEDIA = True # False = synthetic fallback (kept for fast iteration)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Synthetic corpus generation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
WORDS = [
|
|
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel",
|
|
"india", "juliet", "kilo", "lima", "mike", "november", "oscar", "papa",
|
|
"quebec", "romeo", "sierra", "tango", "uniform", "victor", "whiskey",
|
|
"xray", "yankee", "zulu",
|
|
"computer", "wikipedia", "physics", "history", "music", "biology",
|
|
"mathematics", "philosophy", "economics", "literature", "chemistry",
|
|
"geology", "astronomy", "psychology", "sociology",
|
|
]
|
|
|
|
|
|
def load_wikipedia_corpus(n_docs: int) -> list[Document]:
|
|
"""Read the first n_docs articles from the 2003 Wikipedia cur dump.
|
|
|
|
Real wikitext, real canonicalization cost — this is the bench cell
|
|
that matters for the M-decision because synthetic random-words docs
|
|
underestimate ingest per-doc work.
|
|
"""
|
|
if not WIKI_CUR_PATH.exists():
|
|
raise FileNotFoundError(
|
|
f"Wikipedia dump not found at {WIKI_CUR_PATH}. "
|
|
f"Run `make fetch-cur` first."
|
|
)
|
|
src = WikipediaCurDump(WIKI_CUR_PATH)
|
|
docs = []
|
|
for doc in src.iter_documents():
|
|
docs.append(doc)
|
|
if len(docs) >= n_docs:
|
|
break
|
|
return docs
|
|
|
|
|
|
def gen_corpus(n_docs: int, seed: int = 42) -> list[Document]:
|
|
"""Generate N deterministic synthetic docs. Same seed → same content →
|
|
same document_root across runs. Routes deterministically by hash."""
|
|
rng = random.Random(seed)
|
|
docs = []
|
|
for i in range(n_docs):
|
|
# Each doc ~3 KB of text drawn from word pool. Common words appear
|
|
# more often (so FTS queries on them hit many docs), some rare
|
|
# tokens (doc_NNN tags) appear in exactly one doc.
|
|
sentences = []
|
|
for _ in range(60):
|
|
length = rng.randint(8, 20)
|
|
words = rng.choices(WORDS, k=length)
|
|
sentences.append(" ".join(words).capitalize() + ".")
|
|
# Inject a unique rare token per doc so we can do "find doc X" queries.
|
|
sentences.append(f"This is the unique marker bench_doc_{i:04d}.")
|
|
content = " ".join(sentences)
|
|
docs.append(Document(
|
|
uri=f"bench://doc_{i:04d}",
|
|
content=content,
|
|
source_type="html", # so license_class is "unknown" but ingest works
|
|
title=f"Bench Doc {i:04d}",
|
|
))
|
|
return docs
|
|
|
|
|
|
def doc_to_shard(doc: Document, M: int) -> int:
|
|
"""Content-hash routing: deterministic shard index from canonicalized
|
|
content. Matches the #000065 routing function — first 8 hex chars of
|
|
document_root (= leaf-Merkle root over canonical chunks)."""
|
|
# Approximate document_root using a stand-in: hash of canonical content
|
|
# bytes. For the bench, what matters is determinism + uniform spread,
|
|
# not exact match to ingest's hash.
|
|
h = hashlib.sha256(canonicalize(doc.content).encode("utf-8")).hexdigest()
|
|
return int(h[:8], 16) % M
|
|
|
|
|
|
class ListSource(Source):
|
|
source_type = "html"
|
|
|
|
def __init__(self, docs: list[Document]):
|
|
self.docs = docs
|
|
|
|
def iter_documents(self):
|
|
yield from self.docs
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Ingest worker — runs in a subprocess so SQLite writer locks are real
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _ingest_worker(args: tuple[str, list[dict]]) -> dict:
|
|
"""Subprocess worker: ingest the assigned docs into one shard.
|
|
args = (db_path, list of {uri, content, title} dicts).
|
|
Returns timing + RSS info.
|
|
"""
|
|
import resource
|
|
# Bench infrastructure recycles shard paths between runs. Clear the
|
|
# migration cache so each iteration re-runs SCHEMA_SQL on the fresh
|
|
# (just-deleted) DB. Without this, the M=2+ iterations see a path
|
|
# in _MIGRATED_SHARDS and skip schema creation → "no such table".
|
|
from arborist import store as _store
|
|
_store._MIGRATED_SHARDS.clear()
|
|
db_path, doc_dicts = args
|
|
docs = [
|
|
Document(
|
|
uri=d["uri"], content=d["content"],
|
|
source_type="html", title=d["title"],
|
|
)
|
|
for d in doc_dicts
|
|
]
|
|
src = ListSource(docs)
|
|
t0 = time.perf_counter()
|
|
conn = connect(db_path)
|
|
try:
|
|
result = ingest_source(conn, src)
|
|
finally:
|
|
conn.close()
|
|
wall = time.perf_counter() - t0
|
|
rss_kb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
return {
|
|
"db": db_path,
|
|
"wall_s": wall,
|
|
"docs_ingested": result.inserted,
|
|
"chunks_ingested": result.chunks_total,
|
|
"peak_rss_mb": rss_kb / 1024,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bench loop
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def reset_bench_dir() -> None:
|
|
if BENCH_DIR.exists():
|
|
shutil.rmtree(BENCH_DIR)
|
|
BENCH_DIR.mkdir(parents=True)
|
|
# Also clear the parent process's migration cache; the in-process
|
|
# M=1 path uses it.
|
|
from arborist import store as _store
|
|
_store._MIGRATED_SHARDS.clear()
|
|
|
|
|
|
def bench_ingest(M: int, docs: list[Document]) -> dict:
|
|
"""Run ingest with M shards + M parallel workers. Returns aggregate stats."""
|
|
reset_bench_dir()
|
|
# Hash-partition docs into M groups.
|
|
groups: list[list[Document]] = [[] for _ in range(M)]
|
|
for doc in docs:
|
|
groups[doc_to_shard(doc, M)].append(doc)
|
|
sizes = [len(g) for g in groups]
|
|
# Each worker gets one shard.
|
|
db_paths = [str(BENCH_DIR / f"{i:03d}.db") for i in range(M)]
|
|
args_list = [
|
|
(db_paths[i], [
|
|
{"uri": d.uri, "content": d.content, "title": d.title}
|
|
for d in groups[i]
|
|
])
|
|
for i in range(M)
|
|
]
|
|
t0 = time.perf_counter()
|
|
worker_results = []
|
|
if M == 1:
|
|
# No need for process pool when there's one shard.
|
|
worker_results.append(_ingest_worker(args_list[0]))
|
|
else:
|
|
with ProcessPoolExecutor(max_workers=M) as pool:
|
|
futures = [pool.submit(_ingest_worker, args) for args in args_list]
|
|
for fut in as_completed(futures):
|
|
worker_results.append(fut.result())
|
|
wall = time.perf_counter() - t0
|
|
total_chunks = sum(r["chunks_ingested"] for r in worker_results)
|
|
return {
|
|
"M": M,
|
|
"phase": "ingest",
|
|
"wall_s": wall,
|
|
"per_shard_size_min": min(sizes),
|
|
"per_shard_size_max": max(sizes),
|
|
"total_chunks": total_chunks,
|
|
"per_worker_wall_s_max": max(r["wall_s"] for r in worker_results),
|
|
"per_worker_wall_s_min": min(r["wall_s"] for r in worker_results),
|
|
"per_worker_peak_rss_mb_max": max(r["peak_rss_mb"] for r in worker_results),
|
|
}
|
|
|
|
|
|
def gen_queries(rng_seed: int = 7) -> list[str]:
|
|
"""Mix of head-term (common, hits many docs) and tail-term (one doc) queries."""
|
|
rng = random.Random(rng_seed)
|
|
queries = []
|
|
# Half head queries: random common words.
|
|
for _ in range(N_QUERIES // 2):
|
|
n_words = rng.randint(1, 3)
|
|
queries.append(" ".join(rng.choices(WORDS, k=n_words)))
|
|
# Half tail queries: a specific bench_doc_NNNN marker.
|
|
sample_docs = rng.sample(range(N_DOCS), N_QUERIES - N_QUERIES // 2)
|
|
for i in sample_docs:
|
|
queries.append(f"bench_doc_{i:04d}")
|
|
return queries
|
|
|
|
|
|
def bench_query(M: int, queries: list[str]) -> dict:
|
|
"""Run query suite against the M-shard layout."""
|
|
# Use connect_query which ATTACHes all the shards.
|
|
snap_shards = discover_shards(BENCH_DIR)
|
|
if not snap_shards:
|
|
return {"M": M, "phase": "query", "wall_s": 0, "p50_ms": 0, "p99_ms": 0,
|
|
"queries": 0, "error": "no_shards_discovered"}
|
|
t_attach_0 = time.perf_counter()
|
|
conn = connect_query(":memory:", shards_dir=BENCH_DIR)
|
|
attach_ms = (time.perf_counter() - t_attach_0) * 1000
|
|
try:
|
|
latencies_ms = []
|
|
t_total_0 = time.perf_counter()
|
|
for q in queries:
|
|
t0 = time.perf_counter()
|
|
# FTS5 query across the union view.
|
|
rows = conn.execute(
|
|
"SELECT COUNT(*) FROM chunks_fts WHERE chunks_fts MATCH ?",
|
|
(q,),
|
|
).fetchall()
|
|
latencies_ms.append((time.perf_counter() - t0) * 1000)
|
|
total_wall = time.perf_counter() - t_total_0
|
|
finally:
|
|
conn.close()
|
|
latencies_ms.sort()
|
|
return {
|
|
"M": M,
|
|
"phase": "query",
|
|
"wall_s": total_wall,
|
|
"attach_ms": attach_ms,
|
|
"p50_ms": latencies_ms[len(latencies_ms) // 2],
|
|
"p99_ms": latencies_ms[int(len(latencies_ms) * 0.99)],
|
|
"mean_ms": statistics.mean(latencies_ms),
|
|
"queries": len(queries),
|
|
}
|
|
|
|
|
|
def main():
|
|
print(f"== shard-count sweep ==")
|
|
print(f" N_DOCS={N_DOCS}, N_QUERIES={N_QUERIES}, M ∈ {SHARD_COUNTS}")
|
|
print(f" bench dir: {BENCH_DIR}")
|
|
print()
|
|
|
|
if USE_WIKIPEDIA:
|
|
print(f" corpus: real Wikipedia ({WIKI_CUR_PATH.name}, first {N_DOCS} docs)")
|
|
t_load = time.perf_counter()
|
|
docs = load_wikipedia_corpus(N_DOCS)
|
|
print(f" loaded {len(docs)} docs in {time.perf_counter() - t_load:.1f}s")
|
|
else:
|
|
print(f" corpus: synthetic ({N_DOCS} docs)")
|
|
docs = gen_corpus(N_DOCS)
|
|
queries = gen_queries()
|
|
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
ts = time.strftime("%Y-%m-%dT%H-%M-%SZ", time.gmtime())
|
|
csv_path = RESULTS_DIR / f"shard-count-sweep-{ts}.csv"
|
|
|
|
all_rows = []
|
|
summary = []
|
|
for M in SHARD_COUNTS:
|
|
print(f"-- M={M} --")
|
|
ingest = bench_ingest(M, docs)
|
|
print(f" ingest: {ingest['wall_s']:.2f}s "
|
|
f"chunks={ingest['total_chunks']} "
|
|
f"worker_max={ingest['per_worker_wall_s_max']:.2f}s "
|
|
f"peak_rss_mb={ingest['per_worker_peak_rss_mb_max']:.1f}")
|
|
all_rows.append(ingest)
|
|
query = bench_query(M, queries)
|
|
print(f" query: {query['wall_s']:.2f}s for {query['queries']} qs "
|
|
f"p50={query['p50_ms']:.2f}ms p99={query['p99_ms']:.2f}ms "
|
|
f"attach={query['attach_ms']:.1f}ms")
|
|
all_rows.append(query)
|
|
summary.append({
|
|
"M": M,
|
|
"ingest_wall_s": ingest["wall_s"],
|
|
"ingest_chunks_per_s": ingest["total_chunks"] / ingest["wall_s"],
|
|
"query_p50_ms": query["p50_ms"],
|
|
"query_p99_ms": query["p99_ms"],
|
|
"query_attach_ms": query["attach_ms"],
|
|
})
|
|
|
|
# Write CSV
|
|
with open(csv_path, "w") as f:
|
|
if all_rows:
|
|
keys = set()
|
|
for r in all_rows:
|
|
keys.update(r.keys())
|
|
w = csv.DictWriter(f, fieldnames=sorted(keys))
|
|
w.writeheader()
|
|
for r in all_rows:
|
|
w.writerow(r)
|
|
print()
|
|
print(f"== summary ==")
|
|
print(f"{'M':>2} {'ingest_wall_s':>14} {'chunks/s':>10} "
|
|
f"{'q_p50_ms':>10} {'q_p99_ms':>10} {'attach_ms':>10}")
|
|
for s in summary:
|
|
print(f"{s['M']:>2} {s['ingest_wall_s']:>14.2f} "
|
|
f"{s['ingest_chunks_per_s']:>10.1f} "
|
|
f"{s['query_p50_ms']:>10.2f} {s['query_p99_ms']:>10.2f} "
|
|
f"{s['query_attach_ms']:>10.2f}")
|
|
print()
|
|
print(f"results → {csv_path}")
|
|
|
|
# Tidy up bench dir.
|
|
if BENCH_DIR.exists():
|
|
shutil.rmtree(BENCH_DIR)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|