Skip to content

perf(reactor): RX-epoll receive path, no epoll_ctl per keep-alive request - #371

Merged
gg582 merged 1 commit into
devfrom
perf/epoll-fastpath
Oct 13, 2026
Merged

gg582 merged 1 commit into
devfrom
perf/epoll-fastpath

Conversation

@gg582

@gg582 gg582 commented Oct 13, 2026

Copy link
Copy Markdown
Member

Why

When cwist can't get an io_uring ring, it falls back to epoll (CWIST_REACTOR_BACKEND=epoll, or a container whose seccomp profile blocks io_uring_setup). Docker's default profile does this, which matters for the the-benchmarker comparison in #319. On the epoll path, each keep-alive request cost four syscalls:

per request (strace, 1 worker, wrk -c64) io_uring epoll (before) epoll (this PR)
sendmsg 1 1 1
recvfrom 0 1 1
epoll_ctl 0 2 (ADD → EEXIST, then MOD) 0 (132 in total, all per connection)
wait (io_uring_enter / epoll_wait) ~0.03 ~0.02 ~0.02

Two problems on the old path:

  • The one-shot re-arm always tried EPOLL_CTL_ADD first, so every keep-alive request failed one epoll_ctl before the MOD.
  • EPOLLONESHOT needs a MOD on every request anyway.

What

  • RX-epoll. reactor_rx.h's receive contract (cwist_reactor_recv_arm plus the completion handler) is now served on epoll as well.
    • The connection's fd stays registered edge-triggered: EPOLLIN|EPOLLRDHUP|EPOLLET, no EPOLLONESHOT, user_data = fd << 1 | 1.
    • On readiness, the reactor recv()s into the armed buffer and calls the same rx_cb. http.c is unchanged.
    • No stranded bytes: if a receive fills the buffer, or an edge arrives while nothing is armed, the fd is queued and received before the next wait (epoll_wait gets a zero timeout).
    • Thread rule: arms are accepted on the reactor's run thread only. Off-thread arms return false, and the existing POLL fallback re-arms from the run thread.
    • Leaving RX mode: any cwist_reactor_add/_mod/_del of the fd takes it back to the one-shot path, and the next RX arm registers it again. Both MOD and ADD report an already-readable fd, so no edge is lost when switching.
    • -EAGAIN is never reported: the receive stays armed until bytes, EOF or an error arrive.
  • Registration hint. A process-wide per-fd state table, with chunks allocated lazily: 24 B per fd in the ranges actually used.
    • One-shot re-arms of an already-registered fd go straight to EPOLL_CTL_MOD.
    • The accept path resets the state of each fresh fd (cwist_reactor_fd_forget).
    • Every state is only a hint with an ENOENT/EEXIST fallback. RX mode is trusted only for the same connection pointer.
  • Knob. CWIST_RX_EPOLL=0 restores the old one-shot path. It's documented in docs/runtime-config.md (plus the ko mirror).
  • Comment only: the RX comment in the public http.h struct. The layout is unchanged and check_api_baseline passes.

Numbers

Contract app from benchmarks/web-frameworks (GET /), CWIST_WORKERS=4, server on 2 physical cores (cpus 0,1,6,7), wrk -t8 on the other 4, Ryzen 5600X. Each value is the median of 3 interleaved rounds:

conns epoll before epoll after io_uring before io_uring after
64 req/s 330k 356k (+8%) 347k 347k
64 P50 0.181 ms 0.166 ms (−8%) 0.173 ms 0.176 ms
512 req/s 324k 355k (+10%) 364k 361k
512 P50 1.53 ms 1.40 ms (−9%) 1.34 ms 1.36 ms

A second, noisier pass (4 rounds, with other load on the desktop) showed the same relative change: epoll +7% / +9% / +8% req/s at 64 / 256 / 512 conns, with P50 down 10% / 9% / 9%. io_uring stayed within noise.

So epoll is now within a few percent of io_uring at 512 conns, and ahead of it at 64.

Tests

  • New tests/test_rx_epoll.c (wired into TEST_TARGETS, runs under TSan). It includes reactor.c and counts epoll_ctl calls, like test_reactor_wake.c does. It covers:
    • off-thread arms are refused
    • 200 keep-alive receives with zero epoll_ctl after the first arm
    • 100 bytes through a 10-byte buffer with no new edges
    • bytes that arrive while unarmed reach the next arm
    • one-shot cwist_reactor_add takes the fd over, and RX registers it again afterwards
    • a new connection pointer on the same fd registers again
    • EOF
    • cwist_reactor_fd_forget
  • Local runs:
    • make test: passes in full both with CWIST_REACTOR_BACKEND=epoll and on the default backend.
    • test_rx_epoll: clean under TSan and under ASan/UBSan.
    • check_test_wiring, check_env_knobs, check_api_baseline and cwist audit --gate all pass.

Refs #319.

…uest

On the epoll reactor (no io_uring ring, e.g. under a container seccomp
profile that blocks io_uring_setup) every keep-alive request paid four
syscalls: recv, send, and two epoll_ctl for the one-shot re-arm, because
cwist_reactor_add always tried EPOLL_CTL_ADD first and only switched to MOD
after it failed with EEXIST. The io_uring reactor pays one send per request,
since the re-arm rides the SQE batch.

- Serve the RX contract of reactor_rx.h on epoll too. A connection's fd stays
  registered edge-triggered (no EPOLLONESHOT, user_data fd << 1 | 1), and on
  readiness the reactor recv()s into the armed buffer and reports through the
  same completion handler, so http.c needs no change. A full buffer, or an
  edge that lands while no receive is armed, queues the fd for a receive
  before the next wait, so no bytes are stranded by the edge trigger. Arms are
  taken on the run thread only; elsewhere the existing POLL fallback re-arms
  from the run thread. Any cwist_reactor_add/_mod/_del takes the fd back to
  the one-shot path.
- Keep a process-wide per-fd registration hint so one-shot re-arms go straight
  to EPOLL_CTL_MOD. The accept path resets it for fresh fds
  (cwist_reactor_fd_forget).
- CWIST_RX_EPOLL=0 restores the one-shot path.

A steady keep-alive request on epoll now costs recv + send. Locally (5600X,
4 workers on 2 physical cores, wrk -t8 on the rest): +8-9% req/s and -9-10%
P50 on forced epoll, close to the io_uring reactor; io_uring is unchanged.

tests/test_rx_epoll.c covers the keep-alive loop with no epoll_ctl, the
small-buffer drain without new edges, bytes arriving while unarmed, the
hand-off to and back from the one-shot path, a new connection on the same
fd, EOF, and off-thread arms.
@gg582
gg582 merged commit 7505ab7 into dev Oct 13, 2026
25 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