Performance benchmark suite
Status: designed and written, never run. Every number this suite will print still has to be produced. The first
run on a quiet machine becomes the baseline; until then the only performance figures on record are those of the load
bench (pnpm bench, see Operations).
1. What it is for
rifleDB promises low-latency realtime writes and shared live controls. The suite answers three questions, each with its own layer:
| Question | Layer | Measures |
|---|---|---|
| Did a change make a hot code path slower? | micro | CPU time per call of pure functions and in-process components (ns/op), no network |
| How does the server behave in a realistic workload shape? | scenarios | Latency distributions, throughput and server load for one workload each (fan-out, presence, REST, …) over real sockets/HTTP |
| Does it hold the headline targets, and stay flat over time? | load / soak | The existing load bench (server/): ≥ 20 000 ops/s; ack p99 ≤ 15 ms at 5 000 ops/s with ≥ 400 clients; heap slope over a soak |
Results are JSON files with the raw samples and an environment fingerprint, so any two runs can be compared, and a comparison says "slower", "faster" or "unchanged" only when the difference is larger than the noise.
Non-goals. Browser rendering (the 3D demo's frame rate needs a GPU and Playwright; the quality tiers cover it
today), multi-machine or horizontally scaled setups (v1 is a single process), absolute numbers that hold across
machines (compare only like with like), and replacing profiling (--cpu-prof of the load bench stays the tool for
finding why).
2. Layout
Benchmarks live next to the code they measure; the tooling is one small workspace package.
bench/ @rifledb/bench: harness, runner, statistics, comparison (no rifleDB dependencies)
src/index.ts the public API (re-exports)
src/harness.ts suite()/bench()/consume() for micro benchmarks
src/scenario.ts scenario() for scenario benchmarks
src/registry.ts what a bench file registered
src/stats.ts median, MAD, confidence interval, Mann–Whitney U
src/env.ts environment fingerprint
src/discover.ts finds bench and scenario files
src/runner.ts, src/child.ts, one child process per file, the IPC messages between them
src/ipc.ts
src/results.ts, src/types.ts result files and their schema
src/compare.ts, src/report.ts comparison and Markdown/terminal reports
src/args.ts, src/cli.ts `pnpm perf …`
src/redact.ts keeps `REDIS_URL` out of messages and result files
test/ unit tests of the pure parts, plus one runner test on a no-op fixture file
packages/protocol/bench/*.bench.ts paths, values, ops, signing strings, digests, rate buckets, wire encoding
packages/demo-world/bench/*.bench.ts terrain, drop/merge/alchemy, season checks
packages/rifle/bench/*.bench.ts SDK cache, optimistic layer, client-side signing
server/bench/micro/*.bench.ts engine, registry, access checks, idempotency, sequencer, rooms, metrics, HTTP signatures
server/bench/scenarios/*.scenario.ts workloads against a server in its own thread
server/bench/load.ts … the existing load bench, unchanged; wrapped by the `load` scenarioThe protocol, demo-world and SDK packages have a bench/ (Node types; their sources stay browser-safe)
that is part of the package's typecheck; the server's own tsconfig already covers server/. Files run with their package as working directory, so the
server's JSX config resolves as it does for the server itself.
Results go to . (git-ignored), named <kind>-<time>[-label].. Baselines are per machine and
kind: . (kind is micro or scenarios), where <machine> is a short
hash of CPU model, core count, memory, platform and architecture (not the OS release, so an OS update keeps the
baselines; the release is recorded in the result). A team that wants shared baselines (a fixed CI runner) commits that runner's file deliberately; nothing else is
committed.
3. Method
3.1 Micro benchmarks
A bench file registers suites at module level:
import { bench, consume, suite } from '@rifledb/bench'
suite('protocol/ops', () => {
const list = Array.from({ length: 100 }, (_, i) => i)
bench('applyOp push (100 items)', () => consume(applyOp(list, 'push', { item: 1 })))
bench('applyOp inc', () => consume(applyOp(41, 'inc', { by: 1 })), { params: { op: 'inc' } })
})Per benchmark the harness:
- runs the suite's
beforeAllhooks (once per suite, before its first selected bench;afterAllafter its last), then the bench's ownsetup(outside any timing); - warms up for
--warmupms (default 250) and at least 1 000 calls, so V8 has optimized the code (for benches slower than about 1 ms per call the call minimum stops atmax(warmup,);1 s) - calibrates a batch size so that one timed sample takes ≥ 10 ms (timer resolution and loop overhead become
negligible), capped by the bench's
maxBatchfor benches that consume state; - takes
--samplessamples (default 20, time-boxed by--time, default 2 000 ms, never fewer than 8): each sample times a batch withprocess.and records ns per call; a bench'shrtime. bigint() beforeEachruns before each sample, untimed; - reports median ns per call, median absolute deviation, mean, min, max, the 95 % confidence interval of the mean as
a relative margin (rme), and ops/s from the median. Raw per-sample values are kept in the result file. A bench that
does several operations per call (the pipelined engine writes, a 16-op batch) declares
opsPerCall; reports then show ns and ops/s per operation (nsPerOp), while the file keeps ns per call.
With --rounds N every bench file runs N times, each in a fresh process, one after another; the result keeps the
pooled samples and each round's median. One process cannot show how much results move between processes (core
placement, other apps, heat): on a laptop that alone is a few percent, close to the default threshold.
Async benches are awaited call by call (the measured cost includes one promise turn, which is what callers pay).
consume(v) stores into a sink V8 cannot prove dead, so results are never optimized away. Each bench file runs
in a fresh child process (node --expose-gc --import tsx), and the harness collects garbage between benches, so one
file's JIT state and heap never leak into another's. --shuffle randomizes bench order within each suite (suites
keep their hooks around their own benches) to expose order effects; --seed N reproduces an order.
3.2 Scenarios
A scenario starts the bench server (server/: the real config plus the bench folders,
bench-only limits) in a worker thread, drives it from the main thread or from client worker threads, and returns
named measurements:
scenario({
name: 'fanout',
description: 'one writer, N prefix subscribers: delivery latency',
params: { subscribers: [10, 100, 1000], rate: [10, 100] },
defaults: { durationMs: 10_000, warmupMs: 2_000 },
async run(ctx, p) {
// … returns { metrics, distributions, valid, notes }
},
})- Every parameter combination is one case;
--param subscribers=100narrows the matrix. - Each case runs
--repstimes (default 3) on a fresh server; the summary per metric is the median of the reps with their min–max range. - Measurements are taken only after a warm-up period, over a fixed measurement window.
- Load generators run open loop where the scenario is about throughput (the next request does not wait for the
previous answer, so a slow server shows up as latency, not as a quietly lower rate) and report their own sender
lag (
senderLagP50/senderLagP99); a case whose sender lag p99 exceeds 10 ms, or whose clients did not all connect, is marked invalid rather than reported as slow — the same rule as the load bench. - Calls still unanswered when the window closes (after a 3 s drain) are counted (
unansweredPct) and recorded in the latency distribution at the latency they had reached; above 0.1 % the case is invalid, as are failures or skipped sends above 0.1 % and any rate-limit refusal (E_, HTTP 429): a case that meets a limiter measures the limiter, not the server.RATE - A writer that feeds subscribers (
fanout,sse) carries its actual send time in the value, so delivery latency is measured from the send; latency counted from the schedule is kept as thedeliveryFromDuedistribution. - Throughput scenarios with a concurrency parameter (
rest) run closed loop at that concurrency instead: the parameter then defines the load, and the client thread's own event-loop load is checked instead of sender lag. - A case is also invalid when a client worker thread's event loop is more than 90 % busy: a saturated receiver would show up as server latency.
- Limits: the bench server lifts the per-address limits (
BENCH_,LIMITS BENCH_), and a scenario can lift more for itself (PUBLIC_ WRITE_ RATE limits,publicWriteRate,httpRatePerIpoptions ofserver-worker):ack-latencylifts the per-socket message rate,resyncthe per-session write bucket,restthe HTTP rate. Limits that cannot be lifted that way (device sign-in, registration, demo drops, SSE streams per address) are avoided inconnect,actionsandsseby giving every client its own address withX-Forwarded-For, which only the bench server trusts (trustProxy, bench-only, off by default). The other scenarios' clients share 127.0.0.1. - Latencies across threads use
performance., which shares one clock in a process.timeOrigin + performance. now() - The server thread reports its event-loop utilization, event-loop delay p99, heap and RSS every second
(
server-worker's existing samples); scenarios wait for the sample of the window's last second and summarise the samples of the measurement window. - Store: MemoryKV by default;
REDIS_runs the same cases against Redis (useURL pnpm redis:dev, never a production instance). The result records which store ran, never the URL.
3.3 Environment fingerprint and noise control
Every result file carries: Node and V8 versions, OS and architecture, CPU model, core count, memory, load average at
start, power source on macOS (pmset -g batt), Node flags, the store kind, the git commit and whether the tree had
uncommitted changes (read-only git rev-parse / git --no-optional-locks status --porcelain), macOS Low Power Mode,
the suite options and a label. Error messages written to results have REDIS_ redacted.
The runner warns when the load average is above half the core count, refuses to start on battery or in Low Power Mode
unless --allow-battery, never runs two benchmark files at the same time, and kills a child that sends nothing for
--child-timeout ms (default 5 minutes, longer for long scenario cases; raise it for a very long --time), recording
an error and moving on. A killed scenario child cannot clean up: its Redis keys under bench:<id>:, its directory under
. and, for load, the load bench it started (which ends on its own) are left behind. The machine should be on AC power, with other
heavy apps and dev servers closed; servers already running on the machine are not touched (scenario servers listen on
an ephemeral port on 127.0.0.1).
4. Catalogue
4.1 Micro
| Suite | Benchmarks (inputs) | Why it matters |
|---|---|---|
protocol/ | parsePath short and 16-deep, isAncestor, relPath | every message resolves paths |
protocol/ | validateValue small object, 16 KB array, 64 KB string, fast tuple | every write validates its value |
protocol/ | validateOpArgs + applyOp for set, inc, toggle, push (100 / 256 items), removeWhere (256), merge, strAppend | applied on the server and optimistically in the SDK |
protocol/ | JSON./parse of a chg packet with 1 / 32 / 256 entries; wsAuthString, httpSignString, signature header format/parse | broadcast cost and auth overhead |
protocol/ | contentDigest 1 KB / 64 KB, kidFromPub, Ed25519 sign and verify (node:crypto, WebCrypto) | HTTP signatures, socket auth |
protocol/ | TokenBucket. | charged on every message |
demo-world/ | generateTerrain; drop on an empty, a typical and a near-full island; a worst-case merge cascade; fillRatio, shouldEndSeason | the drop action runs inside a folder transaction |
rifle/ | apply a 1 / 256-entry chg, prefix listing of 1 000 keys, supersedes | every change a client receives |
rifle/ | queue N pending ops on a key and confirm / roll back | the SDK's optimistic layer |
rifle/ | Ed25519 sign with @noble/ (the fallback for insecure contexts) vs WebCrypto | identity proofs on weak devices |
server/ | Engine. set / inc on a loaded folder (MemoryKV), pipelined 1 000 applies, batch of 16, a 4-op transaction, hot read, since over 10 000 keys | the write path minus the network |
server/ | FolderRegistry. with 10 000 folders at depth 8, authority glob match | every access check |
server/ | can() for public / private-with-grant / secret / authority paths | every message |
server/ | IdempotencyCache insert and lookup at capacity (eviction), Sequencer write burst, roomsForChange | per-message bookkeeping |
server/ | histogram record, percentile read, collector tick | runs on every op |
server/ | verify a signed request (headers, digest, signature, nonce), mergePatch, etagOf | every REST call |
4.2 Scenarios
| Scenario | Workload | Parameters | Key metrics |
|---|---|---|---|
ack-latency | one client, one op at a time (closed loop) | folder normal / fast; op set / inc | ack RTT p50 / p99 / max, ops/s: the latency floor including the group-commit tick |
fanout | one writer at a fixed rate, N clients subscribed to the prefix | subscribers 10 / 100 / 1 000; rate 10 / 100 per s | delivery latency from send p50 / p99 / max, deliveries/s, server ELU, bytes out/s |
presence | N clients each publish their ghost tuple at 20 Hz to demo/ and subscribe to it (the demo's real folder, slot rule and coalescing) | clients 25 / 50 / 100 | update delivery p50 / p99 (includes up to 50 ms of the folder's 20 Hz coalescing), share of updates received (below 100 % is expected: 20 Hz writes meet a 20 Hz flush), sender lag, ELU, bytes out/s |
rest | HTTP/1.1 keep-alive clients, closed loop at a fixed concurrency | method GET / PUT / PATCH (rifle-op+json inc); concurrency 1 / 16 / 64 | req/s, latency p50 / p99, error rate |
sse | N ?stream subscriptions, one writer | streams 10 / 100 / 500 | delivery latency from send p50 / p99, missed events |
connect | N sockets opened as fast as allowed (bounded concurrency); device keys are registered before the storm, so only sign-in is timed | anonymous / device-key auth; N 200 / 1 000 | handshakes/s, time to ready p50 / p99, server heap per connection (after a forced GC), server RSS; no server event-loop metrics (the forced GCs would dominate them) |
cold-load | a folder with K keys in storage, loaded by a fresh engine (no sockets) | keys 100 / 1 000 / 10 000; value 64 B / 1 KB | first-read latency, full-snapshot (since 0) latency |
actions | N clients calling an action at a fixed rate | action bench/ (transaction overhead) / demo drop (rules + merges); clients 10 / 100 | ack p50 / p99, actions/s, sender lag; refusals by code in the notes (any rate refusal makes the case invalid) |
resync | a subscriber goes away, M writes happen, it comes back with since | M 100 / 1 000 / 10 000; with since vs fresh subscribe | time until caught up, bytes received |
load | the existing load bench (load.) as one case | clients 500; rate 10 per client | its acked ops/s, ack p50 / p99, error counts and its target verdicts |
The headline targets stay those of the load bench. Every other scenario is judged against the baseline, not against
an absolute number, until a first baseline exists; budgets can then be added per scenario (budgets in its
definition) and are reported as met / missed.
5. Result format
schema: "rifledb-bench/, one file per run:
{
"schema": "rifledb-bench/1",
"kind": "micro", // or "scenarios"
"label": "", "startedAt": "…", "finishedAt": "…",
"env": { "node": "…", "v8": "…", "os": "…", "arch": "…", "cpu": "…", "cores": 10, "memGb": 32,
"loadAvg": [1.2, 1.1, 1.0], "power": "ac", "lowPower": false, "store": "memory", "machine": "m_3f9a…",
"git": { "sha": "…", "dirty": true }, "execArgv": ["--expose-gc", "--import", "tsx"] },
"options": { "samples": 20, "timeMs": 2000, "warmupMs": 250, "rounds": 3, "filter": null },
"micro": [{ "id": "protocol/ops › applyOp inc", "suite": "protocol/ops", "name": "applyOp inc",
"file": "packages/protocol/bench/ops.bench.ts", "params": {}, "unit": "ns/op",
"batch": 65536, "opsPerCall": 1, "samples": [12.1, 12.0, …], // pooled over the rounds
"stats": { "median": 12.0, "nsPerOp": 12.0, "mad": 0.1, "mean": 12.05, "min": 11.9, "max": 12.6,
"rme": 0.4, "opsPerSec": 83333333 },
"rounds": [{ "median": 11.9, "mad": 0.1, "mean": 11.95, "rme": 0.3, "samples": 20, "batch": 65536 }, …] }],
"scenarios": [{ "id": "fanout[subscribers=100,rate=10]", "name": "fanout", "params": { … },
"reps": [{ "valid": true, "metrics": { "deliveryP99": { "value": 4.1, "unit": "ms", "better": "lower" } },
"distributions": { "delivery": { "unit": "ms", "count": 10000, "p50": 1.2, "p95": 3, "p99": 4.1, "p999": 7, "max": 9 } },
"notes": [] }],
"summary": { "deliveryP99": { "median": 4.1, "min": 3.9, "max": 4.4, "unit": "ms", "better": "lower" } } }]
}A run that fails part-way still writes what it has, with errors per file or case.
6. Comparison
pnpm perf compare <base. matches benchmarks by id and prints a table (and, with --md, a
Markdown file for a PR):
- Micro, both sides with ≥ 3 rounds: significant when the medians of the round medians differ by at least
--threshold(default 5 %) and the min–max ranges of the round medians do not overlap. This is the rule to gate on. - Micro, otherwise: significant when the two-sided Mann–Whitney U test on the pooled samples gives p < 0.01
and the medians differ by at least the threshold. The report then warns that a single process does not capture
between-process noise. Verdicts:
faster,slower,same, plusnew/gonefor benchmarks present on one side only andinvalidfor a bench that failed. - Scenarios: per metric, significant when the medians of the reps differ by at least
--threshold-scenarios(default 10 %) and the min–max ranges of the reps do not overlap. Invalid cases are listed, never compared. - A bench whose
opsPerCalldiffers between the two files isinvalid(it measures a different workload); a scenario metric whose base median is 0 issame, with a note (there is no relative change from zero). - When the two environments differ (CPU, core count, Node version, store, power, Low Power Mode), the report says so at the top and marks the comparison as indicative.
- The table lists
slowerfirst, thenfaster,invalid,gone,newandsame. --fail-on-regressionexits with status 1 when anything is significantly slower, gone or invalid — for a CI job on a dedicated runner. Shared CI runners are too noisy for micro thresholds of 5 %; use scenarios or raise the threshold there.- With single-round micro input,
--fail-on-regressionprints the between-process warning and still gates. It also fails on everygonerow, so gate a--filtered run only against a baseline made with the same filter. - Calibrate once per machine: run the same commit twice with
--rounds 3and compare the two files (an A/A comparison). Anything it reports asslowerorfasteris noise; raise--thresholduntil it reports nothing. With exactly 3 rounds per side, round ranges fail to overlap by chance about one time in ten, so the threshold does most of the work;--rounds 5makes the range test itself strict (under 1 %).
7. Commands
pnpm perf micro [--filter <regex>] [--package protocol|demo-world|rifle|server] [--rounds 1] [--samples 20] [--time 2000] [--shuffle] [--seed N] [--label text]
pnpm perf scenarios [--filter <regex>] [--param key=value …] [--reps 3] [--duration 10000] [--warmup 2000] [--label text]
pnpm perf all # micro, then scenarios, one result file each
pnpm perf compare <base.json> <head.json> [--threshold 5] [--threshold-scenarios 10] [--md report.md] [--fail-on-regression]
pnpm perf compare <head.json> # against this machine's baseline of the same kind
pnpm perf baseline [<result.json>] [--force] # store a result (default: the newest of each kind) as this machine's baseline
pnpm perf list # the suites, benchmarks and scenario cases that would run (imports the files, runs nothing)
pnpm bench -- … # the load bench, unchangedA typical regression check: pnpm perf micro --rounds 3 --label before on the base commit, the same with
--label after on the change, then pnpm perf compare on the two files. A full scenario matrix takes roughly
cases × reps × (warm-up + duration) plus server start-up — about 35–40 minutes with the defaults; narrow it with
--filter and --param. pnpm perf --help lists the further flags (--seed, --warmup, --allow-battery, …).
8. Before the first run
No benchmark or scenario in this suite has been executed. What did run: the tooling's unit tests (one of which
drives the child-process runner on a no-op fixture file), and, during review, each micro benchmark's setup once
without timing, to check that it builds the state it claims. Expect to fix small things on the first run (a timeout too short for a slow
machine, a parameter that saturates the load generator before the server). The first full run on the reference
machine, on AC power and otherwise idle, should be stored with pnpm perf baseline; budgets for the scenarios follow
from it.