Skip to content

perf(engine): inline WS/SSE broadcast egress on the dispatch goroutine (+43% io_uring & epoll) - #404

Merged
FumingPower3925 merged 5 commits into
mainfrom
perf/ws-inline-egress
Jul 3, 2026
Merged

perf(engine): inline WS/SSE broadcast egress on the dispatch goroutine (+43% io_uring & epoll)#404
FumingPower3925 merged 5 commits into
mainfrom
perf/ws-inline-egress

Conversation

@FumingPower3925

@FumingPower3925 FumingPower3925 commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Break the WS/SSE broadcast throughput ceiling on the loop engines by issuing the socket send inline on the dispatch goroutine instead of funnelling every detached-conn write through the single event-loop/worker thread — the same thing the std engine does (goroutine-per-conn, direct write(2)).

Stacked on #403 (eventfd-wakeup coalesce). Together they are the v1.5.7 WS-broadcast work.

Root cause (measured)

On ws-hub-broadcast at 1024 conns, std beat io_uring/epoll ~2×, and all loop variants clustered at a shared ~500K ceiling (flat at 128 conns — a high-connection cliff). CPU profile @1024c (epoll): the loop thread was 50% of all CPU, of which flushWrites→unix.write was 37.5% — every conn's write(2) serialized on one thread per core, while std spreads them across GOMAXPROCS goroutines. detachQMu contention was ~1% (ruled out). #403 removed the wakeup-syscall storm (+17%) but left this funnel.

Fix

epoll: in the detached guarded writeFn, after the writeBuf append, attempt flushWrites(cs, false) inline. It runs inside the same detachMu critical section the loop-thread dirty-flush and closeConn take, and writeBuf is one ordered buffer flushed from writePos, so it can neither race the loop's flush nor reorder nor touch a closed fd. Full drain → skip the loop handoff entirely; partial/EAGAIN/error → fall through to the existing enqueue path (surfacing OnError on I/O failure).

io_uring (SINGLE_ISSUER-safe): the ring may only be driven by the worker, so use a raw unix.Write(cs.fd), valid iff no ring SEND is in-flight for the conn (!cs.sending && !cs.zcNotifPending && sendBuf/bodyBuf empty, all read under detachMu) and the fd is real (!cs.fixedFile — under ACCEPT_DIRECT, cs.fd is a ring file-table index, so those conns keep using the ring). Companion hardening: completeSend and the handleSend SEND_ZC/error branches now clear cs.sending/cs.zcNotifPending/sendBuf under detachMu so the dispatch-goroutine read is race-free.

Results (amd64, fixed_files=false)

engine baseline (#403) inline egress gain
epoll 638K ~916–960K +43.6%
io_uring 663K ~950K +43.2%

Profile (epoll): loop-thread CPU 50.4% → 0.3% — the write(2) moved onto the dispatch goroutines.

Validation

  • go test -race clean on engine/epoll, engine/iouring (102s), middleware/websocketincluding an 8KB-frame run that exercises the SEND_ZC-completion-vs-inline-write path (the 64B benchmark never crossed the 4096B ZC threshold).
  • Two adversarial review passes (5 lenses each): zero wire-interleave / lost-frame / reorder / stuck-conn bugs. Both flagged items closed — epoll's missing OnError on the inline error path, and io_uring's handleSend send-state race.
  • New CI regression test TestNativeEngineHubBroadcastInlineEgress (both engines, -race) asserts no corruption/reorder under concurrent large-frame broadcast.

Known limitation

The io_uring inline path is gated on !cs.fixedFile. On kernels where ACCEPT_DIRECT works, WS conns keep the ring path (no regression, no inline win there — a FIXED_FD_INSTALL follow-up could materialize a real fd). The win applies wherever fixed files aren't in use, including the benchmark cluster (kernel 7.0 rejects ACCEPT_DIRECT).


Update: pre-existing WS upgrade/close hardening (commit ba3887a)

Validating this branch through the nightly matrix-validate gate surfaced pre-existing (v1.5.6) races + a panic in the engine WS detach/close path — not introduced by the inline-egress change (they reproduce on v1.5.6 without it). Fixed here so the gate passes:

  1. atomic.Value inconsistent-type panic in Conn.writeErr — this was the validator's code=2 / I-LIVENESS crash. OnError stored errors of varying concrete types; boxed via storedWriteErr so atomic.Value holds one type (+ skip nil).
  2. WSReady upgrade-completion barrier (H1State.WSReady) — the WS middleware wires the detached callbacks after Detach releases detachMu; closeConn read them concurrently on a peer-RST-mid-upgrade. WSReady is Stored (release) as the final wiring step and Loaded (acquire) before closeConn touches the callbacks, so it sees a fully-wired conn or skips WS teardown (conn still closed via the fd path). Gated in both engines.
  3. PauseRecv/ResumeRecv drop race — same barrier.

Validation: full nightly matrix-validate (every refapp × engine, both archs, -race + checkptr, incl. the I-CONN leak-balance properties) is GREEN; go test -race clean on websocket + both engines + conn.

Known separate item: a deliberately brutal RST-mid-upgrade -race repro still shows a different pre-existing race cluster — a Context recycled while a detached async handler holds it. It does not trip the validator (v1.5.6 passed it) and is unrelated to v1.5.7; filed as follow-up hardening.

The io_uring and epoll engines serialise off-event-loop writes (WebSocket
frames, SSE events) through a per-loop detachQueue: an off-loop goroutine
appends the conn and writes a wakeup eventfd so the event loop drains the
queue and flushes each conn's writeBuf.

Under a Hub broadcast fan-out the write closure runs once per
(message x connection) from the GOMAXPROCS*4 dispatch goroutines, and
every call issued its own eventfd wakeup write. At 1024 connections that
is a storm of redundant wakeup syscalls — the loop needs only one wakeup
per drain cycle, because a single drain already flushes every queued
conn.

Coalesce with an edge-triggered wakeup: only the enqueue that takes the
detach queue empty->non-empty writes the eventfd, gated by
`wasEmpty := detachQPending.Swap(1) == 0` under detachQMu. drainDetachQueue
clears the flag (Store(0)) under the same lock *before* it swaps the queue
out, so a racing enqueue is either captured by the drain's swap or
observes pending==0 and re-arms the eventfd — never both missed. No wakeup
is dropped.

The detachMu-guarded writeBuf mutation is untouched, so the
WS-write-vs-flushWrites ordering invariant (celeris#284) is preserved.
Applied uniformly to every off-loop enqueue site in both engines (WS
write, recv pause/resume backpressure, async-detach setup); loop-thread
enqueues are unchanged since they run on the drain thread and never race
the flag.

Benchmarks (ws-hub-broadcast, 1024 conns, amd64, median of 3 x 12s):
  io_uring  773,823 -> 918,111 rps  (+18.6%)
  epoll     718,965 -> 838,918 rps  (+16.7%)

go test -race passes on both engine/iouring and engine/epoll.
Detached WS/SSE writes funneled through the single event-loop thread
(append to writeBuf under detachMu, enqueue to the per-loop detachQueue,
wake the loop, which did the write). On a broadcast fan-out that serializes
1024 sends across N loop threads (N/thread), while the std engine does them
inline on GOMAXPROCS goroutines across all cores — the ~2x broadcast gap.

Issue the send inline on the dispatch goroutine when the conn is clean:
flushWrites(cs,false) runs inside the same detachMu critical section that
already guards orig() and that the loop-thread dirty-flush + closeConn take,
so it can neither race the loop's flush nor touch a closed fd; writeBuf is
one ordered buffer flushed from writePos, so no reorder. On full drain we
skip the loop handoff entirely; on partial/EAGAIN/error we fall through to
the existing enqueue path (surfacing OnError on I/O failure first).

ws-hub-broadcast @1024 conns (epoll, amd64): +43-48% (638K -> ~930-960K),
loop-thread CPU 50%% -> 0.3%% (write moves to the dispatch goroutines).
-race clean on engine/epoll + middleware/websocket.
…_ISSUER-safe)

Port the epoll inline-egress ceiling-breaker to io_uring. SINGLE_ISSUER
forbids the dispatch goroutine from submitting a ring SEND, but a raw
unix.Write(2) on the socket fd is legal iff no ring SEND is in-flight for
the conn — else the two writes interleave on the wire. detachMu gates it:
every ring SEND is submitted under detachMu with cs.sending set, and
completeSend now CLEARS cs.sending under detachMu too (was cleared before
the lock — a cross-thread read race the inline path would hit). Gated on
!cs.fixedFile: under ACCEPT_DIRECT cs.fd is a ring file-table index, not a
syscall'able fd, so those conns keep using the ring (same guard hijack
uses). Also gates on !zcNotifPending + empty sendBuf/bodyBuf.

ws-hub-broadcast @1024 conns (io_uring, amd64, fixed_files=false): +43.2%
(663K -> ~950K). -race clean on engine/iouring (102s) + middleware/websocket.
…ne egress

Broadcasts 8KB frames (>= sendZCMinBytes, so io_uring uses SEND_ZC) to 32
detached conns while dispatch goroutines issue inline writes, asserting every
received frame is byte-intact and per-conn in order. Run under -race in CI this
covers the SEND_ZC-completion-vs-inline-write interaction the 64B ws-hub
benchmark never reaches. Passes -race x3 on both epoll + io_uring.
@FumingPower3925
FumingPower3925 changed the base branch from perf/ws-broadcast-wakeup-coalesce to main July 3, 2026 11:21
…panic

Three pre-existing (v1.5.6) bugs in the engine WS detach/close path, surfaced
by a -race build under aggressive peer-RST-mid-upgrade:

1. WSReady upgrade-completion barrier. The WS middleware installs the detached
   callbacks (RawWriteFn, pause/resume, idle-deadline, OnDetachClose LAST) on
   the async goroutine AFTER Detach releases detachMu (celeris#273/#309), so
   they were not lock-serialised against closeConn reading OnDetachClose /
   PauseRecv while tearing the conn down on a peer RST. H1State.WSReady is
   Stored(true, release) as the final wiring step; closeConn Loads it (acquire)
   before touching those callbacks, so it sees a fully-wired conn or skips WS
   teardown entirely (conn still closed via fd/read path). Gated in both engines.

2. writeErr atomic.Value inconsistent-type panic (== the validator's I-LIVENESS
   code=2 crash). OnError stores errors of varying concrete types (errPeerClosed,
   syscall errors, …); atomic.Value.Store panics on the second differing type.
   Boxed via storedWriteErr so the stored dynamic type is constant; also skip
   storing a nil error.

-race clean on ./middleware/websocket, ./engine/{epoll,iouring}, ./internal/conn.
@FumingPower3925
FumingPower3925 merged commit 35a9c02 into main Jul 3, 2026
7 checks passed
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