RDNA3 · KFD · HMM · measured on 2× RX 7900 XT

KFD asks for write access on a read-only copy,
and an Ubuntu kernel backport charged a second for it

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.

2× RX 7900 XT · gfx1100 · VFIO guest Ubuntu 7.0.0-28 → -30 ROCm 7.14 · safetensors 0.8.0 measured 2026-07-28 and 2026-08-23

Abstract

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.

1The question, and what it was worth asking from

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.

Where that pair of numbers comes from Those two rows are from the historical part of 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.

2The variable that does trigger it

Two mappings of the same file, in one process, while safetensors holds a checkpoint open:

/proc/self/maps
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.

Figure 1 · one reproducer, three kernel states recomputable from repo check with repro-mmap-prot.hip.cpp
Figure 1. 32 MiB per copy, log axis, from 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.

3Why a read asks for write

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.

The writable mapping is not safetensors' doing safetensors maps read-only — 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.

4What the split commit cost, and who fixed it

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.

This report did not cause the fix 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.

5What the permanent half is worth

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.

Figure 2 · four load paths on the kernel Canonical ships recomputable from repo
Figure 2. Medians from 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.

6The mechanism, in the resident set

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.

Figure 3 · anonymous against file-backed, at peak recomputable from repo
Figure 3. 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.

7What is not established

Why the July baselines do not reproduce

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.

One machine, and it is a guest

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.

Residency per tensor

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.

A 17× asymmetry between the two ranks

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.

8What has changed since

2026-08-23 · the flag's headline number was withdrawn vllm#49991 published a flat 3.9× to 5.6× and called that magnitude permanent. It came from a run with no control over page cache which loaded three models in sequence, so every cell inherited its predecessor's. Re-measured with cache controlled per cell it is 1.5× to 2.0× while the checkpoint fits in RAM and 7.5× when it does not. The PR also said cloning was faster in every configuration measured and that defaulting it on looked sensible; the MoE checkpoint in Figure 2 is the counterexample, and that sentence has to go.
2026-08-20 · Canonical shipped the missing commit 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.
2026-07-30 · the bare-metal gap was closed by a stranger This report could not test a non-virtualised machine. @shineday999 ran one — RX 7900 XTX, ROCm 7.2.1, 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.
Still open The writable-mapping penalty itself is untouched by any of this and is worth 4.8× on -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.
2026-08-29 · three sentences in the abstract were wrong about their own evidence "Every timing lands on an integer multiple of 1000 ms" is contradicted by the example that follows it: 16 019.6 ms is sixteen windows plus real work, and the per-tensor timings include a window and a half. "Proven by revert" describes the opposite of what was done — the missing commit was applied, not removed. And "five weeks later" does not match the dates on this page: the fix shipped in -30 on 2026-08-20. All three corrected in place. Separately, "the path every framework takes" is narrowed to the loader actually measured.
2026-08-29 · figure 2's caption counted cells the figure does not draw It said "89 cells on 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.
2026-09-05 · the line count was wrong This report described 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.