Guidance needed: increasing SQEs per `io_uring_enter` for a request/response client workload
Summary
We migrated an Aerospike client I/O path onto the core netty-transport-native-io_uring
transport, hoping to reduce syscalls versus epoll. Functionally it works well. However,
the vast majority of our io_uring_enter calls carry exactly 1 SQE, so the
syscall-per-operation ratio is close to 1:1 and we have not realised the batching benefit
we were hoping for.
We believe we understand why from reading the Netty source (analysis below), but we would appreciate a sanity check on that understanding, and guidance on whether there is any supported way to increase submission batching for this kind of workload — or whether this is simply the expected floor and we should stop looking.
Environment
| Netty | 4.2.11.Final (netty-transport-classes-io_uring / netty-transport-native-io_uring) |
| JDK | 25 |
| Platform | Linux x86_64, containerised |
| Client library | com.aerospike:aerospike-client-jdk21:10.4.0 |
| Ring size | default (128) |
| Buffer ring | tried both enabled and disabled (see below) |
Event loop group is constructed as:
new MultiThreadIoEventLoopGroup(threads, IoUringIoHandler.newFactory())and handed to Aerospike's NettyEventLoops, which round-robins commands across the loops.
Workload shape
This is a latency-sensitive ad-serving service. The relevant characteristics:
- ~600 QPS of user-profile lookups per pod (mean inter-arrival ~1.67 ms).
- Each lookup is an Aerospike batch read of up to 6 keys.
- The Aerospike client splits those keys by server node, producing one command (one socket write) per distinct node touched — typically 5-7 commands per request.
- Responses arrive independently from each of those 5-7 nodes, at different times, on different sockets.
- Requests originate from a Jetty thread pool (thread-per-request), so calls into the event loop always come from outside the event loop thread.
What we observe
strace on the event loop thread, steady state:
io_uring_enter(0, 1, 0, IORING_ENTER_GETEVENTS|IORING_ENTER_REGISTERED_RING, NULL, 8) = 1
io_uring_enter(0, 1, 0, IORING_ENTER_GETEVENTS|IORING_ENTER_REGISTERED_RING, NULL, 8) = 1
io_uring_enter(0, 6, 0, IORING_ENTER_GETEVENTS|IORING_ENTER_REGISTERED_RING, NULL, 8) = 6
io_uring_enter(0, 1, 0, IORING_ENTER_GETEVENTS|IORING_ENTER_REGISTERED_RING, NULL, 8) = 1
io_uring_enter(0, 1, 0, IORING_ENTER_GETEVENTS|IORING_ENTER_REGISTERED_RING, NULL, 8) = 1
io_uring_enter(0, 1, 1, IORING_ENTER_GETEVENTS|IORING_ENTER_REGISTERED_RING, NULL, 8) = 1Roughly 3150 enters/sec on one loop. The distribution is dominated by = 1, with
occasional = 5/= 6/= 7 groups. Depth never approaches the 128-entry ring.
Our reading of this, per request:
| phase | enters | SQEs each |
|---|---|---|
| write fan-out to 5-7 nodes | 1 | 5-7 |
| responses (one per node, independent arrival) | ~6 | 1 |
| total | ~7 | ~1.7 avg |
600 QPS × ~7 ≈ 4200 enters/sec, which is the right order of magnitude for what we measure.
Our understanding of the mechanism
From reading 4.2.11 sources — please correct us if any of this is wrong:
The write side already batches optimally. The Aerospike client dispatches all per-node commands for one request back-to-back from the calling thread. Only the first
execute()writes the eventfd, becauseIoUringIoHandler#wakeupcoalesces viaeventfdAsyncNotify.getAndSet(true). By the time the loop wakes, all 5-7 tasks are queued,runAllTasksdrains them together, and we get a single multi-SQE enter. This is the= 6line above and it is exactly the behaviour we want.The read side cannot batch, structurally. Each response is a separate CQE arriving at a separate time from a separate socket. Handling it enqueues a re-arm SQE, and then in
processCompletionsAndHandleOverflowthe next pass seesp == 0withneedSubmit()true and callssubmitAndClearNow0→submitAndGetNow()→submitAndGet0(0)→submit(1, 0, IORING_ENTER_GETEVENTS). That is one enter carrying one SQE, per response. These are ~85% of our enters.Ring size is irrelevant here.
SubmissionQueue#enqueueSqeonly force-submits atpending == ringEntries, butIoUringIoHandler#runsubmits at the end of every loop iteration regardless of depth. So the batch size per enter is "whatever accumulated during one iteration", which at our duty cycle is ~1. To fill 128 entries we estimate we'd need something north of 400k QPS on a single loop.
In other words: there appears to be no "wait until N SQEs are pending before entering" mode, and with independent response arrivals there is nothing to accumulate anyway.
What we have already tried (and what happened)
| change | result |
|---|---|
Buffer ring (IORING_REGISTER_BUF_RING, size 256 × 16 KiB, largeAllocation) |
No reduction in enters; the = 5/= 6 groups actually increased slightly. Reverted. |
-Dio.netty.iouring.recvMultiShotEnabled=false |
Investigated because Aerospike's putConnection() → clearRead() → cancelOutstandingReads() submits an extra ASYNC_CANCEL SQE + produces an ECANCELED completion when multishot leaves readId != 0. Did not pursue to a conclusion. |
Event loop threads: 3 → 1 (funnel all Aerospike traffic onto one ring) |
No change in SQEs/enter, and total enters/sec across the process stayed flat. Our hypothesis — that round-robin across 3 loops was splitting batchable work — was wrong: the loop is idle between requests, so there is no concurrent backlog to merge regardless of loop count. Reverted. |
Questions
Is our analysis correct, particularly point 2 — that per-response single-SQE enters are inherent to a request/response client workload with independent completion arrivals, and not something we've misconfigured?
Is there any supported way to defer or coalesce submissions? Something equivalent to "don't call
io_uring_enteruntil either N SQEs are pending or T microseconds have elapsed". We could not find one. Is such a knob something Netty would consider, or is it deliberately avoided because of the latency cost?IORING_SETUP_SQPOLL— we note it is not implemented (noIORING_SETUP_SQPOLLconstant inNative, noIORING_ENTER_SQ_WAKEUP, thoughIORING_SQ_NEED_WAKEUPis defined atNative.java:257but never read). Is that a deliberate design decision? Our own assessment is that it would be a poor fit for us — a kernel polling thread spinning per ring against a loop that is idle ~97% of the time would trade syscalls for a dedicated core, which is the wrong direction since our actual goal is reducing CPU. But we'd value your view on whether SQPOLL is on the roadmap and what workload shape you'd consider it appropriate for.IORING_SETUP_DEFER_TASKRUN— we understand this is enabled automatically viaNative#setupFlagswhensingleIssueris true (the default) and the kernel supports it. We are adding startup logging ofIoUring.featureString()to confirm it is actually active on our kernel. Are there other setup flags or system properties you'd recommend for a many-small-request client workload like this?Is ~1 syscall per network operation simply the expected floor here, with io_uring's benefit for our shape being reduced per-syscall cost and better completion handling rather than fewer syscalls? If so we're happy to accept that — we'd just like to stop chasing it. A clear "yes, that's expected" is a genuinely useful answer.
Non-goals / things we know about
- We are aware we could add an application-level coalescing layer — buffering independent requests for a small time window and merging their keys into one Aerospike batch, so that N requests hitting the same node collapse into one command. This would genuinely help (the client merges keys per node, so command count is bounded by cluster size rather than key count). We're keeping it as a fallback since it trades latency and adds real complexity. This issue is specifically about whether anything at the Netty/io_uring layer can help first.
Thanks very much for the transport work — happy to provide more traces, run experiments, or test patches if that would be useful.
Source: netty/netty