Benchmark
The current build, measured at scale: a 32-core machine, saturation curves, 10,000-site density, write throughput. Every run is reproducible with one script: bench.sh on GitHub downloads the official binary, pins the CPUs and drives the load itself.
Setup — the scale round
| what | value |
|---|---|
| engine | CMSnap-LITE-M 0.2.2, official release binary, downloaded and pinned by the script itself |
| hardware | AMD EPYC 9554 (KVM guest), 32 physical cores / 64 SMT threads — two guest-visible 16-core CCDs with separate L3; 128 GB RAM, Debian 13 |
| server under test | pinned to 16 cores (taskset) — a separate guest-visible L3 domain |
| load | wrk on the other 16 cores, 32 threads — it never shares a guest-visible cache domain with the server it measures; 10 s warmup, 3-minute runs |
| page | this site’s landing page, ~11.4 KB, no compression requested |
| fairness | access log ON for every run |
| scope | all runs over localhost — the engine’s CPU ceiling; over a real network NIC, TLS and bandwidth add on top |
Throughput vs latency — cached page, one site
| connections | req/s | p50 | p99 |
|---|---|---|---|
| 100 | 953,617 | 89 µs | 3.1 ms |
| 150 | 988,728 | 118 µs | 2.9 ms |
| 200 | 1,012,350 | 175 µs | 3.2 ms |
| 500 | 1,039,784 | 435 µs | 3.5 ms |
| 1000 | 1,042,959 | 900 µs | 4.1 ms |
The knee sits near 200 connections: a million requests per second at a sub-200 µs median, from 16 server cores. Past the knee, connections buy queue depth, not throughput — the last 3% cost a fivefold median. The flat ~3 ms p99 is hypervisor scheduling jitter: it does not move with queue depth.
Density — full sites, not stubs
| sites on the node | cached page, req/s | page from database, req/s |
|---|---|---|
| 1 | 1,039,784 | — |
| 100 | 1,057,618 | 261,416 |
| 10,000 | 987,228 | 241,535 |
Each of the 10,000 sites is a complete clone of this site: its own SQLite database with 28 articles, its own admin, users and keys. Hosting ten thousand of them instead of one costs five percent of throughput and 30 µs of median latency; on the live-database path, 7.5%. Routing is by Host header, an equal share to every site.
Live database and JSON
With the cache off every request is a SQLite read plus a template render: 255,302 req/s at a 416 µs median across 100 sites (ceiling 261k at deeper queues). The JSON API — a ~50-row listing straight from a view — does 293,123 req/s with a 1.24 ms p99: the same read path, no template, a 3.9 KB response.
Writes scale with databases
| databases taking inserts | committed inserts/s | p50 | p99 |
|---|---|---|---|
| 1 | ~197,000 | 535 µs | 4.7 ms |
| 100 | 494,769 | 491 µs | 99 ms |
| 10,000 | 352,869 | 734 µs | 252 ms |
The write test POSTs the public contact form — honeypot, per-IP rate limiter and a redirect on every request. Committed rows are counted in the databases after the queues drain — the public script prints the count after every write run, so the metric comes out of the script, not on trust. The p50/p99 above are HTTP response latency, not commit latency. One database drains ~197k inserts a second; spread the same flood across sites and the total more than doubles — every site’s database has its own writer, so a database per site turns isolation into write parallelism. The HTTP intake itself accepts 765k POSTs/s; past a writer’s drain rate the excess is shed to protect the site, behind a queue of 100,000 that absorbs any realistic burst. No real site sees a fraction of these rates — the headroom is the point.
Running a dense node
What the 10,000-site node costs the operator: ~8 MB RSS and ~21 memory mappings per full site, so raise vm.max_map_count (the 65,530 default runs out near 3,000 sites) and ulimit -n.
Why a CMS can serve at file-server speed
Hot pages are rendered once, compressed once and kept in RAM — the “CMS layer” costs nothing at request time. Totals come from in-memory counters, error pages are prerendered, view SQL is precompiled. Under overload throughput stays flat and latency grows only with the queue — no timeouts, no dropped keep-alives.