数据截至 (上游 commit cd6a3406572e)
HF Datasets — 架构与原理
30 秒导读: 🤗 Datasets 是 Hugging Face 的训练数据加载与预处理库。它做两件事:一是
load_dataset("org/name")一行把 Hub 上(或本地)的数据变成可直接喂给训练循环的对象;二是用map/filter/shuffle做大规模预处理,结果按"函数指纹"缓存在磁盘上,第二次运行直接复用。底层靠 Apache Arrow 的内存映射,所以语料比 RAM 大也能工作;另有一套IterableDataset支持不下载、边下边读的流式训练。
1. 这是什么(零基础也能懂)
一句话定义
HF Datasets 是一个把"数据集"变成带类型的、可随机访问、可流式迭代的表的 Python 库:底层是一张 Apache Arrow 列式表,上面包了一层缓存、指纹、格式转换的 API。
它要解决谁的什么问题
假设你要训练一个模型,数据是 500 GB 的 JSONL 语料。你会立刻撞上三堵墙:
- 装不下:全读进内存直接爆掉。
- 预处理贵:分词、清洗一遍要几小时;改一行代码重跑脚本,不想再等几小时。
- 下载慢:有时候你只想边下边训,不想先把 500 GB 落盘。
HF Datasets 的三件套正好对应这三堵墙:Arrow 内存映射(数据留在磁盘,按需换页)、指纹缓存(预处理结果按内容寻址,重复运行直接命中)、流式 IterableDataset(远程文件逐块读)。
它能做什么
| 能力 | 入口 |
|---|---|
| 一 行加载 Hub / 本地数据集(CSV、JSON、Parquet、Arrow、文本、imagefolder 等) | load_dataset()(src/datasets/load.py:1480) |
| 惰性流式读取,不下载全量 | load_dataset(..., streaming=True) |
| 大规模预处理:map / filter / shuffle / select / sort / train_test_split | Dataset 的方法(src/datasets/arrow_dataset.py:718) |
| 多进程预处理 | map(num_proc=N) |
| 预处理结果磁盘缓存,重跑零开销 | 自动(指纹机制,src/datasets/fingerprint.py) |
| 多数据源按比例混合 | interleave_datasets()(src/datasets/combine.py:18) |
| 转 NumPy / Pandas / PyTorch / TensorFlow / JAX / Polars 格式 | set_format() |
| 存取自有格式 | save_to_disk() / load_from_disk() / push_to_hub() |
| 检索索引 | add_faiss_index() / add_elasticsearch_index()(src/datasets/search.py) |
用起来什么样
# 典型用法(摘自 README 与 docstring 示例)
from datasets import load_dataset
ds = load_dataset("rajpurkar/squad", split="train") # 下载+转成 Arrow+内存映射打开
ds = ds.map(lambda ex: {"len": len(ex["question"])}) # 预处理,结果写进磁盘缓存
ds = ds.shuffle(seed=42).select(range(1000)) # 打乱+选子集(只写索引映射)
ds.set_format("torch") # 取出时自动转 torch.Tensor
for batch in ds: # 喂给训练循环
...
# 流式:不下载,边下边读
ds = load_dataset("allenai/c4", "en", split="train", streaming=True)
for example in ds.shuffle(seed=42, buffer_size=10_000):
...
一句话直觉
把 HF Datasets 想成"数据集世界的 Git + 虚拟内存"。 磁盘上的 Arrow 文件是对象库,内存映射让它像内存一样被随机访问;每次 map/filter 相当于一次 commit——结果文件以"指纹"为名,指纹由输入数据 + 变换函数的内容决定,同样的输入同样的函数永远命中同一个缓存文件。
2. 顶层全景(它大概怎么转)
2.1 一张图看全局
怎么读这张图: 自上而下是 load_dataset() 一次调用的四个阶段;①② 只有第一次跑才做重活,③④ 是之后每次运行的常态路径。
load_dataset("rajpurkar/squad", split="train")
│
▼
┌──────────────────────────────┐
│ ① 路由:工厂链 │ load.py · dataset_module_factory
│ 本地目录? Hub 仓库? 什么格式? │ → 选中一个打包好的格式构建器
└──────────────┬───────────────┘ (parquet / json / csv / ...)
▼
┌──────────────────────────────┐
│ ② 构建:下载 + 写成 Arrow │ builder.py · download_and_prepare
│ 缓存已存在则整段跳过 │ → <cache>/名字/配置/版本/哈希/*.arrow
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ ③ 打开:内存映射,不进 RAM │ table.py · MemoryMappedTable
│ Dataset._data 指向磁盘文件 │ → 随机访问由 OS 换页支撑
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ ④ 变换:map / filter / shuffle │ arrow_dataset.py + fingerprint.py
│ 结果以"指纹"命名缓存 │ → cache-<新指纹>.arrow,下次直接命中
└──────────────────────────────┘
旁路:streaming=True → IterableDataset(迭代器树),跳过 ②③,不落地
2.2 部件一句话职责
| 部件 | 干什么 | 在哪 个文件 |
|---|---|---|
load_dataset | 总入口:路由 → 构建 → 打开 | src/datasets/load.py:1480 |
dataset_module_factory | 判断"数据在哪、什么格式",返回构建器模块 | src/datasets/load.py:966 |
DatasetBuilder | 下载原始文件、生成 Arrow 缓存目录 | src/datasets/builder.py:214 |
ArrowWriter | 把例子成批写成 Arrow IPC 流文件 | src/datasets/arrow_writer.py:487 |
ArrowReader / ReadInstruction | 按 split 指令(如 train[:10%])读缓存文件 | src/datasets/arrow_reader.py:285、:456 |
Dataset | map 式数据集;_data(主表)+ _indices(可选索引映射) | src/datasets/arrow_dataset.py:718 |
MemoryMappedTable | 磁盘 Arrow 文件的内存映射视图,pickle 时只存路径+变换历史 | src/datasets/table.py:1055 |
ConcatenationTable | 多张表的逻辑拼接(行/列两个方向),不复制数据 | src/datasets/table.py:1339 |
Hasher / update_fingerprint | xxhash + dill 给"函数+参数"算指纹 | src/datasets/fingerprint.py:196、:253 |
IterableDataset | 流式数据集,包着一棵惰性迭代器树 | src/datasets/iterable_dataset.py:2503 |
_BaseExamplesIterable | 迭代器树节点基类(map/filter/shuffle 各是一种节点) | src/datasets/iterable_dataset.py:202 |
Features | 列类型 schema(文本、类别、图像、音频……) | src/datasets/features/features.py:1863 |
DataFilesDict | 把 data/train-*.parquet 这类模式解析成"哪个 split 用哪些文件" | src/datasets/data_files.py:648 |
2.3 主线走一遍
走 load_dataset("rajpurkar/squad", split="train") 这条路:
- 路由(
src/datasets/load.py:966dataset_module_factory):path不是本地目录、不是打包格式名,形如org/name→ 走 Hub 路径,先下载 README(数据集卡)并把 revision 钉死成 commit hash(src/datasets/load.py:1121-1130),按仓库里的数据文件后缀选中 Parquet 构建器。 - 构建(
src/datasets/builder.py:702download_and_prepare):先查<cache>/squad/配置/版本/哈希/下有没有dataset_info.json——有就直接返回(第二次运行的全部秘密在:842-848这六行);没有就下载 Parquet、逐 split 写成 Arrow,整个过程在<目录>.incomplete临时目录里做,成功后一次性 rename(:859-876)。 - 打开(
src/datasets/builder.py:1076_as_dataset→src/datasets/arrow_reader.py:317read_table):默认MemoryMappedTable.from_file——pa.memory_map打开文件,数据一页都不进 RAM,直到你真的去读某一行。 - 变换:之后每次
ds.map(fn)都会算出新指纹,写成cache-<指纹>.arrow;再跑一遍同 样的fn直接Dataset.from_file打开旧文件(src/datasets/arrow_dataset.py:3461-3470)。
这条线最值得记住的一点: ② 的产物(版本化缓存目录)和 ④ 的产物(指纹命名缓存文件)是两套独立的缓存——前者按"数据集身份"寻址,后者按"计算内容"寻址。第 2 章和第 4 章分别讲它们。
3. 阅读地图(建议顺序)
四章由浅入深。时间有限就读 01 → 02——Arrow 内存映射和指纹缓存是这个库区别于"普通 pandas 封装"的全部要害。
| 顺序 | 章节 | 讲什么 | 适合谁 |
|---|---|---|---|
| 1 | 01-arrow-storage.md | 为什么数据比内存大也能随机访问:memory-map、replay 机制、ConcatenationTable | 所有人必读,这是地基 |
| 2 | 02-map-cache-fingerprint.md | 预处理缓存怎么做到"重跑零开销":指纹、缓存命中、多进程、索引映射 | 天天用 map/filter 的人 |
| 3 | 03-iterable-streaming.md | 流式训练:迭代器树、两级 shuffle、断点恢复、分布式切分 | 语料太大不想下载的人 |
| 4 | 04-hub-loading.md | 一行 load_dataset 背后的工厂链、版本化缓存目录、数据文件模式匹配 | 想搞清"数据集怎么被找到"的人 |
4. 巧妙之处(可借鉴的技术)
先给速览,每条在对应章节有详细展开:
- pickle 只存"路径 + 操作历史"。
MemoryMappedTable.__getstate__返回的是{"path": ..., "replays": [...]},反序列化时重新 mmap 文件并把 slice/filter/cast 这些操作"重放"一遍(src/datasets/table.py:1087-1107)。数据永远不被复制进 pickle 流——这就是多进程 map 传 Dataset 不爆内存的原因。→ 第 1 章 - 把用户函数本身哈希进缓存键。
Hasher用 dill 把函数(含字节码)序列化后喂给 xxhash(src/datasets/fingerprint.py:196-223),于是"改了函数"自动等于"换了缓存键",旧缓存自然失效,不需要任何手动版本号。→ 第 2 章 - shuffle 不搬数据,只写一列 uint64 索引。
shuffle/select/filter的产物是一张单列索引表盖在原表上(src/datasets/arrow_dataset.py:4586-4591),打乱 1 亿行只写 800 MB 索引而不碰原始数据;代价是随机读变慢,官方文档自己标注"up to 10x slower"(:4851-4855的 docstring),并给出flatten_indices这条"落盘重写"的回头路。→ 第 2 章 - shuffle 拆成两级:shard 顺序 + 固定大小 buffer。
IterableDataset.shuffle先打乱数据源顺序、再交错最多 10 个 shard 填充 、最后用buffer_size的内存 buffer 做近似打乱(src/datasets/iterable_dataset.py:3738-3826),内存占用与语料大小无关,这是 TB 级语料也能"洗开"的关键。→ 第 3 章 - 缓存写入全部走"临时文件 + 原子改名"。 map 的每个分片先写
NamedTemporaryFile,成功后shutil.move到缓存路径(src/datasets/arrow_dataset.py:4044-4049);builder 整目录同理(.incomplete目录)。中途 Ctrl-C 永远不会留下半个缓存文件被下次误命中。→ 第 2、4 章 - 数据集卡 README 兼任配置文件。 Hub 仓库的 README YAML(
configs:字段)直接参与决定"哪些文件进哪个 split、用什么构建器"(src/datasets/load.py:585-620),元数据和数据在同一个 git 仓库里同版本演化。→ 第 4 章
5. 边界与局限
诚实清单,细节散在各章:
- 它不是数据清洗框架。 没有内置去重、PII、质量打分这些语料级流水线;预训练规模的清洗去看 datatrove 和 dolma。
- 指纹机制有失效面。 函数必须能被 dill 序列化,否则指纹退化为随机值、缓存永不命中,只警告一次(
src/datasets/fingerprint.py:259-276);函数引用的全局变量不进指纹,改了全局变量缓存不会失效(inferred:指纹只 hash 函数自身与显式参数,见第 2 章)。 - indices mapping 是甜点也是陷阱。 shuffle/select 之后随机访问慢一个 量级,训练前要么
flatten_indices重写,要么干脆换 IterableDataset。 - 流式模式没有随机访问、没有精确长度。
IterableDataset不支持ds[i],shuffle 是近似的;skip/take之后 shard 顺序被冻结,shuffle 质量下降(src/datasets/iterable_dataset.py:2008-2009)。 - 多进程 map 有进程开销与序列化成本。
num_proc对秒级函数不值;且分片数会跟着缓存时的 num_proc 走,换并行度可能重算(src/datasets/arrow_dataset.py:3488-3502)。 - 数据集脚本时代已结束。 2024 年起 Hub 上的 Python 加载脚本被移除,
load_dataset遇到.py直接报错(src/datasets/load.py:1066-1069)——只认数据文件 + README YAML。
6. 横向对比
同书架(training-data 一带)的兄弟项目,取舍各不相同:
| 库 | 定位 | 与 HF Datasets 的关键差异 |
|---|---|---|
| HF Datasets(本库) | 通用加载 + 预处理 + 分发 | 单机/单机多进程为主;缓存按"函数指纹";Hub 生态是护城河 |
| datatrove | 预训练语料清洗流水线 | 面向"几 TB 网页文本过滤去重",流水线算子 + 分布式执行,不做随机访问 |
| dolma | OLMo 的预训练数据工具集 | 围绕"产出 Dolma 语料"的固定工具链(tag/mix/decontaminate),不是通用加载器 |
| olmo | 完整预训练代码 | 数据侧是训练 loop 的 DataLoader,用 HF Datasets 之外的自有格式(MMAP/JSONL.gz) |
一句话:HF Datasets 是"进训练循环之前"的最后一公里;语料规模大到要先做工业级清洗时,上游接 datatrove/dolma,产出落回 Parquet/Hub,再回到本库做加载与 shuffle。
7. 代码地图(入口级)
每章末尾有自己的细粒度地图,这里只列从零开始读源码的五个入口:
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 总入口 | src/datasets/load.py | load_dataset、load_dataset_builder、dataset_module_factory |
| map 式数据集主体 | src/datasets/arrow_dataset.py | Dataset、Dataset.map、Dataset._map_single |
| Arrow 存储层 | src/datasets/table.py | MemoryMappedTable、InMemoryTable、ConcatenationTable、IndexedTableMixin |
| 指纹与缓存 | src/datasets/fingerprint.py | Hasher、update_fingerprint、fingerprint_transform |
| 流式数据集 | src/datasets/iterable_dataset.py | IterableDataset、_BaseExamplesIterable、BufferShuffledExamplesIterable |