hecks bench measures how many commands a runtime dispatches per second and how long
each one takes (p50 and p99), for the Ruby runtime on each persistence adapter and for
the native Rust binary. This page says how to run it, what it does and does not
measure, and publishes one baseline with the machine it was taken on.
It is a measurement tool, not a gate. It never fails on a slow number, and nothing in CI runs it.
hecks bench # everything, 3 runs per target
hecks bench targets=ruby:memory,rust # only these targets
hecks bench domains=pizzas runs=5 # one domain, median of 5 fresh boots
hecks bench format=json output=tmp/bench.json
hecks bench iterations=20 warmup=5 runs=1 # a quick smoke run
| flag | default | meaning |
|---|---|---|
--domain NAMES |
pizzas,banking |
which example workloads to run |
--targets NAMES |
all five | any of ruby:memory, ruby:sqlite, ruby:postgres, ruby:postgres_era, rust |
--iterations N |
1000 | timed cycles per run (a cycle is 4 commands) |
--warmup N |
200 | cycles dispatched and discarded before timing starts |
--runs N |
3 | fresh boots per target; the report shows the median and the range |
--rust-binary PATH |
build one | use an existing rust binary; needs exactly one --domain |
--format, --output |
markdown |
Markdown to stdout; --output also writes the full JSON, per-run and per-verb |
The Rust targets run cargo build --release --no-default-features --features <domain> in
rust/ first (the build is not timed). With no cargo on PATH and no --rust-binary,
the Rust target is skipped with a message.
ruby:postgres and ruby:postgres_era need a reachable server and the pg gem. Connection
settings come from libpq’s environment (PGHOST, PGPORT, PGUSER, PGPASSWORD). When
none answers, both targets are skipped with a sentence saying why, and everything else
still runs:
skip ruby:postgres: no Postgres server is reachable (connection to server at "127.0.0.1", port 1 failed: Connection refused); start one, or point PGHOST, PGPORT and PGUSER at one
Both write to a uniquely named schema in a scratch database called hecks_bench, which is
created if missing (the database is left in place; each run’s schema is dropped when it
finishes). They never touch the example’s own database, and ruby:postgres does not use
the fuzzer’s shared hecks_fuzz schema, so it is safe to run while hecks fuzz is running.
Both workloads are four commands per cycle, all valid, none refused (a refused command aborts the run rather than being timed):
| domain | cycle |
|---|---|
pizzas |
CreatePizza, AddTopping twice, Purchase |
banking |
Account.Open, Credit twice, Debit (after one untimed Customer.Register) |
Every cycle names its own records, so the store grows by one aggregate per cycle. Every runtime is driven the same way: one caller, one command at a time, each waiting for the previous answer.
Fuzzing::IsolatedBoot, the same isolation
hecks fuzz adapter=… uses), then times each dispatch_flat call in-process with the
monotonic clock. That includes validation, guards, the aggregate update, the
persistence write, event emission and any policy the event triggers.--serve mode and times each round trip: write
one JSON line to its stdin, read one JSON line back. The timer therefore includes two
pipe crossings and the binary’s own JSON parse and print. The report prints a
round-trip floor, the p50 of the same round trip for a step the binary refuses at
once, which is roughly what the pipes cost with almost no domain work behind them.Throughput is commands divided by the wall time of the whole timed pass. p50 and p99
are nearest-rank percentiles over every timed command in a run, then the median across
runs. drift is the p50 of the last tenth of a run over the p50 of its first tenth: above
1.0 means commands got slower as the store filled.
ruby:memory; even that row is not the same
work.rust/host server, the WASM build and the file-backed Heki adapter are
not measured.ruby:postgres_era boots as a superuser under allow_superuser, which
turns the era’s row-level-security write fence off, so its figure leaves that cost out.Taken on 2026-09-26 at commit ba9e123e (Hecks 2.5.1), on a developer laptop that other
work was using at the same time, so read the ranges as well as the medians.
| CPU | Apple M3 Pro, 12 cores |
| Memory | 36 GiB |
| OS | macOS 15.7.2 (arm64) |
| Ruby | 3.3.7, YJIT off (the default) |
| Rust | rustc 1.98.0, --release (Cargo’s default optimization level) |
| Postgres | 14.22 (Homebrew), same machine, local socket, configuration not tuned |
Warmup was 200 cycles (800 commands) and 1,000 timed cycles (4,000 commands) per run. Each figure is the median of the runs’ own figures; the range column is the slowest and fastest run’s throughput.
hecks bench domains=pizzas runs=5 iterations=1000 warmup=200 # 5 runs
hecks bench domains=banking runs=7 iterations=1000 warmup=200 # 7 runs
The two domains were run as separate invocations (the first run of both, at a one-minute load average of 7.4 rising to 15, produced a banking half that was too noisy to publish, so banking was repeated on its own at 5.8 rising to 6.4). Load average on a 12-core machine is a rough guide only: other processes (builds and test suites) shared the machine throughout, so treat every figure as an upper bound on how fast a quiet machine would be, and expect a re-run to differ.
| domain | target | commands/s | range across runs | p50 (us) | p99 (us) | drift |
|---|---|---|---|---|---|---|
| pizzas | ruby:memory | 2,576 | 2,464 to 2,618 | 302.0 | 1,477.0 | 1.11 |
| pizzas | ruby:sqlite | 1,128 | 991 to 1,154 | 794.0 | 2,054.0 | 1.02 |
| pizzas | ruby:postgres | 782 | 671 to 816 | 1,166.0 | 2,690.0 | 0.97 |
| pizzas | ruby:postgres_era | 920 | 611 to 952 | 1,036.0 | 2,213.0 | 1.00 |
| pizzas | rust | 53,850 | 51,956 to 57,127 | 15.0 | 24.0 | 0.94 |
| banking | ruby:memory | 737 | 724 to 760 | 1,169.0 | 3,456.0 | 1.68 |
| banking | ruby:sqlite | 377 | 289 to 407 | 2,364.0 | 4,420.0 | 1.07 |
| banking | ruby:postgres | 254 | 192 to 285 | 3,672.0 | 7,725.0 | 1.06 |
| banking | ruby:postgres_era | 242 | 222 to 250 | 3,869.0 | 6,884.0 | 0.98 |
| banking | rust | 41,614 | 36,579 to 44,399 | 19.0 | 29.0 | 1.00 |
The Rust round-trip floor (p50) was 9 us for pizzas and 10 us for banking, so most of the Rust rows’ 15 to 19 us p50 is the pipe, not the domain.
ruby:postgres_era came out level with ruby:postgres: a little ahead on pizzas and a
little behind on banking, with the ranges overlapping on both. The era adapter ran with its
write fence off (see the caveats above), so this shows no difference between them, not that
one is cheaper.Any machine with Ruby, bundle install done, and (for the Rust row) a Rust toolchain will
run hecks bench with the commands above. Expect different absolute numbers; the shape of the
table (Memory fastest, Sqlite next, the Postgres adapters slowest, Rust far ahead) is what to compare. The
smoke configuration in spec/bench_spec.rb runs the same code with a handful of iterations
so the harness does not rot; it asserts nothing about speed.
CI does not run hecks bench. The figures above were taken on a machine shared with heavy
unrelated work, and ranges as wide as 84 to 259 commands per second (banking, PostgresEra,
in the first attempt) were seen, so a scheduled job on a shared runner would not give a
stable enough signal to publish or gate on.
hecks launcherhecks bench measures dispatch, not how long hecks <verb> takes to start. Measured on 2026-10-01 (macOS arm64, Ruby 3.3.7,
HECKS_ENVIRONMENT=memory, best of the runs shown), a verb’s start is dominated by one thing, whether the verdict cache is warm:
| command | cache cold | cache warm |
|---|---|---|
hecks --help (answers from the projection, boots nothing) |
about 22 s | about 0.77 s |
hecks stores lib/hecks/hecks (boots the Hecks domain) |
about 22 s | about 0.77 s |
hecks gate --list |
not measured | about 1.0 s |
require "hecks" alone |
not applicable | about 0.18 s |
“Cold” is a fresh XDG_CACHE_HOME; “warm” is the next run. The cold run judges every chapter of the language and the chapters Hecks
attaches (MetaValidator::VerdictCache stores the verdicts; HECKS_VERDICT_CACHE=off turns it off).
What decides cold or warm: the cache file’s name carries a digest of every file under lib/, the Ruby and Hecks versions and the
cache format. So an edit to any file under lib/, including a comment, makes the next launch cold, and a fresh checkout or a CI runner
that does not keep the cache directory (Hecks::CacheDir) starts cold.
Not done, and not safe to do without an audit: keying the digest to the files a verdict actually reads. It would keep the cache warm across unrelated edits, but a stale “no refusals” entry would skip validation, so the key must name every input of a verdict, and nothing lists them today.