GPU 训练越来越快,数据加载却仍常沿用一条昂贵的老路径:对象存储下载到本机、写入 NVMe、CPU 解码、转换成训练格式,再复制到 GPU。Onur Satici 介绍的 Vortex 试图缩短这条链路。它是 Linux Foundation 旗下的开源列式文件格式,通过级联轻量编码、基于布局的分段裁剪和零拷贝内存管线,让训练任务可以从 S3 持续向 GPU 输送数据,并在演示场景中达到最高 60 Gbps。
这里真正值得关注的并不只是峰值吞吐量,而是系统边界发生了变化:数据格式不再只是静态存储容器,它开始直接参与查询裁剪、解码调度和设备内存传输。
传统加载链路为什么容易堵塞
典型训练任务可能依次执行以下操作:
- 从 S3 下载完整文件或较大的对象范围。
- 将数据暂存到本地 NVMe。
- 由 CPU 解压、反序列化并转换数据类型。
- 把普通主机内存中的批次复制到页锁定内存。
- 再通过 PCIe 传入 GPU。
每一步单独看都合理,串联后却会产生重复 I/O 和内存复制。GPU 越快,这些固定成本越明显。增加 DataLoader worker 只能提高并行度,无法消除无效读取、重复转换和中间落盘。
Vortex 的思路可以拆成三个互相配合的机制。
级联轻量编码降低解码成本
列式格式可以针对每一列的数据特征选择编码。Vortex 强调级联轻量编码,即组合多个成本较低的编码步骤,在压缩率和解码速度之间寻找适合高吞吐场景的平衡点。
这与一味追求最小文件不同。训练链路更关心的是端到端时间:如果节省的网络字节需要大量 CPU 周期才能恢复,CPU 仍会成为 GPU 前面的闸门。编码策略因此需要同时观察压缩后大小、CPU 解码吞吐和送入设备的速度。
布局元数据让系统跳过无关分段
如果训练或特征读取只需要部分列、部分样本范围,读取整个对象就是浪费。基于布局的 segment pruning 可以利用文件布局和元数据,在请求 S3 数据前判断哪些分段无关。
这项能力会同时减少网络流量、解码工作量和内存压力。它也意味着数据写入时的排序、分段粒度和列布局会影响之后的训练性能。格式本身可以免除统一的预处理转换,但数据布局仍然需要围绕访问模式设计。
零拷贝管线减少中间缓冲区
“从 S3 到 GPU 一次复制”不应简单理解为网络设备在所有环境中都能直接写入显存。更实用的目标是:避免落盘和多轮用户态复制,让对象存储读取、解码结果和设备传输共享可直接交接的缓冲区。
实际效果取决于云网络、S3 客户端、内存分配器、解码实现、GPU 运行时以及硬件拓扑。60 Gbps 是演示中的上限数据,不等于任意数据集和实例类型都能自动达到这一速度。
可以这样搭建一个可测量的加载原型
下面的示例不调用尚未在摘要中给出的 Vortex API,而是用 PyTorch 构造一个可运行的管线骨架:生产线程代表 S3 范围读取和分段解码,页锁定缓冲区让批次可以异步传入 GPU。接入实际项目时,应把 decode_segment 替换成 Vortex 读取器,并由布局裁剪结果生成 S3 Range 请求。
运行前安装 PyTorch;有 CUDA 时会测量 GPU 传输,没有 CUDA 时也能在 CPU 上验证队列和批处理逻辑。
python -m venv .venv
source .venv/bin/activate
pip install torch
python pipeline_demo.py
pipeline_demo.py:
import queue
import threading
import time
import torch
BATCHES = 64
ROWS = 16_384
FEATURES = 256
QUEUE_DEPTH = 4
def decode_segment(index: int) -> torch.Tensor:
# 实际接入时:按布局元数据读取所需 S3 字节范围并用 Vortex 解码。
generator = torch.Generator().manual_seed(index)
return torch.rand((ROWS, FEATURES), generator=generator)
def produce(output: queue.Queue) -> None:
for index in range(BATCHES):
batch = decode_segment(index)
if torch.cuda.is_available():
batch = batch.pin_memory()
output.put(batch)
output.put(None)
def main() -> None:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
pending = queue.Queue(maxsize=QUEUE_DEPTH)
worker = threading.Thread(target=produce, args=(pending,), daemon=True)
started = time.perf_counter()
transferred = 0
worker.start()
while True:
batch = pending.get()
if batch is None:
break
transferred += batch.numel() * batch.element_size()
batch = batch.to(device, non_blocking=torch.cuda.is_available())
_ = batch.square().mean()
if device.type == "cuda":
torch.cuda.synchronize()
elapsed = time.perf_counter() - started
gbps = transferred * 8 / elapsed / 1_000_000_000
print(f"device={device}, batches={BATCHES}, elapsed={elapsed:.2f}s, payload={gbps:.2f} Gbps")
if __name__ == "__main__":
main()
这个原型最重要的部分不是随机数据,而是背压:QUEUE_DEPTH 限制在途批次数,避免生产端持续分配内存。真实实现还应记录每个阶段的耗时,至少包括 S3 等待、下载字节数、裁剪比例、解码时间、主机到设备传输时间和 GPU 空闲时间。
不要只盯着聚合吞吐量
一个加载器显示 60 Gbps,不代表训练一定更快。吞吐测试需要同时回答几个问题:
- GPU 是否因为等数据而空闲,训练步时间降低了多少?
- 统计的是压缩字节、解码后字节,还是实际进入 GPU 的张量字节?
- segment pruning 跳过了多少数据?不同过滤条件下是否稳定?
- CPU 利用率是否下降,还是瓶颈只是转移到了某个解码线程?
- 多个训练节点并发读取时,S3、网络接口和请求配额是否仍能支撑?
- 小对象和随机读取是否产生过多请求费用与尾延迟?
建议用训练 step time 和 GPU 利用率作为主指标,再用网络吞吐解释原因。单独优化 GB/s 很容易得到一个漂亮但与模型训练无关的结果。
采用前的工程检查
Vortex 适合优先在数据量大、重复训练频繁、GPU 等待明显的任务上试点。先选一个真实数据集和固定模型,对比现有链路与新链路的端到端表现,不要只运行格式微基准。
上线前需要确认四件事:布局能否匹配常见列选择和样本过滤;编码是否会把压力重新推给 CPU;缓冲区生命周期是否受到严格控制;对象存储请求成本和失败重试是否纳入测量。还要保留兼容路径,因为格式生态、分析工具和故障排查能力通常比原始吞吐量更慢成熟。
Vortex 展示的核心方向很明确:与其不断扩大本地缓存和 CPU worker 池,不如让文件布局、裁剪、解码与 GPU 传输形成一条连续的数据路径。真正的收益不是省掉某一个函数调用,而是让 GPU 不再等待一连串本可避免的中间步骤。