perf(http): cut per-request syscalls from 83 to 14 — cached addresses + no redundant setsockopt (#1719) - #1720
Open
paul-hammant wants to merge 13 commits into
Open
perf(http): cut per-request syscalls from 83 to 14 — cached addresses + no redundant setsockopt (#1719)#1720paul-hammant wants to merge 13 commits into
paul-hammant wants to merge 13 commits into
Conversation
…equest +2.8% on the load-balancer benchmark (48,073 -> 49,432 rps), with nginx and haproxy measured in the same runs as controls and moving under 0.3%. std/net/aether_http_server.c fetched getpeername and getsockname on every request, with a comment reasoning they are "cache-warm syscalls" and so cheap enough to run per request. Profiling std.http.server.lb against nginx on the same box put a number on that (#1719): syscall aether nginx getpeername 1.0 0 133,162 calls over the run getsockname 1.0 0 133,162 calls over the run Neither address can change while a socket is open, so nginx resolves them once and Aether was paying 2 syscalls, 2 inet_ntop calls and 2 strdups per request for an answer it already had. Caching them on HttpConn is cheap now in a way it was not when that comment was written: connection parking (#1663) made HttpConn heap-allocated and connection-lifetime, so there is somewhere to put them. After: getpeername is gone from the strace profile entirely and getsockname falls from 133,162 calls to 50 — one per connection. Under strace (which makes syscalls expensive and so amplifies the effect) throughput rose 12,969 -> 15,774 rps, +22%. `addrs_resolved` is a separate flag rather than testing for an empty string: a failed lookup — a Unix-domain socket, an fd closed under us — is a real answer worth caching, and without the flag it would retry every request, which is the cost being removed. The request still owns its copies. http_request_free frees req->remote_addr and req->local_addr, so the per-request strdups stay and only the syscalls go; the ownership contract is unchanged. The accessor test now drives three requests down ONE connection, because the single-request version could not tell a working cache from a broken one. It could not before this commit either: the test server never enabled keep-alive, so curl reconnected per request and the cache was repopulated each time. Turning keep-alive on in server.ae is what makes the assertion mean anything — verified by injecting a stale-cache bug, which the test now catches (requests 2 and 3 report peer=) and did not catch before. Constraint held: 305/305 regression, 38/40 http integration. The two failures (http_server_h2's known framing-layer error, forward_proxy's port contention) reproduce on stashed baseline code. Refs #1719 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The keep-alive assertions added with the address cache compared
`wc -l` output against the string "3". GNU wc prints a bare count;
BSD/macOS wc left-pads it to width 8 (" 3"), so the compare
succeeded on Linux and failed on both macOS legs -- the test was
counting correctly and reporting the right number, then rejecting it
on whitespace.
Piping through `tr -d '[:space:]'` normalises the count while keeping
it a string, so the failure branch still prints a readable number.
The three other `wc -l` sites under tests/ feed numeric comparisons,
where the shell strips the padding itself, so they were never exposed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
paul-hammant
force-pushed
the
perf/1719-cache-conn-addresses
branch
from
August 23, 2026 07:40
036efc4 to
4bd99a7
Compare
Under strace against the LB benchmark, setsockopt was the third
costliest syscall: 202,552 calls for 20,000 requests, ~10 per request,
15.2% of syscall time. None of them changed anything.
Two sites, both on the keep-alive path where the socket already carries
the value being set:
- the upstream proxy connection. http_apply_timeouts runs on every
reuse of a pooled connection (aether_http.c:1663), setting the same
SO_RCVTIMEO and SO_SNDTIMEO the previous request left there. A
Transport travels with its connection into the idle pool, so it can
remember what it applied; transport_apply_timeouts skips the pair
when the value is unchanged. The unguarded form stays for the dial
path, where the socket is new and its option state is unknown.
- the client connection. conn_serve applies the idle timeout on entry,
and with connection parking (#1663) a kept-alive connection
re-enters conn_serve once per request. The parking comment already
claimed the window was "only re-applied when it changes" -- that
guard did not exist. Now it does, on HttpConn.
Both sentinels are -1 rather than 0, because 0 is a legitimate timeout
meaning "block indefinitely" and must not read as "never configured".
HttpConn is calloc'd, so its field is set explicitly after allocation
rather than relying on the zeroing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A malformed sed invocation while editing the conn-accessor test created
an empty file literally named `c -l)|X|`, and a `git add -A` on that
directory committed it.
`|` cannot appear in a Windows filename, so every native Windows leg
failed in `actions/checkout` with
error: invalid path 'tests/integration/http_request_conn_accessors/c -l)|X|'
before compiling a line -- which is why all four failed in 9-18s and
their logs were empty, while `Windows / cross-build` (which builds the
same sources on Linux) passed. Not a portability bug in the C.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
http_pool_take and http_pool_put each swept the whole idle connection list under the global pool mutex, on every request. With the default 15s idle window and a proxy reusing upstreams continuously, that sweep frees nothing on the overwhelming majority of calls -- it is a list walk per request to discover there is nothing to do. The pool now records when its earliest connection becomes eligible and returns immediately while that deadline is in the future, recomputing the watermark from the survivors whenever it does sweep. All five paths that can invalidate it are handled: put lowers it (re-arming an empty pool), clear resets it to INT64_MAX, and reconfigure forces a sweep -- that last one matters because shortening the idle window would otherwise leave a deadline further out than the new setting allows and delay every eviction. Separately, http_pool_put counted per-key entries by walking to the end of the list with a strcmp per node, when only whether the count REACHES the cap matters. It now stops at the cap, and skips the walk entirely when the global cap already rejects the connection. NO MEASURABLE THROUGHPUT WIN. Three alternating A/B rounds on the LB benchmark: 48,854 rps before, 48,870 after (+0.03%, baseline ahead in two of three rounds). http_pool_expire_locked showed as 0.52% of cycles, but that figure is inclusive of the transport_close/free work a real expiry still has to do, so skipping the walk recovers much less than the profile implied. Kept because it is strictly less work under a global mutex -- shorter hold times matter more with more upstreams than this two-backend benchmark has -- but it is not a performance claim. Behaviour is unchanged: the watermark is a lower bound, so a stale-early value costs one redundant sweep and never a missed expiry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
paul-hammant
force-pushed
the
perf/1719-cache-conn-addresses
branch
from
August 23, 2026 09:28
1ab4c25 to
184b8c4
Compare
getaddrinfo ran unconditionally at the top of the request path, above the pool lookup, so every request resolved the backend host and then discarded the answer on a pooled hit. Only the http_dial sites use the resolved address, and the pool key is built from dial_host/dial_port rather than the address, so nothing between the two needed it. It profiled at 0.44% and takes a lock. Resolution now happens through resolve_dial_addr, which resolves at most once per request behind a caller-owned once-flag. ALL FOUR dial sites go through it: the initial dial, and the three pooled-connection retry paths (send-failure on headers, send-failure on body, and zero-length response). Those three are precisely the paths where the first resolve was skipped, so each must resolve before reading serv_addr -- an earlier revision of this commit missed two of them and passed an uninitialised sockaddr_in to connect(). The client tests did not catch it; T10 of http_reverse_proxy did, as a 504 that came back 000. A failed resolve leaves the flag clear, so a later caller retries rather than reading a stale address. One deliberate behaviour change: a request hitting a live pooled connection now succeeds even if the host has since stopped resolving, where before it failed with "could not resolve host". An open connection does not need DNS, and holding an established socket hostage to a resolver blip is the less useful behaviour -- it is also what nginx does. A request that has to dial still fails exactly as before. No test depended on the old behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
paul-hammant
force-pushed
the
perf/1719-cache-conn-addresses
branch
from
August 23, 2026 10:00
9992847 to
bb0530d
Compare
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…addresses # Conflicts: # CHANGELOG.md
Resolves the CHANGELOG by rebuilding it from main and re-inserting this branch's four #1719 entries under a fresh [current]. The plain merge had folded them INTO the released [0.576.0] section: main renamed its [current] to [0.576.0] at release time, and the merge combined that heading with this branch's still-unreleased [current], so four unmerged entries ended up inside a tagged release with no conflict marker to show it. Every released section now byte-matches main, and [current] holds only the four #1719 entries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rebuilds the CHANGELOG from main and re-inserts this branch's four #1719 entries under a fresh [current]. Main released 0.577.0 (taking the wasi setjmp fix with it) and has no [current] heading, so the plain merge would have folded four unreleased entries into a tagged section. Note for anyone repeating this: the "does main already have [current]" check must be ANCHORED to a heading. The file's own workflow preamble quotes the token in prose twice, so a substring test says yes when there is no such heading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four data-driven steps at the LB performance gap (#1719). +4.9%, controls flat, no new test failures.
@nicolas-maman — flagging you for a must-review: change 2 touches the parking path and makes one of your comments true that wasn't. Your measurements hold up; it's the change-check the comment describes that was missing. No question for you — I had one about your parking poll and withdrew it once I split the measurement by caller; see the bottom.
Result
Same box, same harness, only the Aether binary changed. nginx and haproxy are measured in the same runs as controls:
Both controls held under 0.3% while Aether gained 4.9%, so the change is the cause rather than box drift. Gap closes 71% → 74.5% of nginx.
Syscalls per request,
strace -c -fover 20,000 requests:setsockoptgetpeernamegetsocknameChange 1 — addresses resolved per connection, not per request
getpeername/getsockname+ 2inet_ntop+ 2strdupran on every request. Neither address can change while a socket is open. Now cached onHttpConn.Cheap now in a way it wasn't when written: connection parking (#1663 / #1684) made
HttpConnheap-allocated and connection-lifetime, so there is somewhere to put them. The request still owns its copies —http_request_freefrees both — so only the syscalls go, not the allocations.Change 2 — stop re-applying socket timeouts that are already set
This is the one #1719 couldn't pin down. The issue said:
Answer, from a raw
strace: per request, at two sites, both on the keep-alive path.a) Upstream pooled connections (
aether_http.c:1663). Every reuse re-appliedSO_RCVTIMEO+SO_SNDTIMEOto the same value the previous request left there. Here is request 3 reusing pooled fd 6 — noconnect, yet both options set again:A
Transporttravels with its connection into the idle pool, so it can remember what it applied. The unguarded form stays on the dial path, where the socket is new and its option state genuinely unknown.b) The client connection — and this is the part that needs your eyes.
conn_serveapplies the idle timeout on entry, and with parking a kept-alive connection re-entersconn_serveonce per request. Your comment ataether_http_server.c:3725(from fbc111c, PR #1684) states:That guard did not exist. In fbc111c,
conn_apply_recv_timeouttook a barefd, kept no previous value anywhere, and calledsetsockoptunconditionally — the helper's own header comment says it is "applied whenever a worker is about to block on this connection", which is the accurate description. The parking comment read as though a change-check was in place; the trace above is fd 5 getting the call on every request.Rather than rewrite your comment I've added the guard so it's now literally true — but say if you'd rather it read differently. Easy thing to miss: without parking, "whenever a worker blocks" was once per connection, and it's parking that quietly turned it into once per request.
Both sentinels are
-1, not0, because0is a legitimate timeout meaning "block indefinitely" and must not read as "never configured".HttpConniscalloc'd, so its field is set explicitly after allocation.Change 3 — the idle pool stops walking its list every request
http_pool_takeandhttp_pool_puteach swept the whole idle connection list under the global pool mutex, on every request. With the default 15s idle window and a proxy reusing upstreams continuously, that sweep frees nothing almost every time — a list walk per request to find there is nothing to do. It now records when the earliest connection becomes eligible and skips the walk until then. The per-key cap check also stops at the cap rather than walking to the end.This one produced no measurable win — 48,854 → 48,870 rps over three alternating rounds, with the baseline ahead in two of them.
http_pool_expire_lockedshowed as 0.52% of cycles, but that is inclusive of thetransport_close/freea real expiry still performs, so skipping the walk recovers much less than the profile implied. I've kept it because it is strictly less work under a global mutex — which should matter with more upstreams than this two-backend benchmark has — but it is not a performance claim, and both the commit message and CHANGELOG say so.Worth flagging how that null result nearly got misread: the first A/B run showed +26%, because the benchmark backends had died and the LB was returning errors quickly instead of proxying. The harness now refuses to report a number unless the LB answers 200 first.
Correctness is the real content of this change. The watermark is invalidated by five different paths; all are handled, and the subtle one is
client_pool_configure— shortening the idle window leaves a deadline further out than the new setting allows, which would delay every eviction. That forces a sweep.Change 4 — resolve the backend only when about to dial
getaddrinforan unconditionally at the top of the request path, above the pool lookup (aether_http.c:1710vs the pool take at:1737), so every request resolved the backend host and discarded the answer on a pooled hit. Onlyhttp_dialuses the address, and the pool key is built fromdial_host/dial_portrather than the resolved result, so nothing in between needed it.Resolution now goes through
resolve_dial_addr, which resolves at most once per request behind a once-flag, at all four dial sites: the initial dial plus three pooled-connection retry paths.+0.56% (47,887 → 48,156 rps, new ahead in two rounds and tied in the third). Modest here because this benchmark's backends are numeric IPs, which
getaddrinfoshort-circuits without touching files or the network — against named upstreams it removes real work.A bug I introduced and caught, worth recording: the first version of this change routed only one of the four dial sites through the resolver. The other three are pooled-connection retries — precisely the paths where the first resolve was skipped — so they passed an uninitialised
sockaddr_intoconnect(). Undefined behaviour. Every client test still passed;http_reverse_proxyT10 caught it, as a 504 that came back000. The commit was amended rather than fixed-forward, and I added a mechanical check that everyhttp_dial(req, &serv_addr, …)has aresolve_dial_addrabove it.One deliberate behaviour change: a request hitting a live pooled connection now succeeds even if the host has since stopped resolving, where before it failed with "could not resolve host". An open connection does not need DNS, and that is what nginx does. A request that has to dial fails exactly as before.
Correction to #1719
Two numbers in that issue were wrong, both mine:
malloc1.52% +cfree1.39% +__strdup0.51% ≈ 3.4%. The rest is unresolved syscall stubs. There is no allocation hot spot to attack, and I no longer think an arena-per-request is the next move.setsockopt2.0/request" — undercounted. It was ~10/request; the earlier profile was taken under conditions that undersampled it.The corrected picture: our own code is ~10% of cycles, the largest single function 1.84%, and the top twelve sum to ~6%. There is no hot spot in Aether's C. The gap was kernel time from syscalls we didn't need to make — which is why this PR is syscall elimination and not code tuning.
Constraint: no new test failures
http_client_forward_proxy— a leaked test binary squatting port 18120; passes once killed. Environmental.http_server_h2— fails identically on the baseline. Verified by reverting both files, rebuilding, re-running. Pre-existing h2 framing bug, not this PR.No question for you after all — I withdrew the one that was here
An earlier revision of this description asked whether your 2 ms grace poll in
conn_next_request_imminentwas still earning its place, on the grounds thatpollis now the largest single syscall cost (36.6% of syscall time, ahead ofsendtoat 22.4% andrecvfromat 17.9%).That question was misdirected, and I've removed it rather than leave you to answer it. Splitting
pollby caller over 20,000 requests:transport_is_live— pooled-connection liveness probe,timeout 0conn_next_request_imminent— your parking grace,timeout 2timeout 1000POLLOUT 30000Your grace poll fires roughly once per 75 requests, not once per request —
http_pool_has_spare_worker()gates it to 0 ms whenever workers are busy, which under this benchmark is nearly always. Even deleting it outright caps out around a third of one percent of syscall time. It is not where the cost is, and your measurements stand.I got there from the #1719 table, which reported "
poll2.0/request" without separating the callers; I attributed it to the parking path because that is the poll carrying a comment. The ~2 per request istransport_is_live, on the client side — my code, unrelated to parking.So: nothing to answer here. The review ask is just changes 1–3 above, and change 2 in particular since it edits your comment.
What I'm taking next (no input needed)
Recording it so it isn't rediscovered: the upstream reuse path has the remaining per-request work, and both items are mine.
transport_is_livepolls every pooled connection before use — ~2 syscalls/request, 99% of all polls. Defensible as written (a stray byte would be read as the head of the next response), but nginx doesn't pay it: it lets the read fail and retries. Removing it changes an error-handling contract, so it needs its own PR and its own argument.getaddrinforuns before the pool lookup —aether_http.c:1710resolves the backend host unconditionally, and the pool take is at:1737. On a pooled hit the resolved address is discarded. It profiled at 0.44% and takes a lock. This one looks like a straightforward ordering bug.🤖 Generated with Claude Code