Skip to content

Latest commit

 

History

28 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

hammer

An HTTP load generator that does not lie about tail latency.

Most load generators quietly under-report p99. hammer measures latency from when each request should have departed, not from when it actually got sent, so the requests that queued up behind a server stall still appear in the histogram. Both measurements ship, so you can see the difference yourself.

No libcurl, no boost, no HTTP library. The sockets, the protocol parsing and the statistics are all in this repo.

Install

npm install -g hammer-load
hammer --version

That pulls a prebuilt binary for Linux x86_64, macOS (Intel and Apple Silicon) or Windows x86_64 and verifies its SHA-256. If there is no prebuilt binary for your platform it falls back to building from source, which needs CMake 3.21+ and a C++20 compiler.

You can also grab a binary straight from releases, or build it yourself:

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release

Use

hammer [options] <url>

  -c, --connections N    total connections           (default 50)
  -t, --threads N        worker threads              (default 4)
  -d, --duration S       test duration in seconds    (default 10)
  -r, --rate N           target req/s; turns on open-loop mode
      --closed-loop      measure from actual send even with --rate set
  -m, --method M         HTTP method                 (default GET)
  -H, --header "K: V"    extra header, repeatable
      --body FILE        request body from file
      --timeout MS       per-request timeout         (default 2000)
      --latency          print the full percentile spectrum
      --json             machine-readable output
      --version          print version and exit
  -h, --help
hammer -c 50 -t 4 -d 30 --rate 20000 --latency http://localhost:8080/

Bad input exits 1 with a message naming the flag. --connections below --threads is an error, not a silent clamp.

Coordinated omission, and why the two numbers disagree

A closed-loop generator sends a request, waits for the response, then sends the next one. When the server stalls for 200ms, the generator sends nothing during the stall. The requests that would have hit that stall are never issued, never timed, and never appear in the histogram. It omits exactly the samples that mattered. Gil Tene named this coordinated omission.

The fix is to decide when each request should depart before the test starts:

intended[i] = t0 + i * (1e9 / rate)
latency[i]  = response_arrival - intended[i]     // corrected
latency[i]  = response_arrival - actual_send     // what most tools report

--rate turns on the schedule. --closed-loop keeps the same schedule and the same requests, and only changes where the stopwatch starts — so the comparison below is the same load measured two ways, not two different loads.

The same server, the same load, measured both ways

tools/pausing_server.py serves a small response quickly and stalls for 200ms every 3000 responses, the way a stop-the-world GC pause behaves. Both runs issued 20,000 requests at 1999 req/s:

python tools/pausing_server.py --port 8082 --pause-ms 200 --every 3000

hammer -c 20 -t 4 -d 10 --rate 2000 --latency http://127.0.0.1:8082/
hammer -c 20 -t 4 -d 10 --rate 2000 --latency --closed-loop http://127.0.0.1:8082/
percentile corrected closed loop ratio
50% 255.62us 183.55us 1.4x
75% 1.70ms 251.78us 6.8x
90% 45.25ms 414.46us 109x
95% 120.65ms 927.74us 130x
99% 182.19ms 3.78ms 48x
99.9% 207.22ms 205.78ms 1.0x
max 211.81ms 210.98ms 1.0x

Read the p99 row. A closed-loop tool reports 3.78ms and you conclude the service is healthy. The corrected number is 182ms. Nothing about the server changed between those two rows — only the arithmetic did.

The max agrees in both, and that is the whole point. The closed loop does catch the one request unlucky enough to be in flight when the pause began. What it misses is the thousands of requests that should have been sent during those 200ms and would each have waited their turn behind it. There were 20 connections, so a 200ms pause hides roughly 400 requests' worth of queueing; only 20 of them ever get timed. That is why the two columns converge again at 99.9% — by then you are looking at the stalled requests themselves, which both modes see.

Reading the report

Running 10s test @ http://127.0.0.1:8082/
  4 threads, 20 connections, open loop @ 2000 req/s

Latency (corrected)
     50%   255.62us
     75%     1.70ms
     90%    45.25ms
     99%   182.19ms
   99.9%   207.22ms
     max   211.81ms

  20000 requests in 10.00s, 1.49MB read
  Requests/sec        1999.51
  Transfer/sec       152.31KB
  Socket errors  connect 0, read 0, write 0, timeout 0
  Dispatch lag   mean 11.87ms, max 194.66ms, 2461 requests behind schedule

  note: 2461 of 20000 requests departed late because every connection was
  still waiting on the server, not because hammer fell behind. The server
  cannot sustain 2000 req/s; the corrected latencies above account for it.

Dispatch lag is how far behind the intended schedule requests actually went out. It is the tool's honesty check, and it distinguishes two very different causes:

  • Every connection was busy waiting on the server. That is the server's problem, and the corrected latencies already account for it. hammer says so and does not blame itself.
  • A connection was idle and hammer still dispatched late. That is hammer's problem, and it prints a warning telling you the numbers describe its own bottleneck rather than the server's. Lower --rate or add threads.

A load generator that admits when it is the problem is worth more than one that doesn't.

--json emits the whole thing as one object — config, percentiles in nanoseconds, throughput, errors by kind, and dispatch lag.

Design notes

Level-triggered epoll on Linux, poll everywhere else. Level-triggered on purpose: edge-triggered means draining to EAGAIN on every wakeup, and one forgotten drain is a hang that shows up under load a week later. The poll backend is O(n) per tick, so it will not hold up past a few thousand connections — that is an honest trade for a build whose job is to be downloadable, not a claim of parity with the epoll build.

Per-thread everything. Connections are sharded across threads at startup. Each thread owns its own histogram, counters and poller; they are merged once at the end. No atomics on the hot path, and no allocation either — there is a test that runs ~90,000 requests and asserts fewer than 200 allocations, so allocation is O(connections), not O(requests).

Timeouts are excluded from the latency histogram. A request that timed out has no latency; it has an absence of one. Folding the timeout value in would put a fictional number in the tail and make a broken server look merely slow. They are counted separately under Socket errors, and a run with a large timeout count should be read as "this did not work", not "this was slow".

A hand-written HDR histogram. Log-linear bucketing, 3 significant digits from 1ns to 60s, O(1) record, fixed 221KB allocated once. Tested against a sorted-vector oracle over 100,000 values at every percentile from 1 to 99.99.

A timer wheel for deadlines, not a per-tick scan over every connection. O(1) arm and disarm, and it allocates nothing after setup.

steady_clock everywhere. The system clock plus NTP silently corrupts timings in a way that is very hard to notice afterwards.

Not implemented

HTTPS, HTTP/2, redirects, cookies, request bodies streamed from anything but a file, and response body validation. Not macOS-specific tuning, not 32-bit.

Things that will bite you

  • ulimit -n defaults to 1024. Above that many connections you get confusing EMFILE errors. Raise it before a large run.
  • Testing a server that sends Connection: close burns through the ~28k ephemeral port range in seconds and then blocks in TIME_WAIT. That is the operating system, not a bug in hammer.
  • Timing under sanitizers is 2-3x slower. Benchmark numbers come from a release build only.

Where these numbers came from

The table above was produced by a Release build (MSVC 19.44, -DCMAKE_BUILD_TYPE=Release, no sanitizers) on Windows 11 against tools/pausing_server.py on loopback. Linux with epoll is the build intended for serious benchmarking; these numbers demonstrate the correction, not hammer's throughput ceiling — a threaded Python server on loopback tops out around 5,300 req/s here, which is the ceiling being measured in the throughput rows, not hammer's.

Development

cmake --preset asan && cmake --build --preset asan && ctest --preset asan

Presets: asan (ASan+UBSan), tsan, release. The sanitizer presets probe whether the toolchain can actually link the sanitizer and fall back to a plain Debug build with a warning if it cannot, so a green local run on a machine without sanitizer runtimes is not evidence of a clean sanitizer run. CI runs the real thing on Linux, and asserts the sanitizer linked.

CI also builds and tests Release on Linux, macOS and Windows on every push.

There is an optional libFuzzer target for the response parser, clang only:

cmake -S . -B build/fuzz -DHAMMER_FUZZ=ON -DCMAKE_CXX_COMPILER=clang++

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages