Skip to content

perf(http1): pick write strategy per buffer with a size heuristic - #4205

Draft
ljluestc wants to merge 1 commit into
hyperium:masterfrom
ljluestc:perf/h1-auto-write-strategy
Draft

ljluestc wants to merge 1 commit into
hyperium:masterfrom
ljluestc:perf/h1-auto-write-strategy

Conversation

@ljluestc

Copy link
Copy Markdown

Closes #2910.

Problem

Buffered::new picks the HTTP/1 write strategy once per connection, purely from AsyncWrite::is_write_vectored():

let strategy = if io.is_write_vectored() {
    WriteStrategy::Queue
} else {
    WriteStrategy::Flatten
};

Vectored writes avoid copying the body, but they are not free — each extra IoSlice costs bookkeeping in BufList, in chunks_vectored, and in the kernel's writev gather loop, and queueing also gives up poll_flush's specialized single-write path. For a small body that overhead is larger than the memcpy it saves, so today every vectored-capable connection pays it regardless of body size.

Change

Adds a WriteStrategy::Auto variant, now the default when the IO supports vectored writes (non-vectored IO still goes straight to Flatten). WriteBuf::buffer resolves it per buffer: flatten anything below AUTO_FLATTEN_LIMIT, queue the rest.

Auto is resolved per buffer rather than latched on the first one, which is a small deviation from the issue's sketch. Latching would let one connection's first chunk fix the strategy for every later response on that keep-alive connection — a small first body would permanently pessimize a later 10MB one. Deciding per buffer costs one comparison and gets both cases right.

One ordering constraint: WriteBuf's Buf impl always yields headers before queue, so once the queue is non-empty, a later buffer must queue too or it would be written ahead of bytes that came first. should_flatten checks !self.queue.has_remaining() for exactly this, and write_buf_auto_keeps_order_after_queueing covers it.

poll_flush now asks write_buf.is_flattened() instead of matching on Flatten directly, so an Auto buffer that ended up entirely in headers still takes the plain-write path — that's where most of the win comes from.

No public API change. http1::Builder::writev(bool) still overrides the default in either direction on both client and server.

Picking the constant

Added benches/h1_writev.rs: it serves a single fixed-size response body over a loopback TCP connection, once with writev(true) and once with writev(false), across body sizes from 64B to 1MB. On an x86_64 Linux machine (median of 3 runs, ns/iter, lower is better):

body flatten queue winner
64B 5,825 5,967 flatten +2.4%
1KB 5,798 5,916 flatten +2.0%
4KB 6,007 6,136 flatten +2.1%
8KB 6,259 6,321 flatten +1.0%
16KB 6,559 6,619 flatten +0.9%
20KB 6,675 6,700 even
24KB 7,174 6,827 queue +4.8%
32KB 7,176 6,987 queue +2.6%
64KB 11,100 10,436 queue +6.0%
256KB 26,835 23,438 queue +12.7%
1MB 92,760 74,891 queue +19.3%

The crossover sits around 20–24KB. AUTO_FLATTEN_LIMIT is 16KB, the nearest power of two below it, staying on the conservative side. The derivation is documented on the constant.

The bench also has an auto mode that leaves the default in place. With this change it tracks whichever forced strategy is faster at every size — e.g. 1MB goes 95,820 (flatten) / 77,542 (queue) / 76,607 (auto), and 64B goes 5,660 (flatten) / 5,771 (queue) / 5,670 (auto).

Trade-off worth flagging

Flattening copies into the headers Vec, whose capacity is retained across requests (Cursor::reset clears but does not shrink). A vectored-capable connection that serves a ~16KB body will now hold onto a ~16KB buffer for its lifetime, where before it held only the header bytes. This is bounded by AUTO_FLATTEN_LIMIT and is already the status quo for every non-vectored connection (e.g. most TLS streams), but it is a real change for servers holding many idle keep-alive connections. Happy to lower the limit if you'd rather trade some of the throughput for that.

Testing

  • 4 new unit tests in proto::h1::io covering small-buffer flattening, large-buffer queueing, ordering after a queue, and that Auto flushes small writes in a single write call.
  • Full suite passes: cargo test --features full — 9 test binaries, 316 tests, 0 failures.
  • cargo fmt --check and cargo clippy --features full -- -D warnings clean.

The HTTP/1 write strategy was chosen once per connection, purely from
`AsyncWrite::is_write_vectored()`: vectored IO always queued, everything
else always flattened. Vectored writes avoid copying the body, but each
extra `IoSlice` costs bookkeeping in `BufList`, in `chunks_vectored`,
and in the kernel's gather loop, so for small bodies queueing is a net
loss against a single `memcpy` plus one `write`.

Add a `WriteStrategy::Auto` variant, now the default when the IO
supports vectored writes, that decides per buffer: flatten anything
below `AUTO_FLATTEN_LIMIT`, queue the rest. Once something is queued,
later buffers must queue too, or they would be written ahead of the
bytes that came first.

`AUTO_FLATTEN_LIMIT` is 16KB, from `benches/h1_writev.rs` (added here),
which serves a fixed-size response body over loopback TCP with each
strategy forced on. On x86_64 Linux the crossover sits around 20-24KB;
16KB is the nearest power of two below it.

No public API change. `http1::Builder::writev` still overrides the
default in either direction.

Closes hyperium#2910

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve auto WriteStrategy with writev heuristic

1 participant