Skip to content

Reduce H2 header allocations#13420

Open
zwoop wants to merge 3 commits into
apache:masterfrom
zwoop:H2Perf
Open

Reduce H2 header allocations#13420
zwoop wants to merge 3 commits into
apache:masterfrom
zwoop:H2Perf

Conversation

@zwoop

@zwoop zwoop commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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).

zwoop added 3 commits July 22, 2026 20:32
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.
@zwoop zwoop added this to the 11.0.0 milestone Jul 23, 2026
@zwoop zwoop self-assigned this Jul 23, 2026
Copilot AI review requested due to automatic review settings July 23, 2026 02:36
@zwoop zwoop added the HTTP/2 label Jul 23, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::LocalBuffer for typical sizes.
  • Add a fast-path handoff to HttpSM using a borrowed pre-parsed HTTPHdr, skipping HTTP/1.1 serialization and parse_req.
  • Normalize :authority and :path in 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.

Comment thread src/proxy/http/HttpSM.cc
Comment on lines +633 to +648
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validation should be applied at parse-time, not on the use of the cached value. The cached value should invariantly already be validated.

Comment on lines +360 to +367
// 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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one needs to be checked.

Comment thread src/proxy/hdrs/URL.cc
Comment thread include/proxy/hdrs/URL.h
Comment thread include/proxy/http/HttpSM.h
Comment on lines +205 to +207
// 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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's still not quite clear to me why the url_print output would have changed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread include/proxy/hdrs/HdrHeap.h
Comment thread src/proxy/http2/Http2ConnectionState.cc
Comment on lines +286 to +295
{
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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason for adding the 4-argument overload?

Comment on lines +2490 to +2494
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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@JosiahWI
JosiahWI self-requested a review July 23, 2026 12:05
Comment thread src/proxy/http/HttpSM.cc
JosiahWI added a commit that referenced this pull request Jul 23, 2026
* Document HTTP methods for #13420 review

* Make changes requested by Brian Neradt

  Put brief sentence on opening line
  Use in/out/in,out parameter markers
  Clarify that `@` headers are also included in length
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants