Skip to content

perf(gateway): reverse proxy throughput optimizations#825

Draft
kvinwang wants to merge 23 commits into
masterfrom
gateway-perf-clean
Draft

perf(gateway): reverse proxy throughput optimizations#825
kvinwang wants to merge 23 commits into
masterfrom
gateway-perf-clean

Conversation

@kvinwang

@kvinwang kvinwang commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Performance work on dstack-gateway's reverse proxy path, developed and benchmarked in session a31279f8.

Rebased onto master with only the 23 gateway proxy commits (no test-plan / full-plan history).

Gateway proxy changes

  • TCP_NODELAY on inbound and upstream sockets
  • Default proxy buffer_size → 64 KiB
  • rustls TLS 1.3 session ticketer for resumption
  • Per-connection proxy logs demoted info → debug; peer disconnects at debug
  • Opt-in splice(2) zero-copy relay for TLS passthrough (Linux), with optional byte threshold
  • Opt-in kTLS offload on the terminate path, with optional adaptive threshold after bulk traffic
  • thread-per-core runtime + SO_REUSEPORT listeners (default on Linux; probe fallback)
  • Higher listen backlog / unified bind path
  • Drop per-operation timers and BiLock from the TCP bridge
  • Eager splice attempt before readiness wait
  • Connection rebalancing across cores (default on with thread-per-core): hand new connections to a less-loaded core; load-scaled threshold; correct reactor registration on handover
  • Skip JoinSet when only one upstream candidate
  • Per-thread splice pipe pool + recycle from both directions

Config ([core.proxy])

Key Default Notes
buffer_size 65536 raised from previous default
thread_per_core true Linux SO_REUSEPORT path
connection_rebalance true requires thread_per_core
tcp_splice_enabled false opt-in zero-copy passthrough
tcp_splice_after_bytes 0 engage splice after N bytes
ktls_enabled false opt-in kernel TLS on terminate
ktls_offload_after_bytes 0 adaptive kTLS handoff

Benchmark notes (from the session)

Validated on dedicated GCP machines against HAProxy where applicable; both TLS passthrough and terminate paths were exercised. Highlights:

  • Small-request keep-alive: connection rebalance improves multi-core fairness under SO_REUSEPORT skew (e.g. passthrough small-req c=50 roughly 166k → 216k rps with rebalance on).
  • Handover / epoll_ctl cost is ~0 in keep-alive windows (placement at accept only); ~0.04–0.15% of gateway CPU in CPS worst case.
  • LLM-style many long-lived streaming connections: splice remains beneficial past the knee; budget 2× connections + 4× concurrent splicing streams for fds.
  • kTLS / splice remain opt-in (defaults off) after measured trade-offs on small vs bulk traffic.

Test plan

  • cargo check -p dstack-gateway / gateway unit tests
  • Smoke: TLS passthrough + terminate with defaults (thread_per_core + rebalance)
  • Toggle tcp_splice_enabled / ktls_enabled and re-run smoke
  • Confirm SO_REUSEPORT probe falls back cleanly on non-supporting envs
  • Review default-on behaviour (thread_per_core, rebalance) for production risk

Comment thread tools/dstack-test/render.py Fixed
kvinwang added 23 commits July 26, 2026 01:33
With TCP_NODELAY enabled, an 8 KiB copy buffer emits many small tail
segments and caps bulk throughput. A 64 KiB buffer keeps writes at full
segment size; measured ~2x passthrough/terminate throughput on a 4-core
gateway with no latency regression. 128 KiB adds <5% for double the
per-connection memory, so 64 KiB is the chosen knee.
TLS 1.2 resumption already works via the in-memory session-ID cache, but
TLS 1.3 resumption needs a ticketer. Without one, every TLS 1.3 reconnect
pays a full handshake (RSA-2048 signing). Measured on tdxlab (4-core
gateway, wrk Connection: close): TLS 1.3 reconnect CPS 6.2k -> 9.6k (+53%)
with the ticketer, and TLS 1.2 CPS unchanged (~20k). Default TLS version is
unchanged.
The accept/handle hot path logged three info-level events plus an
info-level span per connection. At the default info log level this cost
~4-8% of TLS-terminate CPS (measured ~19.2k vs ~20.8k req/s on a 4-core
gateway). Downgrading them to debug (and the span to debug_span) removes
the per-connection logging cost at info while keeping the detail available
when debug logging is enabled.
The TLS-passthrough path is a pure TCP relay (the gateway never sees
plaintext), so it can move bytes kernel-side with splice(2) through a pipe
instead of copying through a userspace buffer. Gated behind the new
proxy.tcp_splice_enabled flag (default off, Linux only).

Measured on a 4-core gateway (tdxlab): bulk passthrough throughput
7.60 -> 8.52 GB/s (+12%) with lower tail latency under load (p99 38 -> 21
ms). Small-request passthrough latency regresses (extra pipe hop per tiny
message), so it stays opt-in. Data integrity verified with a 100 MiB
checksum round-trip.
Adds proxy.ktls_enabled (default off, Linux only). The handshake still runs
in rustls; afterwards the negotiated keys are installed into the kernel TLS
ULP so record crypto happens there, and when combined with
tcp_splice_enabled the payload is relayed with splice and never enters
userspace.

Measured on a dedicated 44-vCPU GCP VM with the gateway pinned to 4 cores
(isolated A/B, interleaved rounds):

  rustls (userspace)  32226 CPS   7.69 GB/s
  kTLS alone          20598 CPS   5.72 GB/s   (-36% / -26%)
  kTLS + splice       22640 CPS   9.63 GB/s   (-30% / +25%)

kTLS alone is a loss: without NIC TLS offload (TlsTxDevice = 0) the kernel's
software AES-GCM is slower than rustls with aws-lc-rs, plus per-connection
setup cost. Combined with splice it gives the highest terminate throughput
measured, so it is worth having for bulk-transfer deployments -- but it stays
off by default because connection setup rate drops ~30%.

Security note: enabling this extracts session keys into the kernel via
dangerous_extract_secrets. Inside a CVM the kernel is part of the measured
TCB; on a normal host this widens key exposure. Hence opt-in.

Verified with a 100 MiB checksum round-trip and TlsDecryptError = 0.
kTLS' cost and benefit sit on opposite sides of the connection-reuse axis:
setup is paid per connection (~34% of connection rate) while the win is per
byte (+29% bulk throughput). Offloading at handshake time therefore penalises
short request/response connections that never earn it back.

With the new proxy.ktls_offload_after_bytes, a connection starts in userspace
rustls and is handed to the kernel only once it has moved that many bytes:
flush, let CorkStream stop at a TLS record boundary, extract the current
traffic secrets (they carry the record sequence numbers, which is what makes a
mid-stream handover sound), install them into the kernel, splice the rest.
0 keeps the previous immediate-offload behaviour.

Measured on a dedicated 44-vCPU GCP VM, gateway pinned to 4 cores, threshold
1 MiB (2 interleaved rounds):

  rustls              32170 CPS   7.70 GB/s   164459 small-req/s
  kTLS immediate      21212 CPS   9.95 GB/s   193208 small-req/s   (-34% CPS)
  kTLS adaptive       26303 CPS   9.93 GB/s   194720 small-req/s   (-18% CPS)

So it recovers about half the lost connection rate while keeping the full
throughput gain, and long-lived small-request connections gain ~18% at lower
CPU because they cross the threshold and then run zero-copy.

Verified: with a 1 MiB threshold a small request does not offload (kernel
TlsTxSw unchanged) while a 100 MiB transfer does (counter +1), checksum
matches and TlsDecryptError stays 0.
Profiling the small-request workload showed the gateway leaving ~0.5 of 4
cores idle while HAProxy pinned all four. The cause was scheduling, not the
data path: the gateway performed ~0.617 context switches per request against
HAProxy's ~0.000. Two sources, both structural:

  * accept runs on its own current_thread runtime and hands every connection
    to a separate multi-threaded worker runtime -- one cross-runtime wakeup
    per connection;
  * tokio's work-stealing scheduler then migrates those tasks between worker
    threads, costing further wakeups and cache locality.

With proxy.thread_per_core = true the proxy instead runs `workers` threads,
each with its own current_thread runtime and its own SO_REUSEPORT listener.
The kernel load-balances new connections across the listeners and a connection
is accepted and served entirely on one thread, so nothing is handed off.

Measured on a dedicated 44-vCPU GCP VM, 4 cores, vs the default model and
vs HAProxy given matching settings:

                        default        thread-per-core   HAProxy
  terminate small-req   158k @3.55     267k @4.03        204k @4.03
  terminate CPS          32.8k @3.65    45.0k @3.96       28.2k @4.00
  passthrough small-req 175k @3.56     312k @4.02        355k @4.02
  terminate throughput  7.5 GB/s       8.3 GB/s          6.0 GB/s

Context switches per request drop to ~0.000 and the proxy now saturates its
cores. That turns two of the three deficits against HAProxy into wins
(terminate small-request +31%, terminate CPS +60%) and closes most of the
third (passthrough small-request now -12%, was -63%).

Off by default: it changes the process model, needs SO_REUSEPORT (Linux), and
per-core accept queues mean a slow connection can only be served by the core
that accepted it. Verified with 100 MiB checksum round-trips on both paths.
Creating a pipe per direction per connection costs two pipe2 calls plus four
descriptor closes. That is pure overhead for short connections and it showed:
enabling splice raised passthrough connection-setup CPU from 134 to 142 us per
connection, while HAProxy -- which pools its pipes -- moved the other way.

Pipes now come from a thread-local pool (cap 64), and are only returned once
the transfer has drained them; a pipe with unknown residue is closed rather
than risk feeding stale bytes to the next connection. Thread-local means no
locking, and under thread_per_core a connection stays on the thread that
borrowed the pipe.

Measured on the 4-core GCP bed (passthrough, 2 interleaved rounds):

  small-request rate   317.8k -> 335.9k rps  (+5.7%, HAProxy 336.8k)
  bulk throughput      unchanged at ~13 GB/s
  connection setup     unchanged

So the win is on keep-alive small-request traffic, where it brings the gateway
level with HAProxy. Verified with repeated 100 MiB checksum round-trips and
interleaved small/large requests, which is what would expose a dirty pipe.
connect_multiple_hosts always built a JoinSet and spawned a task per candidate
address so it could race them. With a single candidate -- the common case for
an app with one instance -- there is nothing to race, and the allocation plus
task spawn was paid on every connection.

Take the address directly in that case.

Measured on the 4-core GCP bed (passthrough connection setup, 2 interleaved
rounds): 139.7 -> 130.7 us of CPU per connection (-6.4%), narrowing the gap to
HAProxy's 123.1 us from 13.5% to 6.2%.
The pool only recycled a pipe when its direction observed EOF, so the other
direction's pipe was closed and recreated on every connection -- syscall
counting showed a steady 1.00 pipe2 per connection instead of ~0.

The pipe is in fact empty at every point between chunks, because each chunk is
fully drained before the next splice from the socket. Track that directly:
mark the pipe recyclable after each complete drain, and only un-mark it while a
chunk is in flight. A pipe is still discarded rather than pooled if the
connection dies mid-drain.

Measured on the 4-core GCP bed (passthrough):

  pipe2 per connection        1.00 -> 0.00
  connection-setup CPU     132.4 -> 120.1 us median (HAProxy 123.4)

That closes the last connection-setup deficit against HAProxy; the two now
overlap within run-to-run spread. Re-verified with repeated 100 MiB checksum
round-trips and interleaved small/large requests, which is what a dirty pooled
pipe would corrupt.
splice costs ~17 syscalls per connection to move a small response -- fill the
pipe, drain the pipe, plus readiness retries -- where a read/write pair needs
two. Its benefit is per byte but its cost is per connection, the same shape as
kTLS, so short request/response connections paid for zero-copy they never used.

With the new proxy.tcp_splice_after_bytes a passthrough connection is relayed
with plain reads/writes until it has moved that many bytes, then switches to
splice for the remainder. 0 keeps the previous behaviour.

The relay buffers come from a thread-local pool for the same reason the pipes
do: a first cut that allocated two buffer_size buffers per connection was
*worse* than the pipe setup it replaced (148.6 us vs 133.5 us per connection).
Pooled, and sized for this phase rather than for bulk copying, it wins.

Measured on the 4-core GCP bed (passthrough, medians of 3 interleaved rounds,
threshold 64 KiB):

                        always splice   adaptive      HAProxy
  connection setup      123.0 us        117.2 us      123.5 us
  small-request rate    300.0k rps      295.3k rps    344.6k rps
  bulk throughput       12.6 GB/s       12.4 GB/s     10.2 GB/s

splice syscalls per connection drop from 16.86 to 0.00 on the connection-churn
workload, and connection setup costs slightly less CPU than HAProxy. Keep-alive
and bulk traffic are unchanged within run-to-run spread (~2%), since those
connections cross the threshold and end up spliced anyway.

Verified with checksum round-trips at 1 MiB and 100 MiB plus payloads sized
just under and just over the threshold, which is where the handover happens.
The thread-per-core path built its listener with socket2 and a 4096-deep accept
queue; the default path used TcpListener::bind, i.e. tokio's default backlog of
1024. That is where SYN drops begin under connection bursts, and it meant the
two modes behaved differently for no reason.

Both paths now share one socket2-based bind, differing only in whether
SO_REUSEPORT is set, with the backlog in a named constant.
A 40-minute soak produced 379 error-level lines and not one real fault. They
were all the peer hanging up: connection reset while bridging, while splicing,
or during the TLS handshake. That is routine -- a browser navigating away, a
mobile network dropping, a load generator ending its run -- and logging it at
error level buries the failures that are actually the gateway's fault.

Classify the error chain: if any cause is a ConnectionReset, BrokenPipe,
UnexpectedEof or ConnectionAborted io::Error, log at debug instead.

Also records what the soak found about the splice pipe pool's descriptor cost
(PIPE_POOL_MAX * 2 * workers, so ~512 for 4 workers and ~4096 for 32), which is
why that cap is not larger.
… probe

Thread-per-core measured 22-77% better on every metric, on loopback and again
over a real network, and it is what turned the deficits against HAProxy into
wins. The only thing holding it back was that it had never run longer than a
benchmark.

A soak of 8 x 60s of mixed load (small-request, bulk, connection-churn) says it
is stable:

  RSS       21.9 MB cold -> ~44 MB steady, flat across all rounds
  fds       31 -> 543, plateauing at the pipe pool cap, then flat
  threads   11, constant
  drift     small-request -2.3%, connection rate -1.3% (both within noise)

Flipping the default needs a safety net, because the model cannot work without
SO_REUSEPORT and failing to serve is much worse than losing the optimisation.
`start` now probes a SO_REUSEPORT bind first and falls back to the shared-runtime
proxy with a warning if it fails. Verified by occupying the port with a
non-REUSEPORT socket: the gateway logs the fallback and keeps serving.

Deployments that want the old process model can still set
proxy.thread_per_core = false. The one behavioural difference to be aware of is
that per-core accept queues mean a connection is served by the core that
accepted it, so a slow core cannot be helped by its neighbours.
…bridge

Two costs the small-request passthrough profile put at the top of userspace,
both in io_bridge:

  * `tokio::io::split` wraps the stream in a `BiLock`, and its `poll_read`
    showed at 1.1% of total time. Passthrough always has a plain socket on both
    sides, so it can use `TcpStream::split`, which hands out borrowed halves
    with no lock. Added `bridge_tcp` for that case.
  * every read, write and flush was wrapped in `tokio::time::timeout`, i.e.
    three timer arm/disarm pairs per request; `TimerEntry` drop plus
    poll_elapsed came to 2.9%. Replaced with one watchdog per connection that
    samples per-direction progress counters -- the hot path now costs an integer
    increment instead of touching the timer wheel, and no clock is read.

The watchdog is coarser than the old per-operation timeouts: it fires within
`idle/4`, so a stalled connection is closed in up to 1.25x `idle` rather than
exactly at it. Verified with `idle = 3s`: old path closed at 3.0s, new at 3.7s.

Measured on the 4-core GCP bed with the corrected harness (physical-core
disjoint pinning, warmed up, n=5, sd<1%):

  passthrough small-request, default config   257 151 -> 290 440 rps  (+12.9%)
  cpu per request                             15.7 us -> 13.9 us
  p99                                         221 us  -> 197 us

Note this only moves the default path: with `tcp_splice_enabled` the relay is
`splice_bidirectional`, which never used either mechanism.

An earlier attempt to answer the same question by toggling
`data_timeout_enabled` reported "no effect" -- that was wrong, because the bench
config generator wrote the key under `[core.proxy]` instead of
`[core.proxy.timeouts]`, so serde silently ignored it and both arms ran with
timers on.
`splice_one` awaited `readable()` / `writable()` before every splice, even when
the socket was already known to be ready. That builds, polls and drops a
`Readiness` future each time; on the small-request passthrough profile
`ScheduledIo::poll_readiness` plus `Readiness::drop` came to 2.4% of total time.

tokio's `try_io` already decides with a single atomic read of the cached
readiness flag and returns WouldBlock without calling the closure if the socket
is not ready, so the wait is only needed on that path. Reordered to attempt the
syscall first and await readiness only when it blocks.

Measured (4-core GCP bed, corrected harness, n=5, sd 0.3%):

  passthrough small-request, splice enabled   291 546 -> 294 583 rps  (+1.0%)
  cpu per request                             13.9 us -> 13.7 us

Smaller than the profile share suggested, but reproducible and above noise.
100 MiB checksum round-trip re-verified.
Each thread-per-core worker used to bind its own SO_REUSEPORT listener, so the
order sockets joined the group was whatever the thread scheduler produced. Bind
them all up front instead and hand listener i to worker i: the setup leaves the
per-thread path, and group order becomes ours.

That order matters for anything that steers connections by listener index, which
is what motivated this. Recording the result of that attempt here, because it
was a dead end worth not repeating:

SO_REUSEPORT's default 4-tuple hash distributes moderate numbers of long-lived
connections unevenly, and thread-per-core cannot rebalance afterwards. At 16
connections over 4 cores the per-core utilisation came out like [99, 42, 101,
101] and throughput swung 46% between restarts purely on how the hash fell.

SO_ATTACH_REUSEPORT_CBPF was tried to replace the hash. Classic BPF has no maps,
so round-robin is not expressible; the only useful signal it exposes is the CPU
handling the packet, so the program steered by `cpu % n`. Measured over 6
restarts each at 16 connections:

  default hash    245 539 rps   4.0 of 4 cores active   lowest core 73%
  cpu steering    167 331 rps   2.3 of 4 cores active   lowest core 100%

It collapses the distribution instead of evening it out: connections are
established in a burst, so only the one or two client CPUs live at that moment
get sampled, and every connection lands on the same one or two listeners. The
knob was removed rather than shipped -- a setting that costs 32% in the only
scenario measured is worse than no setting.

A real fix needs per-connection state, i.e. SO_ATTACH_REUSEPORT_EBPF with a
counter map, or accepting from a shared queue as HAProxy does.
SO_REUSEPORT picks a listener by hashing the connection's 4-tuple, and
thread-per-core cannot move a connection afterwards, so a core that draws few
connections idles while its neighbours saturate. At 16 connections over 4 cores
utilisation came out like [99, 42, 101, 101] and throughput varied 46% between
restarts on nothing but how the hash fell.

Steering the kernel's choice with SO_ATTACH_REUSEPORT_CBPF was tried first and
measured worse (see the previous commit). This fixes it on our side instead: the
accepting core compares its own connection count against the least loaded core
and, if it is ahead by more than a small slack, sends the connection over a
channel for that core to serve. The cost is one channel send per *rebalanced
connection* -- never per connection, never per request -- so everything already
in flight keeps the thread-per-core locality that made this model fast.

Measured over 6 restarts per arm, 4-core gateway, passthrough small-request:

  connections   off                          on
  16            236 110 rps, worst core 72%  251 840 rps, worst core 85%  (+6.7%)
  50            291 932 rps                  287 440 rps                 (-1.5%)
  CPS           17 636                       17 557                      (-0.4%)

The worst case improves more than the average: no core dropped below 76% with
rebalancing, against a low of 47% without it. At high connection counts the hash
is already even, so the balancer only costs its own bookkeeping.

Off by default (`connection_rebalance`) since it trades a little at the high end
for a lot at the low end, and which matters depends on the deployment.
…fer-pool wiring

`Balancer::slot_for_self` became dead once handoffs carried their slot with
them, and `PooledBufs::pair` once the relay borrowed the buffer fields
directly.
…s reactor

`TcpListener::poll_accept` hands back a `TcpStream` already registered with the
accepting core's reactor. Moving that object to another core left the
registration behind, so every read and write on a rebalanced connection went
through the accepting core's epoll and woke the owning core across the channel
-- for the whole life of the connection, not just at handover.

That turned the rebalance into a net loss whenever the hash was already even.
Measured on passthrough small-request at 50 connections, where only ~50 accepts
happen and migrations should be invisible:

    rebalance off                        295 444 rps
    rebalance on, code path but no       295 033 rps   (+0.2%)
      migrations (slack raised to 999)
    rebalance on, migrating              288 825 rps   (-1.9%)

The middle arm is what isolates it: every line of the balancer runs, and it
costs nothing. The cost was per *migrated connection*, and scaled with how
aggressively the threshold migrated -- which is what a permanently misplaced
reactor registration looks like, not what a channel send looks like.

Handing over the plain `std::net::TcpStream` deregisters on the way out and
re-registers on the way in. The penalty disappears: +0.4% at 50 connections
(was -2.5%), +0.8% on TLS terminate (was -2.1%), and the gain at 16 connections
grows from +4.0% to +10.7%.
…ng a fixed slack

A fixed slack of 2 is the right amount of hysteresis at 4 connections per core
and far too little at 12: it keeps migrating on differences that are noise at
higher load. Comparing thresholds on passthrough small-request:

    connections            16        50
    slack 2           236 646   290 542
    slack 1           240 190   285 047
    slack 0           239 335   284 847
    proportional      246 216   287 714

and the worst-loaded core at 16 connections goes 81% (slack 2) -> 95%
(proportional). `least + 1 + least/4` migrates on relative imbalance, so it
still reacts when one core has 6 connections and another has 1, and stops
reacting when they have 13 and 12.

Also tried and dropped: capping accepts per scheduler turn the way HAProxy's
`maxaccept` does, and pushing every connection through the handoff channel even
when it stays local (HAProxy does this deliberately, for cache locality). Both
measured within noise here once the threshold was right.
This was opt-in because handing a connection over cost 2-3% wherever the
`SO_REUSEPORT` hash was already even. That cost was a bug, not a trade -- the
migrated socket kept its readiness registration on the accepting core's reactor
-- and with it fixed there is no longer a workload where the rebalance loses.

Measured on a 4-core gateway, 3-5 runs per arm after warmup, with bulk
throughput re-run interleaved (off/on/off/on, n=5 each) because a single pass
had suggested a 2% regression that turned out to be noise:

| workload                              |        off |         on |        |
|---------------------------------------|-----------:|-----------:|-------:|
| passthrough small-request, 8 conns    |    113 350 |    143 341 | +26.5% |
| passthrough small-request, 16 conns   |    231 043 |    258 787 | +12.0% |
| passthrough small-request, 50 conns   |    294 937 |    295 311 |  +0.1% |
| TLS terminate small-request, 50 conns |    244 304 |    246 228 |  +0.8% |
| TLS terminate, connections/s          |     41 024 |     43 019 |  +4.9% |
| passthrough, connections/s            |     17 431 |     17 319 |  -0.6% |
| passthrough, bulk throughput          | 12.10 GB/s | 12.16 GB/s |  +0.5% |

The gain concentrates where the hash has fewest connections to spread: at 16
connections the quietest core goes from 63% to 97% busy. At 8 and 16
connections this also moves us from behind HAProxy to level with it (143 341 vs
140 278, and 258 787 vs 261 407).
@kvinwang
kvinwang force-pushed the gateway-perf-clean branch from 84c0238 to 8be5386 Compare July 26, 2026 08:34
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.

2 participants