feat(proxy): serve from thread-per-core workers with per-worker upstream pools - #891
Draft
membphis wants to merge 3 commits into
Draft
feat(proxy): serve from thread-per-core workers with per-worker upstream pools#891membphis wants to merge 3 commits into
membphis wants to merge 3 commits into
Conversation
Add three bench-only knobs, all default-off, used by the Linux performance verification of the onthebench throughput gap: - BENCH_RT_WORKERS=N pins the tokio multi-thread worker count. - BENCH_RT_TPC=N short-circuits the plain-HTTP proxy listener into N thread-per-core workers (one OS thread + current_thread runtime + own listener each). BENCH_RT_TPC_MODE=reuseport|stride picks SO_REUSEPORT on one shared port vs a port-per-worker stride. - BENCH_THREAD_LOCAL_CLIENT=1 hands every OS thread its own upstream reqwest client, so a thread-per-core worker's upstream connections are polled by its own runtime and a response arrival never needs a cross-thread wakeup. Production behavior is unchanged when the variables are unset. Measured on 4 pinned vCPUs proxying to a local mock upstream (c=128 saturation, 25s windows, fail=0): mt work-stealing 24.0k rps 152us CPU/req p99 9.1ms TPC + SO_REUSEPORT 39.0k rps 100us CPU/req p99 5.9ms TPC + thread-local client 44.6k rps 89us CPU/req p99 4.6ms With the 10ms-TTFT mock (leaderboard methodology) the same A/B is 21.4k vs 39.4k rps (+84%). The whole win is kernel-side: the futex park/unpark storm (232k context switches/s down to 0.2k) and the per-response cross-thread eventfd wakeup (1.77/req down to 0). Thread-local clients under work-stealing show zero gain, confirming the mechanism is connection/task thread locality, not pool sizing.
…eam pools
The proxy hands a request between threads about twice per request: once
when a work-stealing worker picks up the task, again when the upstream
response lands on whichever thread happens to own that connection. Each
handoff costs a wakeup and a context switch, and on a 4-core saturation
run that is where most of the CPU goes -- 89% of it is kernel time, at
232k context switches per second.
Serve instead from N independent workers, each with its own runtime, its
own SO_REUSEPORT listener on proxy.addr, and its own upstream connection
pool, so a request is accepted, dispatched, answered, and its upstream
call polled all on one thread.
Two bootstrap knobs, applied at startup:
- proxy.thread_per_core: on for Linux when omitted, off elsewhere, since
the kernel spreading this relies on is a Linux behavior. Set false to
serve from one shared runtime on any platform.
- proxy.workers: defaults to the parallelism available to the process,
which follows a cgroup CPU limit or a taskset affinity mask. Rejected
at load when zero.
Per-worker pools hang off the one chokepoint every handler family
already dispatches through (client_for_provider_key), keyed by a
per-thread marker rather than a process-wide flag -- the playground runs
proxy handlers on the shared runtime, and those correctly keep using the
process-wide pool. Provider keys carrying a TLS override keep their
dedicated client. A scan test holds every dispatch client to one user
agent, since one per-worker pool now stands in for all of them.
The listeners co-bind, which would turn a second gateway on the same
address from a startup failure into a silent traffic split, so the mode
probes the address with a non-reuseport bind first and keeps failing
loudly. A worker leaving for any reason -- accept loop error, panic
unwinding -- brings the process down rather than leaving it serving on
fewer listeners than it reported binding.
Measured on 4 pinned vCPUs against a local mock upstream, 25s windows,
fail=0, config knobs only:
c=128 thread-per-core 45,447 rps 398% CPU 88us/req p99 4.38ms
work-stealing 24,297 rps 365% CPU 150us/req p99 8.96ms
c=768, 10ms TTFT mock (leaderboard methodology)
thread-per-core 39,873 rps 398% CPU 100us/req p99 24.9ms
work-stealing 21,897 rps 358% CPU 164us/req p99 55.4ms
Below about four client connections per worker the kernel's
per-connection spreading leaves workers uneven: c=8 measures -26.8%.
That is documented on the knob.
The e2e suite passes in full in both modes (175 files, 463 tests); CI
runs it as a two-leg matrix.
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…ux-verify-8ba7e1 # Conflicts: # config.example.yaml # config.managed.yaml # crates/aisix-core/src/config.rs # crates/aisix-proxy/src/state.rs # tests/e2e/src/harness/app.ts
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.
What this changes
The proxy hands a request between threads about twice per request: once when a work-stealing worker picks up the task, again when the upstream response lands on whichever thread happens to own that connection. Each handoff costs a wakeup and a context switch. On a 4-core saturation run that is where most of the CPU goes — 89% of it is kernel time, at 232k context switches per second, 11.9 syscalls per request.
This serves instead from N independent workers, each with its own runtime, its own
SO_REUSEPORTlistener onproxy.addr, and its own upstream connection pool, so a request is accepted, dispatched, answered, and its upstream call polled all on one thread. Syscalls per request drop to 5.0 — 4 network send/recv plus the request-idgetrandom— and context switches to 222/s.Two bootstrap knobs, applied at startup (a restart is required to change either):
proxy.thread_per_corefalseto serve from one shared runtime on any platform.proxy.workerstasksetaffinity mask. Rejected at load when0.Both are reachable through the environment as
AISIX_PROXY__THREAD_PER_CORE/AISIX_PROXY__WORKERS(note the double underscore), which is how a managed deployment sets them.Which mode a process is running is visible without any new endpoint: the startup log prints one line per worker, and worker threads are named
tpc-N(ps -T -p <pid>) versus tokio'stokio-rt-workeron the shared runtime.Measurements
4 pinned vCPUs, local mock upstream, 25s windows,
fail=0everywhere. The only difference between legs isproxy.thread_per_corein the config file — no build flags, no environment tuning. Rig floor (mock direct, c=128) is 461,691 rps, ten times the loaded figures.c=128 was repeated four times: 44.0k / 44.4k / 44.7k / 46.1k against 24.0k / 24.1k / 24.2k / 24.7k. CPU per request falls from 150µs to 88µs.
The c=8 row is the documented cost, not a regression to fix: below about four client connections per worker the kernel's per-connection spreading leaves workers uneven, and CPU only reaches 199%. It is called out on the knob's own documentation. Real gateway traffic sits far above that connection count.
p99 at c=128 lands at 48-52% of the baseline across reps — halved, but sitting exactly at half rather than comfortably under it.
Verification
thread-per-core,work-stealing) so the fallback stays a real option.cargo clippy --workspace --all-targets -- -D warnings,cargo fmt --all --check,git diff --check: clean.workers: 0rejection naming the field, and a source scan holding every dispatch client to one user agent.tpc-Nthread names;available_parallelismhonouringtaskset -c 0-3(4 workers, not 12); the fallback binding exactly one listener; graceful shutdown draining and exiting 0 in 1.01s; a second gateway on the same address failing loudly;workers: 0refused at load.from_tcp_rustlsper worker, andlistener-tls-e2eruns it in whichever mode the suite leg selects.Reliability details worth reviewing
SO_REUSEPORTlets a second gateway bind the same address and silently take half the traffic. The mode probes the address with a non-reuseport bind first, so a conflict stays the loud startup failure it has always been.RustlsConfig, cloned. Per-worker TLS configs would cut the session-resumption hit rate to 1/N, since rustls keeps that cache perServerConfig.socket2now declaresfeatures = ["all"]—set_reuse_portresolved before only because other dependencies happened to enable it.Deliberate behaviour changes
pool_max_idle_per_hostnow applies per worker. One pool per worker serves every dispatch client, which is safe only while those clients agree on their user agent — a scan test enforces that and will fail anyone who gives one bridge its own.upstream-pool-idle-e2eis pinned tothread_per_core: false. It asserts that two sequential requests reuse exactly one upstream connection, which under per-worker pools becomes a question about which worker the kernel picked. Pinning keeps it measuring the idle deadline that is its actual subject. Flagging this for a reviewer rather than treating it as a test to relax.Known gaps — please do not let these close with the PR
api7/docsconfiguration page is not written.proxy.thread_per_coreandproxy.workersare undocumented for users outside this repo'sconfig.example.yamlcomments. Needs a separate PR inapi7/docs: both knobs, restart-to-apply, the platform default, theAISIX_PROXY__*double-underscore form,ps -Tfor checking the live mode, and the low-connection-count caveat.m7g.4xlargerun against this branch is the release gate. If thread-per-core does not win there, flipping the Linux platform default back is a one-line change with no other code impact.ExporterPipelines::get_or_createspawns its delivery task from the request path, so in this mode it lands on whichever worker first emits a usage event for that exporter and stays there, carrying the whole process's export delivery on one worker. Not a correctness problem; worth its own issue.No control-plane counterpart is required. These are bootstrap-config knobs read from the local config file and the environment, not etcd resources validated against
cp-admin.yaml, so nothing here is gated on the closed schema.Not in scope
The request-id
getrandomper request and the metrics macro registration hot path are both real and both measured, but they are user-space work — 11% of total CPU on this rig — and land separately.