Testing from another machine
A server generating its own traffic competes with the thing it is measuring, so BenchKit drives the load test from a second machine by default. You need:
- A machine near your server, ideally in the same datacenter or region.
- oha installed on it.
Pick the machine
The closer that machine sits, the more of your server you measure and the less of the network in between.
| Where your second machine is | What you end up measuring |
|---|---|
| Same datacenter or region | Your server. This is what you want |
| Same LAN, or a VM on the same host | Your server, mostly. A VM still shares the host's CPU |
| Another region or provider | The network between the two machines |
| A laptop on wifi | Your wifi |
Two more things to consider:
- Give it enough CPU. A single-core box runs out of headroom before your server does. Match it roughly to the machine under test.
- Do not let it sleep. A laptop that sleeps mid-run takes the stage down with it.
oha is closed-loop: each connection waits for a reply before sending again. One connection sends one request per round trip, so no generator can beat:
max requests per second = connections ÷ round trip in seconds
BenchKit offers at most 512 connections. At a 0.5 ms round trip that is a ceiling above a million requests a second, far beyond any PHP application. At 30 ms the same 512 connections cap out near 17,000, and at 100 ms near 5,000. Your server is not slower. You cannot ask it for more from that far away.
BenchKit measures the round trip when the generator connects and sizes the sweep from it, so a generator further away is asked for proportionally more connections. There is a limit to how far that can stretch: a server answering in a fraction of a millisecond behind a 13 ms round trip would need around a thousand connections at once to reach its worker pool, and BenchKit stops well short of that. When it does, the result says so and names the number it would have taken — that is your cue to move the generator closer, not to distrust the server.
Install oha (on the generator machine)
Run these commands on the machine you want to simulate traffic from.
brew install oha
pacman -S oha # Arch
cargo install oha # any distro, needs Rust
No package manager? oha ships a plain binary, so this is the whole install. No root, no Rust:
mkdir -p ~/.local/bin
case "$(uname -m)" in
x86_64) ASSET=oha-linux-amd64 ;;
aarch64) ASSET=oha-linux-arm64 ;;
esac
curl -fsSL "https://github.com/hatoo/oha/releases/latest/download/$ASSET" -o ~/.local/bin/oha
chmod +x ~/.local/bin/oha
Debian and Ubuntu can also add Azlux's repository for apt install oha.
winget install hatoo.oha
Run the BenchKit command itself inside WSL.
BenchKit looks in ~/.local/bin and ~/.cargo/bin as well as your PATH.
Run the pairing command
Start a run with the web server load test enabled. BenchKit shows you a command:
curl -kfsSL https://your-benchkit-instance/bench/generator/<token>/script | sh
Paste it on the generator machine. It connects, the run starts, and the load test runs first so your part is over early.
Each measured window claims a line. The first batch is the probe — every route at one connection, which sizes everything after it:
────────────────────────────────────────────────
Load test · 4 measured windows
────────────────────────────────────────────────
1/4 static sweep c=1 1,204 req/s ✓
2/4 json sweep c=1 986 req/s ✓
3/4 db-read sweep c=1 742 req/s ✓
4/4 io sweep c=1 9 req/s ✓
Two more batches follow: the sweep, at the concurrency levels each probe earned, then a response-time pass below the maximum the sweep found. Expect around thirty windows in all.
Leave the terminal open until it finishes. Closing it stops the load test.
- Checks oha is installed, and stops with instructions if not
- Confirms it can reach your BenchKit instance
- Times five requests to work out the round trip
- Handshakes, so the dialog shows it connected
- Waits until the run reaches its web server stage
- Runs oha through each batch of windows, uploading every result as it lands
It installs nothing, needs no root, and writes one temporary file that it removes on the way out. Read it before you run it if you like: open the same URL in a browser, or drop the | sh.
About -k: it skips certificate verification. BenchKit instances are disposable and usually run on a self-signed certificate, where verification would fail on a certificate nobody meant to be trusted. The token in the URL authorizes the pairing, not the certificate. On a plain http:// instance the flag does nothing.
About color: output is colored only for a terminal. Piped to a file or a CI job it degrades to plain text, and NO_COLOR=1 turns it off everywhere.
If it does not connect
| Message | Fix |
|---|---|
oha is not installed on this machine | Install it. The script prints the exact download line for that machine |
Could not reach <url> from this machine | Check the URL and any firewall between the two machines |
The server did not accept the handshake | The pairing expired or was replaced. Start the run again for a fresh command |
This pairing is no longer valid | The run finished or was cancelled while you waited. Start again |
A route ends in ✗ | The server refused that upload and prints why on the same line |
The generator needs to reach the URL you have open in your browser, on /bench/*. Nothing needs to be open in the other direction.
BENCHMARK_HTTP_URL to an address the generator can reach.Driving oha yourself
The benchmark routes are open endpoints with session and CSRF middleware skipped, so any machine that can reach the server can point oha at them.
Results driven this way are not recorded into a run. Use this for ad-hoc probing, and for tail latency, which no fixed-connection test can measure honestly.
| Endpoint | What it represents |
|---|---|
/bench/static | Framework baseline. No database |
/bench/json | An API response. A 25-item JSON payload built in PHP |
/bench/db-read | A database read. 20 rows per request |
/bench/io?ms=100 | Simulated I/O. Sleeps for the milliseconds you pass |
Throughput
Hold a set number of connections open and see what the server gets through:
oha -z 30s -c 50 http://<your-server-ip>/bench/db-read
Run one endpoint at a time. Overlapping runs compete and neither result means much.
pm.max_children, you are measuring the pool rather than the server. Raise PHP_FPM_PM_MAX_CHILDREN to at least your core count first. See Customizing the image.Tail latency
A fixed-connection run understates p99: when the server slows down, the client waits instead of sending more, so the requests that would have been slowest are never sent. Gil Tene named this coordinated omission.
A paired run already does this for you. The response times on your result come from a separate pass at a held rate below the maximum, with the correction applied — that is what the three measurements are for. What follows is how to do it by hand against your own routes.
Two passes:
Find your maximum throughput
Run the throughput command above at a few connection counts and note the highest requests per second. One connection count is one point on a curve, and the maximum is rarely at the first number you try.
Run again at about 70% of that
oha -q <req_per_sec> -z 30s --latency-correction -w http://<your-server-ip>/bench/db-read
Staying below your maximum is the point. At saturation every client queues, and the numbers describe the backlog rather than latency.
--latency-correction does nothing without a target rate, which is what -q sets. And without -w, oha abandons the requests still in flight when the clock runs out — which are the slowest ones, so dropping them biases the tail down again.Reading what you get
Numbers you drive by hand are not the numbers in the Web Server Load Test panel. Yours include network time and whatever flags you passed. Do not compare the two directly. For an external number that is comparable, pair the machine with a run.
/bench/db-read answers 503 until the table it queries exists. That table is prepared when the HTTP stage starts, so run a benchmark with the load test enabled once before driving that endpoint from outside. The other three work immediately./bench/env, reports what PHP looks like in the process serving requests rather than in the CLI process. It is not a load target. BenchKit calls it to record the environment on your results.When a self-test is fine
A self-test runs oha on the BenchKit server itself — no second machine, no setup. It is the right tool for comparing two configurations on the same box, because the request path is identical between your runs. What it cannot give you is the absolute number, since the generator competes with what it measures, so treat a self-test figure as a floor.
Every result records which mode produced it, and the gallery never compares the two on one axis. See Reading your results for what else applies.