Skip to content

Commit 6605602

Browse files
FDecaYedConnor-XY
andauthored
Add packed THD context parallelism for DSv4 (#7469)
Signed-off-by: Deyu Fu <deyuf@nvidia.com> Co-authored-by: Yan Xu <yxu1@nvidia.com>
1 parent 1f6de31 commit 6605602

17 files changed

Lines changed: 3460 additions & 230 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
2+
"""Contiguous packed sequence rolls with explicit groups and differentiable boundary exchange."""
3+
4+
import torch
5+
import torch.distributed as dist
6+
7+
8+
def _continuation_mask(tensor, packed_seq_params, cp_group, dims):
9+
"""Return the local rows whose next token belongs to the same real document."""
10+
size = cp_group.size() if cp_group is not None else 1
11+
rank = cp_group.rank() if cp_group is not None else 0
12+
length = tensor.shape[dims]
13+
rows = torch.arange(rank * length, (rank + 1) * length, device=tensor.device)
14+
if packed_seq_params is None:
15+
ends = torch.full_like(rows, length * size)
16+
return rows + 1 < ends
17+
real = packed_seq_params.cu_seqlens_q
18+
physical = packed_seq_params.cu_seqlens_q_padded
19+
physical = real if physical is None else physical
20+
seq = torch.bucketize(rows, physical[1:], right=True)
21+
in_pack = seq < physical.numel() - 1
22+
seq = seq.clamp_max(physical.numel() - 2)
23+
ends = physical[seq] + (real[1:] - real[:-1])[seq]
24+
valid = in_pack & (rows >= physical[seq]) & (rows + 1 < ends)
25+
return valid
26+
27+
28+
class _RollLeft(torch.autograd.Function):
29+
"""One differentiable left roll, including the reverse neighbor exchange."""
30+
31+
@staticmethod
32+
def forward(ctx, tensor, group, dims):
33+
"""Shift local values and receive the next rank's first row."""
34+
ctx.group, ctx.dims = group, dims
35+
result = torch.roll(tensor, -1, dims)
36+
result.select(dims, -1).zero_()
37+
if group is not None and group.size() > 1:
38+
rank, size = group.rank(), group.size()
39+
send = tensor.select(dims, 0).contiguous()
40+
recv = torch.empty_like(send)
41+
ops = []
42+
if rank > 0:
43+
ops.append(
44+
dist.P2POp(dist.isend, send, dist.get_global_rank(group, rank - 1), group)
45+
)
46+
if rank + 1 < size:
47+
ops.append(
48+
dist.P2POp(dist.irecv, recv, dist.get_global_rank(group, rank + 1), group)
49+
)
50+
for work in dist.batch_isend_irecv(ops):
51+
work.wait()
52+
if rank + 1 < size:
53+
result.select(dims, -1).copy_(recv)
54+
return result
55+
56+
@staticmethod
57+
def backward(ctx, grad):
58+
"""Return the boundary gradient to its original token owner."""
59+
group, dims = ctx.group, ctx.dims
60+
result = torch.roll(grad, 1, dims)
61+
result.select(dims, 0).zero_()
62+
if group is not None and group.size() > 1:
63+
rank, size = group.rank(), group.size()
64+
send = grad.select(dims, -1).contiguous()
65+
recv = torch.empty_like(send)
66+
ops = []
67+
if rank + 1 < size:
68+
ops.append(
69+
dist.P2POp(dist.isend, send, dist.get_global_rank(group, rank + 1), group)
70+
)
71+
if rank > 0:
72+
ops.append(
73+
dist.P2POp(dist.irecv, recv, dist.get_global_rank(group, rank - 1), group)
74+
)
75+
for work in dist.batch_isend_irecv(ops):
76+
work.wait()
77+
if rank > 0:
78+
result.select(dims, 0).copy_(recv)
79+
return result, None, None
80+
81+
82+
def roll_contiguous(tensor, dims, cp_group, packed_seq_params=None, fill_value=0):
83+
"""Roll a dense or packed contiguous shard by one, preserving real document boundaries."""
84+
if tensor.shape[dims] == 0:
85+
return tensor.clone()
86+
valid = _continuation_mask(tensor, packed_seq_params, cp_group, dims)
87+
shape = [1] * tensor.ndim
88+
shape[dims] = tensor.shape[dims]
89+
return _RollLeft.apply(tensor, cp_group, dims).masked_fill(~valid.view(shape), fill_value)
90+
91+
92+
def roll_contiguous_fields(tensors, cp_group, packed_seq_params=None, fill_values=None):
93+
"""Roll non-differentiable token fields together, preserving each dtype and fill value.
94+
95+
This batches one depth's IDs/positions/masks; activations continue to use the
96+
differentiable roll above. No cross-layer cache or model-wide state is introduced.
97+
"""
98+
present = [tensor for tensor in tensors if tensor is not None]
99+
if not present:
100+
return tuple(tensors)
101+
reference = present[0]
102+
length = reference.shape[-1]
103+
if any(
104+
t.requires_grad or t.shape[-1] != length or t.device != reference.device for t in present
105+
):
106+
raise ValueError("Token fields require matching sequence lengths/devices and no gradients.")
107+
if fill_values is None:
108+
fill_values = (0,) * len(tensors)
109+
if len(fill_values) != len(tensors):
110+
raise ValueError("Each token field needs one fill value.")
111+
if length == 0:
112+
return tuple(None if t is None else t.clone() for t in tensors)
113+
valid = _continuation_mask(reference, packed_seq_params, cp_group, -1)
114+
rank = cp_group.rank() if cp_group is not None else 0
115+
size = cp_group.size() if cp_group is not None else 1
116+
outputs, received, sends, ops = [], [], [], []
117+
for tensor, fill in zip(tensors, fill_values):
118+
if tensor is None:
119+
outputs.append(None)
120+
continue
121+
result = torch.roll(tensor, -1, -1)
122+
result[..., -1].fill_(fill)
123+
outputs.append(result)
124+
if rank > 0:
125+
send = tensor[..., 0].contiguous()
126+
sends.append(send) # Retain temporary send storage until all work completes.
127+
ops.append(
128+
dist.P2POp(dist.isend, send, dist.get_global_rank(cp_group, rank - 1), cp_group)
129+
)
130+
if rank + 1 < size:
131+
recv = torch.empty_like(tensor[..., 0])
132+
received.append((result, recv))
133+
ops.append(
134+
dist.P2POp(dist.irecv, recv, dist.get_global_rank(cp_group, rank + 1), cp_group)
135+
)
136+
for work in dist.batch_isend_irecv(ops) if ops else []:
137+
work.wait()
138+
for result, recv in received:
139+
result[..., -1].copy_(recv)
140+
for result, fill in zip(outputs, fill_values):
141+
if result is not None:
142+
result.masked_fill_(~valid, fill)
143+
return tuple(outputs)

‎megatron/core/tensor_parallel/mappings.py‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,3 +625,98 @@ def all_to_all_hp2sp(input_, group=None):
625625
)
626626
output = torch.cat(split_tensors, dim=-1)
627627
return output
628+
629+
630+
class _AsyncCollectiveHandle:
631+
"""Awaitable tensor result of an asynchronous collective."""
632+
633+
def __init__(self):
634+
self.tensor = None
635+
self.work = None
636+
# Keep a temporary contiguous input alive until NCCL has consumed it.
637+
self._input_buffer = None
638+
639+
def wait(self):
640+
"""Wait at the first consumer and return the collective output."""
641+
if self.work is not None:
642+
self.work.wait()
643+
self.work = None
644+
self._input_buffer = None
645+
return self.tensor
646+
647+
648+
class _GatherFromSequenceParallelRegionAsync(torch.autograd.Function):
649+
"""Launch an equal-split first-dimension all-gather without waiting for it."""
650+
651+
@staticmethod
652+
def forward(ctx, input_, group, tensor_parallel_output_grad, handle):
653+
"""Launch the forward all-gather and publish its work through ``handle``."""
654+
ctx.tensor_parallel_output_grad = tensor_parallel_output_grad
655+
ctx.group = group
656+
657+
dim_size = list(input_.size())
658+
dim_size[0] *= group.size()
659+
output = torch.empty(dim_size, dtype=input_.dtype, device=input_.device)
660+
input_buffer = input_.contiguous()
661+
handle._input_buffer = input_buffer
662+
handle.work = dist_all_gather_func(output, input_buffer, group=group, async_op=True)
663+
return output
664+
665+
@staticmethod
666+
def backward(ctx, grad_output):
667+
"""Preserve the synchronous gather's reduce-scatter backward semantics."""
668+
if ctx.tensor_parallel_output_grad:
669+
grad_input = _reduce_scatter_along_first_dim(grad_output, ctx.group)
670+
else:
671+
grad_input = _split_along_first_dim(grad_output, ctx.group)
672+
return grad_input, None, None, None
673+
674+
675+
def async_gather_from_sequence_parallel_region(
676+
input_, tensor_parallel_output_grad=True, group=None
677+
):
678+
"""Launch an equal-split AG and return a handle whose ``wait`` yields its tensor.
679+
680+
The caller must wait before the gathered tensor's first use. The returned
681+
tensor retains the same autograd contract as
682+
:func:`gather_from_sequence_parallel_region`: its backward either
683+
reduce-scatters or splits along the first dimension.
684+
"""
685+
if group is None:
686+
raise ValueError("An explicit process group is required for asynchronous collectives.")
687+
handle = _AsyncCollectiveHandle()
688+
if group.size() == 1:
689+
handle.tensor = input_
690+
return handle
691+
handle.tensor = _GatherFromSequenceParallelRegionAsync.apply(
692+
input_, group, tensor_parallel_output_grad, handle
693+
)
694+
return handle
695+
696+
697+
def async_reduce_scatter_along_first_dim(input_, group=None):
698+
"""Launch an equal-split first-dimension reduce-scatter and return a handle.
699+
700+
This helper is intended for custom backward implementations that have
701+
independent work to execute before the reduced local gradient is consumed.
702+
The caller must invoke ``wait`` before using the returned tensor.
703+
"""
704+
if group is None:
705+
raise ValueError("An explicit process group is required for asynchronous collectives.")
706+
handle = _AsyncCollectiveHandle()
707+
if group.size() == 1:
708+
handle.tensor = input_
709+
return handle
710+
711+
dim_size = list(input_.size())
712+
assert (
713+
dim_size[0] % group.size() == 0
714+
), "First dimension of the tensor should be divisible by tensor parallel size"
715+
dim_size[0] //= group.size()
716+
717+
handle.tensor = torch.empty(dim_size, dtype=input_.dtype, device=input_.device)
718+
handle._input_buffer = input_.contiguous()
719+
handle.work = dist_reduce_scatter_func(
720+
handle.tensor, handle._input_buffer, group=group, async_op=True
721+
)
722+
return handle

0 commit comments

Comments
 (0)