Skip to content

[Cherry-Pick][Performance] Add num_workers to paddle.load for parallel payload reading (#79786) - #79787

Open
DanielSun11 wants to merge 4 commits into
PaddlePaddle:release/3.4from
DanielSun11:feature/parallel-pickle-load-release3.4
Open

DanielSun11 wants to merge 4 commits into
PaddlePaddle:release/3.4from
DanielSun11:feature/parallel-pickle-load-release3.4

Conversation

@DanielSun11

@DanielSun11 DanielSun11 commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

PR Category

Performance Optimization

PR Types

Performance

Description

devPR: #79786

本 PR 含两处改动,针对 dist.load_state_dict 加载路径上两个不同的瓶颈:

一、paddle.load(num_workers=...):并行读取张量数据(cherry-pick of #79786)

paddle.load 全程单线程搬运张量数据。把 8GB checkpoint 放到 /dev/shm 后仍需 6.7s,说明瓶颈不是 IO 而是单线程拷贝:pickle 对张量数据不做任何变换,元信息只占极小比例,耗时几乎全在“把 GB 级字节搬一遍”。

protocol >= 4 下 >= 64KB 的 payload 写在 pickle frame 之外,可以先只读元信息建好完整对象图(此时 numpy 数组已是空缓冲区上的零拷贝视图),再用线程池的 os.preadv 把这些 payload 直接读进最终内存,preadv 期间释放 GIL 所以能真正并行。pickle 格式与解析逻辑均不变,安全反序列化照常生效。

默认 num_workers=1 即原串行行为。BytesIO、macOS、无 os.preadv、protocol == 2、文件中没有大 payload,以及并行过程中的任何异常,都自动回退串行。

二、跳过 local-resume 检查中的全量 metadata 加载

dist.load_state_dict 在 reshard 前先判断“每张卡能否直接从自己的 .distcp 恢复”。这个布尔值目前的代价是对 *.metadata 做完整 paddle.load 再构建 MetadataManager,因为 check_resumable_locally 需要 storage_metadata——而它是 Metadata 里最大的字段,完全副本下有 num_keys * world_size 条记录,真实 154GB checkpoint 上是 227MiB 文件、十几秒 unpickle,全部只为换一个 bool。

改为由两个只读极少字节的读取器回答同一个问题:

  • metadata_reader.load_state_dict_metadata:state_dict_metadata 恰好紧邻 storage_metadata 之前被 pickle,在后者的 key opcode 处植入 STOP,只读文件前缀即可拿到需要的字段,其后的字节一个都不从磁盘读。不符合该假设时退回完整 paddle.load,因此调用永远安全。该函数同时导出为 paddle.distributed.load_state_dict_metadata。
  • distcp_reader.scan_tensor_shapes:只走 pickle 头部得到本卡文件真实的 {key: shape};payload 长度显式写在 opcode 里,故可直接 seek 跳过。

fast_resumable.check_resumable_locally_fast 组合两者,是三态的:只有当 checkpoint 里每个张量都整块存放时,张量名才唯一确定其 LocalTensorIndex,storage_metadata 不携带额外信息,结论可只由本卡文件头得出;一旦有张量被切分,shard 身份依赖仅存在于 storage_metadata 的 global_offset / flattened_range,此时返回 None,调用方原样走原检查。

两点使它成为 drop-in 替换:是否适用只取决于共享的 metadata 文件,所有卡在任何通信之前就得到相同判断;适用时恰好发一次 all_gather_object、不适用时零次,与原实现的集合通信次数一致。它也不比原检查宽松——校验文件的真实内容而非 metadata 的声明,因此没写完或被截断的 .distcp(原检查只做 os.path.isfile)会退回 reshard。

由 load_state_dict(..., fast_resumable_check: bool = True) 控制,置 False 强制走原路径。

是否引起精度变化

否。改动一的并行路径读的是同一文件的同一偏移,与串行结果逐位一致;改动二只替换 check_resumable_locally 的布尔判断,张量数据通路完全未动。

…l payload reading (PaddlePaddle#79786)

Cherry-pick of PaddlePaddle#79786. 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). This
branch still reads checkpoint files directly in local_load_state_dict and
load_state_dict_impl, so the argument is threaded through those call sites
instead of the _load_checkpoint_data_file helper introduced on develop.
@risemeup1111

risemeup1111 commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Paddle-Bot Review Board (review完成)

序号 位置 优先级 规则来源 状态
1 readinto 填充依赖 payload 存活,存在悬垂写入风险 P2 默认规则 ✅
2 num_workers 未加入 _LoadOptions 类型定义 P3 默认规则 🚧
3 readinto 同步化取消跨 payload 并行,≤32MB 张量下 num_workers 失效 P2 默认规则 🚧

Powered by Nyanpasu claude with Opus 4.8 默认推理级别, please check the suggestions carefully.

@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 +90 to +92
# ``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))

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.

P2

readinto(b) 收到的 b 是 CPython 用 PyMemoryView_FromMemory 构造的裸指针视图,它并不持有被写入 payload 的那个对象,所以注释里 “keeping it here also keeps it alive until the fill pass” 不成立。如果某个 >= 1MB 的 payload 没有被反序列化结果引用(pickle 流中该 bytes 对象后面被丢弃),扫描阶段结束、unpickler 释放后这块内存就会被回收,_fill_holes 变成写入已释放内存。

用本模块的真实代码可以复现:构造一个含 2MB BINBYTES8 payload、但反序列化结果不引用它的文件,num_workers=4 时同尺寸的新分配会复用该内存并被覆盖,随后进程以 malloc(): invalid next size (unsorted) 中止;num_workers=1 的串行路径正常返回。

建议在填充前确认每个 hole 的内存仍被结果图中的存活 buffer 覆盖(否则回退串行),或至少修正注释并明确 “被 punch 的 payload 必须被结果引用” 这一前提。

'keep_name_table',
'return_numpy',
'safetensors',
'num_workers',

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

num_workers 已加入 supported_configs 并在 _parse_load_config 中透传,但没有同步加到 python/paddle/framework/io.py:74 的 _LoadOptions;而 load() 的签名是 **configs: Unpack[_LoadOptions],类型检查会把 paddle.load(path, num_workers=8) 报成非法参数。建议补上 num_workers: NotRequired[int](如需保留 None 语义可写成 int | None)。

@codecov-commenter

codecov-commenter commented Sep 17, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.13295% with 3 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (release/3.4@aa8eb32). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...distributed/flex_checkpoint/dcp/load_state_dict.py 94.44% 1 Missing ⚠️
python/paddle/framework/io.py 75.00% 1 Missing ⚠️
python/paddle/framework/parallel_pickle_load.py 98.50% 1 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff               @@
##             release/3.4   #79787   +/-   ##
==============================================
  Coverage               ?   99.13%           
==============================================
  Files                  ?        7           
  Lines                  ?      346           
  Branches               ?        0           
==============================================
  Hits                   ?      343           
  Misses                 ?        3           
  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.

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.

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

Comment on lines +106 to +115
chunks = [
(offset + start, mv[start : start + _READ_CHUNK_SIZE])
for start in range(0, n, _READ_CHUNK_SIZE)
]
# Iterating the map result waits for every chunk and re-raises the first
# exception, so the buffer is complete once readinto returns.
for _ in self._pool.map(
lambda chunk: _pread_exact(self._fd, *chunk, self._path), chunks
):
pass

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.

P2

readinto 现在是同步调用:unpickler 必须等当前 payload 读完才能继续,因此并行度只来自单个 payload 内部的 32MB 分块,跨 payload 的并行被取消了。当 payload ≤ 32MB 时 chunks 只有 1 个(2MB/4MB/16MB 这类常见张量都属此类),num_workers 实际不起作用,整个文件退化为逐个 payload 串行 preadv。

用带计数(每次调用注入 4ms 延迟以便观测重叠)的 os.preadv 包装实测:60 个互不相同的 2MB payload、num_workers=8 时,本实现最大并发为 1,上一版 ad5cf79e 的实现为 8。

PR 描述与 _READ_CHUNK_SIZE 的注释仍按被替换掉的 _HolePunchFile/_fill_holes 跨 payload 方案表述,2.8x~5.8x 的加速数据也来自旧实现,建议一并更新。若希望常见尺寸的张量也能用满线程池,可以按 payload 自适应分块(例如 chunk ≈ max(1MB, ceil(n / num_workers)))后重新测量。

zrr1999
zrr1999 previously approved these changes Sep 20, 2026
zhwesky2010
zhwesky2010 previously approved these changes Sep 20, 2026
LiYuRio
LiYuRio previously approved these changes Sep 21, 2026
`load_state_dict` decides whether every rank can restore straight from its
own `.distcp` before any resharding happens. Answering that question costs a
full `paddle.load` of the `*.metadata` file plus a `MetadataManager` build,
because `check_resumable_locally` needs `storage_metadata`. On a fully
replicated checkpoint `storage_metadata` holds `num_keys * world_size`
entries -- hundreds of MiB and tens of seconds -- all to produce one boolean.

This adds a fast path that answers the same question from two much cheaper
sources:

- `metadata_reader.load_state_dict_metadata` parses only the
  `state_dict_metadata` field of the metadata file. It relies on that field
  being pickled immediately before `storage_metadata`: planting a `STOP` over
  the opcode that would push the `"storage_metadata"` key ends the stream with
  exactly the wanted value on the stack, so nothing past the cut is read. Any
  stream that does not match that expectation falls back to a full
  `paddle.load`, so the function is always safe to call. It is also exported
  as `paddle.distributed.load_state_dict_metadata`.
- `distcp_reader.scan_tensor_shapes` reads the keys and shapes a `.distcp`
  really holds by walking its pickle header and seeking over the weight
  payloads, which costs a few hundred bytes per tensor instead of the file.

`fast_resumable.check_resumable_locally_fast` combines the two. When every
tensor of the checkpoint is stored whole, a tensor key identifies its
`LocalTensorIndex` uniquely and `storage_metadata` carries no information the
key alone does not. When any tensor is sharded, shard identity depends on
`global_offset` / `flattened_range`, which live nowhere but `storage_metadata`,
so the fast check declines (returns None) and the stock check runs unchanged.

Two properties keep it a drop-in replacement: the applicability verdict is a
pure function of the shared metadata file, so all ranks decide identically
before communicating, and exactly one `all_gather_object` is issued when the
check applies and none when it declines -- the same collective count as the
stock check on either branch. The check is never more permissive than the
stock one: it validates the file's real contents, so a truncated `.distcp`
(which the stock check accepts, since it only calls `os.path.isfile`) makes
this one fall back to resharding.

The behaviour is controlled by the new `load_state_dict(...,
fast_resumable_check=True)` argument, so the original code path stays
reachable.

test/flex_checkpoint/test_fast_resumable_check.py covers the three new
modules, mostly differentially against `utils.check_resumable_locally`:
pickle protocols 2/4/5, all supported dtypes, scalar and empty shapes, the
64 KiB frame boundary, dedup'ed checkpoints, `ShardedWeight` inputs,
uninitialized tensors, truncated and corrupt files, the byte-level framing
helpers, and the collective branch.

@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.

本轮针对新提交 64fc94d(跳过 local-resume 检查中的全量 metadata 加载)进行复核:metadata_reader 部分解析、distcp_reader 头部扫描与 fast_resumable 三态检查的集合通信次数、shape 比较与回退语义均与原 check_resumable_locally 一致,且更严格(截断/损坏文件回退 reshard),未发现新的问题。

以下两条既有建议仍未处理,保持关注:

  • paddle.load 的 num_workers 尚未加入 _LoadOptions,Unpack[_LoadOptions] 下类型检查仍会报参数非法(P3)。
  • readinto 改为同步读取后,跨 payload 并行被取消,≤32MB 的常见张量下 num_workers 实际不生效,相关加速数据与注释建议同步更新(P2)。

详情见对应评论线程。

@DanielSun11

Copy link
Copy Markdown
Contributor Author

@risemeup1111 review一下

@risemeup1111

Copy link
Copy Markdown
Contributor

@DanielSun11 已对当前 head 64fc94d 完成 review。

新提交「跳过 local-resume 检查中的全量 metadata 加载」已复核:metadata_reader 部分解析、distcp_reader 头部扫描、fast_resumable 三态检查的集合通信次数、shape 比较与截断/损坏文件回退语义均与原 check_resumable_locally 一致且更严格,未发现新的阻塞问题。

以下两条既有建议仍待处理,详见对应评论线程:

  • paddle.load 的 num_workers 尚未加入 _LoadOptions,Unpack[_LoadOptions] 下类型检查仍会报参数非法(P3)。
  • readinto 改为同步读取后跨 payload 并行被取消,≤32MB 的常见张量下 num_workers 实际不生效,建议同步更新相关注释与加速数据(P2)。

具体条目与状态见 Review Board。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@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.

本轮针对新提交 426b4d1(修复 metadata API 示例的类型检查)进行复核:该提交仅移除 metadata_reader.py 中 load_state_dict_metadata docstring 内一处会导致 doctest 类型检查失败的示例输出(state_dict_metadata["w1"][0].local_shape),不涉及任何运行时逻辑,未引入新的问题。

以下两条既有建议仍未处理,保持关注:

  • paddle.load 的 num_workers 尚未加入 _LoadOptions,Unpack[_LoadOptions] 下类型检查仍会将 paddle.load(path, num_workers=8) 报为非法参数(P3)。
  • readinto 改为同步读取后,跨 payload 并行被取消,≤32MB 的常见张量下 num_workers 实际不生效,相关加速数据与注释建议同步更新(P2)。

详情见对应评论线程。

This branch has not been deployed

No deployments
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.

6 participants