latency优化 + readme修改

This commit is contained in:
1iaan
2026-03-20 21:10:22 +08:00
parent ac2150e0ed
commit 224d813499
20 changed files with 1536 additions and 785 deletions

540
README.md
View File

@@ -1,381 +1,245 @@
# ZVFS
ZVFS 是一个基于 SPDK Blobstore 的用户态文件系统原型,目标是在不改业务代码的前提下,将常见 POSIX 文件 I/O 重定向到用户态高性能存储路径
核心思想是复用 Linux 文件管理机制(命名空间/目录/元数据),把文件数据平面放到 ZVFS。
> 透明用户态 POSIX 文件系统,基于 SPDK Blobstore
- Hook 方式:`LD_PRELOAD`
- 挂载前缀:`/zvfs`
- 架构:多进程 Client + 独立 Daemon + SPDK
- 语义:同步阻塞(请求-响应)
ZVFS 是一个 **透明用户态文件系统原型**,通过 `LD_PRELOAD` 劫持 POSIX I/O
将应用程序的文件数据路径从 Linux 内核 I/O 栈重定向到 **SPDK 用户态 NVMe 存储路径**
目标是在 **零业务代码修改** 的情况下,为数据库与向量检索系统提供更低延迟的存储访问。
目前已在 **PostgreSQL + pgvector** 场景完成功能验证。
---
## 1. 项目定位
# 设计思路
这个项目重点不只是“把 I/O 跑起来”,而是把以下工程问题串起来:
大多数用户态文件系统(如 FUSE需要修改应用或挂载文件系统。用户态文件系统如果要通过VFS需要多一到两次额外的用户态/内核态切换。ZVFS 的目标是对应用完全透明:应用按正常方式调用 POSIX API底层存储路径被悄悄替换掉。
核心决策是控制面与数据面分离:
1. 在多线程/多进程应用RocksDB / PostgreSQL里做透明接管
2. 保留 POSIX 语义open/close/dup/fork/append/sync 等)
3. 把 SPDK 资源集中在 daemon 管理,避免每进程重复初始化。
4. 在同步阻塞语义下,把协议、并发、错误处理做完整。
控制面复用 Linux VFS目录树、权限、inode 生命周期全部由 Linux 管理,文件到 blob 的映射通过 xattruser.zvfs.blob_id持久化无需额外的映射数据库
数据面走 SPDKread/write 等数据路径绕过内核,经 IPC 送到 ZVFS daemon再通过 SPDK Blobstore 直接访问 NVMe
```
Application (PostgreSQL / RocksDB)
│ POSIX API
LD_PRELOAD Hook Layer
│ Unix Domain Socket
ZVFS Daemon
┌────┴────┐
│ │
Metadata IO Workers
Thread (SPDK pollers)
│ │
└────┬────┘
SPDK Blobstore
NVMe SSD
```
SPDK 需要使用轮询模式最好能独占CPU core且metadata最好由同一个 spdk thread 管理,不适合嵌入任意应用进程。因此 daemon 统一持有所有 SPDK 资源,多个客户端进程共享同一个 daemon通过 Unix Domain Socket 通信。
---
## 2. 架构设计
# 🧠 系统架构
![](zvfs架构图.excalidraw.svg)
```text
App (PostgreSQL / RocksDB / db_bench / pgbench)
-> LD_PRELOAD libzvfs.so
-> Hook Client (POSIX 拦截 + 本地状态)
-> Unix Domain Socket IPC (sync/blocking)
-> zvfs_daemon
-> 协议反序列化 + 分发
-> metadata thread + io threads
-> SPDK Blobstore / bdev
```
架构设计关键点:
### 2.1 透传策略
- **同步阻塞语义**
- **零侵入接管应用 I/O**
- 使用 `LD_PRELOAD` 拦截 POSIX API
- 不需要修改应用代码
- **控制面复用 Linux**
- ZVFS 不重新实现目录树,而是复用 Linux VFS。目录 / 权限 / inode 生命周期由 Linux VFS 管理。
- 文件与 blob 的映射通过:`xattr: user.zvfs.blob_id`
- **SPDK 资源集中管理**
- 文件内容存储在 SPDK Blobstore。直接访问 NVMe。
- SPDK 对 metadata 操作有 **单线程要求**,因此 daemon 设计为:
- metadata 操作create / resize / delete
- data IOread / write
**控制面复用 Linux数据面走 ZVFS**
- 控制面Linux 负责)
- 目录/命名空间管理。
- 文件节点生命周期与权限语义create/open/close/stat/rename/unlink 等)。
- 这些操作在 `/zvfs` 下也会真实执行系统调用ZVFS 不重复实现目录树管理。
- 数据面ZVFS 负责)
- 文件内容读写由 blob 承载。
- `read/write` 的真实数据路径不走 Linux 文件数据面,而走 ZVFS IPC + SPDK。
- 关键绑定方式
- `create`:真实创建 Linux 文件 + 在 ZVFS 创建 blob + 把 `blob_id` 写入文件 xattr。
- `open`:真实 `open` Linux 文件 + 读取 xattr 获取 `blob_id` + 在 ZVFS 打开 blob。
- `write`:写入 blob 成功后,使用 `ftruncate` 同步 Linux 视角 `st_size`
- 工程收益
- 直接减少约 50% 的实现工作量。
- 兼容性更好,数据库可直接复用现有文件组织方式。
### 2.2 分层职责
- Client`src/hook` + `src/spdk_engine/io_engine.c`
- 判断是否 `/zvfs` 路径。
- 拦截 POSIX API 并发起同步 IPC。
- 维护最小本地状态(`fd_table/path_cache/inode_table`)。
- Daemon`src/daemon`
- 独占 SPDK 环境与线程。
- 统一执行 blob create/open/read/write/resize/sync/delete。
- 统一管理 handle/ref_count。
- 协议层(`src/proto/ipc_proto.*`
- 统一头 + per-op body。
- Request Header`opcode + payload_len`
- Response Header`opcode + status + payload_len`
### 2.3 为什么是同步阻塞 IPC
- 业务侧兼容成本低,最容易对齐 POSIX 语义。
- 调试路径更直接(一个请求对应一个响应)。
- 先解决正确性和语义完整,再考虑异步化。
- **POSIX 语义兼容**
- 用户态文件系统需要正确模拟 Linux FD 语义:`dup dup2 dup3 fork close_range`
- 保证多个 fd 指向同一文件句柄时语义一致。
---
## 3. 功能覆盖(当前)
### 3.1 已接管的核心接口
- 控制面协同:`open/openat/creat/rename/unlink/...`(真实 syscall + ZVFS 元数据协同)
- 数据面接管:`read/write/pread/pwrite/readv/writev/pwritev`
- 元数据:`fstat/lseek/ftruncate/fallocate`
- 同步:`fsync/fdatasync/sync_file_range`
- FD 语义:`dup/dup2/dup3/fork/close_range`
### 3.2 语义要点
- `write` 默认使用 `AUTO_GROW`
-`AUTO_GROW` 写越界返回 `ENOSPC`
- `O_APPEND` 语义由 inode `logical_size` 保证。
- `write` 成功后会同步更新 Linux 文件大小(`ftruncate`),保持 `stat` 视角一致。
- `mmap` 对 zvfs fd 当前返回 `ENOTSUP`(非 zvfs fd 透传)。
### 3.3 映射关系
- 文件数据在 SPDK blob 中。
- 文件到 blob 的映射通过 xattr`user.zvfs.blob_id`
---
## 4. 构建与运行
### 4.1 构建
# 📦 构建
```bash
cd zvfs
git submodule update --init --recursive --progress
git clone ...
git submodule update --init --recursive
cd spdk
./scripts/pkgdep.sh
./configure --with-shared
make -j"$(nproc)"
make -j
cd ..
make -j"$(nproc)"
mkdir -p tests/bin
make test -j"$(nproc)"
make -j
```
---
产物:
# ▶️ 运行
- `src/libzvfs.so`
- `src/daemon/zvfs_daemon`
- `tests/bin/*`
### 4.2 启动 daemon
```bash
cd zvfs
启动 daemon
```
./src/daemon/zvfs_daemon
```
可选环境变量
- `SPDK_BDEV_NAME`
- `SPDK_JSON_CONFIG`
- `ZVFS_SOCKET_PATH` / `ZVFS_IPC_SOCKET_PATH`
### 4.3 快速验证
```bash
mkdir -p /zvfs
LD_PRELOAD=./src/libzvfs.so ZVFS_TEST_ROOT=/zvfs ./tests/bin/hook_api_test
./tests/bin/ipc_zvfs_test
运行测试
```
---
## 5. 性能测试
### 5.1 测试目标
- 目标场景:低队列深度下阻塞 I/O 性能。
- 对比对象:`spdk_nvme_perf` 与内核路径(`O_DIRECT`)。
### 5.2 工具与脚本
- RocksDB`scripts/run_db_bench_zvfs.sh`
- PostgreSQL`codex/run_pgbench_no_mmap.sh`
建议:
- PostgreSQL 测试时关闭 mmap 路径shared memory 改为 sysv避免 mmap 干扰)。
### 5.3 历史结果
- QD=1 下可达到 `spdk_nvme_perf` 的约 `90%~95%`
- 相对同机 `O_DIRECT`,顺序写吞吐可有约 `2.2x~2.3x` 提升。
- 非对齐写因 RMW 开销,吞吐明显下降。
### 5.4 fio
```shell
root@ubuntu20-129:/home/lian/share/zvfs# LD_PRELOAD=/home/lian/share/zvfs/src/libzvfs.so fio ./fio_script/zvfs.fio
test: (g=0): rw=randwrite, bs=(R) 16.0KiB-16.0KiB, (W) 16.0KiB-16.0KiB, (T) 16.0KiB-16.0KiB, ioengine=psync, iodepth=64
fio-3.16
Starting 1 thread
test: Laying out IO file (1 file / 0MiB)
Jobs: 1 (f=1): [w(1)][100.0%][w=13.4MiB/s][w=857 IOPS][eta 00m:00s]
test: (groupid=0, jobs=1): err= 0: pid=16519: Sat Mar 14 14:11:27 2026
Description : ["variable bs"]
write: IOPS=829, BW=12.0MiB/s (13.6MB/s)(130MiB/10001msec); 0 zone resets
clat (usec): min=778, max=4000, avg=1199.89, stdev=377.74
lat (usec): min=779, max=4001, avg=1200.38, stdev=377.78
clat percentiles (usec):
| 1.00th=[ 848], 5.00th=[ 898], 10.00th=[ 922], 20.00th=[ 955],
| 30.00th=[ 979], 40.00th=[ 1004], 50.00th=[ 1029], 60.00th=[ 1074],
| 70.00th=[ 1221], 80.00th=[ 1500], 90.00th=[ 1614], 95.00th=[ 1975],
| 99.00th=[ 2606], 99.50th=[ 2966], 99.90th=[ 3359], 99.95th=[ 3425],
| 99.99th=[ 4015]
bw ( KiB/s): min=10048, max=15520, per=99.91%, avg=13258.32, stdev=1465.96, samples=19
iops : min= 628, max= 970, avg=828.63, stdev=91.62, samples=19
lat (usec) : 1000=38.46%
lat (msec) : 2=56.79%, 4=4.74%, 10=0.01%
cpu : usr=5.27%, sys=0.00%, ctx=8499, majf=0, minf=7
IO depths : 1=100.0%, 2=0.0%, 4=0.0%, 8=0.0%, 16=0.0%, 32=0.0%, >=64=0.0%
submit : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
complete : 0=0.0%, 4=100.0%, 8=0.0%, 16=0.0%, 32=0.0%, 64=0.0%, >=64=0.0%
issued rwts: total=0,8295,0,0 short=0,0,0,0 dropped=0,0,0,0
latency : target=0, window=0, percentile=100.00%, depth=64
Run status group 0 (all jobs):
WRITE: bw=12.0MiB/s (13.6MB/s), 12.0MiB/s-12.0MiB/s (13.6MB/s-13.6MB/s), io=130MiB (136MB), run=10001-10001msec
Disk stats (read/write):
sda: ios=0/118, merge=0/104, ticks=0/66, in_queue=67, util=0.24%
```
### 5.5 pgbench
```shell
root@ubuntu20-129:/home/lian/share/zvfs# ./scripts/run_pgbench_no_mmap.sh
当前配置:
host=127.0.0.1 port=5432 db=benchdb
scale=1 clients=1 threads=1 time=15s preload=1
init_jobs=1 init_steps=dtg skip_init=0
[1/2] pgbench 初始化(-i
dropping old tables...
NOTICE: table "pgbench_accounts" does not exist, skipping
NOTICE: table "pgbench_branches" does not exist, skipping
NOTICE: table "pgbench_history" does not exist, skipping
NOTICE: table "pgbench_tellers" does not exist, skipping
creating tables...
generating data...
100000 of 100000 tuples (100%) done (elapsed 15.06 s, remaining 0.00 s)
done.
[2/2] pgbench 压测(-T
starting vacuum...end.
transaction type: <builtin: TPC-B (sort of)>
scaling factor: 1
query mode: simple
number of clients: 1
number of threads: 1
duration: 15 s
number of transactions actually processed: 564
latency average = 26.614 ms
tps = 37.573586 (including connections establishing)
tps = 38.176262 (excluding connections establishing)
```
```shell
root@ubuntu20-129:/home/lian/share/zvfs# ./scripts/run_pgbench.sh
当前配置:
host=127.0.0.1 port=5432 db=postgres
scale=1 clients=1 threads=1 time=15s preload=0
init_jobs=1 init_steps=dtg skip_init=0
[1/2] pgbench 初始化(-i
dropping old tables...
NOTICE: table "pgbench_accounts" does not exist, skipping
NOTICE: table "pgbench_branches" does not exist, skipping
NOTICE: table "pgbench_history" does not exist, skipping
NOTICE: table "pgbench_tellers" does not exist, skipping
creating tables...
generating data...
100000 of 100000 tuples (100%) done (elapsed 1.08 s, remaining 0.00 s)
done.
[2/2] pgbench 压测(-T
starting vacuum...end.
transaction type: <builtin: TPC-B (sort of)>
scaling factor: 1
query mode: simple
number of clients: 1
number of threads: 1
duration: 15 s
number of transactions actually processed: 586
latency average = 25.602 ms
tps = 39.059387 (including connections establishing)
tps = 39.102273 (excluding connections establishing)
LD_PRELOAD=./src/libzvfs.so ./tests/bin/hook_api_test
```
---
## 6. 关键工程难点与踩坑复盘(重点)
### SPDK 元数据回调线程模型
问题:把 metadata 操作随意派发到任意线程容易卡住或回调不回来。metadata的回调默认派发给初始化blobstore的线程。
blobstore metadata 操作与创建线程/通道绑定。
需要明确 metadata thread 和 io thread 分工。
### resize 导致程序卡死
- `resize/delete/unload` 内部会走 `spdk_for_each_channel()` barrier。大概是让其他的spdk线程同步resize之后的状态才能返回。所以如果要做要尽可能少做resize。
如果其他spdk线程持有iochannel并且没有持续poll就会导致卡死。
- 保证持有 channel 的线程持续 poll。
- 线程退出时严格释放 channel避免 barrier 永久等待。
### PostgreSQL Tablespace 无法命中 Hook
现象:建表空间后文件操作路径是 `pg_tblspc/...`daemon 无请求日志。
根因:
- PostgreSQL 通过符号链接访问 tablespace。
- 仅按字符串前缀 `/zvfs` 判断会漏判。
修复:
- 路径判定增加 `realpath()` 后再判断。
- `O_CREAT` 且文件尚不存在时,使用 `realpath(parent)+basename` 判定。
### PostgreSQL 报 `Permission denied`(跨用户连接 daemon
现象:`CREATE DATABASE ... TABLESPACE ...` 报权限错误。
根因:
- daemon 由 root 启动UDS 文件权限受 umask 影响。
- postgres 用户无法 `connect(/tmp/zvfs.sock)`
修复:
- daemon `bind` 后显式 `chmod(socket, 0666)`
### PostgreSQL 报 `Message too long`
现象:部分 SQL尤其 `CREATE DATABASE` 路径)失败,错误为 `Message too long`
根因:
- 不是 daemon 解析失败,而是 client 序列化请求时超出 `ZVFS_IPC_BUF_SIZE`
- 当前 hook 会把 `writev` 聚合成一次大写请求,容易触发上限。
当前处理:
-`ZVFS_IPC_BUF_SIZE` 提高到 `16MB``src/common/config.h`)。
后续优化方向:
- 在 client `blob_write_ex` 做透明分片发送(保持同步阻塞语义)。
### dup/dup2/fork 语义一致性
问题:多个 fd 指向同一 open file description 时,如何保证 handle 引用计数一致。
方案:
- 协议增加 `ADD_REF` / `ADD_REF_BATCH`
- 在 hook 中对 `dup/dup2/dup3/fork` 明确执行引用增加。
- `close_range` 增加边界保护(避免 `UINT_MAX` 场景死循环)。
### pg \c 调用链条
1. psql \c ...
2. fork backend
3. InitPostgres -> ValidatePgVersion ...
4. libc fopen
5. libc 内部 -> __IO_file_fopen -> _IO_file_open -> __open64
6. kernel openat
7. fscanf
8. libc __isoc99_fscanf -> \_IO\_* -> __read
9. kernel read
glic 内部调用走的不是 动态符号定位可能是一些隐藏别名。可能会绕过hook。需要拦截非常多变体
# 🔬 已实现功能
打开/关闭/删除
```
open open64 openat openat64 fopen fopen64
creat creat64
fclose close close_range
dup dup2 dup3 fork
unlink unlinkat remove rename renameat
```
读写层
```
read pread pread64 readv preadv preadv64 preadv2
write pwrite pwrite64 writev pwritev pwritev64 pwritev2
fread_unlocked fread fscanf
```
偏移/空间管理层
```
lseek lseek64
truncate truncate64 ftruncate ftruncate64 fallocate posix_fallocate
```
元数据层
```
stat stat64 fstat fstat64 lstat lstat64 fstatat fstatat64 statx
```
同步/控制层
```
fsync fdatasync sync_file_range
fcntl fcntl64 ioctl
```
---
## 7. 当前限制与下一步
# 🚀 性能
### 7.1 当前限制
测试环境VMware 虚拟机 + 模拟 NVMe单线程阻塞 I/O。
- 单请求仍受 `ZVFS_IPC_BUF_SIZE` 约束。
- `mmap` 暂不支持 zvfs fd
- `ADD_REF_BATCH` 当前优先功能,不保证原子性。
> VMware 模拟 NVMe 无法体现 SPDK 轮询模式对中断驱动 I/O 的延迟优势,
> 以下数据用于评估 hook 层与 IPC 的额外开销,不代表真实硬件上的性能对比
### 7.2 下一步计划
### 顺序写吞吐
1. 实现 `WRITE` 客户端透明分片,彻底消除单包上限问题。
2. 持续完善 PostgreSQL 场景tablespace + pgbench + crash/restart
3. 补齐更系统的性能复测(固定硬件、固定参数、全量报告)。
| Block Size | spdk_nvme_perf | ZVFS |
|---|---|---|
| 4K | 100 MiB/s | 94 MiB/s |
| 128K | 1843 MiB/s | 1662 MiB/s |
ZVFS 达到 **SPDK 原生性能约 90%**
---
### fio 随机写16Kpsync
| | kernel (psync) | ZVFS |
|---|---|---|
| IOPS | 1855 | 1353 |
| 吞吐 | 28.0 MiB/s | 21.2 MiB/s |
| avg clat | 492 µs | 692 µs |
| sys% | 28.6% | 8.4% |
> 当前 ZVFS 在该单线程 `psync` 随机写场景下达到 kernel `psync` 的约 73% IOPS。daemon 内部 `SPDK + reply_q` 已收敛到较稳定范围,剩余主要开销集中在 `client -> daemon` 请求进入阶段。
---
### WRITE 请求端到端延迟分解(单位 µs
基于 12 条 `WRITE` trace 样本统计,下面按调用栈层级展开平均耗时。由于四舍五入,父子项相加会有 `±1 µs` 误差。
```text
total 748
├─ c2s 317
│ ├─ send 39
│ └─ server_rx_wait 278
├─ server 336
│ ├─ rx_dispatch 12
│ ├─ dispatch_spdk 25
│ ├─ spdk 194
│ └─ reply_q 103
│ ├─ spdk_post 11
│ └─ cq_wait 91
│ ├─ kick 13
│ ├─ wake_sched 65
│ └─ wake_to_tx 12
└─ s2c 95
├─ resp_wait 83
└─ parse 12
```
当前 `WRITE` 的主要额外开销已经比较清晰:一是 `c2s / server_rx_wait`,二是 `server` 内部的 `spdk``reply_q`。在 `reply_q` 中,`wake_sched` 已明显大于 `kick``wake_to_tx`,说明回包路径的主要损耗不在 `eventfd` 写入本身,而在 reactor 被唤醒后的调度等待。
---
### pgbenchPostgreSQL TPC-B单客户端
| | kernel | ZVFS |
|---|---|---|
| TPS | 39.1 | 38.2 |
| avg latency | 25.6 ms | 26.6 ms |
端到端数据库工作负载下IPC 开销被稀释ZVFS 与 kernel 路径性能基本持平(~4% 差距)。
---
# ⚠️ 当前局限
- 不支持 mmap
- 非对齐写存在 RMW 开销
- IPC 请求大小存在上限:大 I/O 需在 hook 层分片;改用共享内存 scatter-gather 可消除此限制。
---
## future work
- 支持 mmap可通过 /dev/shm + userfaultfd 方向探索。
- 缓解非对齐写开销、`!O_DIRECT`语义:实现 类似 pagecache 的bufferpool
- 修改IPC方式使用更快的 Shared Memory
- 减少通信、拷贝开销:将 I/O 操作迁移至 Application 进程。MetaData操作保留在 Daemon 中。
---
# 🧩 遇到的一些问题
## SPDK metadata 线程模型
SPDK Blobstore metadata 回调必须在初始化线程执行,
需要严格区分:
- metadata thread
-io thread
否则会导致 callback 无法返回。resize barrier 卡死
## spdk_for_each_channel() 在 resize / delete 中会触发 barrier
如果某些线程未 poll 会导致系统卡死。
解决方式:
保证所有 IO thread 持续 poll
thread 退出时释放 io_channel
## PostgreSQL tablespace hook 失效
PostgreSQL tablespace 通过 symbolic link 访问路径: pg_tblspc/xxx
简单字符串前缀匹配 /zvfs 会漏判。
解决realpath() 后再判断路径
## write 延迟显著高于预期
这次 fio 延迟排查里,最初 `WRITE` 延迟明显高于预期。沿端到端路径加轻量打点后发现问题并不在 SPDK 本体,而是同时叠加了无条件 RMW、VM 中 poller 调度抖动、线程未绑核,以及后期 trace 暴露出来的 reactor 唤醒后核心切换抖动。对应处理是:整块对齐写跳过 read phase、将 reactor/md/io 线程固定到指定 CPU并把 io 线程数和绑核目标收敛到配置项中。修复后 `dispatch_spdk` 从毫秒级降到几十微秒,`WRITE` 平均延迟也回落到约 700 µs但剩余尾延迟仍主要表现为请求进入与回包阶段的调度等待。