Skip to content

allocator: register mori as a torch SymmetricMemory backend - #544

Draft
carlushuang wants to merge 30 commits into
ROCm:mainfrom
carlushuang:carhuang/torch-allocator
Draft

allocator: register mori as a torch SymmetricMemory backend#544
carlushuang wants to merge 30 commits into
ROCm:mainfrom
carlushuang:carhuang/torch-allocator

Conversation

@carlushuang

@carlushuang carlushuang commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

📄 Design write-up: https://carlushuang.github.io/mori/torch-symm-backend.html — leads with the programming model (torch's native pointer array peers[p] + offset vs the LSA window base + peer*stride + offset that RCCL and mori's cco build underneath), then what torch's ROCm path does today, how alloc/rendezvous are built, and how one runtime probe covers fabric and fd parts. Everything below in more depth, with the diagrams.

Registers mori with torch as the "MORI" SymmetricMemory backend, so torch's own entry points drive it:

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.float32, device=device)
hdl  = symm_mem.rendezvous(t, group_name)
peer = hdl.get_buffer(1, (1024,), torch.float32)   # or hdl.buffer_ptrs_dev in a kernel

This is stage 1 of #557: peers are published only the way torch's model does it, as the buffer_ptrs / buffer_ptrs_dev array. Every rank does map into one flat VMM span, so those pointers happen to be evenly strided, but that layout is deliberately not part of the API here — exposing base + rank*stride belongs with mori's cco window, whose ccoWindowDevice already defines it (winBase, 4 GiB-quantised stride4G, LSA-rank indexing). Adding a second, parallel scheme in the allocator would just have to be unwound later. Stage 2 hands the allocation to ccoWindowRegister instead and lets cco own the window; stage 3 is scale-out and SDMA, which need cco co-design.

Verified at 2 ranks on three architectures: gfx1250 (ROCm 7.14/7.15, fabric handles), gfx950 (MI355X) and gfx942 (MI308X) (7.2.4, posix_fd). The all-to-all example runs at 8 ranks on gfx950 and 4 on gfx1250, in both HIP and Triton.

Why not shmem or cco today

Both keep a symmetric heap whose peer offsets stay aligned only while every rank allocates and frees in the same order. torch cannot hold that invariant — tensors are freed by Python GC, whose order is not synchronised across ranks. Here each allocation is an independent VMM allocation with its own rendezvous and a purely local free(). The target links neither mori_shmem nor mori_application.

That is an argument against the heap, not against cco. ccoMemImport and ccoWindowRegister overload C already alias an external HIP VMM allocation into cco's flat slot — the header's own example is "e.g. a torch.symm_mem buffer" — and ccoGetPeerPtr computes peer VAs on the host for exactly this kind of pointer table. Wiring those up is stage 2.

Build

Built by torch.utils.cpp_extension as a CppExtension in setup.py, not by CMake: it is the only target that links libtorch, and torch's build_ext derives _GLIBCXX_USE_CXX11_ABI, the pybind11 copy and the module's ABI tag from the installed torch. find_package(Torch) therefore appears nowhere in mori's CMake, and the torch build command is scoped to this one extension so nothing else in the build sees torch's compiler patches. See #549 for mori's packaging layer generally.

gfx9 support

Handle type is probed per device with a granularity-sized alloc/export, because the capability enum is not stable across HIP releases. gfx9 has no fabric at allhipMemCreate itself fails on gfx950 and gfx942, unlike gfx1250 on older ROCm where create succeeded and only export failed — so gfx9 falls back to POSIX fd. fds cannot ride the torch Store, so that path adds a small SCM_RIGHTS exchange; torch has an IpcChannel for this but does not export it from any libtorch .so.

Example: all-to-all, in HIP and in Triton

examples/torch_symm_all2all/ — a one-shot all-to-all over the window, no shmem and no cco, as two self-contained scripts with no build step:

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

all2all_hip.py keeps the HIP kernel as a string and JIT-builds it through torch.utils.cpp_extension.load_inline on first run (concurrent ranks share one build; torch holds the lock). all2all_triton.py does the same push over the same array — peers = peer_ptrs.to(tl.pointer_type(tl.uint64)), then tl.load(peers + peer).to(tl.pointer_type(tl.int32)), the idiom torch's own symmetric-memory Triton kernels use — and needs no C++ at all. Both take only hdl.buffer_ptrs_dev, so stage 1's contract is the entire interface either kernel needs.

The example used to carry a second HIP kernel taking base + peer*stride, to measure the two addressing forms against each other. That is now removed along with flat_view(), since the flat window should arrive as cco's, not as a third scheme invented in an example. What it measured, before removal: within 0.2% on 8×gfx950 with the array ahead as often as behind (1909.4 vs 1912.0 GB/s at 4 MiB; 1814.5 vs 1803.7 on a second box), and on an idle 4×gfx1250 the flat form consistently ahead by a near-constant 0.3-0.6 us — 2% at 4 MiB, 3-7% at 256 KiB. Constant in absolute terms is a per-block cost, not a per-byte one: the array's dependent load sits on the critical path before the first store issues. So stage 1 gives up a fixed sliver of launch-to-first-store latency and nothing per byte, and stage 2's flat window is worth having mostly for kernel ergonomics.

Aggregate over the (world_size-1) chunks that leave the device, 4 MiB per peer: 1746-1874 GB/s on 8×gfx950, 1705.1 GB/s on 4×gfx1250, 184.8 GB/s on 8×gfx942.

HIP vs Triton, us/iter over repeats. 8×gfx950: 128.9-140.6 (HIP) vs 118.6-139.7 (Triton) at 4 MiB, 29.7-30.3 vs 30.1-33.4 at 1 MiB, and 7.9-9.6 vs 9.1-10.0 at 256 KiB — close enough that either is ahead depending on the run. 4×gfx1250 (fabric handles, idle box): 29.6-30.5 vs 32.2-32.5 at 4 MiB, 10.0-10.1 vs 11.9-12.6 at 1 MiB, and 5.9-6.2 vs 12.0-12.1 at 256 KiB.

That last gap is host-side, not device-side. Launches are async, so an iteration costs max(kernel, launch), and timing the launch call alone gives 2.90 us (HIP pybind) vs 9.01 us (Triton 3.7) on gfx950, and 2.44-3.03 vs 10.84-10.94 us (Triton 3.8) on gfx1250. The gfx1250 Triton row is pinned near 12 us at both 256 KiB and 1 MiB despite 4x the data — it is waiting on JITFunction.run, not moving bytes, and a trailing synchronize() adds 0.03 us to it against 1.7-1.9 us for HIP, because no queue ever builds up. Same kernel, no source change between wave64 and wave32, and num_warps=8 on gfx1250 moves nothing.

Every serious library builds the flat window underneath regardless — NCCL's ncclGetLsaPointer is add4G(lsaFlatBase, peer*stride4G) + offset, mori's ccoWindowDevice is winBase + ((uint64_t)peerLsaRank * stride4G << 32) + offset, and NVSHMEM reserves npes * heap_size in one cuMemAddressReserve and then still exposes an array, because its slots are rotated from mype+1 so pe is not the slot index.

Grid shape mattered far more than addressing: one block per destination rank left all but world_size CUs idle and managed 15.8 GB/s on gfx1250, so each chunk is sliced across blocks_per_peer blocks.

The gfx942 column is a driver limitation, not a fabric or allocator one. Granting one peer via hipMemSetAccess re-maps the buffer, in the owner's own page tables, as AMDGPU_PTE_SYSTEM | MTYPE_UC, dropping the owner's access to its own HBM from 2671.5 to 55.8 GB/s — PCIe speed. hipMalloc + hipDeviceEnablePeerAccess is unaffected on the same box, because that path uses hsaKmtMapMemoryToGPUNodes (KFD) while hipMemSetAccess reaches libdrm amdgpu_bo_va_op (DRM). That host also lacks CONFIG_PCI_P2PDMA and CONFIG_DMABUF_MOVE_NOTIFY; details in the example's README.

Notes

The extension binds no torch types at all; its pybind surface is register_backend / shutdown / handle_type / backend_name / signal_pad_supported.

barrier/put_signal/wait_signal are unimplemented and raise, so the signal pad is not reserved either: appending torch's 9216-byte pad to a page-aligned window costs a whole extra 2 MiB page, physical backing being 2 MiB-paged. MORI_SYMM_SIGNAL_PAD=ON reserves it, and mori.allocator.signal_pad_supported() reports which build you have. torch's own symm_mem collectives synchronise through that pad rather than through this backend's barrier(), so they need the flag: one_shot_all_reduce raises a message naming it when the pad is compiled out, and returns the right answer when it is in. The test covers both builds; both pass at 2 ranks on gfx950.

MemPool works: symm_mem.get_mem_pool() + use_mem_pool() allocations rendezvous and drive the kernel correctly, verified at 4 ranks.

Gaps

  • Teardown is disabled by default. Releasing a rendezvous'd window segfaults at world_size >= 4 (2 ranks are fine) in the unmap/release path. Not is_finalizing(), not interpreter shutdown (it faults on an explicit del), not fixed by quiescing with a barrier. Mappings are leaked meanwhile — symmetric buffers are few and long-lived. MORI_SYMM_TEARDOWN=1 re-enables. Main thing blocking the draft. Stage 2 may dissolve it, since cco would own unmap ordering.
  • No multicast: HIP exposes no multicast API, so has_multicast_support() is false and multimem_* is out.
  • world_within_direct_access() returns true unconditionally — correct here (no RDMA path) but means no multi-node group. Stage 3's scale-out is where that becomes false for network peers, the way NVSHMEM's backend flips world_within_cuda_p2p_ when nvshmem_ptr returns null.
  • Backend-specific ops like all_to_all_nd dispatch on a hardcoded if backend == "NCCL", so third-party backends get the generic ops only.
  • Collective test runs at 2 ranks on a single node; the example runs at 8.

Questions

  1. Is a CppExtension in setup.py the right home, or should this build some other way?
  2. For stage 2, is ccoMemImport / ccoWindowRegister overload C the intended path for a torch-owned allocation, and should ccoGetPeerPtr get a Cython binding to go with it? 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? That is the one unresolved item.
  5. How should this relate to pytorch#192524, which is enabling RCCL's LSA symmetric-memory path on ROCm upstream?

Add a CUDAPluggableAllocator over ShmemMalloc/ShmemFree so tensors allocated
inside torch.cuda.use_mem_pool() come from the symmetric heap instead of the
caching allocator.

The motivation is the tensors an engine never allocates by hand. symm_mem-style
explicit allocation only covers buffers you construct yourself, but the ones
worth communicating are usually a KV cache or a GEMM output. Routing them
through a MemPool makes them symmetric at birth, so no staging copy is needed
before a transfer. This is the same integration shape inference engines already
consume from other transports -- SGLang selects a custom pool by type in
maybe_init_custom_mem_pool -- so mori can slot in without touching KV cache
code.

src/allocator/torch_allocator.cpp exports the two C symbols torch binds,
matching the signatures torch fixes:

    void* mori_allocator_malloc(size_t size, int device, hipStream_t stream)
    void  mori_allocator_free (void* ptr, size_t, int device, hipStream_t)

plus mori_allocator_probe(), because allocation happens inside tensor
constructors where raising is awkward; callers can check first and fall back to
the default pool. The probe uses ShmemIsInitialized() rather than inferring
from ShmemMyPe(), which dereferences bootstrap state that does not exist before
ShmemInit and would take the process down.

python/mori/allocator exposes MoriAllocator.get_allocator(device), cached per
device, alongside is_available() and get_so_path().

Built behind BUILD_ALLOCATOR (default ON, requires BUILD_SHMEM), copied into
the wheel next to the other libmori_*.so.

Note the collective contract, documented in the module docstring: ShmemMalloc
is collective, so every rank must enter and leave the pool context in the same
order and allocate the same sizes. Asymmetric allocations inside the context
will hang or corrupt the heap.

Verified: cmake configure succeeds with BUILD_ALLOCATOR=ON and the translation
unit compiles against mori headers on gfx1250 / ROCm 7.14. Full link of the
target was not reached in that container -- mori_application fails earlier on a
missing /usr/lib64/libc.so, unrelated to this change -- so the runtime test in
tests/python/allocator has not been executed yet.
Add src/allocator/symm_backend.cpp, implementing SymmetricMemoryAllocator and
SymmetricMemory over the shmem heap and registering it with torch as "MORI"
via register_availability(). torch then drives everything:

    import mori.allocator; mori.allocator.register_symm_backend()
    symm_mem.set_backend("MORI")
    t   = symm_mem.empty(...)              -> MoriSymmAllocator::alloc
    hdl = symm_mem.rendezvous(t, group)    -> MoriSymmAllocator::rendezvous
    torch.ops.symm_mem.one_shot_all_reduce(t, "sum", group)

Rendezvous is nearly free here because shmem has already bootstrapped the peer
mapping: it is ShmemPtrP2p() per PE, with no handle exchange to perform.

Scale-up versus scale-out falls out of that same call. ShmemPtrP2p returns 0
for a PE reached over RDMA rather than P2P, so buffer_ptrs()[pe] is null
exactly for peers that are not load/store accessible -- the contract NCCL's
backend expresses through ncclGetLsaPointer. world_within_direct_access()
reports whether the whole group is directly mappable, and
get_rank_to_global_rank() is implemented, so a kernel can tell which regime it
is in. Note torch's own CUDA backend hardcodes world_within_direct_access() to
true and leaves the rank mapping NYI, so this path has no other consumer yet.

Verified on 2x gfx1250, ROCm 7.14, torch 2.11:

    backend: MORI
    empty(): ok ptr=0x7910f7e00400
    rendezvous: world=2 rank=0
    buffer_ptrs: ['0x7910f7e00400', '0x790fe9a00400']
      get_buffer(0)[0] = 1.0   get_buffer(1)[0] = 2.0
    one_shot_all_reduce: OK got=3.0 expect=3.0

so torch's collectives run on mori memory today, without waiting for the ROCm
branches of CUDASymmetricMemory to be fixed.

Gaps, all documented: put_signal/wait_signal raise, and barrier is host-side
(hipDeviceSynchronize plus ShmemBarrierAll) rather than spinning on the signal
pad, so ops needing device-side signalling will fail or serialise. No
multicast. The group must be the shmem world, which rendezvous checks and
reports rather than failing obscurely.

Built behind BUILD_TORCH_SYMM because it links libtorch, which mori otherwise
does not; setup.py enables it when torch is importable. Uses raw HIP rather
than c10::cuda/c10::hip, since torch ships both trees and picking the wrong one
pulls cuda_runtime_api.h into a ROCm build.
@carlushuang carlushuang changed the title allocator: back a torch.cuda.MemPool with the mori symmetric heap allocator: register mori as a torch SymmetricMemory backend Aug 11, 2026
Trim the block comments to what is not evident from the code -- why rendezvous
is cheap, why buffer_ptrs can be null, why raw HIP instead of c10::cuda, and
the collective contract on ShmemMalloc. The long-form explanation belongs in
the PR and in the module docstring, not repeated at every call site.

No functional change; both translation units recompiled to confirm.
The pre-commit hook pins clang-format v20.1.8 with mori's .clang-format
(Google, ColumnLimit 100); my formatting assumed a narrower column. No
functional change.
Rebuild the backend on plain HIP VMM instead of mori's shmem allocator, and
probe the shareable handle type per device so gfx9 works.

Dropping shmem is deliberate, not incidental. Both shmem and cco own a
symmetric heap whose peer offsets stay aligned only while every rank allocates
AND frees in the same order. torch cannot hold that invariant: tensors are
freed by Python GC, whose order is not synchronised across ranks, so one rank
collecting a tensor an iteration earlier than another silently desynchronises
the heaps. Here each allocation is an independent VMM allocation with its own
rendezvous, and free() is purely local, so divergent free order costs nothing.
It also means the target links neither mori_shmem nor mori_application -- it
builds with BUILD_SHMEM=OFF.

Handle type is probed per device with a granularity-sized alloc/export, since
the capability attribute enum is not stable across HIP releases. gfx9 has no
fabric support at all -- hipMemCreate itself returns "operation not supported"
on both gfx950 and gfx942, a different failure from gfx1250 on old ROCm where
create succeeded and only export failed -- so those fall back to POSIX fd.
mori's existing hipMemHandleTypeFabricCompat shim covers ROCm 7.1, which does
not even declare the fabric enum and spells the prop field requestedHandleType.

fds cannot ride the torch Store the way a 64-byte fabric handle can, so the fd
path adds a small SCM_RIGHTS unix-socket exchange. torch has an IpcChannel for
this but does not export it from any libtorch .so, so this is a local
equivalent.

Peers are still mapped into one flat span, so buffer_ptrs[r] == flat_base +
r*stride on every path; flat_layout() exposes the pair for kernels.

Verified on three architectures, 2 ranks each, all reporting
uniform base+r*stride and a correct one_shot_all_reduce:

  gfx1250 / ROCm 7.14   handle_type=fabric     stride 2MB
  gfx950  / ROCm 7.2.4  handle_type=posix_fd
  gfx942  / ROCm 7.2.4  handle_type=posix_fd

Two shutdown bugs found and fixed along the way: destructors now bail on
is_finalizing() like torch's own backend does, and the allocator singleton is
intentionally immortal, because register_availability() parks a reference in a
registry owned by libtorch that outlives this extension's statics. Without the
latter every run segfaulted at interpreter exit after the test had passed.

Also drops the CUDAPluggableAllocator/MemPool piece added earlier: it was not
what was asked for, and it depended on ShmemMalloc which this no longer uses.
Add examples/torch_symm_all2all: a one-shot all-to-all kernel written against
the flat symmetric window, plus its own setup.py so the extension is built in
the example directory rather than by mori's CMake.

Pure HIP -- no shmem, no cco. All the kernel needs from the backend is
(flat_base, stride), after which peers are addressed arithmetically:

  recv slot of rank p, chunk from rank r == flat_base + p*stride + r*chunk_bytes

so it takes two pointers and three integers instead of an N-entry pointer
array, and the destination rank can be computed at run time. Push semantics:
each rank writes into every peer's receive window, so there are no remote reads
and no per-peer handshake.

Measured on 8x MI355X (gfx950, ROCm 7.2.4, 256 KiB per peer), aggregate over
the (world-1) chunks that actually leave the device:

  2 ranks   20.2 GB/s
  4 ranks  153.4 GB/s
  8 ranks  794.9 GB/s

with correctness checked as an NxN chunk permutation each time. gfx9 has no
fabric, so this runs the POSIX-fd path; the kernel neither knows nor cares.

Two backend fixes fell out of writing it.

The fd exchange assumed one datagram round per owner, but datagrams from
different owners can arrive in any order, so a rank could pair a peer's fd with
the wrong rank. Each message now carries its sender and the receiver takes
world-1 fds in whatever order they land. Latent at 2 ranks, reachable at 4+.

Releasing a rendezvous'd window segfaults at world_size >= 4 (2 is fine)
somewhere in the unmap/release path. Root cause not yet found: it is not
is_finalizing(), not interpreter shutdown (it faults on an explicit del), and
not fixed by quiescing with a barrier first. Until it is understood, teardown
is off by default and the mappings are leaked -- symmetric buffers are few and
long-lived, so that is much less harmful than crashing. MORI_SYMM_TEARDOWN=1
re-enables it for debugging. Recorded in the module docstring and the PR.
…ayload

The kernel launched one block per destination rank, so a 4-rank job used 4 CUs and
could not keep enough writes in flight to cover interconnect latency. Split each
chunk over blocks_per_peer blocks instead, sized from the CU count.

At 4 MiB per peer this is worth ~2.5x on gfx950 (745 -> 1858 GB/s at 8 ranks) and
~95x on gfx1250 (15.8 -> 1499 GB/s at 4 ranks). README records gfx950, gfx1250 and
gfx942 results; gfx1250 runs the fabric path, the other two fall back to POSIX fd.
… fabric-bound

ubench/06 puts that box's XGMI at 48.4 GB/s per link and 2637 GB/s aggregate via
hipMemcpyPeer, so the 185 GB/s all-to-all is ~7% of the interconnect. Writing the
VMM window there runs at ~50 GB/s vs ~880 GB/s for a hipMalloc tensor, and own-slot
is as slow as peer, so nothing crossing XGMI is implicated.
The previous note blamed VMM memory itself. A standalone HIP repro shows VMM
matches hipMalloc (2670 vs 2536 GB/s) and that flat multi-slot mapping and self
aliasing are free. The actual trigger is hipMemSetAccess: granting one peer GPU
access drops the owner's own bandwidth from 2671 to 55.8 GB/s, reads and writes
alike, with no further cost for more peers. gfx950 is unaffected (6541 GB/s with
grants). Making the memory shareable is what costs gfx942, not the flat window.
… architectural

hipMalloc memory with hipDeviceEnablePeerAccess for all 7 peers keeps full local
bandwidth on the same box (2668.5 -> 2665.2 GB/s), while VMM plus a single
hipMemSetAccess grant drops to 55.8. So peer visibility is not what costs the
bandwidth and the earlier 'no backend can avoid this' claim was wrong; the
hipMemCreate path specifically is what loses it.
…collapse

Tracing amdgpu:amdgpu_vm_set_ptes across 0..3 peer grants shows MTYPE_RW constant
at +492 MiB while each peer adds its own +256 MiB of NC and +256 MiB of UC. The
owner's entries are never re-typed, so the uncached-remap explanation is wrong.
Matches the backend, which already uses world_size, and makes it explicit at
the kernel signature that the argument is an index rather than a count or a
device ordinal. Comment prose still says "rank".
…comments

Granting one peer re-maps the buffer in the OWNER's page tables as
AMDGPU_PTE_SYSTEM|MTYPE_UC, so the owner's access to its own HBM leaves the chip
at PCIe speed. Confirmed by tracing amdgpu_vm_set_ptes against a size-fingerprinted
buffer: a SYSTEM|UC group matching the allocation appears only once a peer is
granted, and hipMemGetInfo is flat, so it is the mapping and not migration.

Also drops the earlier claim that this is not a kernel-config issue. That rested on
hipMalloc+peers being unaffected, but the two paths do not share a kernel interface
-- hipMemSetAccess reaches libdrm amdgpu_bo_va_op while ordinary allocations use
hsaKmtMapMemoryToGPUNodes -- so the missing CONFIG_PCI_P2PDMA and
CONFIG_DMABUF_MOVE_NOTIFY on that host are back to being the leading suspect.
The backend was a CMake target that called find_package(Torch) and linked
${TORCH_LIBRARIES} by hand. That reimplemented torch's build contract only
partially: TORCH_CXX_FLAGS was never applied so _GLIBCXX_USE_CXX11_ABI came from
the compiler default, pybind11 came from the standalone package rather than
torch's bundled copy, and the output had no Python ABI tag, so a 3.11 interpreter
would match a 3.12-built mori_torch_symm.so and fail on symbols.

Declare it as a CppExtension instead. torch's build_ext derives all of that from
the installed torch and emits mori_torch_symm.cpython-312-x86_64-linux-gnu.so into
the package. CMakeBuild now inherits torch's BuildExtension when torch is
importable and routes tagged extensions through build_extensions(); the lazy
compiler setup moved into _ensure_compiler() so both paths share it.

BUILD_TORCH_SYMM, src/allocator/CMakeLists.txt and the .so copy step are gone, so
find_package(Torch) no longer appears anywhere in mori's CMake.

mori.allocator now imports torch before the extension: it links libtorch and
nothing on its RUNPATH resolves that, so importing mori.allocator without torch
already loaded used to fail with a misleading 'extension not found'.

Verified on 8xMI355X: full setup.py build, 2-rank backend test (flat layout, peer
reads, one_shot_all_reduce) and the 8-rank all2all example at 1828 GB/s.
The previous commit made CMakeBuild inherit torch's BuildExtension, which put
every extension -- the CMake pseudo-target and the Cython cco module included --
through torch's compiler patches. Hand the tagged extension to its own
BuildExtension instance instead, so nothing else in the build sees torch.

CMakeBuild is a plain build_ext again and build_extension() is byte-identical to
before; the net change to setup.py is the guarded CppExtension import, one filter
in run(), _build_with_torch(), and _torch_symm_extension().
torch already publishes the peer pointers, so (flat_base, stride) is just
ptrs[0] and ptrs[1]-ptrs[0]; the C++ FlatLayout was re-deriving from the same
members buffer_ptrs is generated from. Verified identical on a 4-rank window.

The Python version takes the handle rather than the tensor, so it needs no group
plumbing, and it adds the check the C++ one lacked: that the stride really is
uniform. That is the part torch's API does not promise -- other backends hand back
scattered per-rank pointers, and aiming a kernel at base + rank*stride on one of
those would corrupt memory silently rather than raise.

Also drops the torch/csrc/utils/pybind.h include, which existed only for the
at::Tensor caster, so the extension no longer binds any torch type. README now
documents the raw two-line derivation so callers know the helper is optional.

Verified on 8xMI355X: 4-rank backend test and the 8-rank all2all at 1859 GB/s.
barrier/put_signal/wait_signal all raise, so the 9216-byte pad torch defines was
being reserved in every window and never used. Physical backing is 2 MiB-paged, so
appending it to a page-aligned request cost a whole extra page: a 64 MiB window
measured 66 MiB. Measured across sizes, the pad was free only when the request had
slack in its last page.

MORI_SYMM_SIGNAL_PAD (default off) now guards it. With it off the pad is not
reserved, the device-side pad array is not allocated or copied, and every signal
entry point -- including get_signal_pad_ptrs()/_dev() -- reports that support is
compiled out and names the flag. A 64 MiB window is now exactly 64 MiB.

torch's one_shot_all_reduce does not touch the pad, verified at 4 ranks with it
compiled out. Building with MORI_SYMM_SIGNAL_PAD=ON restores the old 66 MiB and
also passes.
…d compare

The example only demonstrated flat_base + peer*stride. Build the same kernel both
ways -- the flat form, and the N-entry peer pointer array that hdl.buffer_ptrs_dev()
gives and that torch's own backends force, since map_block() reserves each peer's VA
independently. --addressing flat|ptrs|both, both correctness-checked.

Measured: no difference worth having. Under 1% at 4 MiB and under 4% at 256 KiB, in
neither direction consistently -- gfx950 marginally favours the array, gfx1250 the
flat form. The flat window earns its place on ergonomics, not throughput, and the
README now says so rather than implying a speed argument.

Also fixes a race the second mode exposed: recv.zero_() is local, but peers write
into that window, so a rank zeroing late wiped a write a peer had already landed.
Passed in isolation and failed only when two modes ran in sequence, more often at
larger chunks. Now barriers between the clear and the pushes.

gfx950 8 ranks and gfx1250 4 ranks, both modes correct on both.
register_symm_backend() had to be called before symm_mem.set_backend("MORI")
would work, which is boilerplate every caller had to know about. Importing
mori.allocator now registers it, so the only call left is torch's own
set_backend().

Registration is best effort at import: if torch or the extension is missing there
is nothing to register, and the first real call still raises with a useful message
rather than making 'import mori.allocator' itself fail. register_symm_backend()
stays exported and idempotent for callers that want the error eagerly.

Deliberately does not auto-activate. register_availability() only makes "MORI"
selectable; making it the active allocator for the device type is what
set_backend() does, and that should stay the caller's choice since other backends
(RCCL's NCCL one) may also be registered.

Verified on 8xMI355X: bare import then set_backend, 4-rank backend test, 8-rank
all2all in both addressing modes.
Peers are published only as buffer_ptrs / buffer_ptrs_dev, which is what torch's
SymmetricMemory model provides and what every other backend does. The window is still
one flat span internally, but flat_layout() is gone from the public API: exposing
base + rank*stride belongs with mori's cco window, whose ccoWindowDevice already defines
that layout (winBase, 4 GiB-quantised stride4G, LSA-rank indexing), rather than as a
second, parallel scheme here. See ROCm#557 for the staged plan.

The example keeps both addressing forms -- it derives (base, stride) from buffer_ptrs
itself -- since that comparison is the evidence for the next stage. Re-measured on
8x gfx950: 1814.5 GB/s array vs 1803.7 flat at 4 MiB, 1639.9 vs 1623.2 at 256 KiB.

Also adds signal_pad_supported(), which the tests need: torch's own symm_mem collectives
synchronise through the pad, so one_shot_all_reduce raises when it is compiled out. The
test now asserts the collective works when the pad is built in and reports unsupported
when it is not; both builds pass.
Same one-shot push as the HIP kernel, written in Triton against the same
hdl.buffer_ptrs_dev. It is the idiom torch's own symmetric-memory Triton
kernels use -- pass the array's address as a plain int, cast it to a
pointer in the kernel, load the peer out of it -- so the backend needs to
offer nothing beyond what stage 1 already publishes, and this path needs
no extension build at all.

all2all.py grows --kernel hip|triton|both alongside --addressing; Triton
appears under the pointer-array form only, since the flat form is a
HIP-side comparison. Timing and correctness now iterate over
(implementation, addressing) variants.

The kernel is declared do_not_specialize on its int arguments: Triton
turns an int argument equal to 1 into a constexpr, so rank 1 alone failed
to compile.

Measured on 8x gfx950, aggregate over the chunks that leave the device:
Triton is within a couple of percent of HIP at 4 MiB (1818.3 vs 1843.3,
and 1852.2 vs 1832.2 on a repeat) and at 1 MiB (1769.5 vs 1784.0), and
18% behind at 256 KiB (1256.6 vs 1536.3) -- a near-constant ~1.3 us of
host-side launch cost, not codegen: both launch 512 blocks storing 1024
int32 each. All variants correctness-checked at 8 ranks, including a
masked tail chunk.
…ncher

Ran the example on 4x gfx1250 (torch 2.11 / Triton 3.8, fabric handles):
all three variants correct, and the Triton kernel needed no change from
the wave64 gfx950 version -- num_warps=8 to get 256 lanes there moves
nothing (32.2 vs 33.2 us at 4 MiB).

It is 2.3x slower at 256 KiB, and that is entirely host-side. Launches
are async, so an iteration costs max(kernel, launch); timing the launch
call alone gives 2.90 us (HIP pybind) vs 9.01 us (Triton 3.7) on gfx950
and 2.44 vs 12.83 us (Triton 3.8) on gfx1250. The gfx1250 Triton row is
pinned near 14 us at both 256 KiB and 1 MiB despite 4x the data -- it is
waiting on JITFunction.run.

That also corrects the gfx950 claim from the previous commit: repeats put
Triton within noise of HIP at every size there (its 9 us launcher sits
just under the 9-10 us kernel), so the earlier 18%-at-256-KiB figure was
an outlier, not a trend. Tables now report us/iter with a range over
three runs, which is steadier than the summed bandwidth.

Also caches the per-launch device-property lookup, which is 0.9 us of a
budget that turns out to be measured in single-digit microseconds.
all2all_kernel.cpython-312-x86_64-linux-gnu.so is output of
`setup.py build_ext --inplace`, swept in by a `git add -A` in the previous
commit. Removed, and examples/**/*.so added to .gitignore so the in-place
build cannot do it again.
The gfx1250 figures were taken while another tenant held all 4 GPUs. On an
idle box the same runs are 12% faster at 4 MiB and 44% faster at 256 KiB,
and reproducible to within 0.5 us across repeats, so the numbers in every
gfx1250 column are replaced and the contention caveats dropped.

Two things sharpen as a result. The Triton launcher measures 10.84-10.94 us
rather than 12.83, and the Triton row now sits at 12.0-12.6 us for both
256 KiB and 1 MiB -- 4x the data, same time, right at the launcher floor.
A trailing synchronize() adds 0.03 us to the Triton launch loop against
1.7-1.9 us for HIP, which is the same finding from the other side: the host
never gets far enough ahead to build a queue.

The addressing comparison also needs a less flat claim than "no measurable
difference". On gfx1250 the flat form is ahead by a near-constant 0.3-0.6 us
at every size, across six runs and whether the modes run together or alone
-- a per-block cost, the array's dependent load ahead of the first store,
not a per-byte one. It is still within 0.2% on gfx950, with the array ahead
as often as behind.
The example was a harness plus a .hip file plus a setup.py, and it ran
four ways through two flags. It is now two scripts, all2all_hip.py and
all2all_triton.py, each of which runs the whole example on its own:

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

No build step either way. The HIP kernel is a string that load_inline
JIT-builds on first run -- concurrent torchrun ranks share one build,
since torch takes a file lock -- so setup.py and all2all_kernel.hip are
gone. A bare `python3 all2all_hip.py` also works now, as a 1-rank smoke
test, by defaulting the rendezvous environment.

The second HIP kernel taking base + peer*stride goes with them, along
with flat_view(). Exposing a flat window is ROCm#557's job and should arrive
as cco's ccoWindowDevice, not as a third scheme an example invented; the
comparison it existed to make has been made, and the README keeps the
result in a sentence rather than a section.

Comments are cut back to the things that are not evident from the code:
grid shape, the int32 overflow, Triton's constexpr folding of 1.

Re-measured on 8x gfx950 with the code as it stands. Small messages come
out a little faster than the old harness reported (256 KiB, 8 ranks:
1522-1829 vs 1459 GB/s), which is what dropping a dict-and-lambda
indirection from a 8 us launch path looks like.
carlushuang added a commit to carlushuang/mori that referenced this pull request Aug 17, 2026
Standalone page for ROCm#544: why the backend exists, how alloc and
rendezvous work, and how one probe covers fabric and fd parts. Added
alongside the generated site rather than into it.
Picks up the ROCm 7.14 container build fixes (ROCm#541), the JIT v2 /
dispatch_combine_v2 targets, and gfx1250 in _supported_arch_list.
No conflicts: upstream's setup.py and CMakeLists.txt edits are in
different regions than the torch symm backend's.
Left over from an earlier revision; the torch symm backend is built by
setup.py's CppExtension, so it touches no CMake at all.
torch's cpp_extension compiles through ninja from build_temp, so the
relative -Iinclude resolved against the wrong directory and
symm_backend.cpp could not find mori/utils/hip_compat.hpp. Only shows
up when __file__ is relative (python setup.py ...); pip passes an
absolute path, which is why the wheel build was unaffected.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant