io_uring 不使用预读
摘要
本文研究了在 Turso 数据库中 io_uring 实现预读的性能影响,表明批处理 I/O 请求可以通过合并减少设备请求来提高效率。
暂无内容
查看缓存全文
缓存时间: 2026/09/01 14:41
# 不含预读机制的 io_uring · Fernando Simões
来源:https://frn.sh/io-uring/
有人提了个 PR(https://github.com/tursodatabase/turso/pull/8389),想给 Turso 加上预读功能。这只是个随手实现的测试,但恰好可以借此机会测试 io_uring 并更深入地了解它。
Turso 有两个后端。`syscall` 后端使用 `pread(2)` 调用。`io_uring` 后端使用 io_uring,并通过 `O_DIRECT` 标志打开数据库文件,不支持使用缓冲 I/O。`O_DIRECT` 会禁用内核的预读功能,因此若要恢复预读效果,就必须在应用层自行实现¹。
PR 的测试结果相当出色。配合应用层缓冲使用的 io_uring *确实*更快。我想弄清楚这背后的原因。
在没有预读的情况下,io_uring 每次只提交一个请求。问题在于缺乏并发性:每次读取都必须等待上一次完成。应用知道“在这个时刻,我需要第 100 页”,这意味着:Turso 为第 100 页提交一个读取 SQE,然后等待它完成。接着继续扫描。现在 Turso 需要第 101 页,于是再为该页提交一个新的读取 SQE。
开启预读机制后情况就不同了。Turso 需要第 100 页,检测到顺序访问模式,于是不再只请求单个页面,而是提交从第 100 页到第 131 页的读取请求。这样就有 32 个读取操作同时进行。
以下测量基于 TPC-H 基准测试(https://www.tpc.org/tpch/),这是一个用于分析型数据库的标准基准,使用 1.2 GiB 的数据库。
- 我测量的查询 Q6 对基准中最大的表 `lineitem` 进行了全表扫描。
- `off` 表示 `PRAGMA prefetch_pages=0`:PR 中未启用预读的代码。
- `on` 表示预读窗口为 32 页。
## 请求合并
我想看看预读对 I/O 路径有什么影响:Turso 提交了多少请求,以及最终到达设备的请求数。因此我在运行 Q6 的同时执行了 `iostat -dxm 1 sda`,并用 `perf stat` 统计 `io_uring:io_uring_submit_req` 追踪点。
| | off (n=1) | on (n=1) |
|--------|-----------|-----------|
| 提交的 SQE 数 | 195,207 | 218,212 |
| 设备请求数 | ~196,000 | ~16,300 |
| `rareq-sz` | 4.37 KiB | 56.53 KiB |
| `%rrqm` | ~0 | 91-93% |
`rareq-sz` 是到达设备的读取请求平均大小。`%rrqm` 是在发出前与另一个请求*合并*的读取请求百分比。
开启预读后,Turso 提交了多出 23,005 个 SQE,获取了比实际需要更多的页面,导致设备读取了更多字节。但设备接收到的请求数却减少了。
如果队列中有两个请求覆盖的扇区是相邻的,块层会将它们合并成一个更大的请求。这仅在两个请求同时存在于队列中时才有效。我用 perf 统计了合并追踪点。
预读关闭时,195,516 个 bios 中只有 140 个被合并。这是合理的,因为队列中只有一个 SQE,没有可合并的对象。开启预读后,218,493 个 bios 中有 202,539 个被合并,设备只收到了 15,951 个请求。因为队列中有多个 SQE,内核对它们进行了合并。
## 轮询线程
Turso 使用带 `sqpoll` 功能的 io_uring。`sqpoll` 使用一个线程不断轮询检查是否有工作提交,以及内核是否需要执行操作。这是一个通过持续占用 CPU 来跟踪工作的线程。
我用 `prefetch_pages=32` 计时了 `sqpoll` 的执行情况,以了解时间花在哪里:实际耗时 8.22 秒,用户时间 3.70 秒,系统时间 8.46 秒(七次运行的中位数)。系统时间大于实际时间,这只有在两个线程同时占用 CPU 时才可能发生。
我原本以为 CPU 周期会消耗在查询代码上,但:
在使用 SQ 轮询的 io_uring 后端上运行 Q6 时,`io_sq_thread`(内核轮询线程)占用了 65% 的 CPU 周期。
因此我重新构建了 Turso,去掉了 SQ 轮询,将 `setup_sqpoll` 构建器替换为普通的 `IoUring::new`(这也是 Turso 在 `sqpoll` 设置失败时的后备方案)。
普通/默认环:实际耗时 8.62 秒,用户时间 3.62 秒,系统时间 1.27 秒。去掉轮询线程后,实际时间略有增加,但系统时间大幅减少。系统时间不为零是因为我们每次提交仍然要调用 `io_uring_enter(2)`,而 Turso 每次只提交一个 SQE。
是否使用轮询取决于机器配置。Didona 等人在 2022 年 SYSTOR 论文(https://atlarge-research.com/pdfs/2022-systor-apis.pdf)中测量过:使用单个 NVMe 驱动器和一个 CPU 核心时,提交轮询仅达到 13 KIOPS(每秒 13,000 次 I/O 操作)——两个线程必须共享一个核心,因此它们交替执行。使用第二个核心时,性能完全恢复。我使用的这台机器有 4 个 vCPU。查询是单线程的,使用一个带轮询线程的环,而且始终有一个空闲核心供轮询线程使用,因此它从未与查询争夺 CPU。
## 缓存未命中
关闭 SQ 轮询后,我再次在两个后端上计时了 Q6:io_uring 耗时 8.55 秒,syscall 耗时 3.02 秒。差异显著。我想了解多出的时间花在哪里,于是用 perf 统计了指令数和缓存未命中数。关于表中的计数器需要说明:它们来自重新构建的主机(谁愿意为闲置时间付钱给 Hetzner?),因此绝对值与上面的计时结果不匹配。
| 后端 | 周期数(中位数,n=7) | 指令数(中位数,n=7) | IPC | 缓存未命中数 | 未命中率 |
|--------------|----------------------|----------------------|------|--------------|----------|
| io_uring 普通 | 5.374 B | 21.497 B | 3.999| 10.666 M | 13.255% |
| syscall | 4.734 B | 21.297 B | 4.499| 5.889 M | 7.691% |
io_uring 多执行了约 0.2 B 条指令,这几乎可以忽略不计,但它多出了 4.8 M 次缓存未命中。
两个后端都使用 DMA:磁盘硬件自行将数据写入 RAM,无需 CPU 执行操作。但两个后端之间也存在差异:在缓冲读取中,磁盘将数据写入页缓存²。然后内核将数据从页缓存复制到进程缓冲区。这个复制是正常的 CPU 工作:CPU 读取字节并将其写入某处。复制的副作用是:数据最终会进入 CPU 缓存(L1/L2/L3)。
而在使用 `O_DIRECT` 时,磁盘直接将数据写入进程缓冲区,跳过了复制步骤,这意味着 CPU 不参与该过程,因此不会有任何东西被复制到 CPU 缓存中。
这个解释是一种假设,计数器显示 io_uring 比 syscall 有更多缓存未命中,但我从未追踪这些未命中与缺失的复制步骤之间的直接关联。
## 成本
经过以上分析,我对每种模型的成本有一些看法:
- 使用 `O_DIRECT` 且没有应用层预读的 io_uring 运行时,环中只有一个 SQE。单个 SQE 意味着没有并发性,没有并发性,块层就没有可合并的内容。这是我的测量中最慢的配置。
- `sqpoll` 在机器拥有多个 vCPU 时是一个合理的模型。但你仍然需要了解你的应用如何使用资源。如果应用本身 CPU 密集,轮询线程将与之竞争 CPU。在这种情况下,最好先测量将一个 vCPU 专用于内核线程的影响,再开启 `sqpoll`。
- 普通/默认的 io_uring 也是合理的,因为它可以批量处理。一次 `io_uring_enter(2)` 调用可以批量提交多个 SQE。SYSTOR 论文对此进行了测量:在队列深度为 64 时,每次 I/O 平均只需 1.01 次系统调用。Turso 目前不做批量处理,因此它为每一页都支付一次系统调用。提交循环已经为所有待处理操作调用 `submit_and_wait`,但分页器请求一页并等待它,因此永远不会有超过一个待处理操作:
除了模型之外,关于成本还有两点:
- 在缓冲读取中,内核将数据从页缓存复制到进程缓冲区,这个复制使用了 CPU。复制有一个副作用:数据会“预热”到 CPU 缓存中。`O_DIRECT` 跳过了复制,但数据最终仍需到达 CPU。在缓冲读取中,这发生在复制期间。使用 `O_DIRECT` 时,这发生在查询期间,表现为缓存未命中。
- 预读会浪费一些工作。Turso 提交了多出 23,005 个 SQE,获取了查询从未使用的页面,设备读取了更多字节。但额外的请求使队列保持满载状态,如果没有它们,队列将退回到每次只处理一个请求的状态。
相似文章
PostgreSQL 19 中的内核异步读取(io_uring)
PostgreSQL 19 通过 io_uring 引入了内核异步读取功能,实现了直接的异步缓冲 I/O,从而在不使用专用工作进程的情况下提高性能。
探索使用 io_uring 的自动缓冲区管理
本文详细介绍了在 UringMachine(一个用于异步 I/O 的 Ruby gem)中使用 io_uring 的缓冲区环实现自动缓冲区管理。它解释了缓冲区环如何通过允许内核使用应用程序提供的缓冲区来实现高效的多重读取/接收操作。
io_uring 的线程身份切换方案
本文介绍了 Jens Axboe 为 io_uring 提交的 RFC 补丁集,该方案提出在不切换上下文的情况下处理可能阻塞的操作,从而通过减少开销来提高性能。
我们用io_uring替换了Rust查询引擎中的MMAP,结果变慢了
Conviva的这篇技术博客文章描述了一个实验:在他们的Rust查询引擎中,用io_uring替换mmap后,在高并发下由于页面缓存抖动和页面错误导致性能变慢。
Linux中Epoll与Io_uring的对比
对Linux中epoll和io_uring的技术对比,解释它们用于异步I/O的架构和性能特点。