跳过正文
  1. Posts/

Lance 适合我们的训练特征存储吗?从 NumPy、mmap 到 GPFS I/O 的一次分析

·4553 字·10 分钟
目录

起因:一个来自运维同事的问题
#

前阵子运维同事找到我,大意是:

“我们现在训练特征大部分还是 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。

也就是说当前的数据路径是:

data path npy

这是一个刻意做出的 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 访问 RAM

shuffle 的是 indices,不是重新随机读取 GPFS。数据先整块读进来,shuffle 完全发生在内存里。

所以:global shuffle 本身不会增加当前 storage IOPS。

这点需要明确,因为很多介绍 Lance 的文章会强调 “random sampling / shuffle / ML training”,然后很自然推导出"Lance random access 很快,所以很适合我们"。

但在我们的当前架构下,这不是主要矛盾。

真正的问题: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 是一种面向现代 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)
1011├── column C
12│     ├── page 0
13│     └── page 1
1415└── ...
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
#

把问题抽象一下。我们面对两个指标的对立:

read amplification tradeoff

真正合理的系统应该位于两者中间。

不要追求"理论最低 bytes read",而应该追求:

  • 适度多读一些数据
  • 保持较大的 I/O granularity
  • 避免大量 random IOPS

下面按应用层连续读取区间做一个简化估算,帮助比较布局;这不是 GPFS 底层实际 I/O 次数,也不是 Lance benchmark:

方案读取量应用层读取区间数(示意)每区间大小
NPY full read480MB一个连续区间~480MB
理想 Lance / grouped100~200MB几十次MB-level
Naive Lance72MB~3000~24KB

在 GPFS 上,很可能中间方案反而最好。

Read Amplification 和 IOPS 是一组 trade-off。

应用选择的列可以离散,但物理读取是否离散,还要看文件布局和 reader 如何合并请求。

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

如果按访问相关性组织 feature,让一次选择只涉及大约 30 个 group,那么估算是:

$$ 30 \times 6\ \text{MB} = 180\ \text{MB} $$

对比:

方案读取量I/O 模式
NPY480MB连续读取整个文件
Grouped (256/group)~180MB几十次 MB-level 读
理论最优72MB数千次 tiny 读

这个估算的前提是只命中 30 个 group。前面说过 feature 选择很分散,如果只是按 column index 连续分组,实际可能命中大部分 group,读取量就会接近全量。能否得到 180MB 这样的收益,需要用真实的 feature subset 验证。

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 的一部分。

feature grouping

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 选择让这个风险值得检查,但仅凭 column index 的分散程度,还不能确定 Lance 文件里实际的 byte range 分布。

还需要注意一点:Lance 主要针对 cloud object storage 优化,而不是 POSIX parallel filesystem。 GPFS 有自己的 pagepool、prefetch、条带化、共享 IOPS quota,和 S3 的 HTTP range request 模型完全不同。“Lance 适合 S3"不能直接推出"Lance 适合 GPFS”。Storage format 的性能没有脱离 workload 的"快"。

目前还不足以支持迁移
#

我们的 workload 是 dense float32 matrix、20k features 中选择约 3k 个离散 feature,底层是 IOPS 敏感的共享 GPFS。过去对 mmap 的观察,让我们暂时选择完整读取 NumPy 文件,再在内存里选列。

前面的估算说明了迁移需要回答的问题:减少读取字节之后,会不会产生更多细碎的 I/O?它还不能证明 Lance 一定比 NumPy 慢。一次应用层 full load 也不等于底层只有一次 I/O,实际请求会经过文件系统和存储栈的拆分、缓存与预读。

要继续评估,我会拿同一组真实 feature subset 比较 NPY full load、Lance projection 和 grouped layout,记录冷缓存与热缓存下的加载耗时、实际读取量、请求分布,以及多任务并发时对共享存储的影响。Feature grouping 的收益也取决于一次选择到底命中了多少组。

目前的判断是先保留 NPY 方案,优先验证实际 I/O 行为。 如果列裁剪能减少读取量,又不让请求变得过碎,Lance 或其他布局仍然值得考虑。

参考资料
#

相关文章