# http-net - Performance
How fast http-net is, how that is measured, how it compares with httparse
and hyper, and the decisions behind the code that makes it fast. Numbers
are for 0.6.0.
## Method
- **Machine:** AMD Ryzen 9 9950X3D (16 cores), Windows 11 x86_64; Linux
numbers in WSL2 Ubuntu (kernel 6.6), Rust stable 1.99, release profile
(`lto = "fat"`, one codegen unit, as published).
- **Harness:** criterion 0.5, 3 s measurement, 1 s warm-up, 100 samples
(flat sampling for the 100,000-request benches), process pinned to one
core with `taskset` on Linux.
- **Interleaving:** a comparison between two builds runs them alternately,
round after round (five by default), and reports the median of each
side, so a change in machine load falls on both. The machine had other
load throughout; single runs vary by 5 to 10%, so only differences
repeated in every round are reported as changes.
- **Library only:** no sockets. Body events borrow the input buffer, so
the 1 MiB cases measure one copy into the buffer plus framing.
- **Inputs:** `benches/bench.rs` in this repository; the comparison
repository uses the same bytes.
### The 5% regression gate
A change that makes a hot-path benchmark (request head, pipelined,
response parse, response write, both 1 MiB bodies) more than 5% slower
than the last release is blocked. `scripts/bench_ab.sh` runs the
interleaved comparison of the working tree against a tag and marks any
bench over the gate:
```bash
scripts/bench_ab.sh v0.6.0 5
```
## Results
Linux x86_64 (WSL2), medians of five interleaved rounds against 0.5.0:
| 12-field browser GET (453 bytes), parsed and answered with 204 | 304 ns | 234 ns | -23% |
| 100,000 pipelined GETs in 16 KiB reads | 10.1 ms | 8.8 ms (11.4 M requests/s) | -13% |
| client parsing a 1 KiB response | 112 ns | 100 ns | -11% |
| response with five fields and a 1 KiB body, serialized | 89 ns | 74 ns | -17% |
| 1 MiB body, `Content-Length`, 16 KiB reads | 14.0 µs | 13.5 µs | -3% |
| 1 MiB body, chunked in 4 KiB chunks | 17.6 µs | 17.0 µs | -3% (-9% in a separate run) |
| `write_head`, the same five fields borrowed | | 128 ns | new in 0.5 |
Windows x86_64, the same machine, single runs: browser GET 243 ns,
pipelined 7.8 ms, client parse 97 ns, response write 72 ns, `write_head`
110 ns, 1 MiB bodies 13.9 and 17.3 µs. aarch64 has not been measured.
### Against the targets (specification section 11.2)
| browser GET parse and 204 | 200 ns | 234 ns | not met |
| head parse, 12 fields (H1) | 1.20x faster than httparse with SIMD, or OD-8 decided with the gap | parity with httparse with SIMD | OD-8 decided (below) |
| pipelined small GETs | 15 M requests/s | 11.4 M | not met |
| 1 MiB `Content-Length` body | 10 µs | 13.5 µs | not met |
| 1 MiB chunked body | 12 µs | 17.0 µs | not met |
| response write, five fields, 1 KiB | 50 ns | 74 ns | not met |
| `write_head`, five fields | 40 ns, zero allocations | 128 ns, zero allocations | allocations met; time not met |
| client response parse, 1 KiB | 70 ns | 100 ns | not met |
| byte-at-a-time head feeding | at most 3x the whole-buffer time per byte | linear growth (below) | the guard holds; the literal ratio cannot (D-51) |
| steady-state allocations | 0 per request | 0 | met |
The 1 MiB targets assume the copy into the buffer runs at memory
bandwidth; most of the remaining time there is that copy. `write_head`
validates every borrowed field on each call (token name, reserved names,
value bytes) and writes each field in four pieces; a prebuilt `Response`
did both once when it was built, which is why `respond` is faster.
### Linearity
A head fed one byte at a time costs time linear in its size (the scanner
resumes where its search stopped and the buffer compacts only when full):
| 4 KiB | 0.78 µs | 41.6 µs | 10.2 ns |
| 16 KiB | 2.89 µs | 162 µs | 9.9 ns |
| 32 KiB | 6.42 µs | 334 µs | 10.2 ns |
Eight times the bytes cost 8.0 times the time. One `poll` per byte costs
about 10 ns against about 0.2 ns per byte for whole input, so the spec's
"3x per byte" cannot hold literally; the guard (`tests/linearity.rs`)
checks the growth instead, which is what catches re-reading (D-51).
## Against other libraries
The comparison lives in its own repository,
[`http-net-bench`](https://github.com/jamesgober/http-net-bench), so this
crate never depends on tokio, hyper, or httparse. It runs identical input
bytes through both sides, and `tests/equivalence.rs` there proves each
pair does the same work: httparse is given a framing pass with exactly
http-net's rules (Content-Length duplicates and lists, final `chunked`, the
CL and TE conflict, TE in HTTP/1.0, body budget, keep-alive, `Host` count
and value, upgrade, `Expect`, target escapes), because it is only a parser.
hyper is driven in memory over a duplex stream on a current-thread
runtime; the cost of moving the same bytes through that runtime with no
HTTP is measured separately.
Linux x86_64 (WSL2), medians of three rounds, http-net 0.6.0 (working
tree), httparse 1.10.1, hyper 1.12.0:
| H1 | request head and framing, 12 fields | 177 ns | httparse with SIMD 172 ns; without SIMD 153 ns; parse only 104 to 114 ns | parity with SIMD, 10% behind without |
| H2 | full server exchange, five fields and 1 KiB body | 250 ns (prebuilt `Response`), 406 ns (built per request) | hyper 1,382 ns (of which about 310 ns is the runtime) | 3.4 to 5.5x faster |
| H3 | response head and framing | 50 ns | httparse 46 to 49 ns | parity to 8% behind |
| H3 | full client exchange | 95 ns | hyper 1,239 ns (about 300 ns runtime) | 13x faster |
| H4 | chunked framing of 1 MiB, data in place | 1.69 µs | httparse `parse_chunk_size` loop 1.22 µs | 38% behind |
| H4 | the same with the body copied out | 17.5 µs | 17.1 µs | parity |
| H5 | 100,000 pipelined requests | 7.32 ms | hyper with `pipeline_flush` 23.6 ms | 3.2x faster |
httparse's SIMD path is slower than its own scalar path on this head: its
lines are short (about 30 bytes), and SIMD setup does not pay off below a
few dozen bytes per line. H6 (plaintext over sockets) waits for serve-net;
the C harnesses the specification lists as report-only (picohttpparser,
llhttp) are not run.
### What the comparison means
- **On the work a server does** (H2, H5) and a client does (H3 exchange),
http-net is three to thirteen times faster than hyper, most of which is
hyper's own HTTP work, not its runtime.
- **On parsing a head alone** (H1, H3 head), http-net is level with
httparse, while doing more: it validates the `Host` value, the target's
characters and escapes, every field value byte, and the framing rules
above, all included in its time, and it does so without `unsafe` code.
- **On chunk framing alone** (H4, in place), httparse's minimal loop is
faster; once the body bytes are moved, the two are level.
### Decisions
- **OD-8 (SIMD): stay with SWAR, no `unsafe`.** The specification's rule
is to stay safe while the SWAR scanners are within 20% of httparse's
SIMD path on H1. They are at parity, and httparse's own scalar path is
faster than its SIMD path on this workload.
- **NFR-8 (20% faster than the best competitor at equal or stronger
security): met on the full exchanges (H2: 3.4x; H5: 3.2x), not met on
head parsing (H1: parity with httparse's default configuration, 10%
behind its scalar build).** The cause is the extra validation listed
above, which the comparison counts in http-net's time but which httparse
leaves to its caller. Reaching 20% ahead on H1 would need SIMD scanning
with `unsafe` intrinsics (OD-8 option b) or dropping checks; neither is
taken. Recorded as decision D-54.
## Where the time goes
Head parsing of the 12-field browser GET, measured by component during
0.6 (single runs): the line scanner about 110 ns before the fast path
(about 85 after), interpretation about 60 ns (field facts about 27 of it;
the request line, target, and `Host` checks the rest).
What 0.6 changed, each change measured by interleaved A/B and kept only if
it helped (`dev/PLAN.md`, decisions D-45 to D-53):
| One pass for the common field line (name to colon, spaces, printable value, CR LF); every other shape through the general path | head -20%, client parse -15%, pipelined -12% (kept, D-48) |
| One pass for the common chunk line (hex digits, CR LF) | chunked body -9%, `ChunkedDecoder` -36% (kept, D-52) |
| Status line from a compile-time table | response write -10% (kept, D-49) |
| Field facts rule out names by length with one bitmask test | client parse -10%, head within noise (kept, D-53) |
| `respond_head_only` and vectored writes for bodies over 4 KiB | saves the body copy (kept, D-50) |
| LF search 16 bytes per step | +12% (rejected, D-45) |
| LF search and value validation in one pass over whole lines | +4 to +9% (rejected, D-46) |
| `match` on name length for field facts | +6% (rejected, D-47) |