学习 3FS。
原理
解决什么问题
3FS 作为一个训推一体框架,解决的几个痛点是:
- 痛点一:AI 训练中海量数据的“随机读取”瓶颈 在模型训练时,数据加载器(Dataloader)需要不断对海量的训练样本进行全局 Shuffle(打乱重排)以防止模型过拟合。这会产生极其庞大的纯随机读取操作。在传统架构下,为了不让 GPU 等待数据,往往需要预先将数据搬运到计算节点的本地硬盘上。3FS 聚合了全局极高的并发随机读取能力,让计算节点可以直接像访问本地文件一样,在全局数据集上进行无感知的随机采样,省去了繁琐的数据预取和搬运。
- 痛点二:传统文件系统“读缓存失效”造成的内存浪费 主流的操作系统高度依赖 Page Cache(页缓存)和预读机制来加速文件读取。但在 AI 训练的随机洗牌场景下,读过的数据短时间内几乎不会再被重复读取。这意味着传统的“缓存”不仅完全无效,还会大量无谓地侵占计算节点的系统内存,甚至干扰训练任务的稳定性。3FS 针对这一点进行了彻底剥离,它主要依赖 Linux AIO 和 io_uring 直接处理 I/O 操作(Direct I/O),完全抛弃了对本地文件缓存的依赖,把宝贵的内存还给了计算任务。
关于这一点,可以参考 Are You Sure You Want to Use MMAP in Your Database Management System? 这个文章。我们在使用 tantivy 的时候,也发现 tantivy 的 mmap 会逃逸 tiflash 读节点的内存管理,导致频繁出现 memory over limit 的软限制,导致查询报错。
我们开发的 tici 使用的 tantivy 会使用 mmap,并且会对每个 fragment 做 cache。mmap 无法直接控制,frag cache 目前也是必须的,所以这一块的内存管理很被动。 - 痛点三:超大规模集群的“并行 Checkpoint”风暴 万卡集群在训练千亿/万亿参数大模型时,需要高频保存模型权重状态(Checkpoint)以防硬件故障导致训练白费。这就要求系统必须在极短的时间内,将几十 TB 甚至上百 TB 的数据同时且高并发地砸进磁盘。3FS 极高的吞吐上限(官方测试可达极高的 TiB/s 级别),将写入 Checkpoint 导致的集群全量停机等待时间压缩到了极致。
这里面的 Checkpoint 不仅是模型参数 Weights(每一层的权重),还包括优化器的状态,如 Adam 优化器的一阶矩和二阶矩,梯度和一些元数据等。主要的内容是 Adam 部分。
假设你在训练一个 1,750 亿参数(175B)的模型,以半精度(16-bit)保存,权重约 350GB。但加上优化器状态后,一个完整的 Checkpoint 可能高达 2TB 左右。如果是一个万亿参数(MoE 架构)的模型,单个 Checkpoint 达到 10TB-20TB 并不罕见。为了容灾和实验回溯,集群通常会保留多个步数的副本,因此总占用达到百 TB 级别是非常正常的。 - 痛点四:推理阶段 KV Cache 的容量与成本限制 在处理超长上下文(Context)的 LLM 推理时,保存在昂贵 GPU 显存或系统内存(DRAM)中的 KV Cache 会迅速触及容量天花板。3FS 极低的读写延迟和超高并发,让 DeepSeek 能够实现“将 KV Cache 卸载到廉价高速磁盘(SSD)上”的技术方案。这为超长文本的推理提供了一种极具性价比的替代路径。
3FS 的方案
对此,3FS 的方案是:
- 把显存 offload 到 SSD 上。主要还是借助 NVMe 和 RDMA 技术。
- Global Prefix Caching:我理解这一部分主要的优化点是超级冗长的系统提示词 (System Prompt) 以及各种套娃。
另外,是 KVCache 的内存开销,其实是和上下文线性相关的
设计 – design notes
3FS 中有个 design notes,这里主要是概括了 design notes。
需要解决的问题
OSS 方面:
- 现在的 OSS 并不支持原子地移动一系列文件或者一整个目录,或者递归地删除整个目录。而 3FS 的场景(实际上数据库的场景也是这样)涉及要创建一个临时目录,然后对这个目录写入数据,最后将这个目录 move 到最终的位置。
- 3FS 需要广泛使用 symbolic 或者 hard 链接
- 提供一个熟悉的文件接口,我理解这也是 3FS 选择 FUSE 的原因
FUSE 方面:
- 在 Fuse 学习一文中介绍了为什么 FUSE 不支持 Zero Copy。但这也是 FUSE 的缺点之一。
- FUSE 使用一个由 spin lock 保护的多线程共享的队列。3FS 团队的测试显示,400K 4KiB reads per second 的写入负载之后,因为 lock contention 的方法。
- Linux 5.x 上的 fuse 不支持对一个文件的并发写。所以很多需要更大带宽的程序会并发写多个文件。
- 对小的随机非对齐读性能不好,SSD 和 RDMA 网络的带宽没有被利用充分。
但是将 client 实现为一个 VFS 内核模块则能解决上面说的问题,但也更有挑战性。内核的 bug 更难定位和修复。此外,升级的时候需要停掉所有访问这个 fs 的进程,或者重启。
因此,3FS 选择在 FUSE daemon 里面设计一套原生的 client,由它来支持异步的 Zero copy IO。其中,File meta operation 例如 open、close 等还是被 FUSE daemon 处理。但是在 open 的时候会把拿到的 fd 通过 native API 注册。然后就可以通过 native client 去读取数据了。
这个 API 类似于 io_uring,其中关键结构如下:
- Iov
user process 和 native client 共享的内存 - Ior
user process 和 native client 通过这个 ring buffer 进行交互。具体方式类似于 io_uring。
请求会被按照 io_depth 攒批执行,不同的 batch 的执行是并行的。
Metadata 存储
chunk 的分布
文件按 chunk 为粒度,被条带化到多个 replication chain 中。
创建新文件的时候,会根据 stripe size,使用 round robin 的方式,选择一系列的 chain。选出来的这些 chain,会随机分给不同的 chunk 写入。
存储 file atributes
为什么 3FS 的 inode 里的 length 会不准确?因为写路径走的是 CRAQ,而 inode 是在 metadata service 里面的。如果每次写操作完都更新一下 metadata,那么会多一次 metadata RTT,写放大严重,吞吐和延迟都会变差。
但是如果 metadata 迟迟不更新,例如是 100mb,而客户端写到 120mb 就挂了,此时,虽然数据已经通过 CRAQ 持久化到 chunk 存储了,但因为读的时候从 metadata 获得的长度偏小,所以还是在效果上丢失数据。
一种方式是按照 interval 上报更新 metadata,但这就存在不一致窗口。不过先考虑容灾问题,大概有两个方案:
- 重启之后,从 chunk 存储中恢复数据,并由此更新 metadata。但是从 chunk 扫描数据恢复的代价很大
- 3FS 的设计是由 client 按照 interval 上报 max writer position,因为 client 上报的 position 一定是已经被 tail 提交了的,所以是安全的。但是如果 client 长期丢失,那么 gap 就得通过第一种方式补齐了。
Chunk 存储
Suppose there are 6 nodes: A, B, C, D, E, F. Each node has 1 SSD. Create 5 storage targets on each SSD: 1, 2, … 5. Then there are 30 targets in total: A1, A2, A3, …, F5. If each chunk has 3 replicas, a chain table is constructed as follows.
| Chain | Version | Target 1 (head) | Target 2 | Target 3 (tail) |
|---|---|---|---|---|
| 1 | 1 | A1 |
B1 |
C1 |
| 2 | 1 | D1 |
E1 |
F1 |
| 3 | 1 | A2 |
B2 |
C2 |
| 4 | 1 | D2 |
E2 |
F2 |
| 5 | 1 | A3 |
B3 |
C3 |
| 6 | 1 | D3 |
E3 |
F3 |
| 7 | 1 | A4 |
B4 |
C4 |
| 8 | 1 | D4 |
E4 |
F4 |
| 9 | 1 | A5 |
B5 |
C5 |
| 10 | 1 | D5 |
E5 |
F5 |
这里的 Version 是配置的 Version,节点下线会导致这个增大。
这里的一个 Chain 类似于一个 Raft Group 的概念。但是它也不是像 TiKV 一样跟某一段数据绑定的。一个 Chain 可以被多个 chain table 包含。引入 chain table 的概念,这样对于每一个 file,metadata service 就可以为它选一个 chain table,并根据这个 table 中的 Chain 去 strip 这个 file 的所有 chunk。
Balanced traffic during recovery
如果一个节点 A 故障了,就需要由 Chain 中的其他节点来承担原来 A 的流量。而之前的 Chain table 中,A 节点基本上只和 B、C 玩。
在新的架构中,A 在 Chain 2 里和 B/D 在一起,在 Chain 5 里和 C/F 在一起。
Data replication
一个 Write request 可能是从 client 或者 Chain 的前驱发送出来的。一个节点收到 Write request 后的处理:
- 校验 write request 中的 chain version。
- 通过 RDMA Read 去 pull 写入的数据。如果 client 或者前驱挂掉了,导致拿不到数据。写入就 abort。
- Once the write data is fetched into local memory buffer, a lock for the chunk to be updated is acquired from a lock manager. Concurrent writes to the same chunk are blocked. All writes are serialized at the head target.
- 读取这个 Chunk 的 committed version,对它 apply change,然后将更新后的版本存储为 pending version。版本号是单调连续递增的。
- If the service is the tail, the committed version is atomically replaced by the pending version and an acknowledgment message is sent to the predecessor. Otherwise, the write request is forwarded to the successor. When the committed version is updated, the current chain version is stored as a field in the chunk metadata.
- When an acknowledgment message arrives at a storage service, the service replaces the committed version with the pending version and continues to propagate the message to its predecessor. The local chunk lock is then released.
实现
Chain replication
CRAQ 的最大的特点是:复制链路是固定的,而 Raft 的复制链路因为存在 Quorum 是随机的。因此,对于单个 entry 的复制,Raft 的延迟可能会比 CRAQ好,但是 CRAQ 的延迟和吞吐更稳定,可以被预测。
延迟维度的对比:
- Raft 最优
a. 条件:follower 延迟几乎一致、没有网络问题
b. commit latency 约等于 RTT - Raft 最劣
a. 条件:quorum 中某个 follower 抖动,例如出现了 Write Stall
b. Throughput 约等于 1 / avg([max(follower latency)],这里的 avg 因为抖动是不均匀的,因为多数派每次都不一定一样。 - CRAQ 最优
a. 条件:链上每个 node 的 hop_latency 相同,管道用满
b. Throughput ≈ min(hop throughput),这个很简单,木桶效应嘛。 - CRAQ 最劣
a. 条件:链上每个 node 变慢
b. 存在木桶效应,每个 entry 的提交都被固定变慢。
吞吐维度的对比:
- Raft 最优
a. 条件:同延迟。
b. Throughput ≈ follower replication rate - Raft 最劣
a. 条件:同延迟。
b. commit latency 等于 max(quorum follower latency),出现了木桶效应 - CRAQ 最优
a. 条件:链上每个 node 的 hop_latency 相同
b. commit latency 约等于 chain_length * hop_latency。因为 CRAQ 在 Tail 返回确认,所以 3 副本也是两次数据传输,和 Raft 的 1 个 RTT 是接近的。但是链长了,CRAQ 就会变慢。 - CRAQ 最劣
a. 条件:存在一个很慢的 node
b. 同上,但是木桶效应被放大很明显。
CRAQ 的其他特点:
- 没有 quorum 容错
- failover 不需要重新选主,但需要重构链。需要根据挂的是哪一个来讨论。