引言
在百亿亿次级超算上,IO 常常比计算更早撞墙。一个百万核的模拟作业,如果每步都把所有进程的数据写成一个独立文件,元数据操作会直接压垮并行文件系统;而如果只让 rank 0 串行写,写入时间又会随着规模线性增长,最终把计算加速比全部吃掉。
本文按「挑战 → 格式版图 → HDF5 → NetCDF → ADIOS2 → MPI-IO → 布局优化 → 检查点 → 实测调优」讲解科学数据格式与并行 IO 栈:HDF5 的数据模型与并行扩展、NetCDF-4 与 CF 约定、ADIOS2 的引擎抽象、MPI-IO 的集合 IO 与两阶段算法、以及 Lustre 上的条带化与访问模式调优方法。
目录
- 1. 并行 IO 的挑战:为什么 IO 会成为瓶颈
- 2. 数据格式版图
- 3. HDF5:数据模型与并行 HDF5
- 4. NetCDF-4 与 CF 约定
- 5. ADIOS2:引擎抽象
- 6. MPI-IO 与 ROMIO
- 7. 数据布局与访问模式优化
- 8. 检查点与容错 IO
- 9. 性能调优与实测
- 10. 速查表与一句话记忆
- 延伸阅读
1. 并行 IO 的挑战:为什么 IO 会成为瓶颈
1.1 三个结构性矛盾
算力与带宽的剪刀差 每代算力提升远快于存储带宽
进程数与文件数的矛盾 百万进程不可能开百万文件
元数据与数据的失衡 open/stat/close 的开销远超预期
1.2 典型 IO 模式
| 模式 | 描述 | 问题 | 适用规模 |
|---|---|---|---|
| N-to-1 | 所有进程写同一文件 | 锁竞争严重 | 小规模 |
| N-to-N | 每进程一个文件 | 元数据爆炸 | 中等规模,且后处理困难 |
| M-to-N | 若干进程聚合到若干文件 | 需要聚合器 | 大规模推荐 |
| N-to-1 集合 | 全部进程协作写一个文件 | 需要 MPI-IO | 大规模首选 |
现代并行 IO 栈的目标就是把「N 个进程各写各的」改造成「M 个聚合器代表全体进程做集合写」。
1.3 两条技术路线
一条是基于文件语义的路线:POSIX 或 MPI-IO 之上,由 HDF5、NetCDF 提供自描述的数据模型;另一条是基于流的路线:ADIOS2 用引擎抽象把「写数据」与「写文件」解耦,同一套 API 可以切到文件、内存、甚至 RDMA 传输。两者在实践中常常共存。
2. 数据格式版图
2.1 主要格式对比
| 格式 | 数据模型 | 并行支持 | 自描述 | 典型用途 |
|---|---|---|---|---|
| HDF5 | 组与数据集 | 并行 HDF5 | 是 | 通用科学数据、检查点 |
| NetCDF-4 | 维度与变量 | 基于 HDF5 | 是 | 气象、海洋、气候 |
| PnetCDF | NetCDF-3 子集 | MPI-IO 原生 | 是 | 大规模气候 IO |
| ADIOS2 | 变量与步 | 多引擎 | 是 | 大规模耦合与在线分析 |
| Zarr | 分块数组 | 云对象存储 | 是 | 云端与后处理 |
| 原始二进制 | 无 | 无 | 否 | 极简、追求极限性能 |
2.2 选型的四个维度
生态兼容 后处理工具链(ParaView、Python、CDO)是否直接读
并行能力 是否支持集合 IO、是否有元数据瓶颈
性能上限 能否绕过格式开销直接写裸数据
长期可读 自描述与版本兼容,十年后还能不能打开
多数项目的现实结论是:存档用 HDF5/NetCDF,高频检查点用 ADIOS2 或自定义二进制。
3. HDF5:数据模型与并行 HDF5
3.1 数据模型
HDF5 用「组(Group)」组织层次结构,「数据集(Dataset)」存放多维数组,「属性(Attribute)」存放元数据。数据集可以是连续的,也可以是分块的(Chunked):
连续存储 数据在文件中连续,适合整体顺序读写
分块存储 按固定块划分,支持压缩与部分读写,但可能放大 IO
紧凑存储 小数据直接放在元数据里,有大小上限
3.2 并行 HDF5
并行 HDF5 建立在 MPI-IO 之上,关键机制包括:
hid_t fapl = H5Pcreate(H5P_FILE_ACCESS);
H5Pset_fapl_mpio(fapl, MPI_COMM_WORLD, MPI_INFO_NULL);
hid_t file = H5Fcreate("out.h5", H5F_ACC_TRUNC, H5P_DEFAULT, fapl);
hid_t dxpl = H5Pcreate(H5P_DATASET_XFER);
H5Pset_dxpl_mpio(dxpl, H5FD_MPIO_COLLECTIVE); /* 集合 IO 是关键 */
H5Dwrite(dset, H5T_NATIVE_DOUBLE, filespace, memspace, dxpl, buf);
集合 IO(Collective IO)是并行 HDF5 性能的命门:所有进程必须一起调用,库才能做两阶段聚合。混用独立 IO 与集合 IO 会退化为每进程独立写,性能断崖式下跌。
3.3 元数据瓶颈
HDF5 的元数据操作在大规模下会成为瓶颈。对策包括:
- 用
H5Pset_metadata_read_attempts减少元数据重试。 - 减少数据集数量,用单个大数据集 + 索引替代成千上万个小数据集。
- 启用
H5Pset_all_coll_metadata_ops让元数据操作也走集合路径。 - 对高频检查点改用 ADIOS2 或 SCR 的「节点本地暂存 + 异步落盘」。
4. NetCDF-4 与 CF 约定
4.1 与 HDF5 的关系
NetCDF-4 的文件格式就是 HDF5,只是在 HDF5 之上定义了一套更贴近地球科学的 API 与命名约定。因此 NetCDF-4 的性能特征与并行 HDF5 完全一致,调优手段也通用。
4.2 CF 约定
CF(Climate and Forecast)约定规定了变量与属性的命名规范,是气候数据互操作的基础:
dimensions: time, lat, lon, lev
variables: temperature(time, lev, lat, lon)
attributes: units = "K"
_FillValue = 9.96921e+36
coordinates = "lat lon"
cell_methods = "time: mean"
_FillValue 与 scale_factor 的组合让数据可以按 short 存储、按 float 读取,节省一半以上空间——这是 NetCDF 生态里最实用的压缩手段之一。
4.3 PnetCDF
PnetCDF 直接基于 MPI-IO 实现 NetCDF-3 语义,绕开 HDF5 的元数据开销,在大规模气候模式里常能取得比并行 HDF5 更好的写入带宽。代价是只支持 NetCDF-3 的数据模型,没有分组与无损压缩。
5. ADIOS2:引擎抽象
5.1 核心概念
ADIOS2 把「写什么」与「怎么写」彻底解耦:
IO 对象 命名空间,管理变量与引擎
Variable 声明数据(形状、类型、步数)
Engine 实际载体:文件、内存、传输
Step 一次写入的逻辑时间片
5.2 主要引擎
| 引擎 | 目标 | 特点 |
|---|---|---|
| BP5 | 文件 | 默认推荐,面向极端规模优化 |
| BP4 | 文件 | 旧版,兼容性好 |
| SST | 流式 | 进程间在线传输,支持 staging |
| Inline | 内存 | 同进程内联,零拷贝 |
| HDF5 | 文件 | 写 HDF5 格式,便于生态兼容 |
5.3 代码形态
adios2::ADIOS adios(MPI_COMM_WORLD);
adios2::IO io = adios.DeclareIO("sim");
io.SetEngine("BP5");
adios2::Variable<double> v = io.DefineVariable<double>(
"pressure", {global_size}, {offset}, {local_size});
adios2::Engine writer = io.Open("out.bp", adios2::Mode::Write);
writer.BeginStep();
writer.Put(v, data);
writer.EndStep();
writer.Close();
注意 offset 与 local_size 的写法:ADIOS2 用全局形状加偏移描述每个 rank 的局部块,这与 HDF5 的 hyperslab 选择语义等价但更直观。
5.4 性能特征
BP5 在超大规模下的优势主要来自:元数据聚合到少数聚合器、块级压缩、异步落盘,其流式引擎还能与 科学工作流引擎:Nextflow/Snakemake 与管线编排 描述的流水线串成在线分析链路。公开测试中,在数万核规模上 BP5 的写入带宽常能达到并行 HDF5 的数倍,但具体差距取决于文件系统与访问模式。
6. MPI-IO 与 ROMIO
6.1 为什么需要 MPI-IO
POSIX 接口下,每个进程的写操作是独立的,文件系统无法知道全局访问模式。MPI-IO 通过集合调用把全局信息暴露给实现层,从而实现聚合:
MPI_File_open / MPI_File_set_view / MPI_File_write_all / MPI_File_close
MPI_File_write_all 是集合版本,MPI_File_write 是独立版本。只要可能,永远用集合版本。
6.2 两阶段 IO
ROMIO 的核心优化是两阶段 IO(Two-Phase IO):
阶段一:把各进程要写的数据先聚合到若干「聚合器」进程的内存缓冲
阶段二:聚合器用大块连续写落到文件系统
这样做把「大量小随机写」变成「少量大连续写」,显著降低文件系统压力。控制参数通过 info hints 传递:
MPI_Info info;
MPI_Info_create(&info);
MPI_Info_set(info, "romio_cb_write", "enable");
MPI_Info_set(info, "cb_buffer_size", "16777216"); /* 16 MiB */
MPI_Info_set(info, "cb_nodes", "8"); /* 聚合器数量 */
MPI_Info_set(info, "romio_no_indep_rw", "true");
MPI_File_set_info(fh, info);
6.3 关键 hints
| hint | 作用 | 建议 |
|---|---|---|
| romio_cb_write / cb_read | 启用两阶段 | 小记录时开启 |
| cb_buffer_size | 聚合缓冲大小 | 8 到 64 MiB 起调 |
| cb_nodes | 聚合器数量 | 与条带数匹配 |
| romio_no_indep_rw | 禁止独立写 | 集合 IO 场景开启 |
| striping_unit / striping_factor | 传给底层文件系统 | 按 Lustre 配置 |
7. 数据布局与访问模式优化
7.1 让进程的局部块连续
最影响性能的不是格式,而是每个进程要写的数据在全局数组里是否连续。分解方式直接决定 IO 效率:
块分解(Block) 每进程一整块,写操作完全连续 ← IO 最友好
循环分解(Cyclic) 每进程间隔取点,写操作高度离散 ← IO 最差
块-循环分解 折中,兼顾负载均衡与 IO 连续性
域分解时若为了负载均衡必须用循环分解,应在写出前做一次重排到块分解(往往比 IO 本身便宜)。
7.2 分块大小与压缩
HDF5 的 chunk 大小需要与访问模式匹配:
chunk 太小 元数据开销大,压缩率高但 IO 次数多
chunk 太大 压缩率下降,部分读写放大
经验法则 chunk 目标 1 到 4 MiB,且与进程局部块对齐
压缩过滤器(zstd、blosc、sz)能显著减少写入量,但要评估压缩本身的开销。对于已经访存受限的模拟,压缩常常是净收益。
7.3 Lustre 条带化
Lustre 上必须让文件条带数与 IO 并行度匹配:
# 设置目录默认条带:8 个 OST,每 OST 1 MiB
lfs setstripe -c 8 -S 1M /scratch/run42
# 查看现有文件条带
lfs getstripe /scratch/run42/out.h5
条带数过小会让所有流量压到少数 OST;条带数过大则会让单个大块写被切碎。经验值是与聚合器数量、OST 总数保持同一量级。
8. 检查点与容错 IO
8.1 检查点的成本模型
检查点写入时间如果超过计算时间的一定比例(通常 5% 到 10%),就必须优化:
总开销 = 写数据量 / 有效带宽 × 频率
对策:减少频率、减少数据量、提高带宽、异步化
8.2 多层检查点
L1:节点本地 NVMe 暂存,快但节点故障即丢失
L2:并行文件系统,慢但持久
L3:归档存储,最慢但容量大
SCR(Scalable Checkpoint Restart)与 VeloC 这类库实现了 L1 与 L2 之间的自动管理:正常运行时优先写本地,节点故障时从冗余副本恢复。
8.3 异步与在线分析
把 IO 与计算重叠是隐藏检查点开销的最有效手段。ADIOS2 的 SST 引擎支持把数据流式送到分析进程,实现「边算边分析、边算边降采样存档」,从根本上减少需要落盘的数据量。
9. 性能调优与实测
9.1 调优 Checklist
□ 确认 IO 模式:N-to-1 集合 优于 N-to-N
□ 确认使用集合 IO(MPI_File_write_all、H5FD_MPIO_COLLECTIVE)
□ 检查进程局部块是否连续,必要时重排
□ 调整 ROMIO hints(cb_buffer_size、cb_nodes)
□ 对齐 Lustre 条带数与聚合器数量
□ 评估 chunk 大小与压缩过滤器
□ 用异步或本地暂存隐藏检查点开销
□ 测量有效带宽并与文件系统峰值对比
9.2 一个气候模式的实测
以某全球大气模式在 4096 核上的单次检查点写入为例(数据量约 400 GB):
| 配置 | 写入时间 | 有效带宽 | 说明 |
|---|---|---|---|
| N-to-N 每进程一文件 | 210 s | 1.9 GB/s | 元数据瓶颈 |
| 并行 HDF5 默认 | 96 s | 4.2 GB/s | 未调 hints |
| 并行 HDF5 + 集合元数据 | 62 s | 6.5 GB/s | 打开集合元数据 |
| 加 ROMIO hints 与条带对齐 | 41 s | 9.8 GB/s | 两阶段生效 |
| ADIOS2 BP5 | 28 s | 14.3 GB/s | 元数据聚合更彻底 |
这个阶梯很典型:每一步的收益都来自「把离散小写变成连续大写」,而不是来自换格式本身。
9.3 常见坑与对策
| 坑 | 现象 | 对策 |
|---|---|---|
| 混用集合与独立 IO | 性能断崖下跌 | 统一用集合调用 |
| 数据集数量过多 | open 阶段极慢 | 合并为大数据集加索引 |
| chunk 与访问模式错配 | 写放大数倍 | 按局部块对齐 chunk |
| 条带数与并行度不匹配 | 部分 OST 成热点 | lfs setstripe 调整 |
| 检查点频率过高 | 计算被 IO 拖垮 | 降频加本地暂存 |
10. 速查表与一句话记忆
| 维度 | 要点 |
|---|---|
| 模式 | 优先 N-to-1 集合 IO,避免 N-to-N |
| HDF5 | 集合 IO 是命门,控制数据集数量 |
| NetCDF | 格式即 HDF5,CF 约定保证互操作 |
| ADIOS2 | 引擎抽象,BP5 面向极端规模 |
| MPI-IO | write_all 是集合版本,必用 |
| 两阶段 | cb_buffer_size 与 cb_nodes 决定效果 |
| 布局 | 块分解 IO 最友好,循环分解要先重排 |
| 文件系统 | Lustre 条带数与聚合器数量对齐 |
| 检查点 | 本地暂存加异步,控制频率与数据量 |
| 度量 | 用有效带宽与文件系统峰值对比 |
一句话记忆:并行 IO 的全部诀窍就是「把 N 个进程的离散小写变成少数聚合器的连续大写」——用集合 IO、用两阶段、让局部块连续、让条带数与聚合器对齐,格式选择只在最后一步起作用。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。