Benchmarks

2026-10-07 — Remaining gaps: soak, HTTP/3 with several instances, distribution

Host: development VM (8 vCPU EPYC 7642, Ubuntu 24.04, kernel 6.8). Binaries: scalws-adaptive-runtime-rc1 (tag, /opt/scalws-rc1) and the development build after it (/opt/scalws-dev). Only interleaved A/B numbers are compared across builds (the VM drifts by several percent within an hour).

rc1 soak, 6 h (scripts/controller-soak.sh, E1)

6.01 h, 175 cycles (1 → 2 → 3 → 2 → 1 instances) with one fault per cycle, 11 kinds × 16: controller restart, SIGKILL of an active / starting / draining instance, failed readiness, upstream dead / slow / resetting, memory pressure, scale beyond max, reload. Seven load streams in 60 s windows (HTTP/1.1 static, 1 MiB, proxy, new connection per request, HTTP/2 + cache, PHP, WebSocket).

Streamrequests in clean windowserrorsp99 ms (median of windows)
HTTP/1.1 static25.5 M00.37
HTTP/1.1 1 MiB0.34 M01.34
HTTP/1.1 proxy7.8 M00.72
HTTP/1.1 new connection5.0 M00.56
HTTP/2 + cache12.4 M10 (timeout)0.51
PHP2.5 M01.01
WebSocket messages10.0 M0–
  • Controller restarts: 16/16 adopted every running instance. No orphaned state record in 2127 samples; zombies at most 2, transient.
  • Resources: controller 7.4 → 7.2 MB RSS, 15 FDs, 3 threads; instances 32.5 → 31.8 MB RSS (mean), ~220 FDs. No growth.
  • Faults: all 11 recovered each time. The memory-pressure probe did not reach its threshold (defect in the soak script — fixed, re-checked by the mixed soak).
  • The host’s TCP “estab” count rose 1166 → 6558: CLOSE_WAIT sockets in the benchmark’s WebSocket echo server (Node HTTP servers are half-open; it never closed after a peer FIN). Fixed in bench/apps/ws/server.js; reproduced and verified (20 of 20 sockets stuck before the fix). ScalWS FDs stayed bounded throughout.
  • The 10 HTTP/2 timeouts (0.8 per million) fell in 4 windows, each the window right after a controller restart. bench/ctl-restart-h2.sh (same stream, 23 controller restarts in 4 min, rc1) produced 0 errors in 192 000 requests, with and without restarts: the restart alone does not cause them. Open; watched in the mixed soak.
  • A 42-minute period (10:00:30–10:42 UTC) is annotated: a parallel test blocked metrics ports of the soak’s instances.

HTTP/3 behind the QUIC demux (bench/h3-instances.sh, D7)

3 runs × 10 s, medians. Instances on CPUs 0–2 (one each), demux and controller on CPU 3, 4 × scalws-h3-probe (16 connections × 4 streams, /hello.txt) on CPUs 4–7. plain-1: one scalwsd with its own UDP socket, no controller, no demux.

Setupreq/sp99 mserrorsinstance CPU µs/reqdemux CPU µs/reqdemux µs/datagramdatagrams/sefficiency
plain-133 8403.08029.1––––
demux, 130 6333.40032.214.514.131 6001.00
demux, 251 7663.06038.112.810.165 6000.84
demux, 375 9472.62038.911.18.4101 0000.83
  • The demux costs 9.5 % of one instance’s throughput; 3 instances serve 2.48× one.
  • The demux is the ceiling: at 3 instances it uses ~0.85 of its CPU (≈ 8.4 µs per inbound datagram). Correction (profile of 2026-10-08, docs/benchmarks/2026-10-08-demux-batching.md): receive is already batched (recvmmsg, ~32 per call); the cost is one SEQPACKET message per forwarded datagram, and sendmmsg did not reduce it. Several datagrams per message would be the next step if one host needs more than ~110 000 inbound datagrams/s. Outbound traffic does not pass through the demux.

Does connection imbalance hurt? (bench/imbalance.sh, E3)

Two instances (one CPU each); 8 HTTP/2 connections × 32 streams opened in a burst plus HTTP/1.1 at 2000 req/s; status.distribution sampled every second; 3 runs per mode.

ModeHTTP/2 req/s (runs)HTTP/2 p99 msbusiest connection sharemax pressure imbalanceharmful seconds
inherited143 541 / 137 403 / 146 6173.66–3.790.630.06–0.110
reuseport148 582 / 111 540 / 160 3523.39–4.730.58–0.670.10–0.220

No harmful second in any run: the policy of ADR-0038 holds — no mitigation is built.

Regression gate and cache-hit CPU

Cache-hit CPU flag (rc1 gate had 26.4 → 29.1 µs/request): interleaved release A/B, 7 × 30 s, one instance:

Buildreq/s (median)p99 msCPU µs/req
baseline103 1739.2227.97
rc1102 9879.1727.94
dev102 1219.1727.85

No difference: the flag was drift between non-interleaved runs.

dev vs rc1 (MODE=release bench/multi.sh, 5 × 30 s; the three scenarios with the largest deltas repeated with 7 runs):

Scenarioreq/sCPU µs/req7-run repeat (req/s, CPU)
static-small−3.2 %+7.1 %+2.9 %, −1.3 %
static-1m−2.2 %−1.3 %
respond−4.3 % (CV 5 %)−2.6 %+0.6 %, +8.6 %
proxy+4.3 %−2.9 %
cache-hit−2.3 %+2.6 %
TLS + HTTP/2+0.7 %+10.3 %−0.8 %, +9.6 %
PHP+1.5 %−5.2 %

Throughput within ±5 % everywhere. CPU per request from a saturated closed loop is noisy (signs flip between repeats), so it was measured at a fixed rate (bench/cpu-fixed-rate.sh, 20 000 req/s, 6 alternating rounds, server on 2 CPUs):

Scenariorc1 µs/reqdev µs/req
TLS + HTTP/231.7730.77 (−3 %)
respond30.7330.84 (+0.4 %)

Gate passed. The only request-path changes since rc1 are an Option check on the Alt-Svc insert and atomic loads of the per-IP limits.

Controller test suite (dev build)

e2e 21/21, d5 (sweep, HTTP/2 scale-in, reload while scaling, max instances, memory guard, bursts), slow-drain, WebSocket drain (20 tunnels, all 1001), HTTP/2 drain 5/5 (only refused-before-processing streams), listener generations 13/13, HTTP/3 12/12 (instance SIGKILL: 3 of 15 connections reset — clients of the killed instance).

Mixed soak, 6 h, dev build (H3=1 scripts/controller-soak.sh)

2026-10-07 18:52 → 2026-10-08 00:53 UTC, 6.01 h, 180 cycles, 11 fault kinds × 16–17. Eight streams: the seven of the rc1 soak plus HTTP/3 through the QUIC demux (connections rebind to a new source port every 5 s).

Streamrequests in clean windowserrorsunsent (retry-safe)p99 ms (median / max)
HTTP/1.1 static28.7 M000.97 / 2.50
HTTP/1.1 1 MiB0.38 M001.42 / 4.04
HTTP/1.1 proxy8.9 M001.34 / 5.34
HTTP/1.1 new connection5.7 M000.93 / 4.61
HTTP/2 + cache14.2 M04 624 (GOAWAY)1.16 / 5.83
HTTP/315.2 M01 9241.46 / 3.36
PHP2.8 M001.35 / 9.89
WebSocket messages11.4 M0140 (in flight at 1001)–

0 errors in 87 M requests/messages in clean windows. Excused windows (overlapping a SIGKILL of a serving instance, or a dead/resetting upstream for the proxy stream): proxy 160 050 (the upstream was dead), static 130, HTTP/2 88, HTTP/3 272, PHP 15, WebSocket 510 — the clients of a killed process.

  • The rc1 soak’s 10 HTTP/2 timeouts did not recur: 0 in 14.2 M, across 17 controller restarts. Cause unknown; the soak keeps checking for it.
  • Controller restarts 17/17 adopted every active instance (3.0 s). All faults recovered every time; no settle timeout.
  • Resources flat: controller 8.0 MB RSS / 16 FDs, QUIC demux 7.9 → 8.0 MB / 13 FDs, instances 33.0 → 32.5 MB / 82 FDs, TCP estab 210 → 206 (the WebSocket echo-server fix holds: rc1 soak 1166 → 6558), 0 orphaned sockets, zombies ≤ 2, no stray state record in 2125 samples. Generations 1 → 18.
  • One p99.9 outlier: the 1 MiB stream had one window at 3.2 s (median 2.2 ms).
  • Memory-pressure probe: still not a valid test. The probe’s memory hog filled pages one by one in Python and was not done when the probe scaled: MemAvailable was ~17 GiB, above min_free_ram (4 GiB), so the scale-out correctly happened. The controller did see the pressure later (MemoryPressure in 45 samples). Fixed: the hog writes 64 MiB chunks and the probe waits until MemAvailable is below the threshold; checked separately (below).

Memory-pressure re-check (FAULTS_ONLY=fault_memory H3=1 SOAK_SECONDS=420, dev build, 3 cycles, all eight streams): every probe ran with MemAvailable at 2.9 GiB (below 4 GiB); the scale request did not start an instance (active 1 → 1) and the controller reported MemoryPressure. After the hog ended the instance count recovered. Load during the run: 0 errors in 1.96 M requests/messages.