keelOS:磁盘性能,虚拟机不可欠缺的性能,4K 随机读追上 KVM!
先说结果
同一台机器、同一块 SSD、同一个 Linux 虚拟机,4K 随机读 QD32:keelOS 81.2k IOPS,KVM 79.4k IOPS,102%。 QD1 也是 101%,QD1 的 p99 延迟还低一些(216 µs 对 264 µs)。两天前,同样的测试我们只有 KVM 的 54%。
为什么盯 4K 随机:虚拟机里的数据库、文件服务、系统盘,落到盘上几乎都是 4K 左右的小块随机读写,这是虚拟机每时每刻都在跑的负载。
keelOS 是什么:一个小的 hypervisor(hyp)常驻内存,FreeBSD 作为它上面的管理系统(root)运行 bhyve 和虚拟机;root 可以升级、重启,虚拟机不停。下面的改动都在 root 里的 bhyve 和它的内核模块 vmm.ko。
起点:只有 KVM 的 54%
2026-10-01 第一次对比,4K 随机读 QD32 keelOS 42.9k,KVM 79.4k。 对比是这样做的:同一台开发机(Supermicro X11SPW,Xeon Gold 5122 4 核 8 线程,16 GB),先装 keelOS 跑一遍,再装 Proxmox VE 9.2(KVM)跑一遍。两边都是 ZFS。最后的对照里两边都只用同一块 NVMe SSD、ARC 上限都是 2.65 GB(ARC 是 ZFS 的内存缓存);10-01 那轮 keelOS 这边还是两块盘做镜像,条件略有不同;虚拟机都是 Debian 13、4 个 vCPU、virtio-blk 磁盘,用 fio 测。
接下来三步,每一步都是先量、找到卡在哪里,再改。
第一步:ZFS 不慢,是 bhyve 的干活线程不够
把 bhyve 处理磁盘请求的线程从 8 个加到 32 个:42.9k → 54.8k。
先排除 ZFS。绕过虚拟机,在宿主上直接用 32 个线程打同一个 ZFS 卷,4K 随机读有 114k,是 KVM 虚拟机里的 1.4 倍。所以读的上限不在 ZFS,在 bhyve 这条路上。
FreeBSD 14.5 自带的 OpenZFS(2.2.9)处理 ZFS 卷的读写是同步的:谁发请求,就在谁的线程里做完,异步 I/O(aio)对它也没有并发(实测 QD32 和 QD1 一样)。于是 bhyve 有几个干活线程,就最多有几个请求同时在跑。bhyve 默认 8 个,虚拟机发 32 个也只能排队。改成 32 个,这一步就是 42.9k → 54.8k。
第二步:每个请求一次 exit,改成“空闲线程看环”
盘忙的时候让虚拟机不必通知设备:54.8k → 70.4k。
在宿主上数 exit:虚拟机每秒 54.8k 个请求,就有 54.9k 次“通知”exit,发请求的那个虚拟 CPU 92% 在忙,32 个干活线程却各只有 6%。原因是 virtio 的规矩:guest 把请求放进环形队列后,要写一个端口叫醒设备;设备可以说“我正忙着,别叫我”,但 bhyve 的 virtio-blk 从来不说。于是每个请求都让虚拟 CPU 停下来,退到 bhyve 里取请求、交给干活线程、再回去。
QEMU 的办法是轮询:它的 iothread 在忙的时候先盯着队列看一小会儿(poll-max-ns,默认 32 µs),这段时间把通知关掉。我们照这个思路做在 bhyve 里:
- 干活线程做完一个请求、手上没活时,先不睡,把 guest 的通知关掉,自己盯着队列 32 µs。guest 下一个请求大多就在这段时间里到,它直接拿走,虚拟 CPU 不用停。
- 任何时刻只有一个线程在看。它退出时一定先把通知打开、再看一眼队列,所以不会有请求留在队列里没人管。
- 虚拟机挂起时先把队列里剩的请求取完,检查点的格式没变,跨宿主重新加载照样工作。
代价:盘忙的时候,每块 virtio-blk 盘多一个宿主线程空转;盘闲的时候没有。窗口加到 100 µs QD32 还能再多一些,但 QD1 时白白多占半个核,所以用和 QEMU 一样的 32 µs。
第三步:一个快了 720 ppm 的定时器
同一台虚拟机有时 55k、有时 39k:原因是 guest 的时钟源从 TSC 掉到了 HPET。
量的时候发现结果有两档,而且一旦进了慢的那档,这台虚拟机就一直慢到重启。guest 的内核日志里有一句:
clocksource: timekeeping watchdog on CPU2: Marking clocksource 'tsc' as unstable because the skew is too large
clocksource: Switched to clocksource hpet
Linux 每 0.5 秒拿 HPET 核对一次 TSC,差得太多就放弃 TSC。TSC 是 CPU 自己的计数器,在虚拟机里读它不用 exit;HPET 是模拟的设备,每读一次就是一次 exit。fio 每个请求读两次时钟,换到 HPET 以后整台虚拟机慢三成。
为什么 TSC 会被判成不准?bhyve 模拟的 ACPI PM 定时器快了 720 ppm:它的周期本该是 1199.86,代码里被截成了整数 1199。Linux 启动时拿 PM 定时器去校 TSC 的频率,每次都校成 3597.41 MHz(实际是 3600.000)。之后每次核对都自带 346 µs 的偏差,离判定线(约 500 µs)只差一点抖动。把这个除法改对以后,guest 校出来就是 3600.000 MHz,跑满负载、挂起恢复 30 轮,时钟源一直是 TSC。
这个问题不只影响磁盘,所有大量读时钟的负载(网络、数据库)都会变慢。回头查,10-01 那轮 keelOS 的结果里,guest 中途也掉到了 HPET。
结果
(图:10-01 起点 42.9k(54%)→ 第一步 32 个线程 54.8k(69%)→ 第二、三步 看环 + 时钟修正 70.4k(89%)→ 同条件重测 81.2k(102%);KVM 79.4k。开发机二实测,2026-10-01 至 10-03;第二、三步是镜像池、ARC 930 MB,最后一根和 KVM 同条件。)
最后一根和前一根之间没有改代码,是把条件换成和 KVM 那轮完全一样再量:一块盘、ARC 2.65 GB。同条件下的对照:
| 4K 随机读 | keelOS | KVM | keelOS / KVM |
|---|---|---|---|
| QD32 | 81.2k IOPS | 79.4k IOPS | 102% |
| QD1 | 6.52k IOPS | 6.46k IOPS | 101% |
| QD1 的 p99 延迟 | 216 µs | 264 µs | 更低 |
三步加起来改动不大:一个默认值、bhyve 里一个看队列的小循环、vmm.ko 里一个除法。难的是先量清楚卡在哪里。这些都不用用户设置,keelOS 默认就是这样。
之后,和还没做完的
这之后我们又做了一件事:把“通知”放在 vmm.ko 里直接转给 bhyve 的线程(和 KVM 的 ioeventfd 同一个思路),虚拟 CPU 不用再退到用户态,一次通知从 5.5 µs 降到 2.5 µs。就算看环没接住,QD32 也有 74k。同一批改动里还有 NVMe 和 Windows 的优化,另文再讲。
还没做完的:
- 4K 随机写:KVM 的 77%。主要差在 ZFS 一侧(宿主上直接写也只有 27–30k),不在虚拟化的路上。
- 顺序读写:KVM 的 70–74%,有一个没验证的线索。
- 代价:盘忙的时候每块盘多一个宿主线程空转,和 QEMU 一样。
4K 随机读是虚拟机磁盘最常见的负载,这一项我们已经不输 KVM。