起因:一个来自运维同事的问题#
前阵子运维同事找到我,大意是:
“我们现在训练特征大部分还是 NumPy / Parquet。最近看到 Lance,说是一个更适合 AI 训练、推理的数据格式。我们为什么没有用 Lance?”
说实话,最开始我对 Lance 也不熟。
但讨论几轮之后,发现真正有意思的问题其实不是"要不要换 Lance",而是:
我们到底需要什么样的训练数据存储格式?
以及:一个号称 AI-native、支持 random access 和 column projection 的格式,放到我们真实的 GPFS workload 下,到底有没有优势?
这篇文章就是沿着这个问题展开的分析过程。
我们现在是怎么存训练特征的#
先交代一下背景。
我们做的是 ML / Quant 类训练,特征基本都是 dense numerical features。不是图片、视频、文本那种多模态数据——就是大量数值。
存储方式很直接:
- NumPy 负责存纯数据(
.npy文件) - feature name、column index 等 schema 信息放在单独的
map.json
可以把它理解成一种非常简单的内部 tensor storage format。
数据放在 GPFS(IBM Storage Scale)的 scratch filesystem 上,按 date + timecut 切分。
一个典型的 NumPy 文件大致是这样的 shape:
1shape = (6000, 20000)
2dtype = float32单文件大小:
$$6000 \times 20000 \times 4\ \text{bytes} = 480{,}000{,}000\ \text{bytes} \approx 480\ \text{MB}$$其中 6000 是 rows / samples,20000 是 features。
实际一个模型通常不会用全部 20,000 个 feature。比较典型的情况是选大约 3000 个。而且这 3000 个 feature 通常很离散,不是 feature[0:3000] 这种连续范围,而更像:
1feature 3, feature 17, feature 81, feature 239, feature 1007, ...一个 highly scattered feature subset。这点后面会反复提到。
为什么不用 mmap#
直觉上,看到这个场景,很容易想到:
1data = np.load("features.npy", mmap_mode="r")
2selected = data[:, feature_indices]模型只需要 3000 / 20000 个 feature,那 mmap 只访问需要的数据,不就省了吗?
这只是从 application / virtual memory 层面看问题。
我们过去实际观察过 mmap 在这个场景下的行为。问题是:
mmap 会把大量离散 feature access 转化成大量 page fault / random I/O,从而明显提高 GPFS IOPS,对共享存储的整体可用性产生影响。
具体来说,NumPy 文件是 row-major 的。当你访问 data[:, col_i] 时,每一行的那个 column 在文件里不是连续的——跨越整行 20000 × 4 = 80KB 的间距。于是每个 feature 的访问模式变成大量散落的 4KB page fault,乘以 3000 个 feature,IOPS 就爆了。
所以现在我们主动选择:
1data = np.load("features.npy") # full load
2selected = data[:, feature_indices]把一个文件完整读入内存,然后在 RAM 里做 feature selection。
虽然模型可能只需要 15% 的 feature,但仍然读取整个 480MB。
也就是说当前的数据路径是:
flowchart LR
GPFS["GPFS"] --> NPY["NPY File
480MB Sequential Read"]
NPY --> RAM["RAM"]
RAM --> FS["Feature Subset
~72MB"]
FS --> SI["Shuffle Indices"]
SI --> T["Training"]
这是一个刻意做出的 engineering trade-off:宁可多消耗一些 sequential bandwidth,也不要制造大量 small random I/O。
需要强调:我们不用 mmap,不是因为不知道 mmap,而是因为在共享 GPFS 上,IOPS 本身就是一种稀缺资源。
Shuffle 不是当前问题#
先澄清一个容易误解的点。
训练会做 global shuffle,但我们并不是让 storage 按随机 sample 顺序读。做法是:
1data = np.load("features.npy")
2indices = np.arange(N)
3np.random.shuffle(indices)
4# 训练按照 shuffled indices 访问 RAMshuffle 的是 indices,不是重新随机读取 GPFS。数据先整块读进来,shuffle 完全发生在内存里。
所以:global shuffle 本身不会增加当前 storage IOPS。
这点需要明确,因为很多介绍 Lance 的文章会强调 “random sampling / shuffle / ML training”,然后很自然推导出"Lance random access 很快,所以很适合我们"。
但对于我们的当前架构,这并不是 Lance 最值得解决的问题。
真正的问题:Feature Subset 导致 Read Amplification#
假设模型选 3000 / 20000 个 feature,有效数据比例:
$$\frac{3000}{20000} = 15\%$$一个文件 480MB,理论上真正需要:
$$480 \times 15\% \approx 72\ \text{MB}$$但当前 np.load() 实际读取 480MB,使用 72MB。
也就是大约存在:
$$\frac{480}{72} \approx 6.7\times \text{ read amplification}$$如果整个训练数据是几百 GB,比如 600GB features,那模型可能真正只需要 ~90GB,但 GPFS 仍然需要提供 600GB read traffic。
用直觉来看:
1当前 NPY:
2
3GPFS 实际读取: ████████████████████ 100%
4模型真正需要: ███ 15%我们当前真正想优化的不是 random shuffle,而是 feature projection 带来的 read amplification。
这才是 Lance 对我们真正产生吸引力的地方。
Lance 到底是什么#
到这里再正式介绍 Lance。
Lance 是一种面向现代 AI / ML workload 设计的 columnar storage format。根据官方文档,它的设计目标包括:
- Columnar layout,支持 selective reads
- 针对 cloud object storage 优化
- 支持 random access
- Arrow-native
- 不采用 Parquet 的传统 row-group 设计,而是使用 page-based 结构
Lance 的 format specification 明确说:
The Lance file format is a columnar container optimized for cloud object stores, random access, and Arrow-native processing.
它在 format 层面分成几层:file format 管 page layout 和 encoding;table format 管 fragments、manifests、versioning;index format 管 search structures。各层解耦。
和我们的讨论最相关的是 file format 这一层——数据到底怎么在磁盘上组织。
Lance 的 Column / Page Layout#
一个 Lance data file 并不是:
1feature_1.lance
2feature_2.lance
3feature_3.lance一列并不等于一个小文件。
更准确的物理结构是:一个 Lance 文件内部包含多个 column,每个 column 由一个或多个 disk page 组成。不同 column 可以有不同数量的 page。文件末尾的 metadata 记录每个 page 的位置和编码方式。
1data.lance
2
3[data pages]
4├── column A
5│ ├── page 0 (some rows)
6│ └── page 1 (remaining rows)
7│
8├── column B
9│ └── page 0 (all rows)
10│
11├── column C
12│ ├── page 0
13│ └── page 1
14│
15└── ...
16
17[footer / metadata]
18 column metadata (offsets, sizes, encoding)每个 disk page 存某一列的一段 rows。
Lance 官方在 protobuf 规范注释里明确建议:
Data pages should be large. … We generally advise that pages be at least 8MB or larger.
原因也很直接:page 太小的话,seek overhead(或者 request overhead)会成为问题。这和我们的 GPFS 场景里担心的问题是一样的。
Lance column select 和 NumPy mmap 的区别#
表面上,两者都能"只读需要的 feature":
1# NumPy mmap
2data = np.load("f.npy", mmap_mode="r")
3selected = data[:, feature_indices]
4
5# Lance
6dataset = lance.dataset("f.lance")
7table = dataset.to_table(columns=selected_columns)区别在哪?
要分清两个层面:
Logical Access——应用真正需要哪些数据:
1feature 3, feature 17, feature 81, ...Physical I/O——storage 最后真正读了多少 byte、多少次 read、每次多大、是 sequential 还是 random。
NumPy mmap 的 abstraction 是 byte offset / virtual address。当你 touch 一个 address 时:
1application touches address → page fault → filesystem → GPFS它完全不知道你在读哪个"feature column"。它只知道某个 byte range 被 access 了,然后按 4KB page 粒度从 storage 拉数据。
Lance reader 则拥有完整的 column/page metadata。它知道:
1我要哪些 columns → 这些 columns 的 pages 在文件哪些 byte ranges → 生成 I/O 请求Lance 的潜在价值不是"lazy load"(因为 mmap 早就能 lazy load),而是 column-aware physical I/O planning。
它能够在发起 I/O 之前,就知道所有需要的 byte ranges,然后有机会做合并、排序、批量发送。mmap 做不到这一点——它只能被动响应 page fault。
3000 个离散 Feature 会不会变成 3000 次 I/O?#
讨论过程中同事提了一个很好的质疑:
“模型需要的 3000 个 feature 很离散。你最终还是得按 offset 去读。那是不是一个 feature 就得读一次?这样请求数还是太多。”
先说明:3000 selected columns 不意味着 3000 个小文件。前面说了,很多 column 可以存在同一个 Lance data file 内。
但问题的本质仍然存在——3000 个离散 columns 可能对应大量离散的 byte ranges。
在我们的场景下,一个 timecut 只有 6000 rows。单个 feature 的数据量:
$$6000 \times 4\ \text{bytes} = 24{,}000\ \text{bytes} \approx 24\ \text{KB}$$如果非常 naive 地理解——20000 个 feature 各自独立存成一小段:
1选 3000 个 feature:3000 × 24KB ≈ 72MB从"总 bytes"看很漂亮。但如果物理行为变成:
1read 24KB, read 24KB, read 24KB, ... × 3000那么对 GPFS:IOPS、random latency、metadata scheduler pressure,可能比当前的 480MB sequential read 糟糕很多。
用图来感受:
1NPY:
2████████████████████████████████
3 ~480MB sequential
4
5
6Naive columnar:
7█ █ █ █ █ █
824K 24K 24K 24K 24K ...
9 数千次 tiny reads所以必须强调:many small files 和 many small I/O operations 是两个完全不同的问题。对 GPFS 来说,后者才是真正危险的。
减少 bytes read,不等于减少 storage cost。
Read Amplification vs IOPS:核心 Trade-off#
把问题抽象一下。我们面对两个指标的对立:
flowchart TB
subgraph ExtremeA["极端 A:NPY full read"]
A1["Read Amplification: 高 (6.7×)"]
A2["IOPS: 低"]
A3["Average I/O Size: 大 (~480MB)"]
end
subgraph ExtremeB["极端 B:精确读每个 feature"]
B1["Read Amplification: 最低 (1×)"]
B2["IOPS: 非常高 (~3000)"]
B3["Average I/O Size: 非常小 (~24KB)"]
end
subgraph Ideal["理想方案"]
I1["适度 Read Amplification"]
I2["可控 IOPS"]
I3["MB-level I/O Size"]
end
ExtremeA -.->|太浪费带宽| Ideal
ExtremeB -.->|太多小 I/O| Ideal
真正合理的系统应该位于两者中间。
不要追求"理论最低 bytes read",而应该追求:
- 适度多读一些数据
- 保持较大的 I/O granularity
- 避免大量 random IOPS
比如:
| 方案 | 读取量 | I/O 次数 | 平均 I/O 大小 |
|---|---|---|---|
| NPY full read | 480MB | ~1 | ~480MB |
| 理想 Lance / grouped | 100~200MB | 几十次 | MB-level |
| Naive Lance | 72MB | ~3000 | ~24KB |
在 GPFS 上,很可能中间方案反而最好。
Read Amplification 和 IOPS 是一组 trade-off。
Logical randomness 不应该直接变成 Physical randomness。
Physical Layout 才是关键:Feature Grouping#
既然单个 feature 只有 24KB,那一个自然的想法是把多个 feature 打成一组。
例如 256 features / group:
$$6000 \times 256 \times 4\ \text{bytes} \approx 6\ \text{MB}$$于是:
1group 0: feature[0:255] ≈ 6MB
2group 1: feature[256:511] ≈ 6MB
3group 2: feature[512:767] ≈ 6MB
4...训练选 3000 个 feature 时,先映射到 feature groups:
1selected features → selected groups假设 3000 个离散 feature 涉及大约 30 个 group:
$$30 \times 6\ \text{MB} = 180\ \text{MB}$$对比:
| 方案 | 读取量 | I/O 模式 |
|---|---|---|
| NPY | 480MB | 1 次大顺序读 |
| Grouped (256/group) | ~180MB | 几十次 MB-level 读 |
| 理论最优 | 72MB | 数千次 tiny 读 |
180MB 方案比 NPY 省了 60% 以上的读取量,同时 I/O 粒度仍然是 MB-level——对 GPFS 来说仍然是 friendly 的。
Storage optimization 往往不是读得越少越好,而是需要选择合适的 physical I/O granularity。
当然 256 只是一个例子。实际应该 benchmark 不同的 group size:64、128、256、512、1024——看哪个在你的 GPFS 集群上是 sweet spot。
另外,当前每个 timecut 只有 6000 rows,单 feature 只有 24KB。如果把多个 timecut 合并(比如 200 个),单 feature 就变成了约 4.8MB,本身就是 MB-level chunk,和 columnar format 的设计更匹配。也就是说 partition 策略本身也是 physical layout 的一部分。
flowchart TD
F["20000 Features"] --> G["Groups of 256"]
G --> SG["Selected Features
→ Selected Groups"]
SG --> IO["几十次 MB-level reads"]
IO --> RAM["RAM"]
RAM --> T["Training"]
Lance Reader 的 I/O Coalescing 能帮到我们吗#
Lance reader 确实实现了 I/O coalescing(见 PR #2636、Issue #1959):当多个请求在 block_size 距离以内时,会合并成一个更大的 read。
但这并不意味着 3000 个离散 column 就能自动合并成几十个大 I/O。如果这些 column 的 page 在文件中物理位置很分散(间隔远大于 block_size),coalescing 帮不上忙。
而在我们的场景下,恰恰就是这种情况——3000 / 20000 的离散 column 选择,物理上大概率是 scattered 的。
还需要注意一点:Lance 主要针对 cloud object storage 优化,而不是 POSIX parallel filesystem。 GPFS 有自己的 pagepool、prefetch、条带化、共享 IOPS quota,和 S3 的 HTTP range request 模型完全不同。“Lance 适合 S3"不能直接推出"Lance 适合 GPFS”。Storage format 的性能没有脱离 workload 的"快"。
结论:在我们的场景下,Lance 并不会比 NumPy 更好#
分析到这里,结论其实已经比较清楚了。
我们的 workload 是:dense float32 matrix、20k features、选 3k highly scattered subset、GPFS 共享存储、IOPS 敏感。
在这个场景下,Lance 的 columnar select 面临一个根本矛盾:
- 它能降低 read amplification(从 480MB 到理论 72MB)
- 但代价是把 1 次大顺序读变成数千次 24KB 的离散读
- 在 IOPS 敏感的共享 GPFS 上,后者很可能比前者更差
而当前的 NPY + np.load 方案:用 bandwidth 换 IOPS,虽然存在 6.7× read amplification,但它只产生一次大顺序 I/O,对 GPFS 的压力非常可控。这不是一个落后的方案——它是在我们的约束条件下刻意做出的 trade-off。
如果真要优化 read amplification,正确的方向是 feature grouping:把 feature 按组打包成 MB-level chunk,用 pread 读取涉及的 group。这不需要换 Lance——一个带 group offset 的简单二进制格式就能做到。
一开始我们讨论的是"要不要把 NumPy 换成 Lance",最后发现这其实不是一个文件格式选择问题。
真正的问题是:在一个 IOPS 敏感的共享 GPFS 上,如何减少 feature projection 的 read amplification,同时又不把大块顺序 I/O 退化成大量细碎的随机 I/O。
Lance 并没有解决这个矛盾。在我们当前的 row count(每 timecut 6000 行)和 feature scatter 程度下,它甚至可能让事情变得更差。