Skip to content

[Issue] [gfx1100]: host→device copies stall one second at a time on kernel 7.0.0-28 — c08972f55594 without 342981fff328 #6523

Description

@cadamcat

Summary

On gfx1100 with ROCm 7.14, a host-to-device copy runs at ~9.4 GiB/s (9 575 MiB/s) when the source is a read-only file mapping (r--p), and at 2.0 MiB/s when the source is a writable private file mapping (rw-p) whose pages have already been faulted into the process.

The kernel decides how bad it gets, and the cause is identified and now confirmed by revert. Ubuntu's 7.0.0-28 picked up drm/amdgpu: fix amdgpu_hmm_range_get_pages (c08972f55594) without the follow-up that repairs its consequence, drm/amdgpu: drop retry loop in amdgpu_hmm_range_get_pages (342981fff328). The first moved mmu_interval_read_begin() out of the per-chunk loop, so notifier_seq is never refreshed across retries and the -EBUSY retry path can never succeed; it busy-spins for the whole HMM_RANGE_DEFAULT_TIMEOUT, which is 1000 ms, then bails with -EAGAIN and the caller tries again. Every timing in this report is an integer multiple of that constant. On the same guest with only the kernel changed, 7.0.0-14-generic does the same copy in 18.6 to 20.2 ms.

A separate and milder penalty, 4× to 8× against a read-only copy of the same bytes, is present on every kernel tested. @ashetaia-amd explained that one below: kfd_svm.c takes the fault permission from the VMA rather than from what the copy will do, so copy-on-write is broken on every resident page even though a host→device copy only reads. That break is also what advances the notifier sequence, which is why only the writable-and-resident case ever enters the futile retry.

Same file, same bytes, same size, same process. The only difference is the prot argument to mmap and whether the CPU has touched the pages first.

This is not a corner case: it is the default path for loading any safetensors checkpoint into PyTorch, because safetensors hands PyTorch a storage created by torch.UntypedStorage.from_file(..., shared=False), and PyTorch maps that writable. On the affected kernel it makes vLLM model loading 19–48x slower than the disk it reads from: a 15.26 GiB checkpoint takes 206 s, against ~19 s with the workaround below and ~11 s for the disk alone.

Measurements

source mapping pages faulted in first 32 MiB copy rate
MAP_PRIVATE | PROT_READ (r--p) yes 3.3 ms 9 575 MiB/s
MAP_PRIVATE | PROT_READ|PROT_WRITE (rw-p) no 15.8 ms 2 020 MiB/s
MAP_PRIVATE | PROT_READ|PROT_WRITE (rw-p) yes 16 020 ms 2.0 MiB/s

Four environments, one reproducer, and only one of them is slow:

environment kernel rw-p resident, 32 MiB
VFIO guest 7.0.0-28-generic 16 019.3 / 16 019.6 / 16 019.9 / 16 020.1 ms
VFIO guest 7.0.0-14-generic 18.6 / 18.9 / 20.2 ms
VFIO guest 6.8.0-136-generic 22.1 / 23.3 / 26.0 ms
bare metal 7.0.14-4-pve 24.1 / 24.3 / 25.1 / 28.6 ms

The first two rows hold everything constant but the kernel ABI — same guest, same ROCm 7.14 userspace, same container image — so passthrough is not a necessary ingredient, which had been the leading suspicion. iommu=pt is not the variable either: this host already boots with it, and the guest has no IOMMU of its own.

In timeout windows those numbers are 16.0, and 17.0 for the 17 020 ms the same reproducer gives on overlayfs and tmpfs. Per-tensor stalls during a real checkpoint load measured 1005, 1042 and 1522 ms, which is 1.0, 1.0 and 1.5. Those tensors are only partially resident, since nothing in the load path touches the pages before the copy, so their window counts are not comparable with the fully resident reproducer figures above. Same mechanism, different amount of resident memory entering it. "Why this hits real workloads" below has the detail.

The first copy any process makes costs about 40 ms whatever the mapping, which is why the reproducer discards one r--p copy before the reading that counts. The pathological case is repeatable to a degree worth stating: on ext4 it landed at 16 019.3, 16 019.6, 16 019.9 and 16 020.1 ms over four runs today, and on overlayfs and tmpfs at 17 020.5 and 17 020.4 ms. Under a millisecond of spread within a filesystem, and a flat 1 s offset between them, because the cost is a whole number of 1000 ms timeout windows and the container filesystems take one retry more.

Against the read-only case this is 4 388× to 4 787× depending on the run, but the ratio is the wrong way to think about it: the numerator is a count of timeout windows. At 256 MiB the copy takes 128 s, which is 128 windows for eight times the pages, so the window count tracks the number of resident pages while each window stays at its constant 1000 ms. The granularity works out to 2 MiB of resident source per window, and it is not specific to this machine: @shineday999 measured 8 MiB in exactly four windows below, on a different board under a different ROCm, and after subtracting the windows the residual real work lands in the same band on both, 1624 MiB/s here against 1575 to 1695 MiB/s there.

Anonymous memory of the same size copies at ~13 GiB/s, i.e. the hardware and the PCIe link are fine.

Reproducer

Dependency-free — PyTorch only, no safetensors, no vLLM. The backing filesystem does not matter either: the same pathological rate appears on ext4 (2.0 MiB/s), on a container's own overlayfs (1.9 MiB/s) and on tmpfs (1.9 MiB/s), so a plain container with no bind mount reproduces it.

import os, mmap, time, torch

PATH, N, DEV = "/data/repro.bin", 32 << 20, "cuda:0"
if not os.path.exists(PATH) or os.path.getsize(PATH) < N * 8:
    with open(PATH, "wb") as f:
        for _ in range(N * 8 >> 20):
            f.write(os.urandom(1 << 20))
with open(PATH, "rb") as f:                       # warm the page cache
    while f.read(1 << 24):
        pass

def run(writable, pretouch):
    fh = open(PATH, "r+b" if writable else "rb")
    prot = mmap.PROT_READ | (mmap.PROT_WRITE if writable else 0)
    mm = mmap.mmap(fh.fileno(), N * 8, flags=mmap.MAP_PRIVATE, prot=prot)
    src = torch.frombuffer(mm, dtype=torch.uint8, count=N * 8)[:N]
    if pretouch:
        src.max()                                 # bulk read: faults the pages in
    torch.cuda.synchronize(); t0 = time.perf_counter()
    g = src.to(DEV); torch.cuda.synchronize()
    dt = time.perf_counter() - t0
    perm = [l.split()[1] for l in open("/proc/self/maps") if "repro.bin" in l][0]
    print(f"{perm} pretouch={pretouch!s:<5} {dt*1000:9.1f} ms  {N/dt/2**20:8.1f} MiB/s")
    del g, src; torch.cuda.empty_cache(); mm.close(); fh.close()

run(False, True)     # warm-up: the first copy in a process costs ~40 ms whatever the mapping
run(False, True)     # r--p, resident ->  ~9500 MiB/s
run(True,  False)    # rw-p, cold     ->  ~2000 MiB/s
run(True,  True)     # rw-p, resident ->     ~2 MiB/s

Where the time goes

perf on a vLLM worker during a real checkpoint load:

99.11%  ioctl
        └─ __x64_sys_ioctl → kfd_ioctl → kfd_ioctl_svm → svm_ioctl
           └─ svm_range_set_attr → svm_range_validate_and_map
              └─ 94.19% amdgpu_hmm_range_get_pages → 86.95% hmm_range_fault

strace over a 12 s window of the same load: 54 ioctls, ~189 ms each.

That profile is the busy spin, not work. svm_range_validate_and_map takes readonly from the VMA's VM_WRITE bit at kfd_svm.c:1777 and passes it to amdgpu_hmm_range_get_pages(), which turns !readonly into HMM_PFN_REQ_WRITE at amdgpu_hmm.c:188. So a copy that only reads its source asks for write access, copy-on-write is broken on every resident page, and that break advances the notifier sequence. On -28 the stale notifier_seq then makes the -EBUSY retry unable to succeed, and it spins out the full timeout. A read-only mapping never invalidates, so it never enters any of this.

Why this hits real workloads

safetensors, on its PyTorch path, does not use its own (read-only) mapping. It calls:

// safetensors/bindings/python/src/lib.rs
torch.UntypedStorage.from_file(filename, shared=False, nbytes=size)

PyTorch maps that file writable, so every tensor a framework loads from a safetensors checkpoint is backed by an rw-p mapping. Confirmed with /proc/<pid>/maps, same file opened two ways in one process:

rw-p ... model-00001-of-00005.safetensors   <- safetensors framework="pt"
r--p ... model-00001-of-00005.safetensors   <- safetensors framework="np"

Effect on vLLM startup on this machine, with and without a workaround that copies each tensor into anonymous memory before the device copy:

model as shipped tensor cloned to anon memory first
Qwen3-8B, 15.26 GiB BF16 206 s 18.7 s
gemma-4-12B, 9.56 GiB w4a16 328 s 10.5 s
gemma-4-31B, 21.67 GiB w4a16 569 s 25.1 s

Those work out to 76, 30 and 39 MiB/s, which is milder than the 2.0 MiB/s of the microbenchmark and consistently so. All three sit between the two writable regimes measured above, which is what a partially resident mapping produces: nothing in the load path reads the pages before the copy, so they are faulted in by the copy itself and only the already-resident fraction pays the worst rate. Timed tensor by tensor inside one shard, the cost is roughly fixed at about 1 s per tensor above a threshold somewhere between 4 and 8 MiB, and negligible below it.

Possibly related, and one thing that does not help

ROCm/ROCm#5952 — "SVM mapping failure during sequential model loads (RDNA3 / RX 7900 GRE)" — is the nearest neighbour. It reports crashes and stalls rather than a reproducible slow copy, but it is the same subsystem on the same generation of hardware, its reporter describes VRAM filling extremely slowly, and in that thread @zichguan-amd reads the logs as an async copy of 27 648 bytes taking 25 s. That machine is bare metal (Ryzen 7800X3D), which our guest-only setup cannot match.

Two things keep us from calling it the same bug. The kernel-side analysis posted there by @Deaththegrim concerns svm_range_restore_work and the userptr restore worker, i.e. the eviction/restore path, whereas our profile sits in svm_range_set_attr → svm_range_validate_and_map → hmm_range_fault, the mapping path. And that thread's symptoms come and go with workflow, RAM pressure and kernel build, while the reproducer below is deterministic and finishes in half a minute. We offer this as an adjacent report rather than a duplicate; if the two do share a cause, the reproducer is probably the cheaper way in.

HSA_USE_SVM=0 does not help. Suggested by ROCm/ROCm#2433, where it recovers pre-5.6 hipHostRegister performance. Measured here: the pathological case is unchanged (16 036 ms vs 16 020 ms), and the read-only fast path gets worse (8 905 → 844 MiB/s) — so SVM is what makes read-only mappings fast, while the writable path is slow for some other reason.

Ruled out

hypothesis result
disk throughput 1.5 GB/s measured on the same file with dd iflag=direct
HSA_USE_SVM=0 no effect on the pathological case; degrades the fast case
page cache cold vs warm 192 vs 212 MiB/s, no meaningful difference
file-backed vs anonymous per se a read-only file mapping is full speed
pages not yet faulted in that is the faster of the two writable cases
a fresh source range per copy no effect on a read-only mapping
ext4 vs overlayfs vs tmpfs 2.0, 1.9 and 1.9 MiB/s — the backing filesystem is not a factor
page-unaligned range start, bf16 vs uint8 no effect
new device allocation per copy vs one reused buffer no effect
swap / memory pressure swap usage 0 throughout

Environment

  • 2× Radeon RX 7900 XT (gfx1100), 20 GiB each
  • ROCm 7.14, PyTorch 2.11, safetensors 0.8.0 — also reproduces on ROCm 7.0.0 with PyTorch 2.9, so the userspace version is not the variable
  • Ubuntu 24.04.4 guest under Proxmox VE / QEMU with VFIO passthrough. The kernel that varies is the guest's: 7.0.0-28-generic for the severe case, 7.0.0-14-generic and 6.8.0-136-generic for the comparisons, all booted from the same install. The host runs 7.0.14-4-pve and is the bare-metal row. The GPUs report PCIE atomic ops is not supported on every guest kernel
  • Checkpoints on ext4 on an NVMe SSD; the reproducer's test file likewise

Confirmed by revert. amdgpu.ko built from the linux-hwe-7.0 7.0.0-28.28~24.04.1 source with 342981fff328 as the only change, swapped into the running -28 kernel, same machine and userspace and reproducer: the copy goes from 16 019.7 ms to 17.0 ms, while the reproducer's other three cases stay where they were. Details in the comments below.

What is still open. Updated 2026-08-28: not the kernel question any more. Canonical shipped 7.0.0-30.30~24.04.1 on 2026-08-20 and it carries the follow-up in effect: the same reproducer on the same machine goes from 16 019.3 ms to 15.3 ms across that upgrade, with the two control rows unmoved (all three kernel states, raw, and the comment below). Bare metal came off this list earlier: @shineday999 reproduced the pathology below on an RX 7900 XTX under ROCm 7.2.1 on 7.0.0-28 with no VM involved, which settles the passthrough question from outside this machine. What remains open is the 4× to 8× permission penalty, which that kernel commit does not touch — it survives on -30 at 4.8× — and which is characterised on gfx1100 only, since we have no CDNA hardware.

What would help

  1. 342981fff328 in the stable trees. It carries neither a Fixes: tag nor Cc: stable@vger.kernel.org, so it was never queued for backport, while c08972f55594 did reach -28. That asymmetry is the whole mechanism, and it is not Ubuntu-specific: any series tracking 7.0.y that took the first commit is in the same state. Per stable-kernel-rules.rst an already-mainlined commit can be requested by mail to stable@vger.kernel.org, and a request from the people who signed both commits would carry more weight than one from me.
  2. A backport into Ubuntu's 7.0.0 in the meantime. -28 is the current HWE kernel, out of noble-updates and noble-security, so it reaches anyone who lets updates run on the distro ROCm supports first. Filed with Ubuntu as LP#2161985, which carries the revert result; raised here because the breaking commit is AMD's.
  3. The 4× to 8× that survives on every kernel is a separate question and the more durable one. Taking the fault permission from the VMA rather than from the requested access means a read-only copy pays for breaking COW on every resident page. Is there a cheaper path for MAP_PRIVATE|PROT_WRITE ranges, or should the runtime stage them rather than map them for DMA?
  4. Independently, torch.UntypedStorage.from_file(shared=False) mapping writable is what exposes every PyTorch user to this. That may be worth a separate conversation with the PyTorch maintainers.

Everything behind this report

https://github.com/cadamcat/dual-radeon-vllm

  • benchmarks/repro-mmap-prot.py is the reproducer above, with the file path and the repeat count made configurable and each case run twice.
  • docs/open-questions.md section 8 lists every hypothesis we tested and discarded, including an earlier root cause we published and then disproved with our own reproducer. If something here looks wrong, that section is the place to check first.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions