🔗Performance
powpow is built to be fast. Its design goals map to concrete mechanisms, most of them visible in the source and the benchmark numbers below.
🔗Mechanisms
- Zero-copy parsing. The HTTP parser materializes request fields lazily from byte offsets — nothing is copied until you ask for it. (parser)
- Zero-copy file transmission. Files are sent with
sendfile(TransmitFileon Windows) — no userspace round-trip. Under the io_uring backend they go throughIORING_OP_SPLICE(file → pipe → socket), which is equally copy-free. (static files) - Zero-copy sends (io_uring).
IORING_OP_SEND_ZCpins the buffer once and reports aIORING_CQE_F_NOTIFbefore the pages may be reused; registered buffers (-d:powpowSendZcFixed) remove the per-op pinning entirely. (io_uring) - SIMD scanning. SSE2-accelerated CRLF / CRLFCRLF detection with a scalar fallback (
proto/simdscan.nim).findDoubleCRLFlocates the header terminator in a few instructions per 16 bytes. - Pointer-based fd dispatch. No hash-table lookups on the hot path; generation-counter stale-event detection; dead-watcher sweep.
- 4-level hierarchical timer wheel. O(1) timer insert/fire/cancel. (event loop)
- Object pooling. Connections, read buffers, parsers, requests, responses, per-connection sessions (
ConnHttp) and WebSockets are pooled and reused;newHttpServerprewarms the pools by default (populatePools, pool size 256). Multi-threaded workers start cold unless-d:powpowWarmWorkersis set (warmup size via-d:WarmWorkerPoolSize=128). AConnection: closeconnection is tracked in an unhashed pending list and never touches the session table, so pool-miss teardown costs no hashing. - Write buffering + corking. TCP writes are buffered and corked (
TCP_CORK/TCP_NOPUSH), flushed when the connection is writable. - Scatter-gather writes.
sendvbatches header + body into onewritev. SO_REUSEPORTdistributes connections across one loop per CPU core with zero cross-thread communication. (concurrency)- Edge-triggered I/O on kqueue/epoll.
🔗io_uring: what to expect
On Linux powpow ships an opt-in io_uring backend (submission-based, enabled with --features:io_uring / -d:powpowIoUring) alongside the default epoll backend. It is built directly on io_uring_setup/io_uring_enter (no liburing dependency) and works on kernels ≥ 5.6.
It helps to be precise about where io_uring wins and where it does not.
🔗Why io_uring is not dramatically faster on plain HTTP/1.x
io_uring's headline advantage is parallelism: many independent operations (files, sockets, storage, buffered I/O) can be submitted in a batch and executed asynchronously, with completions reaped in one pass. But HTTP/1.x over TCP is serialized per connection — at any moment a connection has at most one RECV and one SEND in flight, because a client sends a request and waits for the response before the next request. There is nothing for io_uring to parallelize in that single request/response cycle, so on a small number of connections the two backends do the same amount of work and epoll's lower per-op overhead can keep it at or near parity.
The upshot: on a single-threaded keep-alive /hello workload, don't expect io_uring to beat epoll by a large margin. That benchmark is bounded by TCP serialization and parsing, not by the I/O backend.
🔗Where io_uring does help
The advantage shows up as concurrency scales and for non-socket I/O:
- High connection counts — one
io_uring_entersubmits ops across many connections at once (powpow keeps severalACCEPTops in flight per listener), and completions are reaped in a tight CQ loop. At thousands of concurrent connections this reduces syscall overhead versus epoll's per-eventrecv/sendround trips. Connection: closechurn — the accept/connect/read/write/close lifecycle is syscall-heavy, which is where batching and fixed-file registration pay off.- Zero-copy static file serving —
IORING_OP_SENDFILE(kernel ≥ 6.0) streams a file to the socket with no userspace round trip; older kernels fall back to aREAD+SENDpump.
🔗Runtime-gated fast paths
To stay correct on kernels ≥ 5.6 while still using newer features where present, fast paths are enabled at runtime based on the detected kernel version:
IORING_OP_SENDFILE— kernel ≥ 6.0 (fallback to READ + SEND below).- Multishot
ACCEPT— kernel ≥ 6.0 (fallback to a batch of one-shot accepts). - Provided-buffer multishot
RECV— opt-in via-d:powpowBufferSelect, kernel ≥ 5.19 (falls back to the one-shot RECV path).
A few build-time switches exist for A/B testing the implementation choices: -d:powpowNoFixedFiles (skip the per-connection fixed-file table syscalls), -d:powpowNoSendfile, -d:powpowNoMultishotAccept.
🔗Looking ahead: HTTP/2 and HTTP/3
io_uring's parallelism becomes genuinely relevant with protocols that multiplex many independent streams over a single connection:
- HTTP/2 multiplexes requests over one TCP connection, so multiple reads and writes for independent streams are in flight at once — a much better fit for io_uring than HTTP/1.x. powpow ships an experimental implementation (see HTTP/2) that reuses the same
SEND/SPLICEpumps. - HTTP/3 (QUIC) decouples streams from TCP entirely, running over UDP with its own framing — the clearest case where io_uring's completion-driven model can meaningfully reduce per-stream overhead.
For today's HTTP/1.x, treat the io_uring backend as a way to reach epoll parity with headroom at scale and zero-copy file serving, rather than a guaranteed speed-up on every benchmark.
🔗Dummy benchmarks
From the README (wrk-based, run via the bench.yml GitHub Action). PowPow is the #1 ranked HTTP server on the Web Framework Benchmarks.
🔗Single-threaded (keep-alive)
💥 powpow HTTP server listening on http://localhost:9000
Press Ctrl+C to stop
Running 5s test @ http://127.0.0.1:9000/
4 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.02ms 61.67us 3.25ms 81.86%
Req/Sec 24.63k 818.23 26.67k 68.00%
489946 requests in 5.00s, 277.55MB read
Requests/sec: 97965.24
Transfer/sec: 55.50MB🔗Single-threaded (connection close)
Running 5s test @ http://127.0.0.1:9000/
4 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.97ms 202.12us 4.22ms 80.09%
Req/Sec 9.13k 3.62k 18.16k 71.43%
184469 requests in 5.10s, 103.62MB read
Requests/sec: 36174.22
Transfer/sec: 20.32MB🔗Multi-threaded (keep-alive)
worker #0 ready
worker #2 ready
💥 powpow accepting on 0.0.0.0:9000 with 4 workers (SO_REUSEPORT)
worker #1 ready
worker #3 ready
Running 5s test @ http://127.0.0.1:9000/
4 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 786.79us 1.11ms 11.73ms 84.24%
Req/Sec 50.98k 3.97k 86.53k 91.50%
1018820 requests in 5.03s, 526.62MB read
Requests/sec: 202743.27
Transfer/sec: 104.80MB🔗Multi-threaded (connection close)
worker #0 ready
worker #2 ready
💥 powpow accepting on 0.0.0.0:9000 with 4 workers (SO_REUSEPORT)
worker #3 ready
worker #1 ready
Running 5s test @ http://127.0.0.1:9000/
4 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.31ms 0.95ms 15.44ms 67.66%
Req/Sec 13.24k 614.80 14.91k 81.50%
264133 requests in 5.01s, 135.27MB read
Requests/sec: 52687.29
Transfer/sec: 26.98MB🔗Event loop concurrency (Linux epoll, release build)
From tests/bench_concurrent_events.nim (nim c -d:release -r
tests/bench_concurrent_events.nim, median of 3 runs). Pipe file descriptors registered on the loop, all fired at once — measures dispatch scalability, not HTTP throughput.
Capacity scaling:
| N | throughput | p50 | p99 |
|---|---|---|---|
| 100 | 307k/s | 100µs | 198µs |
| 500 | 304k/s | 504µs | 1.0ms |
| 1k | 277k/s | 1.0ms | 2.1ms |
| 2k | 252k/s | 2.4ms | 4.8ms |
| 4k | 231k/s | 5.2ms | 10.2ms |
| 8k | 233k/s | 10.5ms | 20.5ms |
| 12k | 231k/s | 15.8ms | 30.6ms |
Head-of-line blocking (1024 fast + 64 slow at 200µs):
| p50 | p99 | |
|---|---|---|
| baseline (0 slow) | 1.1ms | 2.2ms |
| with 64 slow | 1.3ms | 2.3ms |
| penalty | ~146µs | ~164µs |
🔗Event loop concurrency (Linux io_uring)
Same harness with -d:powpowIoUring (nim c -d:release -d:powpowIoUring
-r tests/bench_concurrent_events.nim, median of 3 runs). Raw registermaps to IORING_OP_POLL_ADD, so this measures completion-queue dispatch rather than epoll_wait batches.
Capacity scaling:
| N | throughput | p50 | p99 |
|---|---|---|---|
| 100 | 255k/s | 100µs | 209µs |
| 500 | 254k/s | 554µs | 1.1ms |
| 1k | 232k/s | 1.1ms | 2.1ms |
| 2k | 220k/s | 2.5ms | 4.9ms |
| 4k | 206k/s | 5.4ms | 10.5ms |
| 8k | 208k/s | 10.5ms | 23.8ms |
| 12k | 195k/s | 21.2ms | 40.7ms |
Head-of-line blocking (1024 fast + 64 slow at 200µs):
| p50 | p99 | |
|---|---|---|
| baseline (0 slow) | 1.3ms | 2.5ms |
| with 64 slow | 1.2ms | 2.3ms |
| penalty | ~0µs | ~0µs |
🔗Benchmarking it yourself
nim c -d:release examples/httpserver.nim
wrk -t4 -c100 -d5s http://127.0.0.1:9000/
wrk -t4 -c100 -d5s -H "Connection: close" http://127.0.0.1:9000/There is also a dedicated loop benchmark: tests/bench_concurrent_events.nim.
🔗Pool sizing
Keep-alive throughput is the pooling story: a reused connection costs no allocation and no session-table hashing. Connection: close is the pool-miss worst case — a full TCP handshake and teardown per request, bottlenecked by the kernel (TIME_WAIT recycling, ephemeral ports), typically 10–20x below keep-alive on loopback no matter the userspace code.
Size the pools at or above the concurrent connections you serve, or excess connections allocate on demand (server-side pool starvation):
MaxConnPoolSize/MaxBufPoolSize(net/tcp.nim,{.intdefine.}, default 1024 each): connection wrappers + 4KB read buffers.MaxParserPoolSize/MaxReqPoolSize/MaxResPoolSize/MaxConnHttpPoolSize(proto/httpserver.nim,{.intdefine.}, defaults 2048/2048/4048/2048): parsers, requests, responses, sessions.- Override at compile time, e.g.
-d:MaxConnPoolSize=4096 -d:MaxConnHttpPoolSize=4096. Past the caps the server stays correct and just allocates; single-loop prewarm is 256 entries (populatePools), workers warm up on demand unless-d:powpowWarmWorkersis set.