Io_uring Without Readahead
Analysis: Implementing io_uring Without Readahead
A recent Pull Request aimed to introduce readahead capabilities into Turso. While the initial implementation was considered "throwaway," it provided a valuable opportunity to benchmark io_uring and uncover the nuances of its behavior.
The Core Challenge: O_DIRECT vs. Buffered I/O
Turso utilizes io_uring and opens database files using the O_DIRECT flag. This configuration explicitly bypasses buffered I/O.
The Trade-off:
Kernel ReadaheadBy usingO_DIRECT, the kernel's native readahead mechanism is disabled.- Application Responsibility To regain performance, the application must implement its own readahead logic.
The Concurrency Bottleneck
Without application-level readahead, the system suffers from a lack of concurrency. The process becomes strictly sequential:
- Turso identifies a need for Page 100.
- It submits a read Submission Queue Entry (SQE) for Page 100.
- The application waits for the completion.
- Turso identifies a need for Page 101.
- It submits a new SQE for Page 101, and so on.
The Optimized Approach: When Turso detects sequential access, instead of requesting a single page, it submits a batch of reads (e.g., Pages 100 through 131) simultaneously.
Benchmarking Request Merging
To test this, the author used the TPC-H benchmark (a standard for analytic databases) on a database, specifically focusing on Query 6 (Q6), which performs a full scan of the lineitem table.
off:PRAGMA prefetch_pages=0(No readahead).on: Readahead enabled.
I/O Path Observations
By monitoring iostat -dxm 1 sda and the io_uring:io_uring_submit_req tracepoint via perf stat, the following data was gathered:
| Metric | off (n=1) | on (n=1) | Description |
|---|---|---|---|
| SQEs Submitted | Total requests sent to the ring | ||
| Device Requests | Actual requests hitting the hardware | ||
rareq-sz | Avg. size of read request at device | ||
%rrqm | % of requests merged by block layer |
How Merging Works
The block layer can combine adjacent sector requests into a single larger request, but only if multiple requests exist in the queue at the same time.
With readahead on, Turso submits more SQEs and fetches more data than strictly necessary, but the kernel merges out of bios, drastically reducing the load on the device.
The Impact of sqpoll (Submission Polling)
sqpoll utilizes a kernel thread that continuously spins to check for new work, eliminating the need for the application to trigger the kernel.
Timing Analysis
The author measured the execution of sqpoll with prefetch_pages=32:
- Wall time:
- User time:
- System time:
Observation: Since , it is evident that two threads (the query thread and the polling thread) are consuming CPU cycles simultaneously.
Profiling revealed that io_sq_thread (the kernel poller) accounted for 65% of all cycles. To test the alternative, the author replaced setup_sqpoll with a standard IoUring::new (the default fallback).
Comparison: SQ Poll vs. Plain Ring
- Plain Ring: wall, user, system.
While wall time increased slightly, system time plummeted. The remaining system time is due to calling io_uring_enter(2) for each submission.
Context from Literature
The author references a 2022 SYSTOR paper regarding submission polling:
"Submission polling with one NVMe drive and one CPU core reached only ... the two threads had to share one core, so they took turns."
In Turso's case, because the query is single-threaded and a free core was available for the polling thread, there was no direct CPU competition.
Cache Misses and the O_DIRECT Penalty
Comparing io_uring to standard syscall reads (with sqpoll off) revealed a significant performance gap:
io_uring:syscall:
Performance Counters (Median of 7 runs)
| Backend | Cycles | Instructions | IPC | Cache Misses | Miss Rate |
|---|---|---|---|---|---|
io_uring | |||||
syscall |
The Hypothesis: The "Missing Copy"
Both methods use DMA (Direct Memory Access), but they differ in how data reaches the application:
- Buffered Read (
syscall): Disk Page Cache Process Buffer.- The CPU performs the copy from the page cache to the buffer.
- Side Effect: This copy populates the L1/L2/L3 CPU caches.
- Direct Read (
O_DIRECT): Disk Process Buffer.- The CPU is bypassed entirely during the transfer.
- Side Effect: The data is not pre-loaded into CPU caches, leading to more misses when the application actually accesses the data.
Final Conclusions & Recommendations
The author's findings suggest a specific cost-benefit analysis for different I/O models:
-
O_DIRECTwithout App-Readahead: Inefficient. Single SQEs lead to zero concurrency and zero block-layer merging. -
sqpoll: Effective if the machine has multiple vCPUs. However, if the application is already CPU-bound, the polling thread may compete for resources. - Plain
io_uring: A strong alternative because it supports batching.
Key Takeaway:
As noted in the SYSTOR paper, plain io_uring can be highly efficient, achieving as low as syscalls per I/O at a queue depth of .