logo
Kurashizu Blogwhere ideas flow
  • Home
  • News
  • Blog
  • About
  • Home
  • News
  • Blog
  • About

© 2026 Kurashizu. All rights reserved.

Admin·New Post·Service Status
Powered by Cloudflare·31.10.26
← Back to news

Io_uring Without Readahead

Original article
frn.sh|77 points|20 comments|by porridgeraisin|Sep 1, 2026

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 Readahead →\rightarrow→ By using O_DIRECT, the kernel's native readahead mechanism is disabled.
  • Application Responsibility →\rightarrow→ 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:

  1. Turso identifies a need for Page 100.
  2. It submits a read Submission Queue Entry (SQE) for Page 100.
  3. The application waits for the completion.
  4. Turso identifies a need for Page 101.
  5. 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 1.2 GiB1.2\text{ GiB}1.2 GiB 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:

Metricoff (n=1)on (n=1)Description
SQEs Submitted195,207195,207195,207218,212218,212218,212Total requests sent to the ring
Device Requests≈196,000\approx 196,000≈196,000≈16,300\approx 16,300≈16,300Actual requests hitting the hardware
rareq-sz4.37 KiB4.37\text{ KiB}4.37 KiB56.53 KiB56.53\text{ KiB}56.53 KiBAvg. size of read request at device
%rrqm≈0%\approx 0\%≈0%91–93%91\text{--}93\%91–93%% 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 202,539202,539202,539 out of 218,493218,493218,493 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: 8.22s8.22\text{s}8.22s
  • User time: 3.70s3.70\text{s}3.70s
  • System time: 8.46s8.46\text{s}8.46s

Observation: Since System Time>Wall Time\text{System Time} > \text{Wall Time}System Time>Wall Time, 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: 8.62s8.62\text{s}8.62s wall, 3.62s3.62\text{s}3.62s user, 1.27s1.27\text{s}1.27s 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 13 KIOPS13\text{ KIOPS}13 KIOPS... 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: 8.55s8.55\text{s}8.55s
  • syscall: 3.02s3.02\text{s}3.02s

Performance Counters (Median of 7 runs)

BackendCyclesInstructionsIPCCache MissesMiss Rate
io_uring5.374 B5.374\text{ B}5.374 B21.497 B21.497\text{ B}21.497 B3.9993.9993.99910.666 M10.666\text{ M}10.666 M13.255%13.255\%13.255%
syscall4.734 B4.734\text{ B}4.734 B21.297 B21.297\text{ B}21.297 B4.4994.4994.4995.889 M5.889\text{ M}5.889 M7.691%7.691\%7.691%

The Hypothesis: The "Missing Copy"

Both methods use DMA (Direct Memory Access), but they differ in how data reaches the application:

  1. Buffered Read (syscall): Disk →\rightarrow→ Page Cache →\rightarrow→ 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.
  2. Direct Read (O_DIRECT): Disk →\rightarrow→ 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_DIRECT without 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 1.011.011.01 syscalls per I/O at a queue depth of 646464.