Follow-up to #1719, which found the LB spends libc 27.3% / own code 9.8% where nginx is 6% / 33.9%, and described the allocation churn as "diffuse ... rather than one hot call". By allocation count it is not diffuse: more than half of it is header handling, in three functions.
Measurement
std.http.server.lb under valgrind --tool=dhat, two Aether backends, 300 proxied requests. Run twice, because the first run gave every request its own connection and so charged per-connection costs to individual requests:
|
fresh connection per request |
keep-alive, 300 requests over 6 connections |
| allocations per request |
78.2 |
76.2 |
| total bytes |
11.2 MiB |
6.5 MiB |
conn_serve |
3.00/req, 4.8 MiB |
2.02/req, 0.1 MiB |
| the three header sites |
21 / 12 / 11 |
21 / 12 / 11 |
The read buffer amortises correctly across a connection (it is lazily allocated once and compacted between requests), which is where the byte difference comes from. The header cost does not move, because it is per upstream request either way.
Where the 76 allocations go
| site |
allocs/req |
bytes |
http_request_set_header_raw |
21.00 |
95 KiB |
http_parse_request_n |
12.00 |
306 KiB |
http_response_set_header |
11.00 |
35 KiB |
aether_middleware_reverse_proxy |
7.00 |
26 KiB |
string_new_with_length |
6.03 |
67 KiB |
http_request_internal |
5.00 |
5,297 KiB |
http_response_create |
4.00 |
263 KiB |
http_request_raw |
3.00 |
34 KiB |
conn_serve |
2.02 |
102 KiB |
http_extract_response_header |
2.00 |
4 KiB |
44 of 76 allocations are header storage and parsing.
The call sites, read from source
1. Three allocations per header, and a quadratic insert — aether_http.c:780, http_request_set_header_raw. Per header: calloc for the node, strdup for the name, strdup for the value. Then the new node is appended by walking the list to its tail, so N headers cost N(N+1)/2 link traversals. Seven forwarded headers is 21 allocations and 28 traversals.
2. Fixed 50-slot arrays plus two strdups per header — aether_http_server.c:1398, http_parse_request_n. Per request: calloc the request, malloc method, path and query separately, strdup the version, then malloc(sizeof(char*) * 50) twice regardless of how many headers arrive, then strdup key and value per header.
3. Two strdups per header — aether_http_server.c:1727, http_response_set_header, with the same 50-slot arrays allocated lazily.
All three store copies of bytes that are already sitting in a buffer the connection owns.
4. A 16 KiB buffer per upstream request — aether_http.c:1941. full_response starts at 16384 bytes and is freed when the request ends, so a pooled connection that serves thousands of requests allocates and frees 16 KiB each time. Plus a 1 KiB header-build buffer at :1687. That is the 5.3 MiB above, and the largest byte figure in the table by an order of magnitude.
5. Per-upstream constants recomputed per request — aether_proxy_middleware.c. extract_authority(u->base_url) mallocs on every request to recover the host:port of a backend whose base URL cannot change; it belongs on the upstream at registration. build_upstream_url genuinely varies, but only in path and query.
Two corrections to what the tree currently says
The picker is not the bottleneck. std/http/server/lb/module.ae lists as its top development direction:
1. Reduce per-request overhead on the hot path (the picker mutates shared state under a lock/atomics; profile it under load).
It does not. pick_round_robin is one atomic_fetch_add on the cursor plus atomic loads in upstream_eligible; the only mutex there is u->rl_lock, guarded by if (u->rl_max_rps > 0) and therefore skipped entirely unless rate limiting is configured. The pthread_mutex_lock/unlock in #1719's profile is the client idle pool's global lock and the worker queue. That note should be replaced with what this census shows.
One poll per response is one syscall more than needed. conn_next_request_imminent polls the socket to decide whether to park, and the caller then reads it — two syscalls where recv(MSG_DONTWAIT) would both detect and consume the next request. The buffered-data short-circuit it needs (conn->write_pos > conn->read_pos) already exists on the line above. This is mine, from #1684, and #1719 measured it at 30% of syscall time.
Suggested order
- Header storage — one fix covers sites 1–3: a single allocation per header instead of three (name and value in the node's own allocation), plus a tail pointer to kill the quadratic insert. Bigger win available later by pointing into the read buffer rather than copying at all.
- Reuse the 16 KiB response buffer per transport rather than per request — the biggest byte reduction available.
- Hoist the per-upstream constants out of the request path.
- Collapse the
poll + recv pair.
Reproduce with valgrind --tool=dhat --dhat-out-file=... on benchmarks/http/lb_reuse_lb.ae against two lb_reuse_backend.ae instances; the numbers above are 300 requests, and the keep-alive column is what a real proxy workload looks like.
Follow-up to #1719, which found the LB spends libc 27.3% / own code 9.8% where nginx is 6% / 33.9%, and described the allocation churn as "diffuse ... rather than one hot call". By allocation count it is not diffuse: more than half of it is header handling, in three functions.
Measurement
std.http.server.lbundervalgrind --tool=dhat, two Aether backends, 300 proxied requests. Run twice, because the first run gave every request its own connection and so charged per-connection costs to individual requests:conn_serveThe read buffer amortises correctly across a connection (it is lazily allocated once and compacted between requests), which is where the byte difference comes from. The header cost does not move, because it is per upstream request either way.
Where the 76 allocations go
http_request_set_header_rawhttp_parse_request_nhttp_response_set_headeraether_middleware_reverse_proxystring_new_with_lengthhttp_request_internalhttp_response_createhttp_request_rawconn_servehttp_extract_response_header44 of 76 allocations are header storage and parsing.
The call sites, read from source
1. Three allocations per header, and a quadratic insert —
aether_http.c:780,http_request_set_header_raw. Per header:callocfor the node,strdupfor the name,strdupfor the value. Then the new node is appended by walking the list to its tail, so N headers cost N(N+1)/2 link traversals. Seven forwarded headers is 21 allocations and 28 traversals.2. Fixed 50-slot arrays plus two strdups per header —
aether_http_server.c:1398,http_parse_request_n. Per request:callocthe request,mallocmethod, path and query separately,strdupthe version, thenmalloc(sizeof(char*) * 50)twice regardless of how many headers arrive, thenstrdupkey and value per header.3. Two strdups per header —
aether_http_server.c:1727,http_response_set_header, with the same 50-slot arrays allocated lazily.All three store copies of bytes that are already sitting in a buffer the connection owns.
4. A 16 KiB buffer per upstream request —
aether_http.c:1941.full_responsestarts at 16384 bytes and is freed when the request ends, so a pooled connection that serves thousands of requests allocates and frees 16 KiB each time. Plus a 1 KiB header-build buffer at:1687. That is the 5.3 MiB above, and the largest byte figure in the table by an order of magnitude.5. Per-upstream constants recomputed per request —
aether_proxy_middleware.c.extract_authority(u->base_url)mallocs on every request to recover thehost:portof a backend whose base URL cannot change; it belongs on the upstream at registration.build_upstream_urlgenuinely varies, but only in path and query.Two corrections to what the tree currently says
The picker is not the bottleneck.
std/http/server/lb/module.aelists as its top development direction:It does not.
pick_round_robinis oneatomic_fetch_addon the cursor plus atomic loads inupstream_eligible; the only mutex there isu->rl_lock, guarded byif (u->rl_max_rps > 0)and therefore skipped entirely unless rate limiting is configured. Thepthread_mutex_lock/unlockin #1719's profile is the client idle pool's global lock and the worker queue. That note should be replaced with what this census shows.One
pollper response is one syscall more than needed.conn_next_request_imminentpolls the socket to decide whether to park, and the caller then reads it — two syscalls whererecv(MSG_DONTWAIT)would both detect and consume the next request. The buffered-data short-circuit it needs (conn->write_pos > conn->read_pos) already exists on the line above. This is mine, from #1684, and #1719 measured it at 30% of syscall time.Suggested order
poll+recvpair.Reproduce with
valgrind --tool=dhat --dhat-out-file=...onbenchmarks/http/lb_reuse_lb.aeagainst twolb_reuse_backend.aeinstances; the numbers above are 300 requests, and the keep-alive column is what a real proxy workload looks like.