Reduce H2 header allocations - #13420
Conversation
There was a problem hiding this comment.
Pull request overview
This PR reduces HTTP/2 per-request overhead in ATS by avoiding avoidable allocations/copies during HPACK processing and by introducing a fast-path that hands a decoded request header directly to HttpSM (skipping serialize + parse). It also updates the HTTP/2→1.1 conversion to normalize URL components to preserve legacy cache/remap behavior, and adds a gold test to cover the new path.
Changes:
- Decode contiguous HPACK header blocks in-place (avoids per-request malloc/memcpy/free) and encode HEADERS using an on-stack
ts::LocalBufferfor typical sizes. - Add a fast-path handoff to
HttpSMusing a borrowed pre-parsedHTTPHdr, skipping HTTP/1.1 serialization andparse_req. - Normalize
:authorityand:pathin the 2→1.1 converter (host:port split; query/fragment split; leading slash normalization) and add an AuTest replay to validate cache/remap parity.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/gold_tests/h2/replay/h2_request_handling.replay.yaml | New replay validating URL normalization and cache-key parity for the HTTP/2 fast path. |
| tests/gold_tests/h2/h2_request_handling.test.py | New gold test invoking the replay. |
| src/proxy/http2/Http2Stream.cc | Adds fast-path pre-parsed request handoff and strict-uri compliance checks. |
| src/proxy/http2/Http2ConnectionState.cc | In-place header-block decode when contiguous; stack-buffer HEADERS encoding via templated LocalBuffer. |
| src/proxy/http/HttpSM.cc | Consumes an optional borrowed pre-parsed request header instead of parsing from an IO buffer. |
| src/proxy/hdrs/VersionConverter.cc | Normalizes :authority and :path to match legacy serialize+reparse behavior (host/port + query/fragment splits). |
| src/proxy/hdrs/URL.cc | Adds url_is_uri_compliant() helper used for strict-uri checks on the fast path. |
| include/proxy/http2/Http2Stream.h | Adds decode_header_blocks() overload taking an explicit buffer pointer/length. |
| include/proxy/http/HttpSM.h | Adds pre-parsed request setter/query API and backing member. |
| include/proxy/hdrs/URL.h | Declares url_is_uri_compliant(). |
| include/proxy/hdrs/HdrHeap.h | Notes coupling between HdrHeap::DEFAULT_SIZE and the HTTP/2 on-stack encode buffer sizing. |
|
@JosiahWI Note that most of this got rewritten again, from suggestions from Masakazu. We'll have to rerun copilot etc. |
|
[approve ci autest] |
| bool | ||
| url_is_uri_compliant(int strict_uri_parsing, std::string_view value) | ||
| { | ||
| const char *start = value.data(); | ||
| const char *end = start + value.length(); | ||
|
|
||
| switch (strict_uri_parsing) { | ||
| case 1: | ||
| return url_is_strictly_compliant(start, end); | ||
| case 2: | ||
| return url_is_mostly_compliant(start, end); | ||
| default: | ||
| return true; | ||
| } | ||
| } |
There was a problem hiding this comment.
Fixed. url_is_uri_compliant() now returns early on an empty view before touching value.data(), so the {nullptr, 0} that URLImpl::get_query()/get_fragment() return for an unset component no longer reaches the pointer arithmetic.
There was a problem hiding this comment.
Dowvoting Copilot - Copilot is wrong. Adding a 0 offset to nullptr is defined behavior. See this SO answer which references the standard.
Decode HPACK header blocks in place when the whole block is present and contiguous in the frame reader, avoiding a per-request malloc + memcpy + free. Encode HEADERS frames into a stack-backed LocalBuffer (up to 2 * HdrHeap::DEFAULT_SIZE) so typical response headers skip a heap allocation, with a static_assert guarding the stack budget.
The H2 read path serialized each decoded request header so HttpSM could reparse it. Instead, normalize the URL in the 2->1.1 converter (split host:port and path?query, as a reparse would) and hand the decoded header to HttpSM via a refcounted copy(), skipping serialize+reparse. Roughly doubles small-request throughput. The fast path requires a successful 2->1.1 conversion (so a malformed request still gets its 400) and re-checks strict_uri_parsing on the target; non-compliant requests fall back to the serialize path. Header size stays bounded by the aggregate limits still in force here (SETTINGS_MAX_HEADER_LIST_SIZE and request_header_max_size); parse_req's per-field and request-line sub-limits are not separately applied.
Proxy Verifier replay coverage for the HTTP/2 <-> HttpSM fast-path handoff in all three directions: inbound request URL normalization (explicit-port and IPv6 :authority) and query-string / cross-protocol cache-key parity; client response emission (bodyless 204/304/HEAD and header preservation); and outbound server-request handling to an HTTP/2 origin (GET with query, POST with body), verified via proxy-request. Also fix the existing http2 test case 8, which piped curl's stderr with the bash 4 "|&" operator. Autest runs commands through /bin/sh, which is bash 3.2 on macOS, so that run failed with a syntax error before reaching ATS.
Replace the HttpSM::_pre_parsed_ua_request borrow pointer with virtual supports_direct_header_passing(), is_parsed_receive_header_ready(), and parsed_receive_header() methods on ProxyTransaction, and use the same seam for the response and outbound-request directions. All three skip the serialize+reparse round-trip between the HttpSM and the HTTP/2 stream. Request in: HttpSM pulls the decoded header from the transaction in state_read_client_request_header instead of the stream pushing a raw pointer. The stream owns _receive_header and is torn down with the SM, so the copy can never see a dangling borrow; that drops the timeout null-out and the synchronous-delivery assert. Response out / request out: write_response_header_into_buffer and setup_server_send_request hand client_response / server_request to the stream via get_client_response_header() / get_server_request_header() instead of serializing them. update_write_request copies the fields onto _send_header, preserving the pseudo-headers that create(HTTP_2_0) installed (:status for a response; :method/:scheme/:authority/:path for a request) and that the 1.1->2 conversion fills -- a plain copy() would wipe them. The stream signals a pending send header via has_pending_send_header(); a bodyless message (204/304/HEAD, or a GET to an H2 origin) has no body bytes to drive the write, so it is flushed with a zero-length write (HttpTunnel for the response path, the direct do_io_write for the request path). The ready flag is re-armed per header so 1xx interim responses and retried requests each deliver. The interface design is adopted from Masakazu Kitajo's no-header-marshaling patch. The VersionConverter URL parity work and the H2 allocation reductions on this branch are unchanged.
Restore the inbound parse_resp() fallback in update_write_request(): the direct-header fast path only covers final responses, so serialized 1xx responses (100-continue, 103 early-hints) written by setup_100_continue_transfer() were dropped instead of being emitted as HEADERS frames. Gate the request fast path on parse_req-equivalent method-token and Content-Length checks, falling back to serialize+parse otherwise, so non-token methods and conflicting/invalid Content-Length are rejected as before rather than forwarded unchecked. Keep the fast path from mis-mapping an oversized header set to 414 (REQUEST_URI_TOO_LONG); it now returns a generic 400.
| auto parse_req_would_accept = [&]() { | ||
| auto method{_receive_header.method_get()}; | ||
| if (method.empty() || std::any_of(method.begin(), method.end(), [](char c) { return !ParseRules::is_token(c); })) { | ||
| return false; | ||
| } | ||
| if (MIMEField *cl = _receive_header.field_find(static_cast<std::string_view>(MIME_FIELD_CONTENT_LENGTH)); cl != nullptr) { | ||
| auto value{cl->value_get()}; | ||
| if (cl->has_dups() || value.empty() || std::any_of(value.begin(), value.end(), [](char c) { return c < '0' || c > '9'; }) || | ||
| _receive_header.field_find(static_cast<std::string_view>(MIME_FIELD_TRANSFER_ENCODING)) != nullptr) { | ||
| return false; | ||
| } | ||
| } | ||
| return true; | ||
| }; |
| } else if (t_state.client_info.http_version == HTTPVersion(0, 9)) { | ||
| return 0; |
There was a problem hiding this comment.
Is this a dead code path now? Can we remove it?
| bool has_pending_send_header() const override; | ||
|
|
||
| Http2ErrorCode decode_header_blocks(HpackHandle &hpack_handle, uint32_t maximum_table_size); | ||
| Http2ErrorCode decode_header_blocks(HpackHandle &hpack_handle, uint32_t maximum_table_size, const uint8_t *block, |
There was a problem hiding this comment.
Nitpick: you could consider using std::span here.
This reduces per-request HTTP/2 overhead in two independent steps. First, the HPACK header-block decode happens in place when the whole block is contiguous in the frame reader (avoiding a per-request malloc+memcpy+free), and HEADERS-frame encoding uses a stack buffer for typical header sizes. Second — the larger win — the decoded request header is handed directly to HttpSM instead of being serialized and reparsed: the 2→1.1 converter now normalizes the URL (splitting host:port and path?query the way the reparse did), so the pre-parsed header can be copy()'d into the state machine, skipping a full parse_req per request. In local benchmarking this roughly doubles small-request throughput (~800K → ~1.68M req/s).
Co-Author: Masakazu Kitajo