Skip to content

Commit aba27a5

Browse files
Elton Hometa-codesync[bot]
authored andcommitted
Composable send/recv interface (transport + hooks + minimal NVLink path) (meta-pytorch#2829)
Summary: Pull Request resolved: meta-pytorch#2829 First, interface-only diff for the composable device send/recv framework that fused compute/comm kernels (all-to-all, allgather, ...) will build on. No production pipeline is wired; review overhead is intentionally low. New package `comms/dsl/`: * `ctx.py` — `Ctx`, the DSL-agnostic field-set spec of the hook contract, realized on-device per DSL (Triton `_aggregate` in `triton/ctx.py`). * `ops.py` — the device transport-ops seam (`put` / `get` / `signal` / `wait`): the only transport-specific device primitives. * `transport.py` — user-owned p2p transport objects: - `P2pTransport` Protocol + `PeerEndpoint` (host-resolved per-peer device state), - real, minimal `NvlTransport` + `nvl_rendezvous` (one collective rendezvous; no hidden cache — the user holds the object), - reserved `IbTransport` / `ib_rendezvous`, and `MeshTransport` which routes per peer via `link_kind` so a single collective can mix NVLink (intra-domain) and IB (inter-domain) later, - `check_transfer()` — fail-loud guard that numel fits the per-peer staging region and num_blocks fits the signal-pad slots (a violation would silently overrun the next peer's region on the remote rank). * `triton/` — minimal, real (no-pipeline, single-shot) device kernels `send_tiles`/`recv_tiles` written against the ops seam; hooks take a single `Ctx` aggregate (`produce(ctx) -> regs`, `consume(ctx, regs)`) — `triton/ctx.py` realizes `Ctx` as a Triton `_aggregate`. `nvl_ops` (real, over the framework's own self-contained PTX in `triton/device_utils.py`, no `comms/pipes` dep); `ib_ops` (reserved); `copy_*` default hooks; `send`/`recv`/ `sendrecv` launchers + a commented mixed-transport sketch. * `cute/` — `send`/`recv` interface stubs reserved for the CuTe backend (made real in the next diff). Design notes: * The hook seam is **full-leg** (one `produce`/`consume` per direction) — minimal yet sufficient: it subsumes address-gather, value-transform, packed staging, accumulate, and compute-into-send. The `addr_fn`+`transform` form will be a composer on top (follow-up). * Hooks take a single opaque `Ctx`, so the contract is churn-proof: future needs (pipeline `slot`/`step`, MoE `expert_counts`, FP8 `scales`, warp-spec role, ...) become `Ctx` fields and never change a hook signature. `Ctx` is also schedule-agnostic, so the same hooks serve a user-written schedule today and a library schedule skeleton later. * Transport choice binds **per call-site** (ops as `constexpr`), which is what lets one kernel mix NVLink and IB once the IB ops/transport are filled in — no change to `send`/`recv`, hooks, or `Ctx`. * **CUDA-graph-ready (architecture).** The collective rendezvous + symm-mem allocation run at setup (outside capture); the captured region is pure kernel launches over persistent, user-held buffers. Verified on 2x H100 for the **Triton** path: send/recv captures and a single replay is correct (after a host-side signal-pad reset; kernels must be warmed up before capture). NOTE: repeated replay is not yet gated — the minimal single-shot signal (`seq=1`) is not re-armed — so guaranteed repeat-replay support lands with the monotonic-counter pipelined follow-up. (CuTe graph capture is a separate follow-up — see D107283879.) Follow-up stacks: (1) Triton — real pipelined `send`/`recv` + port `all_to_all_single`; (2) CuTe — real CuTe `send`/`recv` on the same contract + cross-DSL interop test; (3) IB + mixed transport. Differential Revision: D107172780
1 parent 868f93f commit aba27a5

17 files changed

Lines changed: 1260 additions & 0 deletions

comms/dsl/__init__.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary.
2+
3+
# pyre-strict
4+
5+
"""Composable send/recv framework — DSL-agnostic contract.
6+
7+
This package exposes the stable, backend-independent contract:
8+
9+
* the hook contract :class:`Ctx` (+ ``ProduceFn`` / ``ConsumeFn`` aliases),
10+
* the user-owned p2p transport objects (:class:`NvlTransport` now,
11+
:class:`IbTransport` / :class:`MeshTransport` reserved) and
12+
:func:`nvl_rendezvous`.
13+
14+
Device ``send``/``recv`` live in the per-DSL subpackages
15+
(``comms.dsl.triton``, ``comms.dsl.cute``).
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from .ctx import ConsumeFn, Ctx, ProduceFn
21+
from .transport import (
22+
check_transfer,
23+
ib_rendezvous,
24+
IbTransport,
25+
LinkKind,
26+
MeshTransport,
27+
nvl_rendezvous,
28+
NvlTransport,
29+
P2pTransport,
30+
PeerEndpoint,
31+
)
32+
33+
__all__ = [
34+
"Ctx",
35+
"ProduceFn",
36+
"ConsumeFn",
37+
"LinkKind",
38+
"P2pTransport",
39+
"PeerEndpoint",
40+
"NvlTransport",
41+
"nvl_rendezvous",
42+
"check_transfer",
43+
"IbTransport",
44+
"ib_rendezvous",
45+
"MeshTransport",
46+
]

comms/dsl/ctx.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary.
2+
3+
# pyre-strict
4+
5+
"""DSL-agnostic hook contract for the composable send/recv framework.
6+
7+
``Ctx`` describes the *fields* a per-tile compute hook may read. It is the stable
8+
contract shared by every backend, realized on-device per DSL: the Triton backend
9+
as a ``@_aggregate`` struct (``triton/ctx.py``) and the CuTe backend as a
10+
``@dataclass`` (``cute/send_recv.py``). The field set stays identical so the same
11+
hook is portable across backends and across schedules (a user-written schedule
12+
today, a library schedule skeleton later).
13+
14+
This dataclass is the DSL-agnostic *spec* of that field set (and is used by the
15+
GPU-free tests). The device hooks read the per-backend realization; adding a
16+
field here (and to each realization) never changes a hook signature.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from collections.abc import Callable
22+
from dataclasses import dataclass, field
23+
24+
25+
@dataclass
26+
class Ctx:
27+
"""The full-leg hook contract (field set a ``produce``/``consume`` may read)."""
28+
29+
# --- pointers ---
30+
in_ptr: int = 0
31+
out_ptr: int = 0
32+
# Base pointer for the current tile's transport-addressable data region.
33+
# On send paths derived from PeerEndpoint.send_dst; on recv from .recv_src.
34+
region_ptr: int = 0
35+
slot_base: int = 0
36+
# --- pipeline position ---
37+
tile_idx: int = 0
38+
slot: int = 0
39+
step: int = 0
40+
tile_rows: int = 0
41+
tile_cols: int = 0
42+
# --- intra-block work split ---
43+
block_id: int = 0
44+
flat_tid: int = 0
45+
num_blocks: int = 0
46+
# --- schedule state (lets one hook serve multiple schedules) ---
47+
peer: int = 0
48+
recv_peer: int = 0
49+
shard_idx: int = 0
50+
# --- layout ---
51+
in_strides: tuple[int, ...] = ()
52+
out_strides: tuple[int, ...] = ()
53+
# --- op-specific extension point (e.g. expert_counts, scales) ---
54+
extra: dict[str, object] = field(default_factory=dict)
55+
56+
57+
# The two hooks. ``produce(ctx) -> regs``: HBM -> staging (reads the input leg).
58+
# ``consume(ctx, regs)``: staging -> HBM (writes the output leg with the loaded
59+
# tile payload ``regs``). These aliases document the contract; concrete device
60+
# hooks are DSL kernels reading the per-backend ``Ctx`` realization.
61+
ProduceFn = Callable[[Ctx], object]
62+
ConsumeFn = Callable[[Ctx, object], None]

comms/dsl/cute/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary.
2+
3+
# pyre-strict
4+
5+
"""CuTe backend: send/recv interface stubs (reserved for the CuTe stack)."""
6+
7+
from .send_recv import recv, send
8+
9+
__all__ = ["send", "recv"]

comms/dsl/cute/send_recv.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary.
2+
3+
# pyre-strict
4+
5+
"""CuTe send/recv — minimal interface stubs.
6+
7+
Reserves the CuTe backend's device API so the contract is visible now; the real
8+
CuTe kernels (mirroring the Triton ``send``/``recv`` against the same transport +
9+
ops seam) land in the CuTe stack. Kept as plain stubs to avoid pulling the CuTe
10+
DSL dependency into this interface diff.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from typing import Any
16+
17+
_RESERVED = "framework CuTe send/recv is reserved; implemented in the CuTe stack"
18+
19+
20+
def send(transport: Any, send_buf: Any, peer: int, **kwargs: Any) -> None:
21+
raise NotImplementedError(_RESERVED)
22+
23+
24+
def recv(transport: Any, recv_buf: Any, peer: int, **kwargs: Any) -> None:
25+
raise NotImplementedError(_RESERVED)

comms/dsl/ops.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary.
2+
3+
# pyre-strict
4+
5+
"""Device transport-ops seam.
6+
7+
These four device primitives are the *only* transport-specific operations.
8+
``send``/``recv`` are written once against this seam and treat the per-peer
9+
buffers as opaque, so a single ``send``/``recv``:
10+
11+
* serves NVLink today and IB later, and
12+
* serves **mixed-transport kernels** — a schedule may use NVLink for an
13+
intra-domain peer and IB for an inter-domain peer by passing different ops
14+
at each call site (the binding is per-call-site, not per-kernel).
15+
16+
There is no runtime dispatch: in Triton the concrete ops are passed as
17+
``tl.constexpr`` and selected at compile time. Concrete implementations live per
18+
transport + DSL (e.g. ``framework/triton/nvl_ops.py``). This module documents
19+
the contract.
20+
21+
Conceptual signatures (the concrete versions are ``@triton.jit`` / cute kernels):
22+
23+
put(region, idx, regs, mask) -> None # produced tile -> peer region
24+
get(region, idx, mask) -> regs # received tile <- region
25+
signal(sig_ptr, sig_idx, seq) -> None # publish "data ready"
26+
wait(sig_ptr, sig_idx, seq) -> None # wait for peer's data
27+
"""
28+
29+
from __future__ import annotations
30+
31+
from typing import Protocol
32+
33+
34+
class TransportOps(Protocol):
35+
"""Structural description of the four device ops a transport must provide.
36+
37+
Used for documentation/typing only; the attributes are DSL kernels
38+
(``@triton.jit`` functions for the Triton backend), not plain callables, so
39+
the parameter types are intentionally left loose.
40+
"""
41+
42+
put: object
43+
get: object
44+
signal: object
45+
wait: object

comms/dsl/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary.

comms/dsl/tests/test_interface.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
# (c) Meta Platforms, Inc. and affiliates. Confidential and proprietary.
2+
3+
# pyre-unsafe
4+
5+
"""GPU-free interface tests for the composable send/recv framework.
6+
7+
Asserts the *contract* (types, signatures, transport abstraction, reserved
8+
stubs) without compiling or launching any kernel, so this runs in CI with no
9+
GPU. The real end-to-end path is exercised by ``test_minimal_sendrecv``.
10+
"""
11+
12+
import dataclasses
13+
import inspect
14+
import unittest
15+
from typing import Any, cast
16+
17+
from torch.utils._triton import has_triton
18+
19+
20+
class ContractTest(unittest.TestCase):
21+
def test_ctx_fields(self) -> None:
22+
from comms.dsl import Ctx
23+
24+
names = {f.name for f in dataclasses.fields(Ctx)}
25+
for expected in (
26+
"in_ptr",
27+
"out_ptr",
28+
"region_ptr",
29+
"tile_idx",
30+
"block_id",
31+
"flat_tid",
32+
"num_blocks",
33+
"peer",
34+
"recv_peer",
35+
"shard_idx",
36+
"in_strides",
37+
"out_strides",
38+
"extra",
39+
):
40+
self.assertIn(expected, names)
41+
# Construct + extension point works.
42+
ctx = Ctx(in_ptr=1, extra={"scales": 7})
43+
self.assertEqual(ctx.in_ptr, 1)
44+
self.assertEqual(ctx.extra["scales"], 7)
45+
46+
def test_hook_aliases_importable(self) -> None:
47+
from comms.dsl import ConsumeFn, ProduceFn
48+
49+
self.assertIsNotNone(ProduceFn)
50+
self.assertIsNotNone(ConsumeFn)
51+
52+
def test_exports(self) -> None:
53+
import comms.dsl as fw
54+
55+
for name in (
56+
"Ctx",
57+
"NvlTransport",
58+
"nvl_rendezvous",
59+
"MeshTransport",
60+
"LinkKind",
61+
):
62+
self.assertIn(name, fw.__all__)
63+
64+
65+
class TransportTest(unittest.TestCase):
66+
def test_rendezvous_signature(self) -> None:
67+
from comms.dsl import nvl_rendezvous
68+
69+
params = list(inspect.signature(nvl_rendezvous).parameters)
70+
for expected in ("group", "device", "per_peer_bytes"):
71+
self.assertIn(expected, params)
72+
73+
def test_nvl_link_kind(self) -> None:
74+
from comms.dsl import LinkKind, NvlTransport
75+
76+
# handle is not touched by link_kind, so we can build this GPU-free.
77+
t = NvlTransport(
78+
handle=cast(Any, None), world_size=4, local_rank=0, per_peer_bytes=1024
79+
)
80+
self.assertIs(t.link_kind(1), LinkKind.NVLINK)
81+
82+
def test_check_transfer_guards(self) -> None:
83+
import torch
84+
from comms.dsl import check_transfer, NvlTransport
85+
86+
# per_peer_bytes=4096, fp32 -> 1024 elems per peer; 4 signal slots.
87+
t = NvlTransport(
88+
handle=cast(Any, None),
89+
world_size=2,
90+
local_rank=0,
91+
per_peer_bytes=4096,
92+
max_blocks_per_peer=4,
93+
)
94+
# Valid transfer: no raise.
95+
check_transfer(t, numel=512, dtype=torch.float32, num_blocks=2)
96+
# numel exceeds per-peer capacity -> raise (would corrupt the next peer).
97+
with self.assertRaises(ValueError):
98+
check_transfer(t, numel=2000, dtype=torch.float32, num_blocks=2)
99+
# num_blocks exceeds signal slots -> raise (would OOB-write the pad).
100+
with self.assertRaises(ValueError):
101+
check_transfer(t, numel=512, dtype=torch.float32, num_blocks=8)
102+
# num_blocks must be >= 1.
103+
with self.assertRaises(ValueError):
104+
check_transfer(t, numel=512, dtype=torch.float32, num_blocks=0)
105+
# MeshTransport delegates max_blocks_per_peer to intra, so the
106+
# num_blocks guard still fires through a mesh.
107+
from comms.dsl import MeshTransport
108+
109+
mesh = MeshTransport(intra=t)
110+
check_transfer(mesh, numel=512, dtype=torch.float32, num_blocks=2)
111+
with self.assertRaises(ValueError):
112+
check_transfer(mesh, numel=512, dtype=torch.float32, num_blocks=8)
113+
114+
def test_ib_reserved(self) -> None:
115+
from comms.dsl import ib_rendezvous, IbTransport
116+
117+
ib = IbTransport(world_size=8, per_peer_bytes=1024)
118+
with self.assertRaises(NotImplementedError):
119+
ib.endpoint(1, dtype=cast(Any, None))
120+
with self.assertRaises(NotImplementedError):
121+
ib_rendezvous(cast(Any, None), cast(Any, None), 1024)
122+
123+
def test_mesh_routes_by_domain(self) -> None:
124+
from comms.dsl import IbTransport, LinkKind, MeshTransport, NvlTransport
125+
126+
intra = NvlTransport(
127+
handle=cast(Any, None), world_size=8, local_rank=0, per_peer_bytes=1024
128+
)
129+
# No inter transport yet -> everything is NVLINK.
130+
mesh = MeshTransport(intra=intra)
131+
self.assertIs(mesh.link_kind(3), LinkKind.NVLINK)
132+
133+
# With an inter transport + domain size 4: peers 0-3 NVLINK, 4-7 IB.
134+
mesh2 = MeshTransport(
135+
intra=intra,
136+
inter=IbTransport(world_size=8, per_peer_bytes=1024),
137+
local_domain_size=4,
138+
)
139+
self.assertIs(mesh2.link_kind(2), LinkKind.NVLINK)
140+
self.assertIs(mesh2.link_kind(5), LinkKind.IB)
141+
142+
143+
@unittest.skipUnless(has_triton(), "Triton not available")
144+
class TritonInterfaceTest(unittest.TestCase):
145+
def _arg_names(self, jit_fn: object) -> list[str]:
146+
names = getattr(jit_fn, "arg_names", None)
147+
if names is not None:
148+
return list(names)
149+
return list(inspect.signature(getattr(jit_fn, "fn", jit_fn)).parameters)
150+
151+
def test_send_recv_are_jit(self) -> None:
152+
from comms.dsl.triton import recv_tiles as recv, send_tiles as send
153+
from triton.runtime.jit import JITFunction
154+
155+
self.assertIsInstance(send, JITFunction)
156+
self.assertIsInstance(recv, JITFunction)
157+
for p in ("in_ptr", "produce", "put", "signal"):
158+
self.assertIn(p, self._arg_names(send))
159+
for p in ("out_ptr", "consume", "get", "wait"):
160+
self.assertIn(p, self._arg_names(recv))
161+
162+
def test_nvl_and_ib_ops_present(self) -> None:
163+
from comms.dsl.triton import ib_ops, nvl_ops
164+
from triton.runtime.jit import JITFunction
165+
166+
for ops in (nvl_ops, ib_ops):
167+
for name in ("put", "get", "signal", "wait"):
168+
self.assertIsInstance(getattr(ops, name), JITFunction)
169+
170+
def test_hook_ctx_contract(self) -> None:
171+
# Hooks take a single Ctx aggregate; consume also gets the tile payload.
172+
from comms.dsl.triton import hooks
173+
from comms.dsl.triton.ctx import Ctx
174+
from triton.runtime.jit import JITFunction
175+
176+
self.assertIsInstance(hooks.copy_produce, JITFunction)
177+
self.assertIsInstance(hooks.copy_consume, JITFunction)
178+
self.assertEqual(self._arg_names(hooks.copy_produce), ["ctx"])
179+
self.assertEqual(self._arg_names(hooks.copy_consume), ["ctx", "regs"])
180+
self.assertIsNotNone(Ctx)
181+
182+
183+
class CuteInterfaceTest(unittest.TestCase):
184+
def test_cute_stubs_reserved(self) -> None:
185+
from comms.dsl.cute import recv, send
186+
187+
with self.assertRaises(NotImplementedError):
188+
send(None, None, 1)
189+
with self.assertRaises(NotImplementedError):
190+
recv(None, None, 1)

0 commit comments

Comments
 (0)