Reduce H2 header allocations#13420
Conversation
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.
ATSReplayTest driving HTTP/2 traffic through the fast-path handoff: query-string caching, explicit-port and IPv6 literal :authority routing (host:port split correctly), and cross-protocol cache-key parity (fill over HTTP/1.1-over-TLS, read over HTTP/2 -> same key). Proxy Verifier based; named broadly so further request-side H2 scenarios can land here.
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. |
| if (_pre_parsed_ua_request != nullptr) { | ||
| // The UA_FIRST_READ gate above never fires on this path: the read buffer stays empty. | ||
| if (milestones[TS_MILESTONE_UA_FIRST_READ] == 0) { | ||
| ATS_PROBE1(milestone_ua_first_read, sm_id); | ||
| milestones[TS_MILESTONE_UA_FIRST_READ] = ink_get_hrtime(); | ||
| } | ||
| t_state.hdr_info.client_request.copy(_pre_parsed_ua_request); | ||
| _pre_parsed_ua_request = nullptr; | ||
| bytes_used = t_state.hdr_info.client_request.length_get(); | ||
| state = ParseResult::DONE; | ||
| } else { | ||
| state = t_state.hdr_info.client_request.parse_req(&http_parser, _ua.get_txn()->get_remote_reader(), &bytes_used, | ||
| _ua.get_entry()->eos, t_state.http_config_param->strict_uri_parsing, | ||
| t_state.http_config_param->http_request_line_max_size, | ||
| t_state.http_config_param->http_hdr_field_max_size); | ||
| } |
There was a problem hiding this comment.
Validation should be applied at parse-time, not on the use of the cached value. The cached value should invariantly already be validated.
| // Delivery is synchronous (stream mutex): the borrow is copied, or the txn | ||
| // finished and _sm is null. A still-pending borrow is unreachable; recover anyway. | ||
| if (this->_sm == nullptr || !this->_sm->has_pending_pre_parsed_ua_request()) { | ||
| return; | ||
| } | ||
| ink_assert(!"pre-parsed handoff was deferred"); | ||
| this->_sm->set_pre_parsed_ua_request(nullptr); | ||
| } |
There was a problem hiding this comment.
This one needs to be checked.
| // Match the reparse so url_print() stays byte-identical (legacy path | ||
| // unchanged); require full consumption, else a stray '/', '?' or '#' is dropped. | ||
| const char *astart = authority.data(); |
There was a problem hiding this comment.
It's still not quite clear to me why the url_print output would have changed.
There was a problem hiding this comment.
It seems to me like these are fixes to the HTTP/2 to HTTP/1.1 header conversion, which aren't directly part of the optimization itself. Is that right?
| { | ||
| return this->decode_header_blocks(hpack_handle, maximum_table_size, header_blocks, header_blocks_length); | ||
| } | ||
|
|
||
| Http2ErrorCode | ||
| Http2Stream::decode_header_blocks(HpackHandle &hpack_handle, uint32_t maximum_table_size, const uint8_t *block, uint32_t block_len) | ||
| { | ||
| Http2ErrorCode error = | ||
| http2_decode_header_blocks(&_receive_header, (const uint8_t *)header_blocks, header_blocks_length, nullptr, hpack_handle, | ||
| _trailing_header_is_possible, maximum_table_size, this->is_outbound_connection()); | ||
| http2_decode_header_blocks(&_receive_header, block, block_len, nullptr, hpack_handle, _trailing_header_is_possible, | ||
| maximum_table_size, this->is_outbound_connection()); |
There was a problem hiding this comment.
What's the reason for adding the 4-argument overload?
| uint32_t buf_len = send_hdr->length_get() * 2; // Make it double just in case | ||
| // Keep typical header sets off the heap; oversized ones spill to it. | ||
| static_assert(HdrHeap::DEFAULT_SIZE * 2 <= 8192, "keep HEADERS encode stack buffer within the event-thread stack budget"); | ||
| ts::LocalBuffer<uint8_t, HdrHeap::DEFAULT_SIZE * 2> local_buffer(buf_len); | ||
| uint8_t *buf = local_buffer.data(); |
There was a problem hiding this comment.
Do I understand correctly that you are increasing the buffer capacity here to increase the chance that typical response headers will fit in the buffer?
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).