RDNA3 · KFD · HMM · 实测于 2× RX 7900 XT

KFD 对只读拷贝申请写权限,
而 Ubuntu 内核的一处 backport 为此每次多收一秒

在 Radeon 硬件上实测权重加载;沿 KFD 与 HMM 追踪,对照未打补丁的内核重试循环与 Canonical 的更新

一个 9.56 GiB 的 checkpoint,磁盘大约六秒就能读完,实际却花了 328 秒。造成延迟的是两个彼此独立的缺陷。第一个是权限缺陷,导致 GPU 去申请本不需要的写权限;AMD 已确认该问题并指出具体代码行,它至今依然存在。第二个是发行版的一处 backport,把该缺陷的每一次触发都拖长为整整一秒;仅补入缺失 commit 重新编译便证实了这一点,Canonical 已在 -30 中修复。

2× RX 7900 XT · gfx1100 · VFIO 直通 Ubuntu 7.0.0-28 → -30 ROCm 7.14 · safetensors 0.8.0 实测 2026-07-28 与 2026-08-23

摘要

第一个效应是权限取错了地方。host-to-device 拷贝只需读取源数据。然而 KFD 是根据 VMA 标志推导所需权限的,因此带有 VM_WRITE 的映射会迫使 hmm_range_fault() 索取写权限,并在每一个常驻页上破坏 copy-on-write。PyTorch 在映射 safetensors checkpoint 时将其设为可写,因为 storage 必须支持修改,这正是 PyTorch 在此处走过的路径。AMD 确认了该问题并指出了代码行。在当前发行的内核上,该缺陷的开销为 4.8×。

第二个效应源于一次 backport。Ubuntu 的 7.0.0-28 合入了 c08972f55594,却漏掉了后续的 342981fff328,留下一个会跑满整个 HMM_RANGE_DEFAULT_TIMEOUT 的重试循环。所有耗时均体现为整块的 1000 ms 窗口,而非带宽受限:一次 32 MiB 拷贝耗时 16 019.6 ms,即十六个窗口加上 19.6 ms 的实际操作。仅补入缺失 commit 重新编译 amdgpu.ko 后,耗时降至 17.0 ms。Canonical 于 2026-08-20 在 -30 中发布了修复,同一个复现脚本如今测得 15.3。

这两个缺陷的相互作用非常明确:破坏 copy-on-write 会推进 notifier 序列,因此权限缺陷是诱因,而缺失的 commit 则把每次触发都拖长为整整一秒。只读映射从不引发失效,自然绝不会进入重试循环。

本仓库此前公布的一个数据未能立足。随 vllm#49991 发布的 3.9× 至 5.6× 收益,来自未对 page cache 做控制的测试。在逐格严格控制缓存重新测量后:checkpoint 能放入内存时提升为 1.5× 至 2.0×,放不下时达到 7.5×,而在 128 专家的 MoE 模型上甚至略微为负。

1这个问题,以及它是从什么上面问出来的

本文开篇的这次测量虽然最为粗糙,却引发了后续的所有工作,因此我们将其连同出处放在最前面。2026-07-24,在同一文件上通过 dd iflag=direct 测得磁盘速度为 1.5 GB/s;然而加载 15.6 GiB 的 BF16 checkpoint 速率仅有 77 MB/s,9.56 GiB 的 w4a16 更是只有 29。这比底层存储介质的速度慢了十九倍和五十二倍。

这两个数字是从哪来的 这两行记录来自 docs/open-questions.md §8 的历史部分,并未入库对应的数据文件。它们引出了问题,但未确立任何结论。从图 1 开始的所有数据均对照入库文件实测,均可重算。原文档中的小节标题写的是 19–48× 而非 19–52×,原因在于它除以的是十进制 MB 速率,而表格给出的是 MiB/s;原文对此已有说明,且这与最终答案无关,因为结论是直接测量得出的,并非来自比值推导。

最初的假说是错的,但记录下来有助于解释后续的方法。本仓库曾提出“数据源为基于文件的 mmap 页面,而 ROCm 对此类页面不会走 pinned-staging 路径”。一个无外部依赖的复现脚本推翻了这一假说。对热缓存文件进行普通的 mmap 映射后,拷贝速度完全正常。排查了八个变量:匿名内存与文件映射对照、缺页中断是否触发、每次拷贝更换全新地址范围、overlayfs 与 ext4 对照、每次拷贝重新申请设备端显存、1–256 MiB 尺寸遍历、非页面对齐的起始偏移、数据类型,无一能够复现异常。此后,任何新解释必须达到的门槛都是:换个陌生人也能直接跑通。

2真正会触发它的那个变量

同一个文件在同一个进程里的两个映射,此时 safetensors 正打开着一个 checkpoint:

/proc/self/maps
75d757200000-75d845520000 rw-p ... model-00001-of-00005.safetensors   <- safetensors
75d468a00000-75d556d20000 r--p ... model-00001-of-00005.safetensors   <- 我们的

触发根源在于:本应只读的 checkpoint 被设置了 MAP_PRIVATE 且可写映射。单独隔离该变量后(同一文件、相同字节,仅用普通 mmap 搭配 torch.frombuffer,完全脱离 safetensors 与 vLLM),用六十行代码即可完整复现所有现象。若再引入第二维度,即在拷贝前 CPU 是否访问过该映射,现象会分化为三种状态而非两种。真实的模型加载恰好落入第三种状态。

图 1 · 一个复现器,三种内核状态 可由仓库重算 可用 repro-mmap-prot.hip.cpp 自行核对
图 1. 每次拷贝 32 MiB,对数轴,取自 benchmarks/hmm-kernel-three-states.json。只有第三种情况会动。 中间那一行是从 Ubuntu 自己的 -28 源码构建、只加了 342981fff328 这一处改动、然后换进正在运行的内核里的模块 —— 它是用来证明成因的,不是任何人会装到的内核。第三行是 Canonical 现在发的版本, 在同一台机器上用同一个复现器二进制、并且在升级之前当天重跑过一次基线, 所以升级是唯一的变量。两行对照在三种状态里都待在各自的区间:3.0 到 3.2 ms,以及 14.5 到 13.1。

16 019.6 ms 不是带宽受限的表征,也不是测量噪声。它由整整十六个 1000 ms 的 HMM_RANGE_DEFAULT_TIMEOUT 窗口,加上 19.6 ms 的实际工作组成。此前看似固定每页开销的亚毫秒级可复现性,本质上就是 jiffies 定时器超时。只要按这一视角去理解该数据,其余疑团便迎刃而解:两个文件系统之间刚好相差的一秒就是额外多出了一次重试,而单张量测得的 1005、1042 和 1522 ms 耗时,恰好对应一个、一个以及一个半窗口。

3为什么一次读会去要写

AMD 在 ROCm#6523 上确认了机理并指出了具体位置。在上游源码树中,这一调用链经过了三跳:

权限取决于 VMA 本身,而非 GPU 对该地址范围的实际操作。host-to-device 拷贝仅需读取源数据。然而,正因 VMA 带有 VM_WRITE 标志,KFD 便向 hmm_range_fault() 申请了写权限,进而导致每个常驻页上的 copy-on-write 均被破坏。这属于永久性缺陷。在我们测过的每一个内核版本上该问题均存在,且相关代码行的历史比这些内核都要早。

无需引入额外假设,该机制就能合理解释前述三种状态。未经 CPU 访问的页面尚未进入 copy-on-write 共享状态,因此“未常驻”情形无需承担写时复制被破坏的代价。这也将前后两部分缺陷衔接了起来:破坏 copy-on-write 会推进 notifier 序列;反之,只读映射从不会发生失效,绝不会收到 -EBUSY,更不会陷入 c08972f55594 遗留的无效重试循环。

可写映射不是 safetensors 干的 safetensors 内部采用只读映射(map_copy_read_only,设置 framework="np" 可看到 r--p)。但在走 PyTorch 路径时,它并不使用该映射,而是调用 torch.UntypedStorage.from_file(filename, shared=False, nbytes=size);PyTorch 则因 torch storage 必须支持修改而将文件映射为可写。因此,任何框架只要经由 PyTorch 路径从 safetensors checkpoint 加载张量,其底层均为 rw-p 映射。

4被拆开的那个 commit 值多少,以及是谁修的

342981fff328 既未标注 Fixes: 标签,也未抄送 Cc: stable@vger.kernel.org,因而从未进入 stable 分支排队;然而 c08972f55594 却进入了 -28。未打标签的修复补丁不会自动跟进被其修复的 commit。这属于上游打标签时的疏漏,而非发行版打包失误;任何跟进 7.0.y 且合入了首个 commit 的内核分支,都会陷入相同的困境。

在四种配置下运行同一套复现脚本,慢的只有一种:在同一台虚拟机(guest)中,7.0.0-28 耗时十六秒;而在 7.0.0-14 下仅需 18.6 至 20.2 ms;6.8.0-136 为 22.1 至 26.0 ms;Proxmox 7.0.14 裸机构建则为 24.1 至 28.6 ms。最关键的是前两行对照:相同的客户机环境、相同的 ROCm 用户态、相同的容器镜像、相同的复现脚本,仅仅替换了内核 ABI。尽管此前我们与 AMD 均优先怀疑 PCI 直通问题,但实测表明直通并非决定性变量。

这份报告不是修复的原因 linux-hwe-7.0 于 2026-08-20 在 noble-updates 与 noble-security 源中升级至 7.0.0-30.30~24.04.1,并合入了 drm/amdgpu: drop retry loop in amdgpu_hmm_range_get_pages。此前专门要求合入该 backport 的 Launchpad bug 状态仍为 New 且尚未分诊,表明该 commit 是经由常规 stable 渠道合并的。在 24.04 上的解决方式如今只需执行系统升级;图 1 中自行重新编译的模块仅用作溯源论证,而非推荐方案。

5永久的那一半值多少

排除了第二个效应后,首个缺陷的影响才首次得以在端到端层面被精确测量。四个 checkpoint、四条加载路径,且逐格控制 page cache:每一格开始前,要么执行 drop_caches,要么在 drop_caches 之后完整通读一遍 checkpoint;并且每一格都在一个全新进程中运行。

图 2 · 在 Canonical 现在发的内核上,四条加载路径 可由仓库重算
图 2. 中位数,取自 benchmarks/loader-flag-kernel-30.json, 7.0.0-30 上 89 格里做了缓存控制的那 73 格。默认视图是相对每格自己 baseline 的倍数, 因为绝对秒数被一个格子主导,而问题问的是每条路径值多少。把鼠标停在任意一条上 可以看到中位数、极差以及它跨了多少个进程。clone 是 vllm#49991 那个开关; eager 是 vLLM 自带的 --safetensors-load-strategy eager; pread 是 safetensors 0.8.0 的 backend="pread",它根本不映射文件。

单项测试脚本的测量结果偏向乐观,高出了 10 至 14 %。在 12B 模型上执行真实的 vllm serve 启动,读取加载器自身打印的 Loading weights took 耗时:热缓存下提速为 1.79×,冷缓存下为 1.52×;而上述测试脚本给出的数据则是 1.98 与 1.73。两者的绝对秒数咬合得很紧,这证明该测试脚本具备实用价值;但加速比未能吻合,因为一次真实的 server start 包含了测试脚本未涉及的额外开销。请将图 2 中的比值视作该开关能够带来的收益上限。

该图中有三个现象尤为突出。31B 冷缓存格子的加速比达到了 7.5×,这是因为在 23.4 GiB 内存中加载 21.67 GiB 的 checkpoint 不仅失去了 copy-on-write 的益取,更将加载器直接逼入了 swap。MoE 格子则是一个反例:该 checkpoint 包含 35 743 个以小尺寸为主的张量,几乎没有哪个张量大到需要承担 copy-on-write 的破坏代价,这就使得多出的一次主机端内存拷贝成为单纯的开销,导致开启该开关的表现反而略逊于不做任何改动。而在 safetensors 0.8.0 中引入且仅在更新说明中提过的 pread,除了在 MoE 上胜出之外,在其余场景均慢于 clone。

6机理,就在常驻集里

只要在加载进行过程中对常驻内存集(resident set)进行采样,就能直观看出该惩罚为何并非固定比例,而无需依赖间接推导。safetensors 会在迭代器遍历完一个 shard 的瞬间将其取消映射(unmap),因此若在循环结束后才去采样,看到的只能是一个空进程。我们最初的测量尝试一无所获,相关数据已被舍弃。

图 3 · 峰值处的匿名内存与文件页 可由仓库重算
图 3. 每 64 个张量采样一次 RssAnon 与 RssFile。 在 31B 上默认路径峰值是 21 390 MiB 匿名对 782 MiB 文件页,加了 clone 之后两者互换。 破坏 copy-on-write 把整个 checkpoint 变成了私有脏内存,而私有脏内存不可回收 —— 这就是为什么一个占宿主内存很大比例的 checkpoint 和一个不占的表现完全不同。 分片能把损失框住:Qwen3-8B 同样是 15.26 GiB 但分成五个 shard,峰值只有 4 535 MiB, 大约是一个 shard。pread 从不映射文件,所以两样都不占。

7没有被确立的部分

为什么七月的基线复现不出来

在 -30 上不控制缓存重测 2026-07-28 的流程,8B 耗时 13.87 s(当时为 36.4 s),31B 为 126.61 s(当时超过 900 s 超时)。缓存无法完全解释此差距。两次测试间内核状态改变,且虚拟机的 PCIe atomics 已启用。但 32 MiB 复现脚本耗时仅从 17.0 降至 15.3 ms,单次拷贝路径无法解释此差异,成因目前未知。七月的基线数据已被废弃,未能归因和解。

一台机器,而且是虚拟机

所有测试均在单台 VFIO 虚拟机(gfx1100、ext4)上完成。裸机 Ubuntu -28 的情况已由他人补全:@shineday999 在非虚拟化环境中使用 RX 7900 XTX 与 ROCm 7.2.1 复现了相同的病理与整数窗口计数。然而 Canonical 原生分发的模块在裸机上仍未测过;两次测试均使用了自编模块或用户态补丁。CDNA 架构未测,宿主机内存远大于 checkpoint 的场景亦未做测量。

每个张量的常驻程度

窗口数量计算公式为 resident_bytes / 2 MiB,该结论由 32 和 256 MiB 的复现脚本确立并在另一块硬件上独立验证。8 MiB 与 96 MiB 张量各自仅耗费一个窗口即由此得出:拷贝开始前未被预先读取,常驻比例极低。但每个张量的实际常驻情况从未被直接采样验证。

两个 rank 之间 17 倍的不对称

在某次测试中,一个 rank 的单次 ioctl 耗时约 190 ms,而另一个仅为 11 ms。并发进程、内存压力以及 torchrun 配合 RCCL 均未能复现该现象,这一不对称性至今未获解释。

8此后发生的变化

2026-08-23 · 那个开关的头条数字被撤回 vllm#49991 此前发布了固定的 3.9× 至 5.6× 并称其具有普遍性。该数据源于未对 page cache 做控制的测试,当时因连续加载三个模型而导致每格均继承了前序缓存。在逐格控制缓存重新测量后:checkpoint 能载入内存时收益为 1.5× 至 2.0×,超出内存容量时为 7.5×。PR 中原称“在所测全部配置中 clone 均更快,默认开启合情合理”;图 2 中的 MoE 模型已构成反例,该论断必须删除。
2026-08-20 · Canonical 发出了缺失的那个 commit 7.0.0-30.30~24.04.1 进入了 noble-updates 与 noble-security。一秒级延迟窗口已消除:同一天在同一机器上运行同一复现脚本,升级前耗时 16 019.3 ms,升级后仅 15.3 ms。ROCm#6523 记录了该测量结果。
2026-07-30 · 裸机那个缺口被一个陌生人补上了 本报告此前无法测试非虚拟化机器。@shineday999 在非虚拟化环境(RX 7900 XTX、ROCm 7.2.1、7.0.0-28)下复现了完全相同的病理与整数窗口计数,并补充了逐张量分解数据,四次独立测试均落在四个整数窗口上。两个 ROCm 版本的吻合也表明用户态版本并非致病变量。
仍然开着 可写映射所造成的性能惩罚未受上述任何更新影响,在 -30 上仍会带来 4.8× 的开销。ROCm#6523 保持开放;vllm#49991 亦保持开放且需将其摘要数据修正为上述实测区间。ROC_FORCE_STAGED_D2H 原本可彻底绕开该拷贝,但 @ashetaia-amd 指出 ROCm/rocm-systems#6676 中的长期修复无法改善该拷贝路径,因此不能寄望于此。
2026-08-29 · 摘要里有三句话和它自己的证据不符 “所有计时均落在 1000 ms 的整数倍”与紧随其后的示例相悖:16 019.6 ms 包含十六个窗口外加实际工作,且按张量计时中还包含一个半窗口。“通过 revert 证实”与实际操作相反,实测是补回了缺失 commit 而非将其移除。“五周后”亦与页面日期矛盾:修复于 2026-08-20 随 -30 发布。三处均已就地更正。此外,“所有框架都会走的路径”已修正为实际测量的特定加载器。
2026-08-29 · 图 2 的图注数进了这张图没画的格子 图注此前称“7.0.0-30 上的 89 格”。数据文件虽包含 89 格,但 genfig-loader.py 仅绘制了受缓存控制的 73 格(冷缓存与热缓存);其余 16 格为 asis 顺序对照组,按 benchmarks/README.md 所述特意未做缓存控制。与“逐格控制 page cache”并置时,容易被误读为全部 89 格均受控。图注计数现已改为该图实际绘制的 73 格;前文论断对这部分格子本来就成立。
2026-09-05 · 行数报错了 本报告此前将 benchmarks/repro-mmap-prot.py 描述为三十行。该文件实际为 60 行(50 行非空,49 行非注释),且在本仓库历史中从未少于 55 行。正文现已更正为六十行,与 wc -l 统计一致。

自行复现。复现脚本见 benchmarks/repro-mmap-prot.py;若环境中未安装 PyTorch,可使用 repro-mmap-prot.hip.cpp。图 1 绘自 hmm-kernel-three-states.json;图 2 与图 3 绘自 loader-flag-kernel-30.json(记录了 89 格独立控制的 page cache 状态)。完整的排查记录及早期被推翻的假说,详见 open-questions.md §8。

缺陷已提交至 ROCm#6523(@ashetaia-amd 确认了 copy-on-write 诱因并指认了 kfd_svm.c,@shineday999 提供了裸机复现数据);并向 Ubuntu 报告为 LP#2161985。342981fff328 为 Honglei Huang 编写的补丁,经 Christian König 审查、Alex Deucher 提交;c08972f55594 由 Christian König 编写、Alex Deucher 签署。