External upstream PR we are tracking (not one of our own optimization experiments).
- Upstream: libevent/libevent#1866 by
widgetii - Head:
widgetii/libevent@ac1caa8e(fetch viarefs/pull/1866/head) - Size: +2032/−12, 22 files, 9 commits. State: OPEN, all CI green, no maintainer review yet.
- Supersedes: the experimental #1356 (closed); closes #1019.
Opt-in io_uring(7) fast path for socket bufferevent reads/writes, gated per
event_base via EVENT_BASE_FLAG_IO_URING. Multishot recv with a provided
buffer ring (reads skip epoll once enabled); writes batch in the SQ and submit
once per loop iteration; wakeup polls the ring fd directly (no eventfd read).
Degrades gracefully without liburing / on non-Linux / with EVENT_NOIO_URING.
ABI-additive (one flag + one internal event_base field).
Clean layering: all ring state in event_io_uring.c; bufferevent consumes it
via accessors (event_io_uring_submit_readv_/writev_, _recv_multishot_,
_cqe_more_, _cqe_buf_id_, _buf_addr_, …) and never includes liburing.h.
test/bench_bufferevent_io --bytes B --rounds R --pairs P [--uring]:
| payload | pairs | syscall MiB/s | io_uring MiB/s | Δ |
|---|---|---|---|---|
| 64 KiB | 1 | 2116 | 2193 | +4% |
| 64 KiB | 64 | 1183 | 1926 | +63% |
| 64 KiB | 128 | 1093 | 1866 | +71% |
| 1 KiB | 16 | 154 | 244 | +58% |
Wins concentrate at high concurrency / small payloads (multishot amortises per-round submission overhead).
Bufferevent read/write timeouts are not enforced while a multishot recv is in
flight (only at submission boundaries). Author's intended fix:
IORING_OP_LINK_TIMEOUT linked to each recv SQE. Left for a follow-up.
- Ring setup flags —
IORING_SETUP_DEFER_TASKRUN | COOP_TASKRUN | SINGLE_ISSUERinevent_io_uring_init_. A single-threaded event loop is the textbook case for DEFER_TASKRUN (task_work deferred toio_uring_enter, fewer IPIs/wakeups). Cheap, low-risk, expected single-digit-to-20% throughput on the syscall-bound path. Primary — measurable, minimal diff. - Timeout enforcement via
IORING_OP_LINK_TIMEOUTlinked to recv — closes the documented limitation; what azat would ask for. Medium effort. IORING_OP_SEND_ZC(zero-copy send) for large writes — bigger win at large payloads; needs notification-CQE handling. Higher effort.
Reproduce on the c4 GCP box (x86, kernel 6.14, liburing-dev): build the PR with
liburing, run bench_bufferevent_io syscall-vs---uring to get our own
baseline, then apply target #1, rebuild, re-bench, compare. Validate with
make verify + the new regress_io_uring group + ASAN. Log numbers below.
Baseline reproduced — the io_uring win holds on our host:
| point | syscall MiB/s | io_uring MiB/s | Δ |
|---|---|---|---|
| 64 KiB × 64p | 2007 | 2702 | +35% |
| 64 KiB × 128p | 2079 | 3048 | +47% |
| 1 KiB × 16p | 441 | 487 | +10% |
| 1 KiB × 64p | 426 | 487 | +14% |
(Lower absolute Δ than the author's 6.18 host, but the multishot win is confirmed.)
| Variant | Change | io_uring 128p | Verdict |
|---|---|---|---|
| baseline | — | 3048 | — |
| V1 | BUF_NBUFS 128→1024, QUEUE_DEPTH 256→512 |
2698 | REJECT −11% |
| V2 | IORING_SETUP_SINGLE_ISSUER (+ fallback) |
3044 | reject (~0%, noise) |
Both passed the io_uring/* regress group. Findings:
- Enlarging the provided-buffer pool hurts. Buffer exhaustion is NOT the bottleneck; the 128×16 KiB pool (2 MiB) stays L3-resident and hot, while a 16 MiB pool thrashes cache. The author's small pool is deliberately right.
SINGLE_ISSUERis a no-op here — the loop is already a single submitter and the internal sync it elides is not on the hot path for this workload.DEFER_TASKRUN/COOP_TASKRUNwere NOT tried — the design polls the ring fd via epoll ("POLLIN asserted when the CQ has unread entries"), and deferred task-run only posts CQEs on the issuer'sio_uring_enter, which would strand completions while the loop blocks inepoll_wait→ missed wakeups. Confirmed architecturally incompatible; correctly avoided.
Conclusion: the PR's performance configuration is near-optimal on this host;
the cheap, safe knobs do not improve it. The genuine remaining improvement is
correctness completeness, not throughput: the documented timeout limitation
(target #2, IORING_OP_LINK_TIMEOUT linked to each multishot recv). That is the
change worth making — and the one azat is most likely to ask for.
Branch redis-performance/libevent:io-uring-read-timeout (commit d7493e51, on top of
PR #1866). Closes the documented limitation.
- Approach: a libevent min-heap inactivity timer on the bufferevent (armed at multishot
submit, reset on each data CQE, fires
BEV_EVENT_READING|BEV_EVENT_TIMEOUT+ cancels the multishot on idle). NotIORING_OP_LINK_TIMEOUT(the author's suggestion): a linked timeout is a one-shot deadline, but libevent read timeouts are inactivity timeouts; re-linking per CQE would defeat multishot. A socket-specificadj_timeoutsmakesbufferevent_set_timeouts()work on a live multishot. - Validation (c4): 2 regress tests (
read_timeout,read_timeout_after_enable), each fails without the respective fix; full regress 377/377 ok; ASAN clean; throughput unchanged (128p: 3035 vs 3048 — the timer is a no-op without a configured timeout). - 7-maintainer review: GO-WITH-FIXES, 82/100, 6/7 green. The panel validated the
design: "libevent min-heap timer WINS, decisively… LINK_TIMEOUT rejected. The author's
reasoning is correct." Their one real blocker —
set_timeouts()-after-enable silently dropping the timeout (genericadj_timeoutscan't see the out-of-epollev_read) — was a genuine hole I'd under-weighted as a "doc footnote"; now fixed + regression-guarded. The two style nits (merge the duplicateif (trigger_user); comment the deferred arm) were folded in.
Profile first (c4, software task-clock; GCP VMs have no HW PMU): at 64 KiB × 128p the
--uring path is copy-bound — _copy_to_iter 17% + _copy_from_iter 12% (kernel socket
copies, inherent to loopback) + libc memcpy 17% (the userspace evbuffer_add() copy of the
provided buffer) + ~10% memcg/page-zero. The one addressable userspace lever is the recv copy.
Change (branch io-uring-zerocopy-recv, fdefb9dd): reference the provided buffer via
evbuffer_add_reference() (released to the ring by a cleanup cb on chain free) instead of copying.
Measured: 64 KiB ×64p 2702→3770 (+40%), ×128p 3048→3492 (+15%), 1 KiB unchanged. io_uring
regress green, full regress 377/377, ASAN clean.
7-maintainer review: GO-WITH-FIXES, 45/100, 2/7 green — NOT mergeable as-is. Two real blockers (the review earned its keep):
- Teardown UAF — a referenced provided buffer is in an app-owned evbuffer that can outlive
event_base_free(); the cleanup then dereferences the freedbuf_relctx/ring/base. The 377/377 + ASAN-clean result proved nothing because every test frees bufferevents before the base and drains promptly. A genuine lifecycle bug I missed. - Shared-pool fairness — zero-copy unconditionally pins the shared 128-buffer pool; one slow consumer starves multishot for all connections. The +40%→+15% (64p→128p) drop is that pressure. The panel also correctly refuted a (non-)bug three lenses raised (per-bid slot reuse is safe — the kernel won't re-deliver a bid until released).
To make it mergeable: (a) a teardown-safe release context (refcount that survives the base, or force-drain-on-teardown), (b) a copy-fallback under watermark / low free-buffer count to bound the pool, (c) regress tests for evbuffer-outlives-base (ASAN) and pool exhaustion. The win is real and wanted; the path is clear. Scoped as the next step.
The +40% was real but the v1 review (45/100) found a teardown UAF + a shared-pool fairness flaw. Hardened across two more rounds, each re-reviewed by the 7-maintainer panel:
- The UAF was real — reproduced with a standalone program: an
evbuffer(moved out of the bufferevent) freed afterevent_base_free()→ SEGV inevent_io_uring_buf_release_. The 377/377 + ASAN-clean suite had missed it because no test let an evbuffer outlive the base. - v2: refcounted
event_io_uring_bufpoolthat outlives the base (cleanup never touches freedbase->io_uring) + low-water copy-fallback to bound the shared pool. Re-review: GO-WITH-FIXES 80/100, B1 closed ✓, B2 closed ✓ ("a real root-cause fix, not a band-aid"). But it caught a new refcount leak (ref reserved beforeevbuffer_add_reference; leaks on failure) — and the sharp insight that the leak "corrupts the B2 bound" by inflatinginflight. - v3: undo the reservation on
add_referencefailure +EVUTIL_ASSERT(inflight>0)guard + checkpatch decl fixes + the two regress tests (recv_ref_outlives_base,pool_pressure).
Final state (branch io-uring-zerocopy-recv, 7ccce898): +39% (64p) / +13% (128p),
full regress 379/379, ASAN clean (incl. the once-SEGV repro), throughput-cost-free bound.
All three blockers the panels raised (teardown UAF, fairness, refcount leak) are closed; the
chair's stated merge conditions ("fix the leak, the checkpatch decls, and the assert") are met.
Net for PR #1866: two upstreamable follow-ups — io-uring-read-timeout (82/100, closes the
documented limitation) and io-uring-zerocopy-recv (a profile-driven +39% that the review loop
turned from a UAF-carrying draft into a hardened, tested, bounded change).
Question raised: "TLS excluded — fast path is plaintext socket bufferevents only?" True.
Code: the io_uring hooks are entirely in bufferevent_sock.c (24 refs; 0 in
bufferevent_openssl.c/mbedtls/ssl); the changelog says "io_uring acceleration for socket
bufferevents." bufferevent_openssl_socket_new(fd) uses a socket BIO (recv/send), never
the multishot path. (Only a TLS filter over an io_uring socket bev gets it, second-hand.)
Measured proof — added --ssl to test/bench_bufferevent_io (branch io-uring-zerocopy-recv
f4fce954) and ran syscall-vs---uring on identical code/hardware:
| workload | syscall | io_uring | Δ |
|---|---|---|---|
| TLS 64 KiB ×64p | 1163 | 1207 | +3.8% (noise) |
| TLS 64 KiB ×128p | 1173 | 1170 | −0.3% |
| TLS 1 KiB ×64p | 201.8 | 201.4 | −0.2% |
| plaintext 64 KiB ×64p | 2049 | 3785 | +84.7% |
| plaintext 64 KiB ×128p | 2122 | 3521 | +65.9% |
io_uring does nothing for TLS while delivering +66–85% for plaintext. TLS tops at ~1160 MiB/s (≈3× slower; decrypt + copies), all of it untouched by io_uring.
Integration design (scoped, not yet built): route bufferevent_openssl_socket_new(fd) through
an internal io_uring socket bufferevent via BIO_new_bufferevent — i.e. fd-mode reuses the
already-tested filter machinery (bufferevent_ssl.c's underlying paths). Effort is moderate;
risk concentrates in lifecycle (SSL bev + internal transport bev + zero-copy refs = three lifetimes)
- the connect path +
get_fd. Perf caveat:bio_bufferevent_readstillevbuffer_remove-copies ciphertext into SSL, so the gain is the recv-syscall elimination (helps at high concurrency), not the zero-copy copy-elimination. Next step is the lifecycle-heavy prototype + ASAN + the panel.
Standalone TLS+plaintext socket-bufferevent throughput benchmark for upstream (independent of io_uring;
fills a real gap — libevent had only event-loop and HTTP benches). Branch tls-bufferevent-bench off
master. Opened libevent/libevent#1869.
Two 7-maintainer review rounds:
- v1 NO-GO (28/100): caught real OpenSSL-version portability blockers (
EVP_RSA_gen3.0+,X509_getm_*1.1.0+,TLS_*_method) that would have broken the 1.0.2/1.1.1/LibreSSL CI jobs, plus error-path leaks, autotools gating, a double-close — and a false "hang" (misread data flow). - Fixed the real items by mirroring
regress_openssl.c(pre-baked PEM key,SSLv23_method,openssl-compat.h); refuted the hang empirically (pairs=64×rounds=10000 completes). - v2 GO-WITH-FIXES (86/100, would-merge): the chair refuted two panel false positives itself — the re-raised SSL "leak" ("adding SSL_free would double-free" — the bev owns ssl via BEV_OPT_CLOSE_ON_FREE) and the hang. One mechanical nit (hoist loop decls) applied. Builds with/without OpenSSL, ASAN-clean.
This is the 2nd upstream PR (after #1868). The review loop again earned its keep: real CI-portability bugs caught, false positives correctly refuted (by me from source, and by the chair).