Contention-aware per-event send budget for QUICStream - #13554
Conversation
A single stream on a QUIC connection could only send 16KB per write event, a fixed fairness quantum that protects sibling streams from being starved. That protection has a real cost when a connection has little or no contention: a single-stream, multi-megabyte response needs dozens of separate event-loop round trips to drain, each paying real per-call overhead, even though there's nobody else to be fair to. This scales the per-event budget by how many streams were writable in the previous event on the connection: down toward the original 16KB floor under real contention, up toward a new 256KB ceiling when a stream has the write path to itself. The count is remembered rather than recomputed each event, since quiche's writable-stream iterator is one-shot with no free count, and recomputing it would cost as much as the round trips being eliminated. A brief one-event lag when contention changes abruptly is bounded and self-correcting, never a starvation risk. Benchmarking a 1MB single-stream response over QUIC (h2load, -m 1) went from 812.7 MB/s to 1553.0 MB/s; a small (8KB) object, already under one turn's floor, was unaffected, confirming the fix targets exactly the large single-stream case without disturbing anything else.
Http3Transaction is constructed once per request and was allocating and freeing five separate objects on the heap for its own private, fixed-lifetime framers/handlers (header framer, data framer, protocol enforcer, header/data VIO adaptors) -- these become plain value members instead, owned directly by the transaction. Http3FrameHandler::interests() ran on every handler registration and constructed and returned a fresh std::vector by value each time, even though the interest set is fixed per handler type; it now returns a reference to a function-local static vector built once. Http3FrameDispatcher's per-type handler list moves from a std::vector (heap-allocated on first push) to a small fixed-size inline array sized for the real maximum handler count. Http3HeadersFrame gains a constructor that shares the caller's IOBufferReader via a clone instead of copying the header block into a freshly malloc'd buffer, used on the receive path where the block is already sitting in an IOBuffer. Unrelated one-line fix: QUICNetProcessor was checking dbg_ctl_vv_quiche with tag_on(), which only tests whether the debug tag pattern matches and ignores whether debug output is globally enabled -- switched to on() so a coincidental tag match doesn't install quiche's trace-level logger unconditionally.
QMuxConnection::_handle_write_streams() is the third of the three QUICStream::send_data() call sites, alongside QUICNetVConnection and OpenSSLQUICNetVConnection -- it was left on the fixed 16KB-per-event cap when the other two call sites moved to the contention-aware budget, since QUICStream::compute_fair_send_budget() didn't exist on the branch QMux's source lives on until this rebase brought both together. Same pattern as QUICNetVConnection::_handle_write_ready(): a _last_writable_stream_count member remembers the previous event's writable-stream count to size this event's budget, no extra allocation or extra quiche_conn_writable() call versus today's code.
Http3UnknownFrame was never instantiated anywhere -- no allocator for it exists (unlike every other frame type), and Http3FrameFactory::create()'s fallback for an unrecognized frame type constructs a plain Http3Frame directly instead. The whole subclass, both its constructors, and its to_io_buffer_block() override were orphaned code, predating this branch. Http3HeadersFrame's ats_unique_buf constructor and the create_headers_frame(const uint8_t*, size_t) factory overload that built it were also unreachable in production: Http3HeaderFramer.cc, their only real caller, has used the IOBufferReader*-based overload since this file's original introduction. The one place still exercising the owned-copy constructor was a unit test constructing it directly; removed along with it since the sibling "via factory" section already covers identical serialization output through the live construction path.
Http3DataFrame's send constructor takes IOBufferReader&, with its factory dereferencing a pointer to call it. Http3HeadersFrame's send constructor -- added when the reader-cloning path replaced the old copy-into-owned-buffer one -- took IOBufferReader* directly instead, with no actual need for pointer semantics: the constructor only calls clone() once, which works identically through a reference. Aligned it to the same &-parameter/factory-dereferences convention already used by every other IOBufferReader-taking constructor in this file (both frame types' receive-path constructors, and Data's send constructor).
_last_writable_stream_count was declared unconditionally but only read/written by the quiche write path, breaking the OpenSSL-native-QUIC build under -Werror=unused-private-field. Http3FrameDispatcher's new _handlers array lacked the same zero-initializer as its paired count array, a latent fragility if the count-gated read path is ever changed. Found in a deep review pass across both build backends.
| const uint64_t total_len = this->_adapter->total_len(); | ||
|
|
||
| while (written_this_event < MAX_STREAM_SEND_BYTES_PER_EVENT) { | ||
| while (written_this_event < max_bytes_this_event) { |
There was a problem hiding this comment.
This is the biggest improvement. Fixed size cap -> Variable size cap based on the number of streams.
There was a problem hiding this comment.
Pull request overview
This PR improves QUIC/qmux write throughput for large single-stream responses by making QUICStream’s per-write-event send budget scale with observed connection contention (min 16KB under contention, up to 256KB when uncontended). It also reduces per-transaction overhead in the HTTP/3 frame path by removing dead code and eliminating several heap allocations.
Changes:
- Add contention-aware
QUICStream::compute_fair_send_budget()and plumb a per-event budget through QUICNetVConnection/qmux/OpenSSL QUIC write paths. - Reduce HTTP/3 per-transaction allocations by moving several
Http3Transactionhelpers to value members and switchingHttp3FrameDispatcherto fixed inline handler storage. - Remove dead HTTP/3 frame code and make HEADERS frame send-path avoid copying by cloning an
IOBufferReader.
Reviewed changes
Copilot reviewed 31 out of 31 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/proxy/http3/test/test_Http3FrameDispatcher.cc | Update test handler interests() to return a stable const& list. |
| src/proxy/http3/test/test_Http3Frame.cc | Update HEADERS frame test to use reader-based factory path. |
| src/proxy/http3/test/Mock.h | Update mock handler interests() to return a stable const& list. |
| src/proxy/http3/Http3Transaction.cc | Convert several HTTP/3 helpers from heap pointers to value members; adjust call sites. |
| src/proxy/http3/Http3StreamDataVIOAdaptor.cc | Return stable interests() list; make has_data() const. |
| src/proxy/http3/Http3SettingsHandler.cc | Return stable interests() list by const&. |
| src/proxy/http3/Http3ProtocolEnforcer.cc | Return stable interests() list by const&. |
| src/proxy/http3/Http3HeaderVIOAdaptor.cc | Return stable interests() list by const&. |
| src/proxy/http3/Http3FrameDispatcher.cc | Replace per-type std::vector with inline handler storage; remove per-dispatch copies. |
| src/proxy/http3/Http3FrameCounter.cc | Return stable interests() list by const&. |
| src/proxy/http3/Http3Frame.cc | Remove unknown-frame implementation; make send-path HEADERS frame use cloned reader; remove dead factory overload. |
| src/iocore/net/unit_tests/test_QUICStream.cc | Add unit tests for compute_fair_send_budget() edge cases. |
| src/iocore/net/QUICNetVConnection.cc | Compute per-event stream send budget from previous writable-stream count; pass budget into send_data(). |
| src/iocore/net/QUICNetProcessor.cc | Use DbgCtl::on() instead of tag_on() when enabling quiche debug logging. |
| src/iocore/net/quic/QUICStream.cc | Implement compute_fair_send_budget() and budgeted send_data() loop. |
| src/iocore/net/qmux/QMuxConnection.cc | Compute per-event stream send budget from previous writable-stream count; pass budget into send_data(). |
| src/iocore/net/P_QUICNetVConnection.h | Track previous writable-stream count for quiche path budget computation. |
| src/iocore/net/OpenSSLQUICNetVConnection.cc | Update send_data() calls to new signature (using min budget for legacy behavior). |
| src/iocore/net/CMakeLists.txt | Add new QUICStream unit test to test_net when QUIC or qmux is enabled. |
| include/proxy/http3/Http3Transaction.h | Include concrete HTTP/3 helper types and store helpers as value members. |
| include/proxy/http3/Http3StreamDataVIOAdaptor.h | Update interests() return type; make has_data() const. |
| include/proxy/http3/Http3SettingsHandler.h | Update interests() return type. |
| include/proxy/http3/Http3ProtocolEnforcer.h | Update interests() return type. |
| include/proxy/http3/Http3HeaderVIOAdaptor.h | Update interests() return type. |
| include/proxy/http3/Http3FrameHandler.h | Change interests() interface to return std::vector<...> const &. |
| include/proxy/http3/Http3FrameDispatcher.h | Switch to inline handler storage and add max-handlers limit. |
| include/proxy/http3/Http3FrameCounter.h | Update interests() return type. |
| include/proxy/http3/Http3FrameCollector.h | Reserve fixed generator capacity to avoid vector growth reallocations. |
| include/proxy/http3/Http3Frame.h | Remove unknown frame type; add reader-backed HEADERS send-path constructor; remove dead factory overload. |
| include/iocore/net/quic/QUICStream.h | Add min/max per-event budgets; expose compute_fair_send_budget() and new send_data() signature. |
| include/iocore/net/qmux/QMuxConnection.h | Track previous writable-stream count for qmux budget computation. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The test freed the source MIOBuffer while the frame it produced was still alive, so the frame's destructor deallocated a reader into already-freed memory when it went out of scope. Reset the frame before freeing the buffer, per the constructor's own documented lifetime requirement.
Summary
Benchmarking qmux (HTTP/3-over-TCP) against H2 showed a throughput deficit
specific to large, single-stream responses, absent for small objects and
mostly recovered once multiple concurrent streams share a connection.
QUICStream::send_data()capped how much of one stream's data it sendsper write event to a fixed 16KB, regardless of contention. A large object
on a solo stream needs many separate event-loop round trips to drain,
each paying real per-event overhead (quiche iterator alloc/free, an FFI
crossing, a flush). This makes that per-event budget contention-aware:
it scales down toward the existing 16KB floor under real contention
(preserving today's fairness guarantee exactly), and grows toward a new
256KB ceiling when a stream has the connection's write path to itself.
The budget is sized from the writable-stream count observed during the
previous write event on that connection -- an intentional one-event-lag
tradeoff that avoids draining
quiche_conn_writable()twice ormaterializing a container just to count entries.
Also, while touching the HTTP/3 frame-handling path, removed some dead
code and per-request allocations found along the way:
Http3FrameDispatcher's per-type handler list moved from aheap-allocated
std::vectorto fixed inline storage, since at most ahandful of handlers ever register per frame type and this dispatcher
is a per-transaction object.
Http3Transaction's five per-transaction helper objects (framegenerators/handlers) converted from heap-allocated pointers to value
members, removing five allocations/frees per HTTP/3 transaction.
Http3UnknownFrameandHttp3HeadersFrame'sats_unique_bufconstructor/factory overload, both fully dead code.
Http3HeadersFrame's send-path constructor to takeIOBufferReader &likeHttp3DataFrame's equivalent, instead of*.Test plan
test_QUICStream.cc) coverscompute_fair_send_budget()'s no-contention, heavy-contention,mid-range, and floor-boundary cases.
test_netandtest_http3pass against BoringSSL + quiche + qmux.test_netandtest_http3pass against OpenSSL's native QUICbackend (a separate, non-quiche
NetVConnectionimplementationthat also compiles this code).