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

whatvalue
engineCMSnap-LITE-M 0.2.2, official release binary, downloaded and pinned by the script itself
hardwareAMD 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 testpinned to 16 cores (taskset) — a separate guest-visible L3 domain
loadwrk 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
pagethis site’s landing page, ~11.4 KB, no compression requested
fairnessaccess log ON for every run
scopeall 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

connectionsreq/sp50p99
100953,61789 µs3.1 ms
150988,728118 µs2.9 ms
2001,012,350175 µs3.2 ms
5001,039,784435 µs3.5 ms
10001,042,959900 µs4.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 nodecached page, req/spage from database, req/s
11,039,784
1001,057,618261,416
10,000987,228241,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 insertscommitted inserts/sp50p99
1~197,000535 µs4.7 ms
100494,769491 µs99 ms
10,000352,869734 µs252 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.

← All articles in this group