Skip to content

Commit 4f04d78

Browse files
committed
Enable contiguous context-parallel attention for softmax attention layers
Signed-off-by: Haoran Zhang <haoranz@nvidia.com>
1 parent 3914483 commit 4f04d78

10 files changed

Lines changed: 862 additions & 529 deletions

File tree

‎megatron/core/context_parallel/sequence_roll.py‎

Lines changed: 97 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,16 @@
55
import torch.distributed as dist
66

77

8-
def _continuation_mask(tensor, packed_seq_params, cp_group, dims):
8+
def _continuation_mask(tensor, packed_seq_params, cp_group, dims, sp_group=None):
99
"""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
1110
rank = cp_group.rank() if cp_group is not None else 0
11+
if sp_group is not None:
12+
rank = rank * sp_group.size() + sp_group.rank()
1213
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
1714
real = packed_seq_params.cu_seqlens_q
1815
physical = packed_seq_params.cu_seqlens_q_padded
1916
physical = real if physical is None else physical
17+
rows = torch.arange(rank * length, (rank + 1) * length, device=tensor.device)
2018
seq = torch.bucketize(rows, physical[1:], right=True)
2119
in_pack = seq < physical.numel() - 1
2220
seq = seq.clamp_max(physical.numel() - 2)
@@ -25,75 +23,106 @@ def _continuation_mask(tensor, packed_seq_params, cp_group, dims):
2523
return valid
2624

2725

26+
def exchange_neighbor_tokens(tokens, group, directions, *, wrap=False):
27+
"""Batch boundary exchanges, preserving each token's shape and dtype.
28+
29+
Each direction is -1 (send left, receive right) or +1 (send right, receive left).
30+
Missing neighbors return None unless wrap is enabled. Callers own boundary fills
31+
and autograd; this helper only performs communication on the supplied group.
32+
"""
33+
rank = group.rank() if group is not None else 0
34+
size = group.size() if group is not None else 1
35+
received, ops = [], []
36+
for token, direction in zip(tokens, directions):
37+
send_rank, recv_rank = rank + direction, rank - direction
38+
if wrap:
39+
send_rank, recv_rank = send_rank % size, recv_rank % size
40+
if 0 <= send_rank < size:
41+
ops.append(
42+
dist.P2POp(
43+
dist.isend, token.contiguous(), dist.get_global_rank(group, send_rank), group
44+
)
45+
)
46+
recv = None
47+
if 0 <= recv_rank < size:
48+
recv = torch.empty_like(token, memory_format=torch.contiguous_format)
49+
ops.append(dist.P2POp(dist.irecv, recv, dist.get_global_rank(group, recv_rank), group))
50+
received.append(recv)
51+
# P2POp retains each contiguous send buffer until the batch completes.
52+
for work in dist.batch_isend_irecv(ops) if ops else []:
53+
work.wait()
54+
return received
55+
56+
2857
class _RollLeft(torch.autograd.Function):
2958
"""One differentiable left roll, including the reverse neighbor exchange."""
3059

3160
@staticmethod
32-
def forward(ctx, tensor, group, dims):
61+
def forward(ctx, tensor, group, dims, sp_group):
3362
"""Shift local values and receive the next rank's first row."""
34-
ctx.group, ctx.dims = group, dims
63+
ctx.group, ctx.dims, ctx.sp_group = group, dims, sp_group
3564
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)
65+
boundary = tensor.select(dims, 0)
66+
if sp_group is None or sp_group.rank() == 0:
67+
(boundary,) = exchange_neighbor_tokens((boundary,), group, (-1,))
68+
if boundary is None:
69+
boundary = torch.zeros_like(tensor.select(dims, 0))
70+
# Owners are ordered CP rank first, then SP rank. Relay the next CP
71+
# shard's first token from SP rank zero to the last SP rank.
72+
if sp_group is not None and sp_group.size() > 1:
73+
(boundary,) = exchange_neighbor_tokens((boundary,), sp_group, (-1,), wrap=True)
74+
result.select(dims, -1).copy_(boundary)
5475
return result
5576

5677
@staticmethod
5778
def backward(ctx, grad):
5879
"""Return the boundary gradient to its original token owner."""
59-
group, dims = ctx.group, ctx.dims
80+
group, dims, sp_group = ctx.group, ctx.dims, ctx.sp_group
6081
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
82+
boundary = grad.select(dims, -1)
83+
if sp_group is not None and sp_group.size() > 1:
84+
(boundary,) = exchange_neighbor_tokens((boundary,), sp_group, (1,), wrap=True)
85+
if sp_group is None or sp_group.rank() == 0:
86+
(boundary,) = exchange_neighbor_tokens((boundary,), group, (1,))
87+
if boundary is None:
88+
boundary = torch.zeros_like(grad.select(dims, -1))
89+
result.select(dims, 0).copy_(boundary)
90+
return result, None, None, None
8091

8192

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."""
93+
def roll_contiguous(tensor, dims, cp_group, packed_seq_params=None, fill_value=0, *, sp_group=None):
94+
"""Roll a contiguous shard by one, preserving real document boundaries.
95+
96+
With sequence parallelism, each CP shard is further split in SP rank order.
97+
Both boundary exchanges are differentiable and use the supplied groups.
98+
"""
8499
if tensor.shape[dims] == 0:
85100
return tensor.clone()
86-
valid = _continuation_mask(tensor, packed_seq_params, cp_group, dims)
101+
cp_size = cp_group.size() if cp_group is not None else 1
102+
sp_size = sp_group.size() if sp_group is not None else 1
103+
if cp_size == sp_size == 1:
104+
result = torch.roll(tensor, -1, dims)
105+
result.select(dims, -1).fill_(fill_value)
106+
else:
107+
result = _RollLeft.apply(tensor, cp_group, dims, sp_group)
108+
if fill_value != 0:
109+
last_cp = cp_group is None or cp_group.rank() == cp_size - 1
110+
last_sp = sp_group is None or sp_group.rank() == sp_size - 1
111+
if last_cp and last_sp:
112+
result.select(dims, -1).fill_(fill_value)
113+
if packed_seq_params is None:
114+
return result
115+
valid = _continuation_mask(tensor, packed_seq_params, cp_group, dims, sp_group)
87116
shape = [1] * tensor.ndim
88117
shape[dims] = tensor.shape[dims]
89-
return _RollLeft.apply(tensor, cp_group, dims).masked_fill(~valid.view(shape), fill_value)
118+
return result.masked_fill_(~valid.view(shape), fill_value)
90119

91120

92121
def roll_contiguous_fields(tensors, cp_group, packed_seq_params=None, fill_values=None):
93122
"""Roll non-differentiable token fields together, preserving each dtype and fill value.
94123
95124
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.
125+
differentiable roll above.
97126
"""
98127
present = [tensor for tensor in tensors if tensor is not None]
99128
if not present:
@@ -110,34 +139,27 @@ def roll_contiguous_fields(tensors, cp_group, packed_seq_params=None, fill_value
110139
raise ValueError("Each token field needs one fill value.")
111140
if length == 0:
112141
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 = [], [], [], []
142+
invalid = (
143+
~_continuation_mask(reference, packed_seq_params, cp_group, -1)
144+
if packed_seq_params is not None
145+
else None
146+
)
147+
boundaries = exchange_neighbor_tokens(
148+
[tensor[..., 0] for tensor in present], cp_group, [-1] * len(present)
149+
)
150+
received = iter(boundaries)
151+
outputs = []
117152
for tensor, fill in zip(tensors, fill_values):
118153
if tensor is None:
119154
outputs.append(None)
120155
continue
121156
result = torch.roll(tensor, -1, -1)
122-
result[..., -1].fill_(fill)
157+
boundary = next(received)
158+
if boundary is not None:
159+
result[..., -1].copy_(boundary)
160+
else:
161+
result[..., -1].fill_(fill)
162+
if invalid is not None:
163+
result.masked_fill_(invalid, fill)
123164
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)
143165
return tuple(outputs)

‎megatron/core/context_parallel/utils.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,12 @@ def _build_packed_seq_params(
178178
if (layout == "contiguous" and cp_size > 1) or use_logical_qkv_seqlens
179179
else physical_cu_seqlens
180180
)
181+
if layout == "contiguous" and cp_size > 1 and pad_between_seqs is None:
182+
# TE's sync-free default uses object identity. Independently materialized
183+
# but equal boundaries are still unpadded and must remain eligible for FA.
184+
pad_between_seqs = cu_seqlens_padded is not None and not torch.equal(
185+
cu_seqlens, cu_seqlens_padded
186+
)
181187

182188
max_seqlen = int(batch['max_seqlen'].item())
183189
local_cp_size = batch.get('local_cp_size')

‎megatron/core/extensions/transformer_engine.py‎

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2231,6 +2231,11 @@ def forward(
22312231
_te_dpa_supports_softcap = (
22322232
"softcap" in inspect.signature(te.pytorch.DotProductAttention.__init__).parameters
22332233
)
2234+
_te_dpa_supports_no_load_balance = (
2235+
hasattr(getattr(te.pytorch, "CPLoadBalancingStrategy", None), "NO_LOAD_BALANCE")
2236+
and "load_balancing_strategy"
2237+
in inspect.signature(te.pytorch.DotProductAttention.set_context_parallel_group).parameters
2238+
)
22342239

22352240

22362241
class TEDotProductAttention(te.pytorch.DotProductAttention):
@@ -2444,6 +2449,39 @@ def __init__(
24442449
**extra_kwargs,
24452450
)
24462451

2452+
if self.config.attention_cp_layout == "contiguous":
2453+
cp_comm_type = cp_comm_type or self.cp_comm_type
2454+
cp_groups = self.cp_group if isinstance(self.cp_group, list) else [self.cp_group]
2455+
if any(get_pg_size(group) > 1 for group in cp_groups):
2456+
if cp_comm_type != "all_gather":
2457+
raise ValueError("Contiguous attention CP requires cp_comm_type='all_gather'.")
2458+
if not _te_dpa_supports_no_load_balance:
2459+
raise RuntimeError(
2460+
"Contiguous attention CP requires a Transformer Engine build with "
2461+
"CPLoadBalancingStrategy.NO_LOAD_BALANCE support."
2462+
)
2463+
if self.attention_type != "self":
2464+
raise ValueError("Contiguous attention CP only supports self-attention.")
2465+
self.set_context_parallel_group(
2466+
self.cp_group, self.cp_global_ranks, self.cp_stream, cp_comm_type
2467+
)
2468+
2469+
def set_context_parallel_group(
2470+
self, cp_group, cp_global_ranks, cp_stream, cp_comm_type="p2p", load_balancing_strategy=None
2471+
):
2472+
"""Keep TE's partition strategy consistent when a caller rebinds the CP group."""
2473+
cp_groups = cp_group if isinstance(cp_group, list) else [cp_group]
2474+
if self.config.attention_cp_layout == "contiguous" and any(
2475+
get_pg_size(group) > 1 for group in cp_groups
2476+
):
2477+
load_balancing_strategy = te.pytorch.CPLoadBalancingStrategy.NO_LOAD_BALANCE
2478+
kwargs = {}
2479+
if load_balancing_strategy is not None:
2480+
kwargs["load_balancing_strategy"] = load_balancing_strategy
2481+
super().set_context_parallel_group(
2482+
cp_group, cp_global_ranks, cp_stream, cp_comm_type, **kwargs
2483+
)
2484+
24472485
@contextmanager
24482486
def _temporary_runtime_context_parallel_group(
24492487
self, packed_seq_params: Optional[PackedSeqParams]
@@ -2462,11 +2500,11 @@ def _temporary_runtime_context_parallel_group(
24622500
if runtime_cp_group.size() == 1:
24632501
# Dynamic CP metadata retains the singleton group, while TE
24642502
# must see CP disabled for this microbatch.
2465-
super().set_context_parallel_group(None, None, None, self.cp_comm_type)
2503+
self.set_context_parallel_group(None, None, None, self.cp_comm_type)
24662504
else:
24672505
if TEDotProductAttention.cp_stream is None:
24682506
TEDotProductAttention.cp_stream = torch.cuda.Stream()
2469-
super().set_context_parallel_group(
2507+
self.set_context_parallel_group(
24702508
runtime_cp_group,
24712509
torch.distributed.get_process_group_ranks(runtime_cp_group),
24722510
TEDotProductAttention.cp_stream,
@@ -2475,9 +2513,9 @@ def _temporary_runtime_context_parallel_group(
24752513
yield
24762514
finally:
24772515
if original_cp_group is None or original_cp_group.size() == 1:
2478-
super().set_context_parallel_group(None, None, None, self.cp_comm_type)
2516+
self.set_context_parallel_group(None, None, None, self.cp_comm_type)
24792517
else:
2480-
super().set_context_parallel_group(
2518+
self.set_context_parallel_group(
24812519
original_cp_group,
24822520
original_cp_global_ranks,
24832521
TEDotProductAttention.cp_stream,
@@ -2542,6 +2580,50 @@ def _forward(
25422580
)
25432581
qkv_format = packed_seq_kwargs.get('qkv_format', self.qkv_format)
25442582

2583+
if self.config.attention_cp_layout == "contiguous" and get_pg_size(self.cp_group) > 1:
2584+
if attention_bias is not None:
2585+
raise ValueError("Contiguous attention CP does not support attention bias.")
2586+
if qkv_format == "sbhd":
2587+
if attn_mask_type != AttnMaskType.causal:
2588+
raise ValueError(
2589+
"Padded contiguous attention CP requires THD sequence metadata."
2590+
)
2591+
# Adapt dense inputs to the THD format required by TE's no-load-balance CP.
2592+
from transformer_engine.pytorch.attention.dot_product_attention.utils import (
2593+
get_full_cu_seqlens,
2594+
)
2595+
2596+
seq_length = query.size(0) * self.cp_group.size()
2597+
cu_seqlens = get_full_cu_seqlens(1, seq_length, query.device)
2598+
sample_params = PackedSeqParams(
2599+
qkv_format="thd",
2600+
cu_seqlens_q=cu_seqlens,
2601+
cu_seqlens_kv=cu_seqlens,
2602+
cu_seqlens_q_padded=cu_seqlens,
2603+
cu_seqlens_kv_padded=cu_seqlens,
2604+
max_seqlen_q=seq_length,
2605+
max_seqlen_kv=seq_length,
2606+
pad_between_seqs=False,
2607+
)
2608+
# Copy each batch once; each sample is then a contiguous THD view.
2609+
samples = [x.transpose(0, 1).contiguous().unbind(0) for x in (query, key, value)]
2610+
outputs = [
2611+
self._forward(
2612+
q,
2613+
k,
2614+
v,
2615+
None,
2616+
attn_mask_type,
2617+
packed_seq_params=sample_params,
2618+
num_splits=num_splits,
2619+
bf16_backward=bf16_backward,
2620+
)
2621+
for q, k, v in zip(*samples)
2622+
]
2623+
return outputs[0].unsqueeze(1) if len(outputs) == 1 else torch.stack(outputs, dim=1)
2624+
if qkv_format != "thd":
2625+
raise ValueError("Contiguous attention CP requires SBHD or THD input.")
2626+
25452627
attention_bias_kwargs = {}
25462628
if attention_bias is not None:
25472629
assert is_te_min_version("1.2.0"), (

‎megatron/core/transformer/attention.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1395,6 +1395,17 @@ def forward_pre_attn_and_core_attn(
13951395
if no_rope:
13961396
rotary_pos_emb = None
13971397

1398+
if (
1399+
self.config.attention_cp_layout == "contiguous"
1400+
and runtime_cp_group is not None
1401+
and runtime_cp_group.size() > 1
1402+
and any(
1403+
value is not None
1404+
for value in (rotary_pos_emb, rotary_pos_cos, rotary_pos_sin, rotary_pos_cos_sin)
1405+
)
1406+
):
1407+
raise ValueError("Contiguous attention CP does not yet support rotary embeddings.")
1408+
13981409
inference_context = deprecate_inference_params(inference_context, inference_params)
13991410

14001411
if inference_context and inference_context.is_dynamic_batching():

0 commit comments

Comments
 (0)