A torch SymmetricMemory backend for mori

The question this PR answers is a programming-model one: how does a kernel name a peer's memory? torch's symmetric memory says "index a pointer array"; RCCL and mori's cco say "add an offset to a base" — the LSA window. Stage 1 ships the first, on plain HIP VMM, across fd-based and fabric-based parts from gfx942 to gfx1250.

ROCm/mori#544 RFC #557 · stage plan stage 1 of 3 gfx942 · gfx950 · gfx1250
In one paragraph

PyTorch has a device-side one-sided memory abstraction — SymmetricMemory — that a growing set of collectives, Triton kernels and inductor passes are written against. It exposes peers exactly one way: as an array of base pointers, one per rank. Underneath, every serious library instead builds a flat VMM window and addresses peers arithmetically — what RCCL and NCCL call LSA (load/store accessible) memory, and what mori's cco calls ccoWindowDevice. This PR registers mori as a torch SymmetricMemory backend and implements the first model faithfully, on plain HIP VMM, with a per-device probe that picks fabric handles where they exist and file descriptors where they do not. The window it builds is already flat and evenly strided — the LSA view is physically there — but naming it is stage 2's job, and it should be named by cco, not invented a fourth time in an allocator.

  1. Why do this at all
  2. The programming model: pointer array vs LSA window
  3. Why it matters
  4. How it is designed
  5. One backend, three architectures
  6. What it measures
  7. Roadmap and known gaps

01Why do this at all

What torch's SymmetricMemory gives you

A symmetric memory window is a buffer that every rank in a process group allocates, and that every rank can read and write directly — a peer's slot is a plain pointer that a kernel dereferences, not a message you enqueue on a communicator. The programming model is one-sided: no matching send for a receive, no collective call in the fast path, just loads and stores plus your own synchronisation.

torch exposes it as three calls, and that is the whole surface an application sees:

import torch.distributed._symmetric_memory as symm_mem
import mori.allocator                     # importing registers the "MORI" backend

symm_mem.set_backend("MORI")
t    = symm_mem.empty(1024, dtype=torch.bfloat16, device=device)
hdl  = symm_mem.rendezvous(t, group_name)

peer = hdl.get_buffer(1, (1024,), torch.bfloat16)   # rank 1's slot, as a tensor
ptrs = hdl.buffer_ptrs_dev                          # or the device pointer array, for a kernel

That model is what fused, low-latency communication is being written against today: one-shot and two-shot all-reduce, MoE all-to-all and combine, KV-cache movement for disaggregated serving, and increasingly Triton kernels that take buffer_ptrs_dev and do the transfer inline with compute instead of around it.

The gap on ROCm

torch ships a CUDA backend for this. Reading it on main, the ROCm arm is a second-class path:

So on a gfx1250 box where the driver exports fabric handles happily, torch would still exchange file descriptors over a UNIX socket. Worse, the pieces you would need to do better are not reusable: torch's IpcChannel, the SCM_RIGHTS helper for the fd path, is declared at CUDASymmetricMemoryUtils.hpp:97 with no TORCH_API — it is not exported from any libtorch .so.

An AMD-side backend is therefore not a duplicate of torch's: it is the only place a per-device probe, fabric handles, and eventually mori's own window and scale-out transports can live.

Why not mori's existing allocators

mori already has two symmetric allocators — the shmem heap and cco. Neither can back torch as-is, for one specific reason:

The invariant torch cannot hold

A symmetric heap keeps peer offsets aligned only while every rank allocates and frees in the same order. torch tensors are freed by Python's garbage collector, and GC order is not synchronised across ranks. One rank dropping a tensor early is enough to skew the heap.

This backend sidesteps the invariant instead of fighting it: every allocation is an independent HIP VMM allocation with its own rendezvous, and free() is purely local, so divergent free order is harmless. The extension links neither mori_shmem nor mori_application, and needs no mori bootstrap — torch's own process group is the only rendezvous channel.

That is an argument against the heap, not against cco. cco's ccoMemImport and ccoWindowRegister already alias an externally-owned HIP VMM allocation into cco's flat slot — the header's own example is "e.g. a torch.symm_mem buffer" — which is exactly the shape of the handoff for stage 2.

02The programming model: pointer array vs LSA window

Everything above is plumbing. The part that a kernel author actually feels — and the part that decides what stage 2 has to look like — is the addressing model: given a peer rank and a byte offset, how do I get a dereferenceable address? There are exactly two answers in use, and this PR implements the first one.

A — pointer array (torch native, this PR) B — LSA window (RCCL / mori cco, stage 2) dst = peers[peer] + offset dst = winBase + peer*stride + offset one dependent load from a world_size-entry table pure address arithmetic, no memory touched peers[0] peers[1] peers[2] peers[3] slot 0 slot 1 slot 2 slot 3 one contiguous VA reservation, uniform stride peer slots may live anywhere in the VA space; the table is the only thing that relates them the relation IS the address; no table exists
The two models are not different optimisations of the same thing — they make different demands on the allocator. A is satisfiable by any mapping, however scattered. B requires that all N slots were reserved together, in order, at one stride.

Model A — the pointer array, which is what torch's interface is

torch's SymmetricMemory C++ interface has exactly two accessors for peer memory: get_buffer_ptrs() and get_buffer_ptrs_dev(). There is no slot in it for a base and a stride. So every backend, whatever it does internally, ends up publishing an N-entry table:

// HIP — examples/torch_symm_all2all/all2all_hip.py
__global__ void All2AllPushPtrs(const uint4* src, void** __restrict__ peers, ...) {
  auto* dst = reinterpret_cast<uint4*>(static_cast<char*>(peers[peer])   // <-- table load
                                       + rank_id * chunk_bytes);
  ...
}
# Triton — the idiom torch's own symmetric-memory kernels use
peers = peer_ptrs.to(tl.pointer_type(tl.uint64))
dst   = tl.load(peers + peer).to(tl.pointer_type(tl.int32))          # <-- table load
dst  += rank_id.to(tl.int64) * chunk

The virtue of this model is that it demands nothing of the allocator. Peer slots can be at unrelated addresses, which is precisely what torch's own CUDA backend produces — it maps each peer's memory separately — and what an hipIpc-based backend would produce too. NVSHMEM's torch backend goes further and rotates the slots so that a peer's index in the array is not its PE id at all. The array is the lowest common denominator, and that is why torch chose it.

Model B — the LSA window: base pointer plus offset

The other model gives up mapping freedom in exchange for arithmetic addressing. Reserve one VA range covering every rank's slot at a uniform stride, and a peer address becomes a computation, with no table and no load:

// NCCL / RCCL device API — LSA = "load/store accessible"
void* p = ncclGetLsaPointer(win, offset, lsaPeer);   // NULL if peer is outside the LSA team
void* q = ncclGetPeerPointer(win, offset, peer);     // world-team index, same window
// implemented underneath as: add4G(lsaFlatBase, peer * stride4G) + offset

// mori cco — include/mori/cco/cco.hpp, struct ccoWindowDevice
//   peer_va = winBase + ((uint64_t)peerLsaRank * stride4G << 32) + offset
//   local   = winBase + ((uint64_t)lsaRank     * stride4G << 32) + offset
struct ccoWindowDevice { char* winBase; uint32_t stride4G; int lsaRank; ccoIbgdaWin ibgdaWin; };

Two details in that snippet carry most of the model. The stride is 4 GiB-quantisedstride4G is perRankSize >> 32, so the multiply is a 32-bit op and the shift is free — and the peer index is an LSA rank, not a world rank. LSA is a team: the set of peers this GPU can reach with ordinary loads and stores. Ranks outside it are not addressable this way at all — ncclGetPeerPointer returns NULL for them, and cco routes them through ibgdaWin instead. The addressing model and the transport boundary are the same concept.

Side by side

 A · pointer arrayB · LSA window
Peer addresspeers[p] + offsetbase + p*stride + offset
Cost per blockone dependent global load before the first store can issuea few ALU ops from values already in registers
Uniformitya loaded value; scalarises only if the compiler can prove p uniformscalar arithmetic off a uniform base
Allocator demandnone — any mapping, any addressesone contiguous VA reservation, all slots, uniform stride
Size granularityexactquantised (4 GiB per rank in cco/NCCL)
Team semanticsnone; one flat world plus a world_within_direct_access() booleanfirst-class — LSA team, cross-node team, rail team; NULL outside
Scale-out storyoutside the modelthe complement of the LSA team, via the same window handle
Defined bytorch (buffer_ptrs / buffer_ptrs_dev)RCCL (ncclGetLsaPointer), mori (ccoWindowDevice)
In this PRyesstage 2

Which is faster, and by how much

Less than the framing suggests. An earlier revision of the example implemented both, over the same allocation, purely to measure the difference:

Constant in absolute terms is the tell: this is a per-block cost, not a per-byte one. The dependent load sits on the critical path between block launch and the first store, and once the stores are in flight the two models are the same instruction stream. So the pointer array costs a fixed sliver of launch-to-first-store latency and nothing per byte, which is why stage 1 can ship it without apology — and why the argument for the LSA window is about expressiveness, not bandwidth.

So why bother with the window at all

Because the things the array cannot express are the things that come next:

Why this PR does not expose it anyway

The backend's rendezvous already produces exactly this layout — one flat span, uniform stride, self-slot aliased so the stride is regular. Publishing base and stride would be a two-line change, and it was deliberately reverted, because it would make a fourth definition of the same window (NCCL's, cco's, torch's-backend-internal, and mori-allocator's) that a later cco integration would have to unwind. Stage 2 hands the allocation to ccoMemImport / ccoWindowRegister and lets ccoWindowDevice be the name for it — with kernels reaching it through cco's accessors, not through a torch handle attribute.

03Why it matters

It is the ecosystem's integration point

NVSHMEM, NCCL's device API and torch's own collectives all converge on the same abstraction. A backend here is how mori gets reached by code nobody wrote for mori.

Zero application change

An application selects a string. The kernel author sees the same buffer_ptrs_dev contract they would get from the CUDA backend, so kernels port unmodified.

Fabric where fabric exists

The probe is per device at run time, not per architecture at compile time — so newer parts get the better handle path without a torch change and without a rebuild.

A staged path, not a fork

Stage 1 ships something small and testable that gives up nothing measurable; the flat window and scale-out land later as cco, rather than as a parallel scheme invented in the allocator.

Concretely, once set_backend("MORI") works: torch's symmetric-memory collectives, MemPool-allocated symmetric tensors, and any Triton kernel written against the pointer array all run on AMD hardware through mori's allocation path — and mori gains a place to put fabric handles, its window layout, and later its RDMA and SDMA transports where torch code will actually reach them.

04How it is designed

Where the code sits

Application / Triton kernel / torch symm_mem collectives symm_mem.empty · symm_mem.rendezvous · hdl.buffer_ptrs_dev · torch.ops.symm_mem.* torch c10d::symmetric_memory (SymmetricMemory / SymmetricMemoryAllocator) register_availability("MORI", ...) makes the backend selectable; set_backend picks it mori_torch_symm — src/allocator/symm_backend.cpp (this PR) MoriSymmAllocator: alloc / free / rendezvous / get_alloc_size MoriSymmetricMemory: buffer_ptrs, buffer_ptrs_dev, rank map, signal pad (opt) ProbeHandleType(dev) · StoreExchange (fabric) · FdChannel/SCM_RIGHTS (posix_fd) HIP VMM hipMemCreate · AddressReserve · Map · SetAccess · Export/Import torch Store (from the process group) all_gather of the rendezvous request: sizes, pid, fabric blob
The backend is a single translation unit built as a torch CppExtension. It binds no torch types beyond the two interfaces it implements; its pybind surface is register_backend / shutdown / handle_type / backend_name / signal_pad_supported.

Allocation

alloc(size, device, group) is deliberately dumb and entirely local — nothing collective happens here, because torch may allocate at any time on any rank:

  1. Probe the device's shareable handle type (cached, once per device).
  2. hipMemGetAllocationGranularity, round size + signal_pad up to it.
  3. hipMemCreatehipMemAddressReservehipMemMaphipMemSetAccess(read-write, self)hipMemset(0).
  4. Record the block in a pointer-keyed table and hand the VA back to torch.

Memory is coarse-grained pinned (hipMemAllocationTypePinned). This was measured, not assumed: fine-grained/uncached windows — which is what cco windows are — halved bandwidth on gfx1250 (712 vs 1499 GB/s at 4 ranks) and changed nothing on gfx950.

Rendezvous

This is where the window is built. Every rank publishes one RendezvousReq through the torch Store — allocation size, buffer size, device index, pid, and (fabric path only) the 64-byte handle blob — then validates that all ranks allocated the same size, reserves one flat VA span of world_size × stride, and maps every rank's physical memory into its own slot.

One VA span reserved per rendezvous, on every rank (here: rank 2 of 4) slot 0imported from rank 0 slot 1imported from rank 1 slot 2 — ourssecond alias, retained handle slot 3imported from rank 3 stride = alloc_size span = world_size × stride What torch is told: buffer_ptrs / buffer_ptrs_dev [ base+0·stride , base+1·stride , base+2·stride , base+3·stride ] Evenly strided as an implementation detail; the API is the array. The flat form is stage 2 (cco's ccoWindowDevice).
The local rank's own memory is mapped a second time, via hipMemRetainAllocationHandle, so the stride is uniform for every slot including our own. That uniformity is what makes the pointer array evenly spaced.

Handle exchange: two paths, one code path around them

 FabricPOSIX file descriptor
Handle is64 opaque bytesan integer index into one process's fd table
Travels overthe torch Store, inline in the rendezvous all-gathera UNIX datagram socket with SCM_RIGHTS
Extra machinerynoneFdChannel — bind /tmp/mori_symm_<pid>_<rank>, send to every peer, receive world_size-1
Orderingall-gather is ordereddatagrams arrive in any order, so each carries its sender's rank as the payload

The fd path exists only because torch's own IpcChannel is not exported. It is ~110 lines and self-contained; if libtorch ever exports the real one, it deletes cleanly.

Deliberate omissions

Lifetime

Two details that are easy to get wrong. The allocator singleton is intentionally immortal, because register_availability() parks a reference in a libtorch-owned registry that outlives the extension's statics. And release is driven from an atexit hook registered on import, not from destructors: by the time static destructors run, HIP is already unwinding and every VMM call segfaults, while is_finalizing() still reports false.

05One backend, three architectures

The interesting design question is not "how do I support gfx1250" — it is how a single build behaves correctly on parts whose capabilities differ, against a HIP whose spelling of those capabilities is still moving. Three mechanisms do the work.

1. Capability by probe, not by architecture

There is no if (gfx1250) anywhere in the backend, and no reliance on a device attribute enum — those are not stable across HIP releases. Instead each device is probed once, at first allocation, by actually doing the thing:

hipMemAllocationHandleType ProbeHandleType(int dev) {          // cached per device
  chosen = hipMemHandleTypePosixFileDescriptor;
  prop   = MakeProp(dev, hipMemHandleTypeFabricCompat);
  if (hipMemGetAllocationGranularity(&gran, &prop, ...) == hipSuccess && gran > 0) {
    if (hipMemCreate(&h, gran, &prop, 0) == hipSuccess) {       // fails outright on gfx9
      if (hipMemExportToShareableHandle(&blob, h, hipMemHandleTypeFabricCompat, 0) == hipSuccess)
        chosen = hipMemHandleTypeFabricCompat;                  // create AND export must work
      hipMemRelease(h);
    }
  }
  hipGetLastError();          // the probe is expected to fail on gfx9; clear the sticky error
  return chosen;
}

One granularity-sized allocation, exported and released. Both steps have to succeed, because they fail in different places on different parts: on gfx9 hipMemCreate itself reports "operation not supported", whereas on gfx1250 with an older ROCm, create succeeded and only the export failed. Checking either one alone would have picked wrong on one of the two.

2. A compat shim for a moving API

Fabric handles are not spelled the same way in every ROCm release. include/mori/utils/hip_compat.hpp pins one name, mirroring the shim RCCL uses:

#ifdef HIP_FABRIC_API
typedef hipMemFabricHandle_t hipMemFabricHandle_compat_t;
#define MORI_MEM_HANDLE_TYPE_FABRIC hipMemHandleTypeFabric
#else
typedef struct { unsigned char data[64]; } hipMemFabricHandle_compat_t;
#define MORI_MEM_HANDLE_TYPE_FABRIC ((hipMemAllocationHandleType)0x8)
#endif

The same header also absorbs an unrelated ROCm break: hipMemImportFromShareableHandle takes the fd as a pointer-sized value from ROCm 7.1, and a pointer to the fd before that. Both quirks are one-line differences that would otherwise be sprinkled through the allocator.

3. A layout the kernel cannot tell apart

Whatever the handle type, rendezvous ends in the same place: one flat VA span, uniform stride, every slot mapped read-write, published as a pointer array. A kernel written against buffer_ptrs_dev sees identical geometry on a fabric part and an fd part. The all-to-all example demonstrates it — the same HIP source and the same Triton source run unmodified on wave64 gfx950 and wave32 gfx1250, with no arch guards and no tuning difference beyond the grid heuristic reading the CU count.

ArchitectureHandle type chosenWhyStatus
gfx1250 (ROCm 7.14 / 7.15) fabric create + export both succeed; handle rides the Store 2 and 4 ranks OK
gfx950 (MI355X) posix_fd hipMemCreate with a fabric handle type is unsupported 2, 4, 8 ranks OK
gfx942 (MI308X) posix_fd same as gfx950 correct, but see the driver caveat
gfx942 caveat — a driver limitation, not an allocator one

On the MI308X box, granting a single peer with hipMemSetAccess re-maps the buffer in the owner's own page tables as AMDGPU_PTE_SYSTEM | MTYPE_UC. The owner's access to its own HBM drops from 2671.5 GB/s to 55.8 GB/s — PCIe speed — and stays there. One grant is enough; more cost nothing further. Confirmed by tracing amdgpu:amdgpu_vm_set_ptes against a size-fingerprinted buffer, and the pages never move.

It is specific to the VMM path: hipMalloc + hipDeviceEnablePeerAccess for all 7 peers keeps full bandwidth on the same box, because that goes through KFD (hsaKmtMapMemoryToGPUNodes) while hipMemSetAccess reaches libdrm's amdgpu_bo_va_op. That host also has CONFIG_PCI_P2PDMA and CONFIG_DMABUF_MOVE_NOTIFY unset, both of which the DRM cross-device path wants. gfx950 on a 6.8 kernel is unaffected.

06What it measures

The example is a one-shot all-to-all over the window, written twice — once in HIP, once in Triton, each a single self-contained script with no build step. Every rank pushes its chunk into every peer's receive slot; the aggregate counts only the world_size-1 chunks that actually leave the device.

torchrun --nnodes=1 --nproc_per_node=8 all2all_hip.py    --chunk-kib 256
torchrun --nnodes=1 --nproc_per_node=8 all2all_triton.py --chunk-kib 256

Aggregate bandwidth, 4 MiB per peer

ranksgfx950gfx1250gfx942
2107.8 GB/s514.6 GB/s54.1 GB/s
4500–567 GB/s1705.1 GB/s112.2 GB/s
81746–1874 GB/s184.8 GB/s

256 KiB per peer, where launch and barrier cost still shows

ranksgfx950gfx1250gfx942
276–84 GB/s92.9 GB/s40.3 GB/s
4461–485 GB/s516.8 GB/s139.4 GB/s
81522–1829 GB/s250.0 GB/s

Ranges are across repeats; anything above 2 ranks moves 6–13% run to run, so read single-value columns as ±10% too. The gfx1250 box has 4 GPUs, hence no 8-rank row.

Three findings worth keeping

07Roadmap and known gaps

Stage 1 — this PR torch's pointer-array model HIP VMM, fabric or fd probe no shmem, no cco, no bootstrap Stage 2 cco owns the window ccoMemImport / WindowRegister winBase + stride4G, LSA-rank indexing Stage 3 scale-out and SDMA ibgdaWin, multi-node groups world_within_direct_access turns false
The staging is described in ROCm/mori#557. Stage 1 ships one addressing model, torch's; stage 2 is where the second one arrives, and arrives as cco's window rather than as a fourth private copy of the same arithmetic (§02).

Open gaps

Open questions for review

  1. Is a CppExtension in setup.py the right home? It is the only mori target that links libtorch, and torch's build_ext is what derives the ABI tag, the pybind11 copy and _GLIBCXX_USE_CXX11_ABI.
  2. For stage 2, is ccoMemImport / ccoWindowRegister overload C the intended path for a torch-owned allocation, and should ccoGetPeerPtr get a Cython binding? How should rendezvous(group_name) map onto ccoCommCreate's up-front perRankVmmSize?
  3. Should barrier() be implemented over the signal pad with P2P stores now, or wait for cco's signal pool in stage 3?
  4. Any idea on the teardown segfault at world_size ≥ 4?
  5. How should this relate to pytorch#192524, which enables NCCLSymmetricMemory (RCCL) support on ROCm upstream?