Skip to content

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
mainfrom
perf/1719-cache-conn-addresses
Open

perf(http): cut per-request syscalls from 83 to 14 — cached addresses + no redundant setsockopt (#1719)#1720
paul-hammant wants to merge 13 commits into
mainfrom
perf/1719-cache-conn-addresses

Conversation

@paul-hammant

@paul-hammant paul-hammant commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

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:

LB before after change
aether 48,073 50,445 +4.9%
nginx (control) 67,723 67,721 −0.003%
haproxy (control) 63,151 62,969 −0.29%

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 -f over 20,000 requests:

before after
total syscalls/request 83.0 14.0
setsockopt 202,552 calls 72
getpeername 1.0/req 0
getsockname 1.0/req 50 total (one per connection)

Change 1 — addresses resolved per connection, not per request

getpeername/getsockname + 2 inet_ntop + 2 strdup ran on every request. Neither address can change while a socket is open. Now cached on HttpConn.

Cheap now in a way it wasn't when written: connection parking (#1663 / #1684) made HttpConn heap-allocated and connection-lifetime, so there is somewhere to put them. The request still owns its copies — http_request_free frees 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:

"setsockopt ×2 — I could not pin this to a single site with certainty … Someone who knows the upstream path should confirm whether that runs per request or per pooled connection."

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-applied SO_RCVTIMEO+SO_SNDTIMEO to the same value the previous request left there. Here is request 3 reusing pooled fd 6 — no connect, yet both options set again:

setsockopt(5, SOL_SOCKET, SO_RCVTIMEO_OLD, "\36\0\0\0...", 16) = 0
setsockopt(6, SOL_SOCKET, SO_RCVTIMEO_OLD, "\36\0\0\0...", 16) = 0
setsockopt(6, SOL_SOCKET, SO_SNDTIMEO_OLD, "\36\0\0\0...", 16) = 0

A Transport travels 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_serve applies the idle timeout on entry, and with parking a kept-alive connection re-enters conn_serve once per request. Your comment at aether_http_server.c:3725 (from fbc111c, PR #1684) states:

"The window is only re-applied when it changes, so a steady connection pays one setsockopt, not one per request."

That guard did not exist. In fbc111c, conn_apply_recv_timeout took a bare fd, kept no previous value anywhere, and called setsockopt unconditionally — 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, not 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.

Change 3 — the idle pool stops walking its list every request

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 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_locked showed as 0.52% of cycles, but that is inclusive of the transport_close/free a 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_configureshortening 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

getaddrinfo ran unconditionally at the top of the request path, above the pool lookup (aether_http.c:1710 vs the pool take at :1737), so every request resolved the backend host and discarded the answer on a pooled hit. Only http_dial uses the address, and the pool key is built from dial_host/dial_port rather 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 getaddrinfo short-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_in to connect(). Undefined behaviour. Every client test still passed; http_reverse_proxy T10 caught it, as a 504 that came back 000. The commit was amended rather than fixed-forward, and I added a mechanical check that every http_dial(req, &serv_addr, …) has a resolve_dial_addr above 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:

  • "27% of cycles in malloc/free" — overstated. The 27% is libc total; the named allocators are malloc 1.52% + cfree 1.39% + __strdup 0.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.
  • "setsockopt 2.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/proxy/TLS integration sweep: 41 pass, 2 fail
  • http_client_forward_proxy — a leaked test binary squatting port 18120; passes once killed. Environmental.
  • http_server_h2fails identically on the baseline. Verified by reverting both files, rebuilding, re-running. Pre-existing h2 framing bug, not this PR.
  • Full CI: 26/26 green, including all three native Windows legs and both macOS Clang legs.

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_imminent was still earning its place, on the grounds that poll is now the largest single syscall cost (36.6% of syscall time, ahead of sendto at 22.4% and recvfrom at 17.9%).

That question was misdirected, and I've removed it rather than leave you to answer it. Splitting poll by caller over 20,000 requests:

poll site calls share
transport_is_live — pooled-connection liveness probe, timeout 0 39,713 99.2%
conn_next_request_imminent — your parking grace, timeout 2 265 0.66%
accept loop, timeout 1000 34 0.08%
connect wait, POLLOUT 30000 24 0.06%

Your 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 "poll 2.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 is transport_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.

  1. transport_is_live polls 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.
  2. getaddrinfo runs before the pool lookupaether_http.c:1710 resolves 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

paul-hammant and others added 2 commits August 23, 2026 08:38
…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
paul-hammant force-pushed the perf/1719-cache-conn-addresses branch from 036efc4 to 4bd99a7 Compare August 23, 2026 07:40
paul-hammant and others added 3 commits August 23, 2026 08:57
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>
@paul-hammant paul-hammant changed the title perf(http): resolve peer/local address once per connection, not per request (#1719) perf(http): cut per-request syscalls from 83 to 14 — cached addresses + no redundant setsockopt (#1719) Aug 23, 2026
paul-hammant and others added 2 commits August 23, 2026 10:28
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>
)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
paul-hammant force-pushed the perf/1719-cache-conn-addresses branch from 9992847 to bb0530d Compare August 23, 2026 10:00
paul-hammant and others added 5 commits August 23, 2026 11:02
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant