Benchmarks · webDragon
Saphira-D webDragon Benchmarks
Reproducible nginx and PHP 8.5 measurements on a real Saphira-D KVM guest, through both the routed network path and the direct Keeper-to-VM path.

Test system
The benchmark runs on a real Saphira-D Linux webDragon guest, not a generic nginx/PHP reference box. The full software stack that was measured is recorded here so the test can be reproduced exactly.
- Guest image
- Saphira-D Linux 0.2
- Virtualisation
- KVM virtual machine hosted on Keeper
- vCPU
- 8
- Memory
- 7.8 GiB RAM, no swap
- Kernel
- Linux 7.1.5
- libc
- musl libc 1.2.6
- init
- systemd 261.2
- Web server
- nginx 1.30.4
- PHP
- PHP 8.5.9 NTS, PHP-FPM
- FastCGI transport
- nginx → PHP-FPM over Unix socket /run/php-fpm85/php-fpm.sock
- HTTPS
- TLS 1.3, cipher TLS_AES_256_GCM_SHA384
- Saphira-D IPv6
- 2a02:8012:bc57:c000::b00b
The direct Keeper test is not localhost. It still traverses the Keeper host's local KVM/virtual networking into the Saphira-D guest, then nginx, PHP-FPM and the Unix FastCGI socket.
The routed test path is Homer → Route → Keeper → Saphira-D. The direct test path is Keeper → Saphira-D KVM guest.
Benchmark tool and exact method
The load generator was ApacheBench 2.3. The load-generator shell was raised to ulimit -n 65535 so it could keep 4,000 sockets open at once.
The static HTTPS benchmark was:
timeout -s INT 30 \
ab -k -n 10000000 -c 4000 https://saphira-d.vm2.uk/The PHP benchmark was:
timeout -s INT 30 \
ab -k -n 10000000 -c 4000 https://saphira-d.vm2.uk/index.phpThe flags mean:
-kenables HTTP keep-alive.-c 4000supplies 4,000 concurrent requests.-n 10000000sets a deliberately unreachable request ceiling.timeout -s INT 30stops the run after roughly 30 seconds while allowing ApacheBench to print its statistics.
Network path proof
The two PHP runs used the same ApacheBench command from two different Linux hosts, each with ulimit -n 65535 set. The difference between them is the network path, and the path is proven with traceroute, not assumed.
From Homer the request crosses three IPv6 hops (the local gateway, then the route node) before reaching the Saphira-D guest. That is the routed test.
Homer → Route → Keeper → Saphira-D
traceroute to saphira-d.vm2.uk (2a02:8012:bc57:c000::b00b), 30 hops max, 80 byte packets
1 _gateway (2a02:8012:bc57:1::1) 0.577 ms 0.549 ms 0.523 ms
2 dragon.breathtechnology.co.uk (2a02:8012:bc57:3::2) 0.317 ms 0.289 ms 0.448 ms
3 saphira-d.vm2.uk (2a02:8012:bc57:c000::b00b) 2.761 ms 2.731 ms 2.716 msKeeper → Saphira-D (one hop, into the KVM guest)
traceroute to saphira-d.vm2.uk (2a02:8012:bc57:c000::b00b), 30 hops max, 80 byte packets
1 saphira-d.vm2.uk (2a02:8012:bc57:c000::b00b) 1.076 ms 1.044 ms 1.012 msFrom Keeper, the guest is a single hop away: Keeper is the hypervisor host, and the trace enters the Saphira-D KVM guest directly. That is the direct test, and it is why moving the generator onto Keeper removes the routed network as a variable.
Both ApacheBench runs reported Server Software: nginx/1.30.4 and TLSv1.3, TLS_AES_256_GCM_SHA384, and served /index.php at 20,363 bytes, the same workload and stack described above.
Workloads
Two documents were served, and both are measured in the results below. The static document is the smallest useful nginx response; the PHP document is the dynamic workload that exercises the full webDragon stack.
- Static nginx page
- 109 bytes
- PHP page
- 20,363 bytes
- PHP version
- 8.5.9
- Saphira-D VM
- 8 vCPU / 7.8 GiB RAM
Every PHP request dynamically performed, for that single response:
- generation of 100 records
- 100 SHA-256 hashes
- array creation
- arithmetic
number_format()htmlspecialchars()- output buffering
- HTML table generation
- dynamic execution-time generation
Content-Lengthcalculation
The complete PHP source served by the benchmark is kept further down on this page.
Results
Full measurements rather than only requests/sec. Keep-alive and ApacheBench's Length failures are shown separately from non-2xx responses. The three rows span the two served documents and the two network paths: a static nginx file over the routed IPv6 path, the PHP workload over the routed Homer → Route → Keeper → VM path, and the same PHP workload over the direct Keeper → Saphira-D VM path.
| Metric | Static HTTPS | Routed · c4000 | Keeper → VM · c4000 | Routed · c1000 | Keeper → VM · c1000 |
|---|---|---|---|---|---|
| Concurrency | 4,000 | 4,000 | 4,000 | 1,000 | 1,000 |
| Requests/sec | 133,531.82 | 5,396.36 | 12,046.10 | 5,522.13 | 13,770.93 |
| Transfer rate | 44.1 MiB/s | 106.3 MiB/s | 236.0 MiB/s | 108.5 MiB/s | 270.1 MiB/s |
| Median | 10 ms | 556 ms | 298 ms | 151 ms | 68 ms |
| 95th percentile | 224 ms | 1,784 ms | 328 ms | 400 ms | 109 ms |
| Completed (~30s) | 4,002,500 | 161,786 | 360,955 | 165,555 | 412,768 |
| HTTP 2xx responses | 4,002,500 | 161,770 | 360,687 | 165,555 | 412,768 |
| Keep-alive | 4,000,422 | 161,770 | 360,687 | 165,555 | 412,768 |
| ab length mismatches | 0 | 17 | 973 | 0 | 1,087 |
| HTTP non-2xx responses | 0 | 16 | 268 | 0 | 0 |
About the headline number
The headline figure remains 12,046 requests per second. The 360,955 is simply how many requests actually completed over the roughly 30-second measurement window, the mean rate multiplied by the run time, not a separate claim. The same applies to the other runs: read Requests/sec as the rate and Completed (~30s) as the count accumulated across the window.
The static test completed more than 4 million HTTPS requests in about 30 seconds with zero non-2xx responses.
Reading the Length failures
The PHP response contains Generated in <?= number_format($elapsed, 3) ?> ms, so the response body does not always have exactly the same byte length. ApacheBench compares each response's length against the first response, which is why its Length failure counter is not suitable as a pure application-failure count for this workload. Keep-alive and Length failures are therefore kept separate from the Non-2xx responses, and the non-2xx responses are not presented as though they were caused by the changing execution-time text.
Proof of real work: sample SHA-256 output
The benchmark is not serving empty pages. The workload is genuine: every request generates 100 distinct records, each with a real SHA-256 hash computed at request time. Below is the actual rendered output of a single request, abbreviated. The first five rows, the last three rows, and the per-request footer are shown, so the hashing is demonstrable rather than asserted.
Saphira-D webDragon seed
nginx from the native package repository. It is so simple it hurts.
PHP 8.5.9
Generated 100 dynamic records.
ID Name Value Hash
1 webDragon-1 7,919 04a54449b3018e8ee288dffd04e90f4921c4768087f1502984ade5520cdec1ed
2 webDragon-2 5,831 bc6fcec358d6c3ef11a688bd4b2e96ac6964fc9dbcb4ec645eddc6314059119e
3 webDragon-3 3,743 c8cdced81ae2ab2bfdf7f4067333ac6f4b5637f471097c7b3127d7383fc36a98
4 webDragon-4 1,655 83c7ad3245934d47eb9e53f5421fee3d55b031248e5bed0744ac2f265efdd037
5 webDragon-5 9,574 2c1bbf31e14116b895a1d32067bf540023d4a6152c812d8c6c0b05d974fbc1ae
... (records 6–97 omitted: 92 further SHA-256 hashes generated per request)
98 webDragon-98 5,523 8fa236be5a2dd22bcdba412289bbd4256af317673951586f3f98f603f5891907
99 webDragon-99 3,435 032d5cd9c0d39e537f2440ea353d11079c30095e3a36584319ea3b5d3b075f52
100 webDragon-100 1,347 39a29749917d12d2895ebcaa283d17ca55c464aa9526c17da44d6af72272b860
PHP memory: 2,097,152 bytes
Generated in 0.775 msEach row's hash is derived from its own inputs (for example hash('sha256', "saphira-d-1-7919")), so the 100 hashes differ on every row and every request. That is the work being sustained at the request rates recorded in the results above: at 12,046 req/s on the direct path, the guest was computing roughly 1.2 million SHA-256 hashes per second, not streaming a static file.
Derived workload rates
Routed PHP: Homer → Route → Keeper → VM (c4000)
- ~539,600 generated records/sec
- ~539,600 SHA-256 hashes/sec
- ~0.89 Gbit/s response payload
Keeper → VM PHP (c4000)
- ~1,204,600 generated records/sec
- ~1,204,600 SHA-256 hashes/sec
- ~1.98 Gbit/s response payload
PHP-FPM scaling
The 12,046.10 req/s result used the following pool configuration:
pm = dynamicpm.max_children = 128pm.start_servers = 16pm.min_spare_servers = 16pm.max_spare_servers = 64
During that benchmark PHP-FPM grew to 129 processes, which was one FPM master plus all 128 configured workers.
Scaling beyond the CPU
A later scaling experiment deliberately raised pm.max_children = 1024. FPM climbed through 135, 143, 159, 191, 223, 255, 287 and 319 total children/processes during load, yet the corresponding 30-second benchmark fell to 11,855.91 req/s, compared with 12,046.10 req/s at the 128-worker ceiling. Increasing the PHP worker count beyond the CPU's useful concurrency did not increase throughput; it reduced it.
Why php-fpm85 had to be tuned
Under the direct Keeper → VM load the dynamic pool could not spawn workers fast enough to keep up with the 4,000 concurrent connections arriving at once. PHP-FPM logged repeated seems busy … spawning N children warnings: the pool was starting from its idle floor and lagging the arrival rate, so requests queued behind a thin worker set while the guest's load average climbed.
The fix was not to raise the ceiling blindly. With the recorded configuration the pool settled at 129 processes (one master plus the 128 configured workers), and the benchmark reached its 12,046.10 req/s there. Raising pm.max_children to 1024 then let the worker count climb through 135, 143, 159, 191, 223, 255, 287 and 319 during load, but throughput fell to 11,855.91 req/s. Past the CPU's useful concurrency, extra workers only added scheduling cost. The tuning lesson is to size the pool to the worker ceiling the 8-vCPU guest can actually use, not to the largest number the config allows.
Resource behaviour
The routed path (Homer → Route → Keeper → VM, three IPv6 hops) capped throughput at roughly 106–108 MiB/s, so the guest never became the bottleneck: load only reached about 1.2–2.4 even while the benchmark was running, and memory sat near 940 MiB. The network limit masked the service limit.
Moving the generator onto Keeper removed that cap. The guest could finally be fed, so the bandwidth that had been spent on the routed path instead became real PHP work, and system load climbed with it. That is the cause-and-effect worth recording: more bandwidth into the guest is what drove uptime's load average up, not a change to the workload itself.
| Approx. time | Load average (1 min) | Memory used | What was happening |
|---|---|---|---|
| 18:25 | 0.46 | 1.2 GiB | Idle, before the load generator attached. |
| 18:27 | 2.62 | 1.1 GiB | Generator attached over the direct Keeper → VM path. |
| 18:30 | 2.93 → 6.79 | ~1.0 GiB | PHP-FPM worker pool filling under concurrency. |
| 18:30:50 | 10.17 | 1.0 GiB | Run queue building on the 8-vCPU guest. |
| 18:30:56 | 14.40 | 1.1 GiB | Peak load, about 1.8× the 8 vCPUs. |
At the 14.40 peak the guest had roughly 1.8 runnable processes per vCPU, so the limit was CPU and process scheduling, not memory: used RAM stayed between 850 MiB and 1.1 GiB out of 7.8 GiB, with no swap ever touched. Giving the guest more bandwidth exposed a service/CPU ceiling that the routed path had been hiding.
The nginx side of the direct run was observed, not tuned for the result: worker_processes auto (8 on this guest), worker_connections 1024, and a service file limit of LimitNOFILE=524288. These are recorded as found; no nginx global tuning is claimed as the cause of any measured number.
Content and workload being served
This is not a PHP "Hello World", and it is not a synthetic localhost benchmark dressed up as a production application. Every request dynamically generates 100 records, performs 100 SHA-256 hashes, formats and escapes the data, constructs approximately 20 KB of HTML, and serves it through PHP-FPM over a Unix socket to nginx using HTTPS. The network and service path it crosses is real; the workload is intentionally a repeatable dynamic one.
The page below is the workload source served by the benchmark.
<?php
$started = hrtime(true);
$rows = [];
for ($i = 1; $i <= 100; $i++) {
$value = ($i * 7919) % 10007;
$rows[] = [
'id' => $i,
'name' => "webDragon-$i",
'value' => $value,
'hash' => hash('sha256', "saphira-d-$i-$value"),
];
}
ob_start();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Saphira-D webDragon seed</title>
</head>
<body>
<h1>Saphira-D webDragon seed</h1>
<p>
nginx from the native package repository.
It is so simple it hurts.
</p>
<h2>PHP <?= PHP_VERSION ?></h2>
<p>Generated <?= count($rows) ?> dynamic records.</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Value</th>
<th>Hash</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row): ?>
<tr>
<td><?= $row['id'] ?></td>
<td><?= htmlspecialchars($row['name'], ENT_QUOTES, 'UTF-8') ?></td>
<td><?= number_format($row['value']) ?></td>
<td><?= $row['hash'] ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p>
PHP memory:
<?= number_format(memory_get_usage(true)) ?> bytes
</p>
<?php
$elapsed = (hrtime(true) - $started) / 1_000_000;
?>
<p>Generated in <?= number_format($elapsed, 3) ?> ms</p>
</body>
</html>
<?php
$body = ob_get_clean();
header('Content-Type: text/html; charset=utf-8');
header('Content-Length: ' . strlen($body));
echo $body;Configuration used
/etc/php-fpm.d/www.conf
This is the pool configuration recorded for the 12,046.10 req/s run. The values start_servers = 64, min_spare_servers = 32 and max_spare_servers = 96 were discussed afterwards and were not the configuration used for that result.
[www]
user = nginx
group = nginx
listen = /run/php-fpm85/php-fpm.sock
listen.owner = nginx
listen.group = nginx
listen.mode = 0660
pm = dynamic
pm.max_children = 128
pm.start_servers = 16
pm.min_spare_servers = 16
pm.max_spare_servers = 64
catch_workers_output = yes/etc/php-fpm.d/www.conf: scaled experiment
The later experiment that raised pm.max_children = 1024 and measured 11,855.91 req/s.
[www]
user = nginx
group = nginx
listen = /run/php-fpm85/php-fpm.sock
listen.owner = nginx
listen.group = nginx
listen.mode = 0660
pm = dynamic
pm.max_children = 1024
pm.start_servers = 16
pm.min_spare_servers = 16
pm.max_spare_servers = 64
catch_workers_output = yes/etc/nginx/http.d/default.conf
The vhost is kept verbatim. It listens on both IPv4 and IPv6 and terminates TLS before passing PHP requests through the Unix FastCGI socket. No nginx global tuning values are claimed here beyond what was recorded for the run.
server {
listen 80;
listen [::]:80;
listen 443 ssl;
listen [::]:443 ssl;
server_name saphira-d.vm2.uk;
ssl_certificate /etc/letsencrypt/live/saphira-d.vm2.uk/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/saphira-d.vm2.uk/privkey.pem;
access_log /var/log/nginx/default.access.log;
error_log /var/log/nginx/default.error.log;
root /var/www/saphira;
index index.php;
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php-fpm85/php-fpm.sock;
}
}What the benchmark is for
The purpose of the benchmark is not to find the largest possible headline number. It is to expose where a Saphira-D webDragon workload reaches network, CPU, worker-pool and service limits, with enough information for somebody else to reproduce the test.