GPU hardware notes
Writing rig's drivers taught us facts about Apple, NVIDIA and AMD GPUs, and
the software around them, that the vendors document poorly or not at all.
Each note states one fact, how it was established, and what a driver does
about it. Paths are relative to packages/rig/, and a quoted name after a path
names a test in that file. A fact seen on one machine is stated with that
machine and may differ on another. "Read in source" marks a fact taken from
vendor code that no run here has confirmed.
The machines:
| Name here | Hardware | Software |
|---|---|---|
| M1 Max | Apple M1 Max, GPU family Apple7, 32 GB | macOS 26.3.1 |
| RTX 5000 Ada | NVIDIA RTX 5000 Ada Generation, PCIe 4.0 x16 | Linux 6.12, NVIDIA kernel driver 615.71.09, CUDA 13.4 |
| R9700 | AMD Radeon AI PRO R9700 (gfx1201, GFX12), resizable BAR off, behind a PCIe 3.0 x16 root port | Linux 6.12, KFD 1.17 |
NVIDIA
A ring entry names one contiguous run of commands
A GPFIFO entry holds a pushbuffer segment's address and its length in words
(NVC56F_GP_ENTRY0_GET, NVC56F_GP_ENTRY1_GET_HI and
NVC56F_GP_ENTRY1_LENGTH in clc56f.h). The channel's DMA engine (PBDMA)
reads that many words from that address, in address order. It knows nothing
of the buffer the driver writes its segments into, so a segment that runs to
that buffer's end continues past it.
On the RTX 5000 Ada, an entry that claimed 16 words starting 36 bytes before
the end of a 1 MiB segment buffer ended both of the device's channels with
the RM's channel error 32 (PBDMA_ERROR). A loop of 16-byte copies, each
taking 64 bytes of segment, reached that position at exactly the 32,768th
copy; the copies before it, and single copies of every memory kind, ran.
A driver that keeps its segments in a ring closes the open segment into its entry whenever its write position returns to the ring's start, also when the last command ended exactly at the ring's end, and opens a new segment there.
A channel's ring holds up to 2^31 entries
A channel's DMA engine holds the log2 of its GPFIFO's entries in a 5-bit
field (LIMIT2, set from the allocation's gpFifoEntries), so a ring holds
at most 2^31 entries. On the RTX 5000 Ada the RM made channels whose rings
held 2^20, 2^24 and 2^31 entries, on the copy engine and, at 2^31, on the
compute engine too, and 300,000 entries and a release then ran on each. A
ring of 2^31 entries takes 16 GiB of host memory, which the RM made the
channel over in about 3 s. Run in forked children on fudge, driver 615.
CUDA's compiled code reads the launch sizes from constant bank 0
Code that NVCC compiles reads blockDim and gridDim from the start of
constant bank 0, an area the CUDA driver fills and no NVIDIA header
describes. nvdisasm of NVCC 13.4's output shows where: for sm_89, blockDim
x, y, z at c[0x0][0x0], [0x4], [0x8] and gridDim at [0xc] to
[0x14]; for sm_120 (Blackwell), blockDim from c[0x0][0x360] and
gridDim from [0x370]. On Ada the shared and local memory windows follow
at 0x18 and 0x20, 64 bits each, and the stack limit at 0x28; kernels
that use them run on the RTX 5000 Ada with these words.
A driver that launches through its own launch descriptors writes these words
itself. Left at zero, a kernel that indexes by blockDim.x computes wrong
addresses and does not fault: on the RTX 5000 Ada a 16-block launch wrote one
block's elements, and 936 of 1,000 sums were missing. With the words written,
a kernel that reads them saw the sizes set
(test/conformance/test_conformance.ml, "a launch writes what its grid and
parameters say, and a launch after it reads that"). The Blackwell offsets
come from the disassembly alone; no Blackwell GPU ran them.
The kernel driver's structures change between releases
The parameters of the RM's escape ioctls and of the unified memory driver's
ioctls change layout between release branches. Between 580, 610 and 615 these
changed: the GPFIFO allocation (610), the channel group allocation (615), the
schedule control (580), the framebuffer information control, the DMA map and
the VA space allocation (580), and the unified memory driver's free and
channel unregistration, which lost a field (610). Release 615's headers
comment out two status codes. Established by generating the layouts from each
release's headers, pinned by SHA-256, and comparing them (lib/nv/gen/gen.py).
The headers also define some names twice under conditionals (the unified
memory driver's ioctl base differs on Windows), so the generator evaluates
them as a 64-bit Linux build does.
A driver reads the release from NV0000_CTRL_CMD_SYSTEM_GET_BUILD_VERSION_V2,
uses that release's layouts, and refuses a release it has none for.
Unified memory needs a second file per process
UVM_MM_INITIALIZE ties the unified memory driver to the process's address
space. It runs on a second open file of /dev/nvidia-uvm and names the
first. Run on the first file, it is refused; registering a GPU's VA space then
fails with NV_ERR_PAGE_TABLE_NOT_AVAIL, and no GPU opens (RTX 5000 Ada,
test/nv/nvidia/test_nvidia.ml). The call is made once per process: a second
one, such as CUDA's in the same process, is refused, and the first serves.
The unified memory driver also registers a GPU once per process. A driver
keeps a GPU's RM device, subdevice, VA space and their registration for the
life of the process, and a device's end releases only its channels'
registrations (test/nv/nvidia/test_nvidia.ml, "a GPU has one device until it
is stopped").
A map into an empty region pays for its page tables
On the RTX 5000 Ada, the RM's own map, NV_ESC_RM_MAP_MEMORY_DMA, of a
64 KiB allocation takes 21.4 µs when nothing else is mapped in its region of
the GPU's address space, and 8.6 µs with the same parameters when one other
allocation lives there. Measured per ioctl with an LD_PRELOAD counter. We
read the difference as the RM building the region's page tables at its first
map and freeing them at its last unmap. A 64 KiB allocation and free through
that map took 73.5 µs alone and 46.0 µs beside one live 4 KiB allocation. No
path makes that map now (the next note).
The unified memory driver's map pays less for an empty region. Through it alone, a 64 KiB allocation and free of Device memory takes 50.3 µs alone and 42.1 µs beside one live 4 KiB allocation, and of Pinned or Mapped memory 46.8 µs and 56.8 µs either way. Measured on fudge, medians of 7 runs of 200, in two runs.
A driver that allocates and frees small Device buffers in an otherwise empty region pays this every time. Long-lived memory of the driver in the same region keeps the tables; allocation benches hold one live allocation of the kind, as a running program does.
The RM's own mappings stop at about 39 GiB a GPU
NV_ESC_RM_MAP_MEMORY_DMA maps a memory object into a device's virtual memory
object (NV01_MEMORY_VIRTUAL), in the RM's own page tables. An object made
with no address space named (hVASpace 0) lies in the device's default
address space, which the RM makes for a device whose client shares it with
itself (vaspaceGetByHandleOrDeviceDefault, deviceInitClientShare; read in
source, open-gpu-kernel-modules 570 and 615). A channel runs in the address
space its context share names, and rig's name the externally owned
FERMI_VASPACE_A that the unified memory driver registers and fills, so its
work never reads the default one's mappings. On the RTX 5000 Ada (32 GiB) in a
machine of 62 GiB, with every map in 4 KiB pages, the RM refuses it with
NV_ERR_NO_MEMORY once about 39.04 GiB is mapped this way for the GPU,
counting GPU memory and host memory alike, and every process's: 39 maps of 1
GiB of one shared memory file, 156 of 256 MiB or 2,498 of 16 MiB; 15 GiB of
host memory beside 24 GiB of GPU memory; 9 GiB in a second process while the
first held 30 GiB. A map succeeds again once others are freed. The unified
memory driver's UVM_MAP_EXTERNAL_ALLOCATION maps the same objects into the
channels' address space, from the memory's own description, and with no RM map
it mapped 160 maps of 1 GiB of one file, and 70 beside 24 GiB of GPU memory.
CUDA page-locked 61 maps of 1 GiB of one file, beside 24 GiB of its own GPU
memory or not, then answered CUDA_ERROR_OUT_OF_MEMORY. Measured from forked
children on fudge, driver 615.
The NVIDIA kernel path maps its memory through the unified memory driver alone, so no RM map bounds it.
Rings in host memory cost less host time than rings in BAR1
The RM accepts a channel's GPFIFO ring and USERD in host memory (an OS-descriptor allocation) as well as in GPU memory, and a one-page coherent host buffer as the channel's error notifier. On the RTX 5000 Ada:
| Rings in host memory | Rings in BAR1 | |
|---|---|---|
| Empty submission, round trip | 3.45 µs | 3.24 µs |
| 16-byte copy, round trip | 4.64 µs | 4.15 µs |
| Host time per submission | 0.25 µs | 1.14 µs |
Rings in BAR1 save 0.2-0.5 µs of latency, since the GPU fetches from its own memory, and cost 0.9 µs of host time per submission: uncached stores, and the BAR1 read that makes them land before the doorbell. The NVIDIA driver keeps its rings in host memory, and pays the BAR1 read only while memory the host writes through BAR1 is live: 0.66 µs per submission.
Device-to-host copies run at 60% of the link
On the RTX 5000 Ada (PCIe 4.0 x16, 31.5 GB/s), copy engine copies of 256 MiB between pinned host memory and GPU memory run at 25.2 GB/s host to device and 18.9 GB/s device to host, through the RM and through CUDA alike (CUDA: 25.1 and 19.1 GB/s). CUDA stages pageable host memory and copies it to the device at 18.5 GB/s. Within GPU memory CUDA copies at 241.8 GB/s, 84% of the 288 GB/s that 576 GB/s of DRAM bandwidth leaves for a read and a write. Copy benches gate on these figures, measured from C over the vendor's interface.
GSP firmware is an ELF file read by section
gsp-570.144.bin, the GSP-RM firmware of driver 570.144, is a 63.5 MB ELF
file of 14 sections whose addresses (sh_addr) are all 0. Its payload is the
.fwimage section, the only one allocated. Laid out as a loadable image, the
file makes a 63 MB copy that no boot step reads. A loader reads the sections
it needs by name and copies .fwimage once, into the memory the GSP boots
from. fmc-570.144.bin is a 32-bit ELF file of 6 sections, also all at
address 0. Read from the firmware files.
CUDA
After a sticky error, queued work does not run (driver 615)
CUDA's reference says that after a sticky error such as
CUDA_ERROR_ILLEGAL_ADDRESS every later call in the context returns it. It
does not say whether work queued behind the fault still runs. On the RTX 5000
Ada with driver 615.71.09 it does not. After a kernel stored to address 0, a
write of the timeline word queued behind it and a copy into a watched pinned
buffer queued behind that never ran; for 200 ms of sampling the word and the
buffer stayed still. cuStreamQuery reported the error within the driver's
1 ms poll, a new context on the GPU in the same process was refused with the
same error, and the GPU was back at its idle power state, with no memory in
use, 11 s after the process exited. Every run of the fault test showed this,
one of them under ASan and UBSan (test/cuda/test_fault.ml, "a fault of the
work is CUDA's report").
A driver that reports a device stopped after a sticky error, promising that its work writes no more memory, rests on this behaviour of one driver release.
Module loads and unloads wait for all of the GPU's work
cuModuleUnload returns only once every kernel running on the GPU has
finished, whichever module it belongs to: on the RTX 5000 Ada an unload took
950 ms behind a 1 s kernel of another module. cuModuleLoadData waits the
same way. A binding to a runtime with a global lock releases it around both,
or every other thread waits with them (test/cuda/test_cuda.ml, "unload lets
other domains run while CUDA waits for the GPU (sampled)"). Measured while a
kernel ran, cuModuleGetFunction took 31 µs and cuMemHostGetDevicePointer
1 µs: neither waits for the GPU.
A page-locked range may span unlocked pages
A driver that accepts host memory another owner page-locked must check that
the whole range is locked. Asking CUDA about the range's two ends is not
enough: on the RTX 5000 Ada, with pages 0 and 2 registered by two calls and
page 1 not, both ends answered and the middle page returned
CUDA_ERROR_INVALID_VALUE. CU_POINTER_ATTRIBUTE_RANGE_START_ADDR gives the
start of the allocation that holds a pointer, for registered memory and
cuMemHostAlloc memory alike, so the driver takes a range as locked only when
both ends report the same start (test/cuda/test_cuda.ml, "map_host shares a
host range both ways"). The GPU reports
CAN_USE_HOST_POINTER_FOR_REGISTERED_MEM as 1, so one pointer from
cuMemHostGetDevicePointer serves every device.
A stream memory-operation wait costs 1.6 µs of host time
On the RTX 5000 Ada, cuStreamWaitValue64 costs 1.62 µs of host time per
call, cuStreamWaitEvent 61 ns and cuEventRecord 69 ns, for events made
with CU_EVENT_DISABLE_TIMING and without CU_EVENT_BLOCKING_SYNC. A round
trip that switches streams takes 4.86-4.93 µs ordered by a memory-operation
wait and 3.64-3.71 µs ordered by an event. Four satisfied waits cost
6.46-6.54 µs as four calls and 1.85-1.87 µs as one cuStreamBatchMemOp,
whose count the reference requires below 256. Measured from C over libcuda
alone, medians of three runs.
The CUDA driver orders work within a device by events, and waits on other devices' words in batches of at most 255.
A copy beside a kernel runs at its own speed
On the RTX 5000 Ada, an upload of 256 MiB from page-locked host memory on a second stream took 10.69 ms beside a 10 ms launch of 600 groups of 256 threads, enough to fill every multiprocessor: as long as it took alone. The two one after the other took 20.69 ms. Measured through rig.cuda's two streams on fudge, medians of 9. A driver that runs its copies on a stream of their own beside its kernels halves such a pair's time.
AMD under KFD
KFD refuses a queue whose context save area is short of its rule
AMDKFD_IOC_CREATE_QUEUE refuses a compute queue whose context save area is
smaller than KFD's own size (kfd_queue_ctx_save_restore_size in
kfd_queue.c). That size is, for each die (XCC), the topology's cwsr_size
plus a debugger area of 32 bytes per wave rounded up to 64 bytes, the total
rounded up to a page. KFD counts 32 waves per compute unit from GFX 10.1, and
before it 40 per compute unit, up to 512 per shader engine, whatever the
compute unit runs: an area sized from the topology's max_waves_per_simd was
refused on the R9700 (test/amd/amdgpu/test_amdgpu.ml, "an R9700's: 2048
waves of 64 compute units"). The queue's control stack size is the
topology's ctl_stack_size.
A host data path flush from a GFX12 compute queue hangs it
On the R9700 under amdgpu, a compute queue that flushed the host data path
(HDP) itself, writing GPU_HDP_FLUSH_REQ and waiting on GPU_HDP_FLUSH_DONE
with WAIT_REG_MEM, hung within a few hundred batches run back to back, and
the kernel driver then reset the GPU for every process on it. At the hang no
wave ran and the MEC waited on a register read
(CP_CPC_STALLED_STAT1.MEC1_WAIT_ON_RCIU_READ). The request sets every
client's bit in one write, in a handshake the kernel driver's own engines use one
bit each. Of four runs of 1,000 back-to-back calls with the flush in the
queue, three hung; of five without it, none (tolk's
test/runtime/ops_amd/test_ops_amd_exec.ml, "a thousand runs back to back
while the host allocates"). A driver
flushes the HDP from the host, as ROCr does.
Host writes through the BAR land after an HDP flush read back
GPU memory that the host writes through the BAR passes the HDP, which can
hold the writes. amdgpu flushes it by writing the remapped
HDP_MEM_FLUSH_CNTL register and reading it back
(amdgpu_hdp_generic_flush); the read returns once the write has landed. The
AMD driver does the same before a doorbell, and only while memory the host
writes through the BAR is live: its rings, words and segments live in host
memory, which the HDP does not carry. Read in source; the R9700's host has no
host-visible GPU memory (below).
Without resizable BAR no GPU memory is host-visible
With resizable BAR off, the R9700's BAR0 is 256 MiB (512 MiB at most once
resized, below) and KFD's topology lists no memory bank of the public heap
type (heap_type 1), only private ones.
Memory the host writes in GPU memory through the BAR does not exist on that
machine, and a driver falls back to host memory: reading 1 MiB took 0.041 ms
from both, where reading it across the bus would take milliseconds. A driver
decides from the topology's heaps, and takes a BAR as small when it is smaller
than the GPU's memory.
An AQL packet is live once its header is
An AQL queue's packet processor may take a packet as soon as its header type
is valid, before any doorbell (the HSA runtime specification's packet header
rule). A writer stores words 1 to 15 of each 64-byte packet, then word 0,
which holds the header, with a release store. A writer that drops packets it
already placed beyond the write position, after a failed fill, stores the
INVALID type (1) into each dropped header, since the processor may already
be reading them. The doorbell carries the index of the last packet written,
one less than the write index. Unverified on hardware: the R9700 runs PM4
compute queues, and no GPU here takes AQL packets.
Write positions count dwords, bytes or packets
A queue's write position, and the value its doorbell takes, count dwords on a PM4 queue, bytes on an SDMA queue and packets on an AQL queue. The PM4 and SDMA units run on the R9700; the AQL unit is unverified there.
The copy engine's fence writes 32 bits
SDMA's FENCE packet writes 32 bits, and SDMA's POLL_REGMEM and PM4's
WAIT_REG_MEM compare 32 bits. A 64-bit timeline word released by two fences,
the low half and then the high half when the low half wraps, reads lower
between the two writes: 0x0_ffffffff becomes 0x0_00000000 before
0x1_00000000, and a waiter comparing 64 bits sees the word move backwards.
The AMD driver releases on the copy queue only values whose high half is
unchanged, and sends the one value in 2^32 whose low half is 0 to the compute
queue, whose RELEASE_MEM writes 64 bits. A wait through a 32-bit compare is
exact only if the value it waits for cannot recur within 2^32 values. On the
R9700, a device started near 2^32 released across it in order
(test/amd/test_amd.ml, "values across 2^32 complete in order, ending on
either queue").
A TRAP after the copy queue's fence makes it visible sooner, mostly
On the R9700, a copy-queue submission of a 16-byte copy and a FENCE of a
word the host spins on completed in 10.8 µs without a following SDMA TRAP
packet and in 3.2 µs with one, in the same run. The AMD driver ends every
copy-queue release with TRAP.
The trap does not always work. Some copies with it take 11-12 µs, as late as
a copy without it, against 3.0-3.6 µs for the rest. Slow copies come in
clusters of 4 to 6 consecutive copies every 1.9-2.1 ms, in every process, and
in episodes of 10 to 100 ms in which half to 97% of copies are slow. Another
process holding KFD queues on the GPU, even idle, sets off such episodes:
with one, 3 of 5 processes ran 25-50% slow for their first 1,300-1,500
copies; alone, none had more than one slow window of 100 copies. Episodes
also came with no other process seen. Ruled out: the spinning CPU (pinned to
seven different cores), load on the CPU that takes the GPU's interrupts, the
teardown of a process that had just exited, the driver (raw KFD queues show
the same modes), and SMU deep sleep, whose features KFD disables while a
process has queues (pp_features). The cause lies below KFD, in what the
firmware schedules, and its registers (MES, IH, SDMA) cannot be read without
root. Measured with a raw KFD copy queue, 16-byte copies one at a time, slow
meaning above 8 µs.
A latency measured on the copy queue has two modes between processes. Benches compare such rows in alternating pairs that report the range, and gate on the fastest worker's median.
A release does not stand in for the next acquire
A compute queue's RELEASE_MEM performs its cache actions at the end of the
pipe, after the packets that follow it have started, and its actions cover
the L2 and the caches between it and the shaders (GL2, GLM, GL1,
GLV), never the scalar cache (GLK), which holds kernel arguments, nor the
instruction cache (GLI). So the next submission's first dispatch can read an
L2 line that the previous release has not yet invalidated, or stale arguments
or code. Each submission that runs compute work starts with its own
ACQUIRE_MEM; letting a release stand in for it would need a wait for the
release's word, a PCIe round trip.
On the R9700 the acquire costs 0.23 µs per pipelined submission at either
scope: at agent scope (the caches above the L2) it costs the same as at
system scope (the L2 and instruction cache too). The cost is the packet. A
release that only writes the L2 back (GL2_WB and SEQ, the fence amdgpu's
gfx12 code writes) costs 1.10 µs per pipelined submission, against 1.28 µs
for one that also invalidates. Measured on a raw KFD compute queue, medians
of 100 batches, variants alternated in one process.
Dispatch and descriptor fields keep the low bits of what does not fit
PM4 dispatch registers and buffer descriptors take the low bits of a value too large for their field, without error, and the kernel runs with the truncated value. A driver bounds each before it writes:
COMPUTE_PGM_RSRC2.LDS_SIZEis 9 bits, in units of 512 bytes on every generation the library encodes except GFX950, which counts units of 1280 bytes (AMDGPUUsage'sLDS_SIZErow; LLVM'sFeatureLDSEncodingGranularity1280). A larger request dispatches with almost no LDS (test/amd/abi/test_pm4.ml, "a GFX950 workgroup's LDS, in 1280-byte units" and "a workgroup's LDS is set up to 511 units and refused past them").COMPUTE_TMPRING_SIZE.WAVESIZEgives a wave's scratch in granules: 13 bits of 1 KiB on GFX9, 15 bits of 256 bytes on GFX11, 18 bits on GFX12, at most 131,056, 131,068 and 1,048,572 bytes per lane. Past it the waves' scratch wraps and they overwrite each other. LLVM compiles no more than this (getMaxWaveScratchSizeinGCNSubtarget.h) (test/amd/abi/test_scratch.ml, "a lane's scratch is taken up to what WAVESIZE holds").- A buffer descriptor's record count is 32 bits. A scratch share of 2^32
bytes becomes 0, which faults the kernels that use it
(
test/amd/abi/test_scratch.ml, "a descriptor holds the base, below 2^48, and n / xccs bytes, below 2^32 a die").
Two AMD sources disagree on counter events
AMD publishes performance counter event numbers in two places: the SOC
enumerations that aqlprofile ships (vega10_enum.h, soc21_enum.h,
soc24_enum.h) and rocprofiler-compute's counter_defs.yaml. They disagree.
For gfx942 and gfx950, 60 of the 91 SQ event names the two share have
different numbers; for GFX12, GL2C_HIT is 39 in the enumeration and 41 in
the YAML. The AMD ABI library's counter table comes from counter_defs.yaml,
the one AMD's profiler counts with (lib/amd/gen/gen.py). The files were
compared; no gfx942 or gfx950 GPU ran the counters here.
CDNA drops completion interrupts whose context id is 0
On gfx9, KFD discards an end-of-pipe interrupt with context id 0 whenever it
expects valid ids, which it does for every gfx9.4.x (MI200, MI300): a
workaround for firmware that sent bogus signals with id 0
(kfd_int_process_v9.c:333-345). A process sleeping in
AMDKFD_IOC_WAIT_EVENTS then wakes only at its timeout. On gfx11 and gfx12 an
id of 0 misses the fast lookup and makes KFD scan the process's signal page
(kfd_int_process_v11.c:355, kfd_events.c:707-735). A release that raises
an interrupt carries its KFD signal event's id in INT_CTXID, as ROCr does.
Read in source; no CDNA GPU here.
Unmapping registered host memory stalls every queue
When a process unmaps host memory that KFD still tracks before freeing it in KFD, the kernel invalidates the range, and KFD evicts every queue of the process and restores them later. On the R9700 the next work on any queue waited 5 to 10 ms. A driver frees the KFD allocation and its GPU mapping first, then unmaps. Memory that KFD allocates itself (GTT) is never evicted this way, so the AMD driver keeps its rings and words there.
KFD's wait timeouts round up to timer ticks
AMDKFD_IOC_WAIT_EVENTS converts its timeout in milliseconds to timer ticks
and rounds up (user_timeout_to_jiffies in kfd_events.c). At 250 Hz a 1 ms
timeout lasts at least a 4 ms tick when no event fires. A code load that
polled a copy's completion with 1 ms sleeps took 8 to 12 ms on the R9700 for a
copy of microseconds, since the copy raised no event. A
driver spins on the word before it sleeps, and sleeps in KFD only on work that
raises an event. A wake through the interrupt path costs about 36 µs more
than spinning (wake/driver against release/driver in bench/amd).
A process has one GPU address space per GPU
KFD keeps one GPU address space per GPU per process, bound to one render node
file by AMDKFD_IOC_ACQUIRE_VM; the process cannot hold a second one on the
same GPU through another file. A memory fault in that address space reaches
every user of the GPU in the process. The AMD bench opens each case's device
in a worker process of its own for this reason.
The stable power state has one holder
amdgpu holds a GPU in its stable power state
(AMDGPU_CTX_OP_SET_STABLE_PSTATE) for one context at a time and refuses
another with EBUSY. The AMD driver takes it for thread traces, which need
steady clocks and shader engines, once per process under a lock, so that two
threads cannot both create a context and ask
(lib/amd/amdgpu/rig_amd_amdgpu.ml, stable_power).
A GFX12 compute queue's 64-bit wait compares all 64 bits
WAIT_REG_MEM64, which the kernel's headers name but do not lay out (PAL's
PM4_MEC_WAIT_REG_MEM64 does), compares the whole 64-bit word on the R9700
with MEC firmware 3010. A probe had a queue wait on a host word for 300 ms in
five cases, releasing any that held with a write a 32-bit reading would also
pass: 2^32 − 1 ≥ 2^32 held until the word became 2^32; 2^33 ≥ 2^32 + 5 and
2^32 + 5 ≥ 0xFFFFFFFF passed immediately; 5 ≥ 2^32 + 3 and 2^32 + 7 ≥ 3·2^32
held. A compare of the low 32 bits gets four of the five wrong. The AMD
suite's waits group keeps the fact: a wait across 2^32 holds through a word
whose low half is above the target's. The AMD driver waits in its queue on
another device's word only from a firmware version seen to do this, per GC
version (wait64_from in lib/amd/rig_amd.ml); elsewhere the wait is the
host's.
A KFD process can outlive its process and pin amdgpu
On the R9700, KFD kept a process for 44 hours after it exited: a directory
under /sys/class/kfd/kfd/proc/ with 320 KiB of GPU memory and no queues,
and a client of the GPU's DRM device in debugfs's clients list, named
<unknown>, whose process did not exist. No live process held a KFD, DRM
or dma-buf file. While it stayed, Rig_amd_pci.detach refused the GPU (the
kernel holds a file of its DRM device for that process) and
modprobe -r amdgpu answered that the module is in use. Only a reboot
cleared it. What left it is not known.
AMD over PCI
On the R9700's host BAR0 grows to 512 MiB at most
The R9700's BAR0 offers every size from 256 MiB to 32 GiB
(resource0_resize reads 0xff00), and the host's firmware assigns it
256 MiB below 4 GiB. Asked for the largest size its bridges hold, as
Rig_amd_pci.detach asks, Linux refused each size from 32 GiB down to
1 GiB, with "can't assign; no space" for every bridge up to the root port,
and assigned 512 MiB at 0xc0000000, the bridges' prefetchable window
becoming 0xb0000000–0xdfffffff. The host gives the GPU no 64-bit window
above 4 GiB, likely because its firmware's Above 4G decoding is off; that
setting was not read. amdgpu, bound after the resize, reports
BAR=512M against 32,624 MiB of memory. A driver-less boot of this GPU
therefore reads the discovery table a word at a time through MM_INDEX,
MM_INDEX_HI and MM_DATA, and places its page tables in their own pool
(Page_table.Pool).
The R9700's blocks
The R9700's discovery table, read through the index window after a detach,
lists GC 12.0.1, SDMA0 and SDMA1 7.0.1, MP0 and MP1 14.0.3, MMHUB 4.1.0,
OSSSYS 7.0.0, NBIF 6.3.1, HDP 7.0.0, ATHUB 4.1.0, DF 4.15.1, UMC 8.14.0,
PCIE 6.1.0, LSDMA 7.0.1, DMU 4.1.0, SMUIO, THM, FUSE and CLKA 14.0.2, with
no instance fused off. Its GC has 4 shader engines of 2 arrays of 8
compute units, 64 in all, 16 waves per SIMD and 64 KiB of LDS. Its
register BAR (BAR 5) holds 131,072 words, so the registers a boot's plan
reads (SCRATCH_REG6, SCRATCH_REG7, the GC hub's fault status) lie
inside it, with no access through the RSMU window. The boot report finds
the eight images rig_amd_pci.mli names for it, and each is laid out as
its format says.
amdgpu leaves the security processor's OS running
Right after amdgpu unbinds from the R9700, the security processor's OS
runs (its C2PMSG_81 is not zero), its bootloader is not ready, and
SCRATCH_REG6, SCRATCH_REG7 and the GC hub's fault status read 0: no
mark of this library, so a driver-less open plans a reset first (Booted
in lib/amd/pci/boot.mli). Rig_amd_pci.reset resets it whole in 1.1 s
with bus mastering off throughout, and its configuration reads back as
saved. amdgpu's probe resets the GPU itself (mode 1) when it finds the OS
running, as it does at every rebind and module load after its own session;
after Rig_amd_pci.reset and Rig_amd_pci.attach its probe initialized
the GPU with no reset of its own, which shows that no firmware ran.
Apple GPUs under Metal
Stores to unmapped GPU addresses do not fault
On the M1 Max, a compute kernel that stored 64 words at GPU address 0x10, at
0x7f0000000000, which nothing maps, or at the GPU address of a buffer freed
and removed from the residency set before the submission, completed: its
command buffer reported no error, later work ran, and the GPU stayed usable.
Two guarded runs, one process each, on 2026-10-08. Metal tells a driver
nothing of a stray store, and a test cannot make a page fault this way; the
Metal driver's failure path is tested through its C completion ring without a
GPU.
Completion handlers run in no documented order
Metal calls each command buffer's completed handler on a thread of its own and
documents no order between the handlers of one queue. A handler that writes a
timeline word directly can pass an earlier command buffer that then reports
failure. The Metal driver gives each command buffer a slot before making it;
a handler completes its slot, and the leading completed slots are released in
commit order, each writing its value unless a slot before it failed
(test/metal/test_metal.ml, "releases in commit order whatever order slots
complete in"). There are as many slots as the queue holds command buffers, so
taking a slot is the back-pressure, and Metal's commandBuffer never blocks.
An indirect command buffer's commands are autoreleased
indirectComputeCommandAtIndex: returns its command autoreleased. A thread
without an autorelease pool of its own, such as an OCaml domain, never drains
one, so each command lives until the thread ends. Code that records an
indirect command buffer wraps the calls in its own @autoreleasepool
(test/metal/test_metal.ml, "an icb's buffer and commands live until it is
unreachable").
Buffers start at multiples of 256 bytes
Apple documents no alignment for a buffer's first byte. On the M1 Max, shared
buffers smaller than a page start at multiples of 256 bytes inside a 16 KiB
page (seen at offsets 7936, 8192, 8448 and 12544). The Metal driver promises
256 bytes and checks every buffer Metal returns (test/metal/test_metal.ml,
"an allocation starts at a multiple of 256 bytes").
Mac GPU families publish no argument offset alignment
A dispatch's argument buffer offset must be a multiple of the GPU's minimum
constant buffer offset alignment. Apple's feature set tables give 4 bytes for
the Apple GPU families and nothing for the Mac families. The Metal driver uses
256 bytes on Mac families, a multiple of every smaller power of two, and the
record compiled code reads carries the value (test/metal/test_metal.ml, "an
Apple GPU aligns arguments to 4 bytes").
A dispatch costs about 200 µs round trip, mostly outside the GPU
On the M1 Max, one dispatch committed and waited with waitUntilCompleted
took 206 µs at the median of 300 interleaved rounds: 98 µs from commit to GPU
start, 9.6 µs on the GPU, 97 µs from GPU end until the host saw completion. A
residency set, a fence, or a completion handler with the host spinning on a
word each changed it by under 5 µs. A submission costs about 5 µs of CPU, of
which creating the encoder takes 2.0 and the commit 1.8. Measured under load:
an empty command buffer completes in 15-46 µs; pipelined, a command buffer
costs about 25 µs; after 20 ms idle, commit to GPU start grows to about
390 µs.
A kernel can write a shared word that the host spins on, which the host sees 98-130 µs after the commit, earlier than the completion. The Metal driver does not take it as completion: Metal reports a failed command buffer only through its status.
A command buffer costs about 26 µs, a dispatch in one about 2 µs
On the M1 Max, command buffers of one one-thread dispatch each, committed back to back, start 31 µs apart on the GPU, each busy 5-7 µs: the host sustains one every 26 µs at most, at 6 µs of CPU each. With 3 in flight, as rig's replay rows run, each costs about 55-60 µs, a third of the round trip. The same dispatches in one serial encoder of one command buffer cost 2.3 µs each, 2.5 µs with an encoder each, and 30 µs with an encoder each that waits for a fence and updates it. A fence at each command buffer's encoder costs under 5 µs.
The GPU's clock follows its load. After 300 ms idle, 3,000 such command buffers with 3 in flight cost 80-145 µs each and never sped up; with 64 in flight they reached 29 µs within 1,000. Rows that run 100 command buffers after their setup idles measure the low clock: 77-124 µs.
An indirect command buffer adds about 16 µs of GPU time
On the M1 Max, running dispatches from an indirect command buffer takes about
16 µs more GPU time per command buffer than encoding them directly, 10-20 µs
more latency on each side of the GPU, and 1.9 µs per dispatch that waits on
the one before. None of these moved it: the default inherit flags,
optimizeIndirectCommandBuffer, the buffer in the residency set or passed to
useResource, a concurrent encoder. Direct serial encoding costs 0.1 µs of
CPU per dispatch and wins in probes of 1 to 512 dependent dispatches.
On real steps of gpt-oss-20b the result depends on the size. Decode steps of 656 dispatches ran 3% faster encoded directly (31.3 against 32.2 ms); prefill steps of 3,452 dispatches ran 12-15% slower (1.21 s against 1.04-1.09 s). Encoding costs 0.5-0.7 µs per dispatch either way. The crossover lies between those sizes and was not located.
An indirect command buffer needs no pipeline set on the encoder
Some runtimes set each pipeline of an indirect command buffer on the encoder, with an empty dispatch, before running it on GPUs before the Apple9 family, after GPU faults seen on M1 and M2 Macs in 2024. On the M1 Max it is not needed. An indirect command buffer of five dispatches over three pipelines, each pipeline from an image loaded for that run and never set on an encoder, ran as the only command of its command buffer 520 times across 13 processes and wrote every word, in two of the processes under Metal's API or shader validation. A runtime that sets the pipelines ran, without that step, 988 indirect command buffers whose pipelines were made apart from those of its direct dispatches; each matched the direct dispatches' result. Neither the Metal driver nor the fills of its tests set an indirect command buffer's pipelines.
Memory is wired again after 1 to 3 seconds idle
After 1.2-3 s without work, the first submission on the M1 Max waits before
the GPU starts, in proportion to the residency set's memory: 20-76 ms for
1 GiB, 2.5-8.6 ms for 64 MiB, 1.4-3.2 ms for 16 KiB, against 80-220 µs warm.
The size-dependent part is macOS wiring the memory again; the 1-3 ms floor is
the GPU waking. requestResidency called just before the commit changes
nothing. Only work or a residency request well before the submission hides
it.
Completion wakes are bimodal per process
On the M1 Max, a thread blocked on a condition that a completion handler
signals wakes about 10 µs after the handler in most processes; in about one
process in four, every wake comes 1.0-1.6 ms after it (p90 4 ms). GPU end to
handler stays 92 µs in both modes, and waitUntilCompleted shows both.
Raising the waiting thread to the user-interactive QoS class, by an override
or by pthread_set_qos_class_self_np, moved neither mode; a command-line
process's main thread already runs at that class. The cause was not found.
Host code
arm64 macOS writes JIT code through a listed callback
On arm64 macOS, code memory is MAP_JIT memory, mapped read-write-execute,
whose write protection each thread lifts for itself. Under the hardened
runtime's com.apple.security.cs.jit-write-allowlist entitlement,
pthread_jit_write_protect_np ends the process at its first call. Code is
written instead by a callback passed to pthread_jit_write_with_callback_np
and listed with PTHREAD_JIT_WRITE_ALLOW_CALLBACKS_NP; without the
entitlement the system lifts the protection around the callback as the pair
of calls did. The system reads only the lists of images present at start-up,
so a bytecode program that loads its C stubs at run time still ends at its
first write, unless it holds jit-write-allowlist-freeze-late and calls
pthread_jit_write_freeze_callbacks_np after loading them. Checked by hand on
the M1 Max with an executable signed with the hardened runtime, allow-jit
and jit-write-allowlist: killed with SIGKILL at the first link while the
loader called pthread_jit_write_protect_np, passing with the callback.
arm64 macOS can start a child of fork with its JIT writes allowed
On arm64 macOS, a child of fork can start with its thread's MAP_JIT write
protection lifted, although the forking thread had set it again. The child's
first call of code in the mapping then dies of SIGBUS, an instruction abort on
the writable mapping. Apple's pages for pthread_jit_write_protect_np(3) and
fork(2) define the protection per thread and say nothing of fork. Checked by
hand on the M1 Max at load ~68, with a C program that repeats a step: map
16 KiB of MAP_JIT memory, write mov x0, #42; ret between
pthread_jit_write_protect_np(0) and pthread_jit_write_protect_np(1),
invalidate the instruction cache, fork, and call the code in the child. It
counts the children that end by a signal:
| Before the child's call | Children ended by a signal |
|---|---|
| Nothing | 3 of 3,000 |
| The parent called the code just before the fork | 4 of 6,000 |
| The child slept 1 ms | 13 of 6,000 |
The child called pthread_jit_write_with_callback_np with a callback that writes nothing |
0 of 6,000 |
The second row shows that the child does not take the state its parent's
thread ran with, and the third that the state stays wrong across a context
switch. A second run with the last row's reset had no failure in 16,000
forks. The failures need a loaded machine; in rig's suite they killed the
forked child of test/remote/proxy/test_link.ml twice in about 500 runs. The
first code mapping of lib/rig_host_code.c registers a pthread_atfork child
handler that makes that reset through the loader's listed callback, before the
child runs anything: pthread_jit_write_protect_np would end a process that
holds jit-write-allowlist.
arm64 cores need an isb to run code another core wrote
Cache maintenance after writing code (__builtin___clear_cache) reaches every
core, but a core that fetched instructions earlier, such as those of a
collected program once mapped at the same address, may run them until it takes
a context synchronization, such as isb (Arm Architecture Reference Manual,
instruction cache coherency). A pool worker running job after job takes none.
The host loader counts installs with a release increment after the
maintenance, and a thread executes isb before entering linked code, from a
host launch or a driver's call of a fill (rig_code_sync), when the count
moved since its last one, a single load when nothing changed. x86_64
keeps instruction fetch coherent with stores.
Windows commits a thread's stack one page at a time
Windows grows a thread's stack through a guard page, so a function whose stack
frame exceeds 4 KiB must touch each page in order, which Windows compilers do with a
stack probe (__chkstk). Code compiled for an ELF target has no probes: rune's
incomplete-beta derivatives, with frames of 18 to 52 KiB, faulted on their first
store. So before a thread first runs linked code, the host commits the rest of
its stack but for its last 64 KiB, which stay reserved so that an overflow still
faults (lib/rig_host_code.c, commit_stack), and a frame of any size within
it runs. Clang's stack clash protection would probe in the code instead, but it
crashed Ubuntu's Clang 18 on some kernels and would have to be asked of every
compiler that targets rig. The host fixture deep.c, a frame of 256 KiB written
first at its far end, runs on the caller's thread and the pool's.
Memory ordering
On x86, the barrier for device memory is mfence
A C11 sequentially consistent fence compiles to mfence with clang and to
lock orq $0, (%rsp) with gcc 14. Linux's mb(), its barrier for device
memory, is mfence, so rig's is mfence written out (lib/pci/rig_pci.h,
rig_pci_barrier), whichever compiler builds it. It costs about 4 ns more:
8.2 against 4.0 ns per fence on the RTX 5000 Ada's host, 7.4 against 3.9 ns on
the R9700's, beside bus round trips of hundreds of nanoseconds. On arm64 the
barrier is dsb sy.
Before a doorbell: fence, publish, read back, ring
The GPU must see every store it will read before the doorbell. Uncached
stores through a BAR are posted, and PCIe keeps them in order. Write-combined
stores may reach the device in any order and after later stores, and AMD's HDP
can hold writes after they arrive. A read through the BAR returns only after
earlier posted writes to it have completed. The NVIDIA and AMD drivers submit
with these steps, after UVM's uvm_hal_turing_host_write_gpu_put and
amdgpu:
- write the ring entries and command segments, then a fence: a store fence
suffices (
sfenceon x86_64,dsb ston arm64: Linux'swmb()), and the AMD driver uses a full one; - publish the write position (NVIDIA:
GP_PUTin USERD); - while memory the host writes through a BAR is live, a full fence and a read through that BAR (NVIDIA: BAR1, after step 2), or the HDP flush and its read back (AMD, before step 2);
- ring the doorbell (NVIDIA: the channel's work submit token, written to
NVC361_NOTIFY_CHANNEL_PENDING).
On the RTX 5000 Ada step 3 adds 0.66 µs per submission.
arm64 Device memory faults on the accesses memcpy makes
On arm64, an unaligned access to Device memory raises an alignment fault, as
does DC ZVA (Arm Architecture Reference Manual). The C library's memcpy
and memset use unaligned and overlapping accesses, and DC ZVA to zero, so
they fault on a BAR mapped as Device memory. Accesses to register windows are
loops of 32- or 64-bit loads and stores aligned by address, since a window may
start off a word (test/pci/test_window.ml, "a mapped word is aligned by its
address"). Not run: no arm64 host with a discrete GPU here.
Address spaces and sanitizers
NVIDIA command segments lie below 2^40
A GPFIFO entry holds 40 bits of segment address: bits 31:2 in GET and bits
7:0 of the high word in GET_HI (clc56f.h). The NVIDIA kernel path maps
memory that the host also maps at the same address on both sides, and keeps
those addresses below 2^40, reserving the range in the process so that
nothing else maps there. Device memory lies at addresses the process reserves
for each allocation wherever it has room. Unified memory maps a range for a
GPU only in the lower half of the CPU's addresses (uvm_gpu_can_address),
which bounds where the kernel places a mapping without a hint.
x86_64 AddressSanitizer owns 2.25 GiB to 2 TiB
On x86_64 Linux, ASan's shadow gap covers 0x00008fff7000 to
0x02008fff6fff, about 2.25 GiB to 2 TiB (compiler-rt's asan_mapping.h). By
default ASan maps the gap inaccessible, so a process under ASan can reserve
nothing there. With protect_shadow_gap=0 ASan maps the gap's own shadow
instead, from 2.3 to 258 GiB, and leaves the rest free. NVIDIA segment
addresses must lie below 2^40, inside the gap: with its GPU addresses from
64 GiB no GPU opened under ASan. The kernel path reserves them from 384 GiB,
and the sanitize profile in the root dune sets protect_shadow_gap=0.
PCI functions on Linux
These matter to a driver that takes a GPU from its kernel driver. A note that a run as root on the R9700's host confirmed says so.
Bus mastering outlives the process
A function unbound from its kernel driver and driven through sysfs keeps bus
mastering on after the process exits. Its GPU can then still write system
memory the process gave back, which by then belongs to another process or to
the kernel. The PCI library clears the command register's bus master bit at
release and in an exit handler (test/pci/test_function.ml, "a function taken
physically stops mastering the bus when released"). Behind an IOMMU, closing
VFIO's device file clears it (vfio_pci_core_disable). Read in source.
flock locks a function's sysfs config file
flock on a descriptor of /sys/bus/pci/devices/<bus>/config locks: kernfs
gives its files no flock operation, so the kernel's generic one locks the
inode, and kernfs keeps one inode per sysfs node. Every process that opens the
file sees the lock. Any user may read config, so any user can hold it
(lib/pci/sysfs.ml, locked). VFIO's group file admits one process at a time
and needs no lock. Read in source.
A BAR resize refused for room may fit smaller
Writing a size to resourceN_resize fails with ENOSPC when the bridge
window above has no room for it, and a smaller size may then fit. Any other
refusal (EBUSY, a bridge that cannot move, no privilege) holds for every
size, and the BAR keeps its own (lib/pci/sysfs.ml, resize). Read in
source, and seen on the R9700: Rig_pci.Driver.detach, run as root, was
refused every size from 32 GiB down to 1 GiB, its bridges' prefetchable window
lying below 4 GiB, and took 512 MiB, where amdgpu had left 256 MiB. The kernel moves the
device's other BARs as it resizes: the doorbell BAR went from 0xa0000000 to
0xb0000000.
A GPU without an IOMMU outlives its process
A GPU that masters the bus without an IOMMU writes physical pages, and keeps
writing after its process dies: SIGKILL, the OOM killer and a crash run no
exit function, so nothing turns bus mastering off, and Linux gives the
process's pages to others immediately. The PCI library keeps a physical take's
memory in huge pages of a file under /dev/hugepages, which keep their pages
allocated until each GPU that reached them is reset (Rig_pci.Driver.reset,
which attach runs), and refuses the process's own memory to such a GPU (Function.pin). A
kernel booted by kexec skips the platform's reset and inherits a GPU still
writing.
Allocated is not in place: the kernel moves pages, freeing the old physical page, and a GPU without an IOMMU keeps writing it. Read in Linux 6.12:
- Only an extra reference stops a move: migration freezes the page's count at
what its mappings explain and gives up otherwise (
__folio_migrate_mapping,migrate_huge_page_move_mappinginmm/migrate.c). Anyget_user_pages/pin_user_pagesreference does;FOLL_LONGTERMmoves the page out ofZONE_MOVABLEand CMA first (mm/gup.c). VFIO pins so behind an IOMMU; its no-IOMMU mode has no DMA mapping call and pins nothing (vfio_noiommu_ioctl,drivers/vfio/container.c). No pin a process holds outlives it: io_uring's registered buffers go with the ring's file. - tmpfs pages are movable (
GFP_HIGHUSER_MOVABLE,inode_init_always). While the process lives,mlockkeeps them off the evictable list, which compaction skips only withvm.compact_unevictable_allowed = 0(isolate_migratepages_block,mm/compaction.c);alloc_contig_range(CMA, virtio-mem), memory offlining and soft offlining move them whatever the setting. At the process's death the unmapping munlocks them (__folio_remove_rmap,__munlock_folio), and ordinary compaction, kcompactd and proactive compaction included, moves them. - hugetlbfs pages are skipped by compaction (
PageHugewithoutcc->alloc_contig) and never swapped, butalloc_contig_rangemigrates them (isolate_or_dissolve_huge_page,mm/hugetlb.c), as do memory offlining and soft offlining; the pool is allocated movable (htlb_alloc_mask). 2 MiB pages migrate on x86_64 and arm64 (ARCH_ENABLE_HUGEPAGE_MIGRATION).
So without an IOMMU nothing keeps a page in place once its process has died: a physical take's memory stays exposed to migration. Small pages would be moved by routine compaction, which is why the library gives such a GPU huge pages only, sharing each among the allocations of its 2 MiB block of addresses. That narrows the exposure to CMA allocations, virtio-mem, and memory going offline or a failing page taken out of service, which an administrator or a driver starts. An IOMMU is the only complete answer.
Unbinding amdgpu leaves KFD refusing every process
On Linux 6.12, after amdgpu is unbound from a GPU and bound again, amdgpu
serves the render node, but every open("/dev/kfd") fails with EINVAL
until the amdgpu module reloads. KFD refuses to create a process while its
module-wide counter kfd_locked is above zero (kfd_process.c:860,
kfd_locked at kfd_device.c:46). The unbind's amdgpu_device_ip_fini_early
calls amdgpu_amdkfd_suspend(adev, false) (amdgpu_device.c:3390), whose
kgd2kfd_suspend raises the counter (kfd_device.c:1021); the device is then
freed without the matching resume, and nothing lowers it again. The kernel
frees KFD's device first only for a surprise removal
(amdgpu_device.c:4756). Seen on the R9700 after Rig_pci.Driver.detach
then Rig_pci.Driver.attach, on Linux 6.12.111; source read at v6.12.111.
Reloading amdgpu (modprobe -r amdgpu, then modprobe amdgpu) clears it, so a session that
detaches an AMD GPU reloads the module before KFD's users come back.
Round trips on these machines
Medians of loops in C (Objective-C on the M1 Max) over each vendor's
interface: what each operation costs with no runtime above it. The
vendor scenario of bench/compare runs the
releases and copies through CUDA, HIP and Metal. The RM and KFD columns
came from loops over the kernel drivers' own interfaces, which no program
in the tree runs; compare's CUDA and HIP cases stand in for them on those
GPUs.
| RTX 5000 Ada, RM | RTX 5000 Ada, CUDA | R9700, KFD | M1 Max, Metal | |
|---|---|---|---|---|
| Empty submission, round trip | 3.39 µs | 3.54-3.60 µs | 14.7 µs | 15-46 µs, under load |
| One empty kernel, round trip | 4.64 µs | 4.71-4.84 µs | 15.6-15.7 µs | 206 µs |
| Each further kernel of one submission | 0.6 µs | 1.2 µs | 0.47 µs | |
| Host to device, 256 MiB | 25.2 GB/s | 25.1 GB/s | 13.9 GB/s | |
| Device to host, 256 MiB | 18.9 GB/s | 19.1 GB/s | 14.0 GB/s | |
| Within GPU memory, 256 MiB | 241.8 GB/s | 206 GB/s |
On the R9700 the copy engine's 206 GB/s within GPU memory is 32% of its 640 GB/s, and one copy queue runs the two directions one after the other: 2 × 256 MiB, one each way, take 38.48 ms, the sum of each alone. Pipelined, a release costs 1.3 µs; the rest of its 15 µs round trip is, as a guess, the command processor waking on an idle queue and fetching the ring. The M1 Max has one memory, so its copies are the host's.
Measuring on these machines
- The RTX 5000 Ada boosts between 2550 and 2805 MHz from run to run and within one, and an unprivileged process cannot pin the clock. A GPU-bound row moves with it: 64 chained launches took 47.6 µs at 2805 MHz and 51.8 µs at 2550. Rows record the P-state and clocks.
- On the R9700 rows that wait on a copy-queue fence are bimodal between processes (see the TRAP note above): a round trip that switches queues takes 14.5 or 18.8-19.8 µs, a 16-byte copy 3.9 to 6.1 µs. One session ran every latency row, the vendor loops included, 1.6 times slower; its cause was not found.
- The R9700's own link reads 32 GT/s to its PCIe switch, but the root port above runs PCIe 3.0 x16, and host copies stop at its 15.75 GB/s. A link budget reads every hop up to the root port.
- On the M1 Max, launch timings drift 10-30% between processes. Variants compared for small differences run interleaved in one process.