Internals: benchmark reports

Release-mode comparison with LiteSpeed Web Server Enterprise (run 7): 2026-10-12

Note (2026-11-13): a newer comparison exists, 2026-11-12-xeon-1.0.0.md (the 1.0.0 package, current nginx and OpenLiteSpeed). This report is kept as the record of its date.

Machine and pinning as in 2026-10-10-xeon-bare-metal-pgo.md: 2× Xeon E5-2650 v2, server on four physical cores (CPUs 0-3), upstream and applications on 4-7, oha on socket 1; performance governor, turbo off, BIOS power profile at maximum performance (see 2026-10-11-xeon-release-nginx-openlitespeed.md). MODE=release: 10 s warm-up, 30 s measurement, 5 runs, medians. scalws: the PGO + BOLT build of tools/pgo-build.sh (ADR-0047, commit a7dc639), which adds the Python allocator settings, the fixed-response shortcut and the controller’s cgroup subgroup to run 6; final with both experimental settings, final-hyper with defaults. LiteSpeed Web Server Enterprise 6.3.7 (official release tarball, trial licence) replaces OpenLiteSpeed as the third server, with bench/configs/litespeed (the OpenLiteSpeed settings in its XML form). It has no fixed-response directive, so respond is not measured for it. Every measurement of every server answered 2xx only. No run was excluded. The clock log (socket-0 cores every 5 s) shows 2 593–2 600 MHz under load; the samples below 2.4 GHz fall almost all into the 1 h 45 min the harness stood still (see below), when the cores were idle.

scenarioscalws finalfinal-hypernginx 1.28nginx 1.31LiteSpeed 6.3.7final / nginx 1.31final / LSWS
static-small108 441106 85896 55199 224110 3461.090.98
static-1m11 58111 58411 64211 6768 9320.991.30
respond204 022199 561201 219209 330n/a0.97n/a
proxy98 88882 88380 01192 49629 9751.073.30
cache-hit160 071159 50784 66985 81948 2801.873.32
tls-h2-static-small96 14894 75166 81168 95469 8621.391.38
php42 56941 81136 21736 89718 6891.152.28
node10 82310 93210 74910 82411 6181.000.93
python6 3666 3606 4656 5405 8590.971.09

Server CPU per request (µs, cpu_us): static-small 36.88 / nginx 1.31 40.30 / LSWS 17.64; static-1m 345.38 / 342.56 / 223.62; respond 19.60 / 19.10 / n/a; proxy 40.34 / 43.16 / 66.42; cache-hit 24.98 / 46.59 / 41.24; tls-h2-static-small 41.30 / 57.88 / 28.57; php 62.00 / 78.40 / 106.31; node 65.69 / 66.38 / 91.79; python 84.89 / 87.38 / 117.93.

Versions: nginx 1.28.3 (Ubuntu 26.04 package) and 1.31.6 (built from the signed source tarball); load: oha with 64 connections.

p99 latency of every server (ms, median of 5 runs, p99_ms):

scenarioscalws finalfinal-hypernginx 1.28nginx 1.31LiteSpeed
static-small0.690.790.740.711.68
static-1m6.848.186.416.6619.04
respond0.400.420.410.40n/a
proxy0.911.141.010.954.13
cache-hit0.500.510.880.832.99
tls-h2-static-small1.371.431.141.091.80
php1.771.792.052.025.83
node11.4911.3711.5311.4510.69
python13.7814.4815.5115.6112.79

Server CPU per request of every server (µs, median of 5 runs, cpu_us_per_req):

scenarioscalws finalfinal-hypernginx 1.28nginx 1.31LiteSpeed
static-small36.8837.4241.4240.3017.64
static-1m345.38345.27343.56342.56223.62
respond19.6020.0419.8719.10n/a
proxy40.3448.2249.9643.1666.42
cache-hit24.9825.0747.2346.5941.24
tls-h2-static-small41.3041.9359.7257.8828.57
php62.0064.2080.3178.40106.31
node65.6976.3974.0466.3891.79
python84.8999.3995.9787.38117.93

Peak resident memory of the server processes (MiB, median of 5 runs, peak_rss_mib):

scenarioscalws finalfinal-hypernginx 1.28nginx 1.31LiteSpeed
static-small4042808071
static-1m4040808071
respond42428080n/a
proxy4344848372
cache-hit4143818072
tls-h2-static-small5349999873
php4446818172
node4242838471
python4244848472

p99 latency (ms), scalws final / nginx 1.31 / LSWS: static-small 0.69 / 0.71 / 1.68; proxy 0.91 / 0.95 / 4.13; cache-hit 0.50 / 0.83 / 2.99; tls-h2-static-small 1.37 / 1.09 / 1.80; php 1.77 / 2.02 / 5.83; node 11.49 / 11.45 / 10.69; python 13.78 / 15.61 / 12.79; respond 0.40 / 0.40 / n/a.

LiteSpeed Enterprise: what to keep in mind

  • Two workers. The trial licence starts two lshttpd workers whatever workerProcesses says (pgrep -c -f 'lshttpd - #' during the runs: 2); nginx and scalws use all four server CPUs. On server-bound scenarios LSWS therefore had half the cores. Its CPU per request is lower on static files (17.6 vs 36.9 µs on static-small) and it still reached 110 346 req/s there, 2 % ahead of scalws final; with the four workers of a larger licence it would likely lead static-small clearly. It was behind on everything that passes through an upstream or the cache (proxy 3.3×, cache-hit 3.3×, PHP 2.3×).
  • Cancelled requests. In every run oha recorded “operation was canceled” against LSWS on static-small (1 707–1 871 of ~3.3 million requests, 0.05 %) and static-1m (7–25), and once “connection closed before message completed”; no other server had any error besides the requests still in flight at the end of the measurement (“aborted due to deadline”). Every response LSWS sent was 2xx.
  • PHP. LSWS’s PHP-FPM used half the application CPU of the others (45.9 s vs ~91 s per run): with two workers and the same pool (four PHP-FPM processes, maxConns 4) it did not keep the pool busy.
  • Node.js is the one runtime where LSWS is ahead (7 %). It sends fewer request headers to the application: no X-Forwarded-For, X-Forwarded-Proto or X-Request-Id (2026-10-12-app-respond-analysis.md measured 83.4k vs 77.0k instructions per request in Node.js for the two header sets). scalws keeps these headers.
  • Shutdown. LSWS ignores SIGTERM unless it has handled a SIGUSR2 first; once in run 7 the TERM came too early and the harness waited 1 h 45 min (16:05–17:50 local). bench/run.sh now repeats both signals until the process is gone.

Compared with run 6

  • Python: final 6 366 (run 6: 5 957), now 0.97× nginx 1.31 with less server CPU per request (84.9 vs 87.4 µs). The managed uvicorn workers get glibc allocator thresholds (MALLOC_MMAP_THRESHOLD_, MALLOC_TRIM_THRESHOLD_), which remove the mmap/munmap per request seen on bare metal; application CPU is equal (60 s per run, two saturated workers), so the remaining 3 % is on the application side.
  • respond 0.97× nginx 1.31 (run 6: 0.95×) with the fixed-response shortcut; the ranges of the five runs overlap (scalws 203 269–210 092, nginx 201 909–213 934).
  • node 1.00× nginx (run 6: 1.00×), php 1.15× (1.06×), proxy 1.07× (1.07×), tls-h2-static-small 1.39× (1.37×).
  • cache-hit final 160 071 vs 173 288 in run 6 while final-hyper is unchanged (159 507 vs 158 764). The only data-plane change on that path is the fixed-response check in routing (one call per request that returns nothing for a proxy route), which final-hyper runs as well. Open: a second PGO build of the same commit would show whether this is profile-to-profile variation.
  • static-small final 108 441 vs 111 956 (−3 %), within the run-to-run spread of earlier bare-metal runs.

Results, in the bench host’s results directory: results/xeon-r7 (aggregate.csv, aggregate.txt, run-1 … run-5); clock log r7-mhz.log on the bench host.