ADR-0030: GPU platform floors and the zero-copy contract¶
Status: accepted (2026-08-14) -- from the NVIDIA-lens external review. Records three decisions and one demotion. Amended 2026-08-24: decision 2's precondition is met -- the ownership contract now exists and the mechanism it stands on is measured. See The contract, and what met it at the foot of this record.
Decision¶
The majority path gets the cheap fix first; zero-copy GPU waits for a lifetime contract it can actually keep. Pinned memory on the shm segment is the next GPU investment; pool IPC is demoted to experimental until its free/sync protocol exists; every GPU capability is probed against a stated floor, never assumed.
1. Pinned-memory strategy (the Windows fix nobody had named)¶
The accepted Windows GPU edge is currently a triple copy, not
double: tensor.cpu() lands in pageable memory, share_memory_()
copies again into the shm segment, then the peer pays H2D. Decision:
cudaHostRegister the shared-memory mapping in both processes and
copy D2H directly into it -- the DMA engine writes straight into
shared memory, the intermediate CPU copy disappears, and it works on
WDDM today with zero handle-passing. This is the highest-value GPU
change available to the majority platform and it precedes any Win32
handle work. (Win32 pool handles stay gated per
ADR-0009; note from the review: the
gating is right but the ask is smaller than 0009 implies --
DuplicateHandle + NULL-DACL same-user duplication replaces
SCM_RIGHTS; the real cost is the per-device probe and a WDDM soak
matrix we have no CI for.)
2. Pool IPC: demoted to experimental until the contract exists¶
The 2026-08 review found the shipped pool-IPC path unsound as a
lifetime protocol: imported pointers are never freed
(cudaMemPoolImportPointer requires importer-frees-before-exporter);
exporter-side pinning rides a bounded metadata cache whose eviction
can free memory under a live parent alias; there is no cross-process
sync beyond a full stream sync at export; and the created pool has no
release threshold. (The parent-side accounting patch that these hazards
also touched was removed entirely in 0.4.22 -- it was experimental,
default-off, and the cause of an environment -> isolation import cycle;
the worker-side pool path below stands on its own.) Decisions:
- Pool IPC stays default-off and is labeled experimental wherever it is surfaced. The enable-time log lines shout EXPERIMENTAL and cite ADR-0005/ADR-0010 today; the settings reference must be updated to match (it still reads "implemented, untested"). No further plumbing investment in the current shape.
- Before any zero-copy GPU path defaults on, a written cross-process
ownership contract must exist: who frees, in what order, with
what sync primitive (CUDA event IPC is the intended one -- it works
under
cudaMallocAsync), verified by the canary battery (ADR-0027). - The intended future is the pluggable-allocator design (ADR-0010 v2 item 6): worker allocations natively in a shareable pool, no accounting lies, no patch-chasing torch's allocator. The parent-side meta-path patch that chased the allocator was a dead end and was deleted in 0.4.22 rather than carried or offered upstream (ADR-0024 entry 8).
3. Floors, probed not assumed¶
- Driver/runtime floor: pool-handle support must be probed per
device via
cudaDevAttrMemoryPoolSupportedHandleTypesbefore pool IPC leaves experimental -- this probe does not exist yet (the current code callscudaMemPoolCreatedirectly and relies on a try/except around the handshake); a stated minimum driver goes in the docs the same day. The ctypes structs pin their layout to the runtime version they were written against (the currentcudaMemPoolPropsworks on 11.x by zeroed-trailing-bytes accident -- comment the invariant, verify on version bumps). - Which libcudart answers: parent-side pool calls resolve against the parent torch's runtime, worker-side against the worker's -- they differ by design (ADR-0007 pins families, not builds). Recorded rule: pool operations on a memory region are made only by the side that allocated it; the other side holds imports. The contract in (2) is what makes that rule checkable.
- Wheel-farm ABI assumption, named (cuda-wheels ADR series owns the mechanics): combo wheels assume patch-level libtorch ABI stability within a torch family -- practice, not contract. The farm's qualification smoke (ADR-0026) runs against each new patch release of a pinned family so the break arrives as a red farm build, not a user segfault.
Context¶
External GPU review verdict, adopted: the forward bets (mempool IPC, pluggable allocator) are correct, but the current pool plumbing bolts export-pointer semantics onto an ack protocol designed for copies, and the platform with the majority of users was paying an unexamined extra copy while the roadmap chased a Linux-only path that is off by default. This ADR reorders the investment accordingly.
Consequences¶
- Windows GPU users get the first real transport improvement (pinned-memory D2H) without any new IPC machinery or security surface.
- Nobody can flip pool IPC on and ship it by accident: experimental labeling plus the contract precondition are now in the record its enable path points to.
- Multi-GPU device-identity rules live in ADR-0025; this ADR's floors apply per-device when that day comes.
The contract, and what met it¶
Amendment, 2026-08-24. Decision 2 demoted pool IPC until a written
cross-process ownership contract existed -- "who frees, in what order,
with what sync primitive". It now does, and the mechanism it rests on
has been verified end-to-end on an RTX 3090 (driver 580.126.20, torch
2.8.0+cu128). The full write-up, with the scripts that produced it, is
Zero-copy CUDA transfer; research/pool-ipc/
in the comfy-env repo holds the code.
The contract:
EXPORT -> READY-event -> IMPORT -> WRITE -> DONE-event -> UNIMPORT -> ACK -> RELEASE
Three things about it answer this ADR's objections directly.
Who frees, resolved by inverting the direction. The unsoundness
recorded above -- a parent left holding an imported pointer it cannot
safely free -- came from the worker exporting results. Instead the
parent allocates the result tensor and the worker writes into it in
place, so the parent's result is an ordinary refcounted
torch.Tensor and the importer (the worker) always releases before
the exporter (the parent) does. The requirement stops being a rule to
enforce and becomes a consequence of the layout.
What sync primitive: interprocess CUDA events, as this ADR
predicted, and they do work under cudaMallocAsync -- now measured
rather than assumed. Two gates: the worker waits on a ready-event
before touching imported memory, and the parent's consumer streams
wait on a write-done event before reading. The ack gates on GPU work
completion, never CPU receipt, because cudaFreeAsync returns bytes
to the pool where the next allocation can immediately reissue them.
Exporter-side pinning is no longer a bounded cache. The parent holds an ordinary Python reference for the transfer's lifetime; there is no metadata cache whose eviction can free memory under a live alias, and the pool carries an explicit release threshold set by us rather than inherited.
The remaining gap is not the contract but the mechanism's reach. The
shipped PoolIPC rung stays experimental and default-off; what
replaces it is a parent-side pool swap (cuDeviceSetMemPool to a
POSIX_FD-shareable pool before torch imports), which makes ComfyUI's
own tensors exportable without patching torch's allocator -- the
pluggable-allocator ambition of ADR-0010 v2 item 6, reached from the
other end and at a fraction of the cost.
Two measured constraints bind any implementation:
cuMemPoolExportPointerfails until the pool has been exported to a shareable handle. Undocumented; export the FD once at startup.cuMemPoolImportPointersegfaults the importer above 5248 MiB per allocation -- a NULL dereference insidelibcuda, with no prior public report anywhere. The limit is per allocation and not cumulative, so multi-tensor payloads of any total size are unaffected; a single larger tensor must take the copy path. A size guard and its fallback are therefore part of the admission arithmetic (ADR-0034), not an afterthought.
Decision 1 is unchanged. Pinned host memory on the shm segment remains the floor and still precedes this work everywhere, because it is the only improvement that needs no handles, no probes and no platform gating -- and because zero-copy's unique win is fitting, not latency. A copy path needs source and destination resident at once; that is what puts a 16 GiB tensor out of reach on a 24 GiB card, and no amount of chunked staging fixes it. Latency was never the argument.
Windows remains unprobed and therefore unchanged: research/pool-ipc/
wddm_pool_probe.py decides whether the same architecture works on WDDM
or whether the worker-side VMM arena is required, and until it runs on
real hardware this ADR's platform gating stands exactly as written.