Fix MNNVL Lamport mailbox publication and cleanup (#53000)
Signed-off-by: Ace Eldeib <aeldeib@coreweave.com> Co-authored-by: Codex <codex@openai.com>
Ace Eldeib committed
Aug 24, 2026 at 02:56 UTC
a047e2543da570a64d1bbfeac4fe44eff3e87a81
3 files changed
+372
-6
.buildkite/test_areas/distributed.yaml
+1
@@ -266,6 +266,7 @@ steps:
266
working_dir: "/vllm-workspace/"
267
num_devices: 2
268
commands:
269
+ - pytest -v -s tests/distributed/test_custom_all_gather_reduce_scatter.py
270
- pytest -v -s tests/distributed/test_context_parallel.py
271
- pytest -v -s tests/distributed/test_nccl_symm_mem.py
272
- pytest -v -s tests/v1/distributed/test_dbo.py
csrc/custom_all_gather_reduce_scatter.cuh
+45
-6
@@ -104,6 +104,38 @@ DINLINE P sanitize_lamport_payload(P packed) {
104
return value.packed;
105
}
106
107
+__host__ __device__ constexpr int mnnvl_lamport_dirty_stage(int current_stage) {
108
+ return (current_stage + 2) % 3;
109
+}
110
+
111
+__host__ __device__ constexpr int mnnvl_lamport_next_stage(int current_stage) {
112
+ return (current_stage + 1) % 3;
113
+}
114
+
115
+template <typename P>
116
+DINLINE void store_multimem_lamport_payload(P* ptr, P packed) {
117
+ static_assert(sizeof(P) == 16);
118
+ // multimem was introduced in PTX 8.1 (CUDA 12.1) for SM90 and newer.
119
+#if !defined(USE_ROCM) && CUDA_VERSION >= 12010 && defined(__CUDA_ARCH__) && \
120
+ (__CUDA_ARCH__ >= 900)
121
+ LamportPack<P> value{.packed = packed};
122
+ // The local alias remains sentinel until the multicast payload becomes
123
+ // observable; readers reject prior or partial values and retry.
124
+ asm volatile("multimem.st.relaxed.sys.global.v4.f32 [%0], {%1,%2,%3,%4};"
125
+ :
126
+ : "l"(ptr), "r"(value.words[0]), "r"(value.words[1]),
127
+ "r"(value.words[2]), "r"(value.words[3])
128
+ : "memory");
129
+#elif defined(USE_ROCM)
130
+ __builtin_trap();
131
+#else
132
+ // Multicast mappings do not exist before SM90. Fail closed if this kernel is
133
+ // ever dispatched for an unsupported target instead of issuing an undefined
134
+ // ordinary store to a multicast address.
135
+ asm volatile("trap;");
136
+#endif
137
+}
138
+
139
template <typename P>
140
DINLINE P wait_lamport_payload(const P* ptr) {
141
auto value = load_lamport_pack(ptr);
@@ -199,7 +231,9 @@ __global__ void __launch_bounds__(kMnnvlLamportAgThreads, 1)
231
int stride = gridDim.x * blockDim.x;
232
uint32_t epoch = epochs[0];
233
int current_stage = epoch % 3;
202
- int dirty_stage = (epoch + 1) % 3;
234
+ // A peer may start the next epoch after we publish but before we finish.
235
+ // Clean the previous stage, which cannot be reused until two epochs later.
236
+ int dirty_stage = mnnvl_lamport_dirty_stage(current_stage);
237
int dirty_size = epochs[2 + dirty_stage];
238
auto local_buffer = reinterpret_cast<P*>(const_cast<void*>(dp.ptrs[rank]));
239
auto current_local = local_buffer + current_stage * stage_size;
@@ -213,8 +247,11 @@ __global__ void __launch_bounds__(kMnnvlLamportAgThreads, 1)
247
P local_value;
248
if (tid < size_per_rank) {
249
local_value = packed_input[tid];
216
- current_multicast[rank * size_per_rank + tid] =
217
- sanitize_lamport_payload(local_value);
250
+ // A CUDA multicast mapping may only be accessed with multimem PTX;
251
+ // ordinary global loads and stores have undefined behavior.
252
+ store_multimem_lamport_payload(
253
+ current_multicast + rank * size_per_rank + tid,
254
+ sanitize_lamport_payload(local_value));
255
}
256
#if !defined(USE_ROCM) && CUDA_VERSION >= 12000 && defined(__CUDA_ARCH__) && \
257
(__CUDA_ARCH__ >= 900)
@@ -241,7 +278,7 @@ __global__ void __launch_bounds__(kMnnvlLamportAgThreads, 1)
278
if (tid == 0) {
279
while (*reinterpret_cast<volatile uint32_t*>(&epochs[1]) < gridDim.x);
280
epochs[2 + current_stage] = total_size;
244
- epochs[0] = epoch + 1;
281
+ epochs[0] = mnnvl_lamport_next_stage(current_stage);
282
epochs[1] = 0;
283
}
284
}
@@ -267,7 +304,9 @@ __global__ void __launch_bounds__(kMnnvlLamportRsThreads, 1)
304
int stride = gridDim.x * blockDim.x;
305
uint32_t epoch = epochs[0];
306
int current_stage = epoch % 3;
270
- int dirty_stage = (epoch + 1) % 3;
307
+ // A peer may start the next epoch after we publish but before we finish.
308
+ // Clean the previous stage, which cannot be reused until two epochs later.
309
+ int dirty_stage = mnnvl_lamport_dirty_stage(current_stage);
310
int dirty_size = epochs[2 + dirty_stage];
311
auto local_buffer = reinterpret_cast<P*>(const_cast<void*>(dp.ptrs[rank]));
312
auto current_local = local_buffer + current_stage * stage_size;
@@ -319,7 +358,7 @@ __global__ void __launch_bounds__(kMnnvlLamportRsThreads, 1)
358
if (tid == 0) {
359
while (*reinterpret_cast<volatile uint32_t*>(&epochs[1]) < gridDim.x);
360
epochs[2 + current_stage] = size_per_rank * ngpus;
322
- epochs[0] = epoch + 1;
361
+ epochs[0] = mnnvl_lamport_next_stage(current_stage);
362
epochs[1] = 0;
363
}
364
}
tests/distributed/test_custom_all_gather_reduce_scatter.py
new
+326
@@ -0,0 +1,326 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Validate production Lamport cleanup from a reachable one-epoch skew.
4
+
5
+The fast rank publishes epoch N+1 before the slow rank runs epoch N cleanup.
6
+Peer slots are prefilled only so each shipping kernel can finish independently;
7
+the test then checks that slow-rank cleanup retained the real N+1 publication.
8
+The production kernels are also seeded at the final two uint32 values to verify
9
+that stage selection remains bounded and never reuses a stage across wrap.
10
+
11
+Each rank's local buffer contains three all-gather stages followed by three
12
+reduce-scatter stages::
13
+
14
+ all-gather: [stage 0][stage 1][stage 2]
15
+ reduce-scatter: [stage 0][stage 1][stage 2]
16
+
17
+Each stage contains one payload slot per source rank.
18
+"""
19
+
20
+import pytest
21
+import ray
22
+import torch
23
+import torch.distributed as dist
24
+
25
+from vllm.distributed.parallel_state import get_tp_group
26
+from vllm.platforms import current_platform
27
+
28
+from ..utils import (
29
+ ensure_model_parallel_initialized,
30
+ init_test_distributed_environment,
31
+ multi_process_parallel,
32
+)
33
+
34
+_SENTINEL = -2147483648
35
+_ROWS = 32
36
+_HIDDEN = 8
37
+_NUM_STAGES = 3
38
+_NUM_RANKS = 2
39
+_FAST_RANK = 0
40
+_SLOW_RANK = 1
41
+_PACK_BYTES = 16
42
+_UINT32_MODULUS = 1 << 32
43
+_UINT32_MAX = _UINT32_MODULUS - 1
44
+_ALL_GATHER_EPOCH_ROW = 0
45
+_REDUCE_SCATTER_EPOCH_ROW = 1
46
+_REDUCE_SCATTER_REGION_STAGE_OFFSET = 3
47
+
48
+
49
+def _supports_multimem():
50
+ capability = current_platform.get_device_capability()
51
+ return (
52
+ current_platform.is_cuda() and capability is not None and capability.major >= 9
53
+ )
54
+
55
+
56
+def _payload(value, device):
57
+ return torch.full((_ROWS, _HIDDEN), value, dtype=torch.bfloat16, device=device)
58
+
59
+
60
+def _payload_bytes(value):
61
+ return value.contiguous().view(torch.uint8).flatten()
62
+
63
+
64
+def _int32_bits(value):
65
+ """Represent an unsigned 32-bit value in the signed epoch tensor."""
66
+ value %= _UINT32_MODULUS
67
+ return value if value < (1 << 31) else value - _UINT32_MODULUS
68
+
69
+
70
+def _epoch_value(epoch):
71
+ return int(epoch.item()) % _UINT32_MODULUS
72
+
73
+
74
+def _stage_slot(buffer, stage_size, region_offset, stage, src_rank, slot_size):
75
+ offset = region_offset + stage * stage_size + src_rank * slot_size
76
+ return buffer.narrow(0, offset, slot_size)
77
+
78
+
79
+def _prepare_rank_state(
80
+ fa, epoch_row, region_offset, epoch, stage, peer_rank, peer_payload
81
+):
82
+ """Reset this rank's replica and seed the peer payload needed to finish."""
83
+ buffer = fa.mnnvl_buffer
84
+ all_epochs = fa.mnnvl_lamport_epochs
85
+ assert buffer is not None and all_epochs is not None
86
+ slot_size = peer_payload.nbytes
87
+ slot = _stage_slot(
88
+ buffer, fa.mnnvl_buffer_size, region_offset, stage, peer_rank, slot_size
89
+ )
90
+ buffer.view(torch.int32).fill_(_SENTINEL)
91
+ epochs = all_epochs[epoch_row]
92
+ # epochs[0] selects the stage, epochs[1] is the CTA completion counter,
93
+ # and epochs[2:5] record the valid pack count for each stage.
94
+ epochs.zero_()
95
+ epochs[0] = epoch
96
+ epochs[2:5].fill_(slot_size * _NUM_RANKS // _PACK_BYTES)
97
+ slot.copy_(_payload_bytes(peer_payload))
98
+ return buffer, slot_size
99
+
100
+
101
+def _all_ranks_agree(checks, device):
102
+ """Give every rank the same result so one failure cannot strand a peer."""
103
+ values = torch.tensor(checks, dtype=torch.int32, device=device)
104
+ dist.all_reduce(values, op=dist.ReduceOp.MIN)
105
+ return [bool(value) for value in values.cpu().tolist()]
106
+
107
+
108
+def _run_all_gather_retention_case(fa, rank, local_rank, base_epoch, device):
109
+ slow_epoch = base_epoch
110
+ fast_epoch = base_epoch + 1
111
+ slow_stage = slow_epoch % _NUM_STAGES
112
+ fast_stage = fast_epoch % _NUM_STAGES
113
+ fast_local = _payload(11, device)
114
+ fast_peer = _payload(21, device)
115
+ slow_peer = _payload(31, device)
116
+ slow_local = _payload(41, device)
117
+
118
+ if rank == _FAST_RANK:
119
+ buffer, slot_size = _prepare_rank_state(
120
+ fa,
121
+ _ALL_GATHER_EPOCH_ROW,
122
+ 0,
123
+ fast_epoch,
124
+ fast_stage,
125
+ _SLOW_RANK,
126
+ fast_peer,
127
+ )
128
+ else:
129
+ buffer, slot_size = _prepare_rank_state(
130
+ fa,
131
+ _ALL_GATHER_EPOCH_ROW,
132
+ 0,
133
+ slow_epoch,
134
+ slow_stage,
135
+ _FAST_RANK,
136
+ slow_peer,
137
+ )
138
+ torch.accelerator.synchronize()
139
+ dist.barrier(device_ids=[local_rank])
140
+
141
+ # Fast rank publishes N+1 through the shipping all-gather kernel.
142
+ fast_output_ok = True
143
+ if rank == _FAST_RANK:
144
+ output = fa.custom_all_gather(fast_local)
145
+ assert output is not None
146
+ torch.accelerator.synchronize()
147
+ fast_output_ok = torch.equal(output, torch.cat((fast_local, fast_peer)))
148
+ dist.barrier(device_ids=[local_rank])
149
+
150
+ # Slow rank verifies that publication, then runs shipping epoch-N cleanup.
151
+ publication_ok = True
152
+ slow_output_ok = True
153
+ retained = True
154
+ if rank == _SLOW_RANK:
155
+ target = _stage_slot(
156
+ buffer,
157
+ fa.mnnvl_buffer_size,
158
+ 0,
159
+ fast_stage,
160
+ _FAST_RANK,
161
+ slot_size,
162
+ )
163
+ publication_ok = torch.equal(target, _payload_bytes(fast_local))
164
+ output = fa.custom_all_gather(slow_local)
165
+ assert output is not None
166
+ torch.accelerator.synchronize()
167
+ slow_output_ok = torch.equal(output, torch.cat((slow_peer, slow_local)))
168
+ retained = torch.equal(target, _payload_bytes(fast_local))
169
+
170
+ checks = _all_ranks_agree(
171
+ [fast_output_ok, publication_ok, slow_output_ok, retained], device
172
+ )
173
+ assert all(checks[:3]), "production all-gather setup did not reach target state"
174
+ assert checks[3], "all-gather cleanup erased the next-stage publication"
175
+
176
+
177
+def _run_reduce_scatter_retention_case(fa, rank, local_rank, base_epoch, device):
178
+ slow_epoch = base_epoch
179
+ fast_epoch = base_epoch + 1
180
+ slow_stage = slow_epoch % _NUM_STAGES
181
+ fast_stage = fast_epoch % _NUM_STAGES
182
+ region_offset = _REDUCE_SCATTER_REGION_STAGE_OFFSET * fa.mnnvl_buffer_size
183
+
184
+ fast_chunk_0 = _payload(11, device)
185
+ fast_chunk_1 = _payload(12, device)
186
+ fast_peer = _payload(21, device)
187
+ slow_chunk_0 = _payload(31, device)
188
+ slow_chunk_1 = _payload(32, device)
189
+ slow_peer = _payload(41, device)
190
+ fast_input = torch.cat((fast_chunk_0, fast_chunk_1))
191
+ slow_input = torch.cat((slow_chunk_0, slow_chunk_1))
192
+
193
+ if rank == _FAST_RANK:
194
+ buffer, slot_size = _prepare_rank_state(
195
+ fa,
196
+ _REDUCE_SCATTER_EPOCH_ROW,
197
+ region_offset,
198
+ fast_epoch,
199
+ fast_stage,
200
+ _SLOW_RANK,
201
+ fast_peer,
202
+ )
203
+ else:
204
+ buffer, slot_size = _prepare_rank_state(
205
+ fa,
206
+ _REDUCE_SCATTER_EPOCH_ROW,
207
+ region_offset,
208
+ slow_epoch,
209
+ slow_stage,
210
+ _FAST_RANK,
211
+ slow_peer,
212
+ )
213
+ torch.accelerator.synchronize()
214
+ dist.barrier(device_ids=[local_rank])
215
+
216
+ # Fast rank publishes its N+1 contribution through shipping reduce-scatter.
217
+ fast_output_ok = True
218
+ if rank == _FAST_RANK:
219
+ output = fa.custom_reduce_scatter(fast_input)
220
+ assert output is not None
221
+ torch.accelerator.synchronize()
222
+ fast_output_ok = torch.equal(output, fast_chunk_0 + fast_peer)
223
+ dist.barrier(device_ids=[local_rank])
224
+
225
+ # Slow rank verifies that contribution, then runs shipping epoch-N cleanup.
226
+ publication_ok = True
227
+ slow_output_ok = True
228
+ retained = True
229
+ if rank == _SLOW_RANK:
230
+ target = _stage_slot(
231
+ buffer,
232
+ fa.mnnvl_buffer_size,
233
+ region_offset,
234
+ fast_stage,
235
+ _FAST_RANK,
236
+ slot_size,
237
+ )
238
+ publication_ok = torch.equal(target, _payload_bytes(fast_chunk_1))
239
+ output = fa.custom_reduce_scatter(slow_input)
240
+ assert output is not None
241
+ torch.accelerator.synchronize()
242
+ slow_output_ok = torch.equal(output, slow_peer + slow_chunk_1)
243
+ retained = torch.equal(target, _payload_bytes(fast_chunk_1))
244
+
245
+ checks = _all_ranks_agree(
246
+ [fast_output_ok, publication_ok, slow_output_ok, retained], device
247
+ )
248
+ assert all(checks[:3]), "production reduce-scatter setup did not reach target state"
249
+ assert checks[3], "reduce-scatter cleanup erased the next-stage publication"
250
+
251
+
252
+def _run_epoch_wrap_case(fa, rank, local_rank, seed_epoch, device):
253
+ """Run both shipping collectives across the uint32 wrap boundary."""
254
+ buffer = fa.mnnvl_buffer
255
+ all_epochs = fa.mnnvl_lamport_epochs
256
+ assert buffer is not None and all_epochs is not None
257
+ buffer.view(torch.int32).fill_(_SENTINEL)
258
+ all_epochs.zero_()
259
+ all_epochs[:, 0] = _int32_bits(seed_epoch)
260
+ torch.accelerator.synchronize()
261
+ dist.barrier(device_ids=[local_rank])
262
+
263
+ ag_input = _payload(rank + 1, device)
264
+ ag_output = fa.custom_all_gather(ag_input)
265
+ assert ag_output is not None
266
+
267
+ rs_input = torch.cat(
268
+ (_payload(rank + 1, device), _payload((rank + 1) * 10, device))
269
+ )
270
+ rs_output = fa.custom_reduce_scatter(rs_input)
271
+ assert rs_output is not None
272
+ torch.accelerator.synchronize()
273
+
274
+ next_stage = (seed_epoch % _NUM_STAGES + 1) % _NUM_STAGES
275
+ checks = _all_ranks_agree(
276
+ [
277
+ torch.equal(
278
+ ag_output,
279
+ torch.cat((_payload(1, device), _payload(2, device))),
280
+ ),
281
+ torch.equal(rs_output, _payload(3 if rank == 0 else 30, device)),
282
+ _epoch_value(all_epochs[_ALL_GATHER_EPOCH_ROW, 0]) == next_stage,
283
+ _epoch_value(all_epochs[_REDUCE_SCATTER_EPOCH_ROW, 0]) == next_stage,
284
+ ],
285
+ device,
286
+ )
287
+ assert all(checks), "Lamport stages did not advance safely across uint32 wrap"
288
+
289
+
290
+@ray.remote(num_gpus=1, max_calls=1)
291
+def _run_stage_cleanup_test(
292
+ monkeypatch: pytest.MonkeyPatch,
293
+ tp_size,
294
+ pp_size,
295
+ rank,
296
+ distributed_init_port,
297
+):
298
+ with monkeypatch.context() as m:
299
+ m.delenv("CUDA_VISIBLE_DEVICES", raising=False)
300
+ m.delenv("HIP_VISIBLE_DEVICES", raising=False)
301
+ m.setenv("VLLM_ALLREDUCE_USE_SYMM_MEM", "1")
302
+ device = torch.device(f"cuda:{rank}")
303
+ torch.accelerator.set_device_index(device)
304
+ init_test_distributed_environment(tp_size, pp_size, rank, distributed_init_port)
305
+ ensure_model_parallel_initialized(tp_size, pp_size)
306
+
307
+ fa = get_tp_group().device_communicator.ca_comm
308
+ assert fa is not None and not fa.disabled
309
+ assert fa.mnnvl_multicast_ptr
310
+
311
+ for base_epoch in range(_NUM_STAGES):
312
+ _run_all_gather_retention_case(fa, rank, rank, base_epoch, device)
313
+ _run_reduce_scatter_retention_case(fa, rank, rank, base_epoch, device)
314
+ # Old unbounded metadata stores UINT32_MAX and then repeats stage 0.
315
+ for seed_epoch in (_UINT32_MAX - 1, _UINT32_MAX):
316
+ _run_epoch_wrap_case(fa, rank, rank, seed_epoch, device)
317
+
318
+
319
+@pytest.mark.skipif(
320
+ not _supports_multimem(),
321
+ reason="MNNVL Lamport collectives require an SM90 or newer NVIDIA GPU.",
322
+)
323
+def test_mnnvl_lamport_stage_cleanup(monkeypatch: pytest.MonkeyPatch):
324
+ if torch.accelerator.device_count() < 2:
325
+ pytest.skip("Need at least two GPUs to run the test.")
326
+ multi_process_parallel(monkeypatch, 2, 1, _run_stage_cleanup_test)