Weight loading measured on Radeon hardware; traced through KFD and HMM against an unpatched kernel retry loop and Canonical's update
A 9.56 GiB
checkpoint that the disk hands over in about six seconds took 328. Two independent
defects caused the delay. The first is a permission bug that makes the GPU ask for write access it does not need,
confirmed by AMD down to a kernel line and still present today. The second was a distro backport that turned
every occurrence of it into a whole second, proven by rebuilding with the missing commit as the only
change, and fixed by Canonical in -30.
The first effect is a permission taken from the
wrong place. A host-to-device copy only reads its source. KFD derives the access it
requests from the VMA flags, so a mapping that carries VM_WRITE forces
hmm_range_fault() to ask for write access and break copy-on-write on every resident page. PyTorch
maps safetensors checkpoints writable because a storage must be mutable, so this is the path PyTorch
takes here. AMD confirmed it and named the line. It is worth 4.8× on the kernel
shipping today.
The second was a backport. Ubuntu's 7.0.0-28 took
c08972f55594 without its follow-up 342981fff328, leaving a retry loop that
spins for the full HMM_RANGE_DEFAULT_TIMEOUT. Every timing is measured in whole 1000 ms
windows rather than in bandwidth: a 32 MiB copy took 16 019.6 ms, which is sixteen
windows plus 19.6 ms of real work. Rebuilding amdgpu.ko with the missing commit as its
only change gives 17.0 ms. Canonical shipped it in -30 on 2026-08-20
and the same reproducer now returns 15.3.
The pair explains itself: breaking copy-on-write is what advances the notifier sequence, so the permission bug is the trigger and the missing commit turned each trigger into a full second. A read-only mapping never invalidates and never enters the retry.
One of this repository's own numbers did not survive. The 3.9× to 5.6× published with vllm#49991 came from a run with no control over page cache. Re-measured with cache controlled per cell it is 1.5× to 2.0× while the checkpoint fits in RAM, 7.5× when it does not, and slightly negative on a 128-expert MoE.
The opening measurement is the weakest in this article, but it prompted all the work that followed.
It goes first, with its provenance attached. On 2026-07-24, with the disk measured at 1.5 GB/s by
dd iflag=direct on the same file, a 15.6 GiB BF16 checkpoint loaded at 77 MB/s and a
9.56 GiB w4a16 one at 29. That was nineteen and fifty-two times slower than the medium it came off.
docs/open-questions.md §8 and have no
committed data file. They set the question; they establish nothing. Everything from
Figure 1 on is measured against a committed file and can be recomputed. The
section heading in that document says 19–48× rather than 19–52× because it divided by a
decimal-megabyte rate while the table quotes MiB/s — noted there, and irrelevant to the answer, which
is measured directly rather than inferred from a ratio.
The first hypothesis was wrong, but stating it explains the method that followed. This repository
published "the source is a file-backed mmap page, and ROCm does not take the pinned-staging path
for those". A dependency-free reproducer disproved it. A plain mmap of a warm file
copies at full speed. Eight variables — anonymous against file-backed, faulted against not, fresh range
per copy, overlayfs against ext4, fresh device allocation per copy, a 1–256 MiB size sweep,
page-unaligned starts, dtype — all failed to trigger anything. After that, the bar for any second
explanation was that a stranger could run it.
Two mappings of the same file, in one process, while safetensors holds a checkpoint open:
75d757200000-75d845520000 rw-p ... model-00001-of-00005.safetensors <- safetensors 75d468a00000-75d556d20000 r--p ... model-00001-of-00005.safetensors <- ours
The trigger is a read-only checkpoint mapped MAP_PRIVATE and writable.
Isolating that one variable — same file, same bytes, plain mmap and torch.frombuffer,
without safetensors or vLLM — reproduces the whole thing in sixty lines. Adding a second dimension, whether
the CPU has touched the mapping before the copy, produces three regimes rather than two. Real loads
sit in the third.
benchmarks/hmm-kernel-three-states.json. Only the third case moves. The middle state is
a module built from Ubuntu's own -28 source with 342981fff328 as its only
change and swapped into the running kernel — it is what proved the cause, and it is not a kernel
anyone ships. The third is what Canonical ships now, measured on the same machine with the same
binary, re-run the same day before upgrading so the upgrade is the only variable. The two control
rows stay inside their own range across all three: 3.0 to 3.2 ms, and 14.5 down to 13.1.
16 019.6 ms is not a bandwidth figure and it is not noise. It is sixteen 1000 ms
HMM_RANGE_DEFAULT_TIMEOUT windows plus 19.6 ms of real work. The sub-millisecond
reproducibility that looked like a fixed per-page cost is a jiffies timeout. Once you read
the number that way, the rest follows: the flat one-second gap between two filesystems is one extra
retry, and per-tensor timings of 1005, 1042 and 1522 ms are one, one and one-and-a-half windows.
AMD confirmed the mechanism on ROCm#6523 and named the site. The chain is three hops in the upstream tree:
The permission is taken from the VMA rather than from what the GPU is about to do with the
range. A host-to-device copy only reads its source. Because the VMA carries
VM_WRITE, KFD asks hmm_range_fault() for write access, which breaks
copy-on-write on every resident page. That is the permanent defect. It appears on every kernel measured
here, and the line predates all of them.
This mechanism also explains the three regimes without extra assumptions. Pages untouched by the CPU
are not yet copy-on-write shared, so the not-resident case never pays for the break. This joins the two
halves: breaking copy-on-write is what advances the notifier sequence, whereas a read-only mapping never
invalidates, never gets -EBUSY, and never enters the futile retry that
c08972f55594 left behind.
map_copy_read_only, and framework="np" yields
r--p. Its PyTorch path does not use that mapping at all: it calls
torch.UntypedStorage.from_file(filename, shared=False, nbytes=size), and PyTorch maps the
file writable because a torch storage must be mutable. Every tensor any framework loads from a
safetensors checkpoint on the PyTorch path is backed by rw-p.
342981fff328 carries neither a Fixes: tag nor
Cc: stable@vger.kernel.org, so it was never queued for the stable trees, while
c08972f55594 reached -28. An untagged fix does not follow the commit
it repairs. That makes this an upstream tagging gap rather than a distro packaging slip, and any series
tracking 7.0.y that took the first commit was in the same state.
Four configurations, one reproducer, and only one was slow: the same guest on
7.0.0-28 at sixteen seconds, the same guest on 7.0.0-14 at 18.6 to 20.2 ms,
6.8.0-136 at 22.1 to 26.0, and bare metal on a Proxmox 7.0.14 build at 24.1 to
28.6. The first two rows are the ones that matter: same guest, same ROCm userspace, same
container image, same reproducer, only the kernel ABI swapped. Passthrough is not the decisive factor,
though it was the leading suspicion here and at AMD.
linux-hwe-7.0 went to 7.0.0-30.30~24.04.1 in noble-updates and
noble-security on 2026-08-20, carrying drm/amdgpu: drop retry loop in
amdgpu_hmm_range_get_pages. The Launchpad bug asking for exactly that backport is still
New and untriaged, so the commit came through the ordinary stable route. The remedy on
24.04 is now an upgrade, and the rebuilt module in Figure 1 is evidence rather than advice.
With the second effect gone, the first is measurable end to end for the first time. Four checkpoints,
four load paths, page cache controlled per cell — every cell preceded either by
drop_caches or by drop_caches plus a full read of the checkpoint — and each
cell a fresh process.
benchmarks/loader-flag-kernel-30.json, the 73
cache-controlled cells of 89 on 7.0.0-30. The default view is the ratio against each cell's own baseline, because the
absolute seconds are dominated by one cell and the question is what each path is worth. Hover any bar
for the median, its range and how many processes it is over. clone is
vllm#49991's flag;
eager is vLLM's shipped --safetensors-load-strategy eager;
pread is safetensors 0.8.0's backend="pread", which never maps the file at
all.
The isolated harness is the optimistic one, by 10 to 14 %. A real
vllm serve start on the 12B, reading the loader's own Loading weights took
line, gives 1.79× warm and 1.52× cold where the harness above says
1.98 and 1.73. The absolute seconds track closely, which makes the harness usable; the
ratio does not, because a server start carries work the harness does not. Take the ratios in Figure 2 as
the upper end of what the flag is worth.
Three findings in that figure stand out. The 31B cold cell reaches 7.5×
because a 21.67 GiB checkpoint against 23.4 GiB of RAM does not merely lose copy-on-write; it pushes the
loader into swap. The MoE cell is the counterexample: almost nothing in a checkpoint of
35 743 mostly small tensors is large enough to pay the copy-on-write cost, making the extra host copy
pure overhead and leaving the flag slightly worse than doing nothing. And pread, which shipped
in safetensors 0.8.0 and is named only in the release notes, is slower than the clone everywhere except
the MoE, where it wins.
Why the penalty is not a constant ratio becomes visible directly rather than by inference once the resident set is sampled during the load. safetensors unmaps a shard the moment the iterator leaves it, so an end-of-loop sample sees an empty process. The first measurement attempt caught nothing and was discarded.
RssAnon and RssFile sampled every 64 tensors. On the 31B
the default path peaks at 21 390 MiB anonymous against 782 file-backed; with the clone the two swap
places. Breaking copy-on-write converts the checkpoint into private dirty memory, which is no longer
evictable — that is why a checkpoint which is a large fraction of host RAM behaves differently from
one that is not. Sharding bounds it: Qwen3-8B is the same 15.26 GiB in five shards and peaks at
4 535 MiB, roughly one shard. pread never maps the file, so it carries neither.
Replaying the 2026-07-28 run on -30 without cache control yields 13.87 s on 8B against
36.4 s then, and 126.61 s on 31B against a timeout past 900 s. Cache does not explain this alone.
Between sessions, kernel state changed and guest PCIe atomics were enabled. But the 32 MiB reproducer
shifted only from 17.0 to 15.3 ms; the per-copy path does not account for the gap, and nothing here explains
it. The July table is superseded, not reconciled.
All runs used one VFIO guest, gfx1100, on ext4. A bare-metal Ubuntu -28 was closed by someone else: @shineday999 reported an RX 7900 XTX on ROCm 7.2.1 with no virtualisation, same pathology and the same integer window counts. But a stock module as shipped by Canonical on bare metal remains untested; both runs
used custom modules or userspace fixes. CDNA is untested, and hosts where RAM dwarfs the checkpoint are
unmeasured.
The window count is resident_bytes / 2 MiB, established from the reproducer at 32 and 256 MiB and confirmed independently on another board. That an 8 MiB and a 96 MiB tensor each take one window follows from it: they are barely resident when the copy starts, since nothing reads them first. Still, per-tensor residency was never sampled directly to verify that step.
One run showed roughly 190 ms per ioctl on one rank against 11 ms on the other. Concurrent processes, memory pressure, and torchrun plus RCCL failed to reproduce it; the asymmetry remains unexplained.
7.0.0-30.30~24.04.1, in noble-updates and noble-security. The one-second windows are
gone: 16 019.3 ms before the upgrade and 15.3 after, on the same machine and the same reproducer
binary the same day. ROCm#6523 carries the
measurement.
7.0.0-28, no virtualisation — and reported the same pathology with the same integer
window counts, then followed up with a per-tensor breakdown at four independent runs landing on four
integer windows. Two ROCm versions agreeing also says the userspace version is not the variable.
-30.
ROCm#6523 is open;
vllm#49991 is open and needs its headline
number replaced with the measured band above. ROC_FORCE_STAGED_D2H would sidestep the
copy entirely, but @ashetaia-amd states that the long-term fix taken in ROCm/rocm-systems#6676 does not
help this copy path, so it is not the remedy to wait for.
-30 on 2026-08-20. All three corrected in
place. Separately, "the path every framework takes" is narrowed to the loader actually measured.
7.0.0-30". The file holds 89, but
genfig-loader.py draws only the cache-controlled ones — 73, warm and cold — and the
other 16 are the asis ordering controls that deliberately do not control cache, as
benchmarks/README.md says. Sitting next to "page cache controlled per cell — every
cell", the pair claimed cache control over all 89. The count is now the 73 the figure draws; the
sentence above it was already true of them.
benchmarks/repro-mmap-prot.py as thirty lines. It is 60
(50 non-blank, 49 excluding comments) and has never been shorter than 55 in this repository's
history. The text now says sixty, which is what wc -l returns.
Run it yourself. The reproducer is
benchmarks/repro-mmap-prot.py,
or for machines without PyTorch,
repro-mmap-prot.hip.cpp.
Figure 1 draws from
hmm-kernel-three-states.json;
Figures 2 and 3 draw from
loader-flag-kernel-30.json,
which records 89 cells with per-cell page-cache state. The full investigation, including the initial
incorrect hypothesis, is documented in
open-questions.md §8.
Reported as ROCm#6523, where @ashetaia-amd
confirmed the copy-on-write trigger and named kfd_svm.c, and @shineday999 contributed the
bare-metal reproduction; and to Ubuntu as
LP#2161985.
342981fff328 is Honglei Huang's patch, reviewed by Christian König, committed by Alex
Deucher; c08972f55594 is Christian König's, signed off by Alex Deucher.