Skip to content

[Performance] Add num_workers to paddle.load for parallel payload reading - #79786

Open
DanielSun11 wants to merge 2 commits into
PaddlePaddle:developfrom
DanielSun11:feature/parallel-pickle-load
Open

DanielSun11 wants to merge 2 commits into
PaddlePaddle:developfrom
DanielSun11:feature/parallel-pickle-load

Conversation

@DanielSun11

@DanielSun11 DanielSun11 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

PR Category

Performance Optimization

PR Types

Performance

Description

背景

paddle.load 加载 checkpoint 时全程单线程搬运张量数据,带宽远低于存储与主机的能力。把 8GB checkpoint 放到 /dev/shm(完全排除磁盘)后 paddle.load 仍需 6.7s(1.2 GB/s),说明瓶颈不在 IO 而在单线程拷贝:pickle 对张量数据不做任何变换,protocol >= 3 存下来的字节与内存逐位相同,元信息只占极小比例(实测一个 2.1GB 的 .distcp 文件中 opcode + 名字 + dtype/shape 合计仅 1,106 字节,占 0.00005%),因此耗时几乎全在“把 GB 级字节搬一遍”。

方案

保持 pickle 格式完全不变,只改变“谁来搬”:

  1. _ParallelPayloadFile 包装 checkpoint 文件。CPython 的 C 版 unpickler 对写在 pickle frame 之外的 payload 会调用文件对象的 readinto()protocol >= 4 的 framer 对 >= 64KB 的数据就写在 frame 之外);
  2. readinto(b) 收到 >= 1MB 的请求时,把 memoryview(b) 按 32MB 切块交给线程池的 os.preadv 并行读,等所有块写完才返回,随后把底层文件指针前进 len(b);小于阈值的请求原样转发给串行 readinto
  3. os.preadv 是系统调用,执行期间释放 GIL,所以线程能真正并行;切块互不重叠,每个字节只被一个线程写一次,无需加锁。切块的原因是张量数量少时(2.1GB 的 .distcp 只有 3 个 payload)“一个 payload 一个线程”用不满线程池。

关键点是数据在 readinto 返回前已经就位:unpickler 观察到的行为与普通 readinto 完全一致,代码对这块内存的生命周期不做任何假设,因此 bytearray / bytes / str 这类在解析期就会拷贝或解码的 payload 同样安全。(初版曾用“先跳过、解析完再回填”的写法,对 protocol 4 的 bytearray 会写进已释放的临时缓冲区,属于越界写;已按 review 意见重写,并补了对应单测。)

pickle 的解析工作一步没少,安全反序列化(RestrictedUnpickler)照常生效,下游 _pack_loaded_dict / StructuredToParameterName@@ / _parse_load_result 等后处理无需改动。

接口

默认关闭,行为与改动前完全一致:

paddle.load(path)                    # 串行,同现状
paddle.load(path, num_workers=32)    # 并行读取 payload

dist.load_state_dict(sd, path, num_workers=32)   # dcp 侧同名参数,默认 1

以下情况自动回退到原串行实现,只 warn 不报错:传入 BytesIO、macOS(走 _pickle_loads_mac)、无 os.preadv(Windows)、文件中没有 >= 1MB 的 payload(含 protocol == 2,payload 以 latin1 文本存储),以及并行读取过程中的任何异常(整体丢弃结果、seek(0) 后重新串行加载,不会返回半填充的张量)。num_workers 的校验放在 _parse_load_config,因此非法参数在所有平台上都会报错。

实测数据

本地 NVMe(ext4),加载真实的 flex_checkpoint .distcp 文件:

  • 2.10 GB / 10 个张量:冷缓存 2.41s -> 0.92s(2.6x),热缓存 2.14s -> 0.64s(3.3x)
  • tmpfs 上 8GB 合成 checkpoint:6.67s -> 1.08s(6.2x)
  • 单卡 dist.load_state_dict(256MB checkpoint):num_workers=1 0.473s -> num_workers=8 0.214s(2.2x)

收益上限取决于存储带宽:在冷读带宽约 1.8 GB/s 的网络盘上只有约 1.4x(IO 本身已经是下界),本地盘或 page cache 命中时可达 3~6x。

单测

新增 test/legacy_test/test_paddle_load_num_workers.py,29 个 case:

  • 精度:多个大张量、大小张量混合、7 种 dtype(fp32/fp64/fp16/bf16/int64/int32/bool)、NaN/±Inf/-0.0、嵌套结构、Layer.state_dict()(含参数 name 恢复)、tensor place 与串行一致、不同 num_workers、重复加载稳定性。全部使用逐字节比对而非 allclose
  • 非张量 payload:bytearray / bytes / str 在 protocol 4/5 下重复加载校验,覆盖上面提到的缓冲区生命周期问题;
  • 参数校验:非 int / 负数分别抛 TypeError / ValueErrorNone/0/1 走串行;
  • 回退路径:protocol 2/3/4/5、仅小张量、BytesIO、缺少 os.preadvsys.platform == 'darwin'、注入 preadv 短读;
  • 白盒:payload 覆盖率 > 99%、大 buffer 会降低命中率(解释 _BUFFER_SIZE = 8192 的取值)、小 payload 不进快路径、短读抛 EOFError

Linux 29 个全部通过;Windows(无 os.preadv)与 macOS(paddle.load_pickle_loads_mac)下依赖快路径的用例用 skipUnless 跳过,其余用例仍校验回退结果与串行逐字节一致。

是否引起精度变化

否。

并行路径与串行路径读取同一文件的同一偏移,结果逐位一致:2.10GB 文件 10/10 个张量、7.83GB 文件 161/161 个张量的 dtype/shape 相同且 tobytes() 完全相等;单测中对 NaN/±Inf/-0.0 也做了逐字节校验。并行读取过程中的任何异常都会整体丢弃结果、退回串行加载,不会返回半填充的张量。

…ding

paddle.load moves tensor bytes with a single thread, which caps the load
bandwidth well below what the storage can deliver: an 8GB checkpoint still
takes 6.7s from tmpfs, where there is no disk involved at all.

Intercept the unpickler's readinto() for out-of-frame payloads, record and
skip them so the first pass only parses metadata, then read the payloads in
parallel with os.preadv() straight into the final buffers. The file format is
unchanged and the fast path is opt-in via paddle.load(..., num_workers=n),
falling back to the serial implementation in every unsupported case.

dcp.load_state_dict gains a matching num_workers argument (default 1).
@risemeup1111

risemeup1111 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

@risemeup1111 risemeup1111 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Powered by Nyanpasu with DeepSeek-V4.1-Flash high, please check the suggestions carefully.

Comment on lines +87 to +95
def readinto(self, b) -> int:
n = len(b)
if n >= self._thresh:
# ``b`` views the buffer the numpy array will use; keeping it here
# also keeps it alive until the fill pass.
self.holes.append((self._f.tell(), b))
self._f.seek(n, 1)
return n
return self._f.readinto(b)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1

这里假设 readinto 收到的 b 就是 payload 的最终存储、解析结束后交给 _fill_holes 写入即可,但 unpickler 传入的是 PyMemoryView_FromMemory 创建的 memoryview(obj is None),它不持有底层缓冲区,只有“解析结果仍然引用这块内存”时缓冲区才有效。对在 unpickle 过程中被拷贝的 payload 这个前提不成立:例如 ≥1MB 的 bytearray(pickle 中以 bytearray(BINBYTES) 保存),解析时 bytearray(...) 已经从仍为空白的缓冲区拷贝过一次,承载它的临时 bytes 随解析结束被释放,_fill_holes 随后便把文件字节写入已释放/已被复用的内存。

实测(pickle.dump({"a": bytearray(b"a"*3_000_000), "b": bytearray(b"b"*3_000_000)}, f, protocol=4) 后调用 parallel_safe_load_pickle(path, f, 8))连续 6 次运行:

  • 5 次:b 的 3,000,000 字节中混入 112 字节来自 a 的 payload、10 字节为 0,其余才正确;
  • 1 次:a 只有前 8,144 字节正确,其余为 0;
  • 6 次都没有 warning、也没有触发串行回退,即静默返回错误数据。

同一输入结果随运行变化,说明这里依赖的是分配器巧合(写入已释放内存)。建议扫描完成后先校验每个 hole 缓冲区仍是返回值的存储(例如收集结果中所有 np.ndarray 的 data 区间、要求 hole 落在其中,否则回退串行),并补一个 ≥1MB bytearray payload 的用例覆盖该分支。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已在新 head 25ee96c5 验证修复:改为在 readinto 内同步完成并行读取后,不再对缓冲区生命周期做任何假设。用 bytearray/bytes/str/大端 numpy 数组/嵌套结构等 12 种 payload 场景各重复多轮与串行逐字节比对,结果全部一致且无 warning,原场景不再出现坏字节。

Comment on lines +171 to +174
if not holes:
# protocol < 3, or tiny tensors only: nothing to parallelize.
f.seek(0)
return safe_load_pickle(f, encoding=encoding)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3

holes 为空时第一趟已经完整读过整个流(没有跳过任何字节),result 本身可用;这里却丢弃它并用 f 重新解析,等于对同一文件做两遍完整解析。实测 21MB、protocol 2 的文件在 num_workers=8 下耗时 95ms,而串行只需 50ms(约 1.9x),这类收益为 0 的文件要付双倍解析成本。若无其他考虑可直接 return result;若确实需要重新解析,建议在 docstring 中说明这份额外开销。

@risemeup1111 risemeup1111 Sep 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

同一提交改为单趟解析、去掉了无 payload 时的重复解析分支,该开销已消失:同样 21MB / protocol 2 的文件在本机实测串行 53.3ms、num_workers=8 53.5ms(比值 1.00,原先约 1.9x)。此条已解决。

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.75281% with 2 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (develop@01d68e3). Learn more about missing BASE report.

Files with missing lines Patch % Lines
python/paddle/framework/io.py 75.00% 1 Missing ⚠️
python/paddle/framework/parallel_pickle_load.py 98.76% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             develop   #79786   +/-   ##
==========================================
  Coverage           ?   97.75%           
==========================================
  Files              ?        3           
  Lines              ?       89           
  Branches           ?        0           
==========================================
  Hits               ?       87           
  Misses             ?        2           
  Partials           ?        0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Review feedback (P1): the memoryview handed to readinto does not own the
payload buffer, so filling it after the parse is only safe when the parsed
object still references that memory. It does not hold for payloads the
unpickler copies or decodes while parsing, e.g. a bytearray at protocol 4,
which is built from a temporary bytes object that is freed right away. Writing
into that freed memory corrupted data in 3 of 6 runs.

Read each payload in parallel from inside readinto instead, so the buffer is
complete before the unpickler continues and no lifetime assumption is left.
This also drops the second full parse for files with no large payload (P3):
nothing is skipped, so the first parse is always usable.

Validate num_workers in _parse_load_config so invalid values are rejected on
macOS too, where paddle.load takes the _pickle_loads_mac path.

Skip the tests that need os.preadv on Windows and the ones that need the fast
path on macOS. Add regression tests for bytearray, bytes and str payloads.
DanielSun11 added a commit to DanielSun11/Paddle that referenced this pull request Sep 18, 2026
Cherry-pick of the review fixes on PaddlePaddle#79786.

The memoryview handed to readinto does not own the payload buffer, so filling
it after the parse is only safe when the parsed object still references that
memory. It does not hold for payloads the unpickler copies or decodes while
parsing, e.g. a bytearray at protocol 4, which is built from a temporary bytes
object that is freed right away. Writing into that freed memory corrupted data
in 3 of 6 runs.

Read each payload in parallel from inside readinto instead, so the buffer is
complete before the unpickler continues and no lifetime assumption is left.
This also drops the second full parse for files with no large payload.

Validate num_workers in _parse_load_config so invalid values are rejected on
macOS too, where paddle.load takes the _pickle_loads_mac path.

Skip the tests that need os.preadv on Windows and the ones that need the fast
path on macOS. Add regression tests for bytearray, bytes and str payloads.
@DanielSun11

Copy link
Copy Markdown
Contributor Author

感谢 review,两条都已修复(commit 25ee96c,release/3.4 对应 PR #79787 同步修复)。

P1 确认成立,已改为在 readinto 内部并行读取。 按你给的场景实测复现(protocol=4 + bytearray,6 次运行):

run0: b 坏字节 2,991,774
run1: b 坏字节 2,991,795
run2: b 坏字节 2,991,770
run3~5: 0

根因与你的判断一致:readinto 收到的 memoryview 由 PyMemoryView_FromMemory 创建、不持有底层缓冲区,只有当解析结果仍引用这块内存时才有效。numpy 的 _frombuffer 是零拷贝视图所以成立,但 bytearray(BINBYTES) 在解析期间就从尚未填充的临时 bytes 拷贝了一次,该临时对象随即释放,_fill_holes 之后写入的是已释放/已复用的内存——不只是数据错,是越界写。

修复方式不是加白名单判断 payload 类型(无法在 readinto 时预知),而是取消"事后填洞",改为在 readinto 里同步完成并行读取:拿到 offset 后立刻用线程池的 preadv 把这块 buffer 填满再返回。这样 unpickler 看到的与普通 readinto 完全一致,对内存生命周期不再有任何假设。_HolePunchFile / holes / _fill_holes 一并移除。

代价是失去了跨 payload 的重叠,单文件收益从 ~5x 降到 ~3.3x(2.10GB .distcp 热缓存 2.14s → 0.64s),单个 payload 内部仍按 32MB 切块并行,所以大张量场景基本不受影响。正确性优先,这个代价可以接受。

新增回归用例 TestNonTensorPayloads,覆盖 bytearray / bytes / str 在 protocol 4/5 下各重复 3~6 次(bug 是间歇性的),修复后累计坏字节为 0。

P3 随之自动解决。 现在没有任何字节被跳过,第一趟解析本身就是完整的,holes 为空的分支不存在了,直接返回 result。实测 21MB / protocol 2 的文件:串行 86ms vs num_workers=8 87ms(比值 1.00,原先 1.9x)。

另外这一版顺带修了 CI:

  • Windows:os.preadv 不存在,12 个依赖它的用例改为 skipUnless 跳过
  • macOS:paddle.load_pickle_loads_mac 分支、不经过本模块,导致 num_workers 的类型/取值校验不生效。已把校验上移到 _parse_load_config,保证所有平台行为一致;依赖快路径的用例在 macOS 跳过
  • 模拟三种平台实测:Linux 29 passed / Windows 29 run 0 fail 12 skipped / macOS 29 run 0 fail 3 skipped

Static-Check 的失败是公开 API 变更需要 RD + Typing group approval,不是代码问题;Fleet Unit test (multi-card) 挂在 test_csa_attention_cp.py 的余弦相似度断言上,与本 PR 无关。

@risemeup1111 risemeup1111 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3

PR 描述「接口」一节把 5 种情况统一写成"自动回退到原串行实现,只 warn 不报错",但实现中只有并行读取抛异常这一条路径会告警(python/paddle/framework/parallel_pickle_load.py:156-161),其余都是静默走串行:

  • BytesIO / 无 os.preadv / macOS:_fast_path_available 直接返回 False(python/paddle/framework/parallel_pickle_load.py:121-130),不打印任何信息;macOS 在 python/paddle/framework/io.py:1300 就已分流到 _pickle_loads_mac
  • 文件中没有 >= 1MB 的 payload:仍会经过一次 _ParallelPayloadFile 解析,只是所有 readinto 都转发给底层文件对象(python/paddle/framework/parallel_pickle_load.py:100-103),同样没有告警。

paddle.load 的 docstring(python/paddle/framework/io.py:1132-1135)写的是 "falls back to the serial implementation otherwise",与实现一致,因此只需把描述改成"静默回退,仅并行读取出错时才 warn",避免使用者以为传了 num_workers 一定会收到提示。

Powered by Nyanpasu with DeepSeek-V4.1-Flash high, please check the suggestions carefully.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants