Skip to main content
  1. Posts/

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

Table of Contents
Note: This article is available in Chinese only. 本文暂无英文版本。 View original

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

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

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

shuffle 的是 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)
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
#

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

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 read480MB~1~480MB
理想 Lance / grouped100~200MB几十次MB-level
Naive Lance72MB~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 模式
NPY480MB1 次大顺序读
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 程度下,它甚至可能让事情变得更差。

参考资料
#

Related