Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions tests/pytorch/test_distributed_weight.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
materialize_weight_for_forward,
materialize_weight_for_backward,
finalize_weight_grads,
weight_grad_dtype,
)


Expand Down Expand Up @@ -170,3 +171,29 @@ def test_finalize_grads_noop_on_plain_tensor():
plain_w = torch.nn.Parameter(torch.zeros(2))
g = [torch.ones(2)]
assert finalize_weight_grads(plain_w, g) == g


@pytest.mark.parametrize("group_size", [1, 2])
def test_weight_grad_dtype_dispatches(group_size):
"""A distributed weight types its wgrad from main_grad, not from the compute dtype."""
w = FakeDistributedWeight(group_size=group_size)
w.main_grad = torch.zeros(2, 2, dtype=torch.float32)
assert weight_grad_dtype(w, torch.bfloat16) == torch.float32
assert weight_grad_dtype([w], torch.bfloat16) == torch.float32


def test_weight_grad_dtype_without_main_grad():
"""No main_grad yet (pre-DDP): fall back to the compute dtype rather than raising."""
w = FakeDistributedWeight()
assert weight_grad_dtype(w, torch.bfloat16) == torch.bfloat16


def test_weight_grad_dtype_noop_on_plain_tensor():
"""A plain weight keeps the compute dtype even when it carries an fp32 main_grad.

Without weight sharding the wgrad is either accumulated into main_grad by the GEMM or
returned as an ordinary .grad, so widening here would only cost memory.
"""
plain_w = torch.nn.Parameter(torch.zeros(2))
plain_w.main_grad = torch.zeros(2, dtype=torch.float32)
assert weight_grad_dtype(plain_w, torch.bfloat16) == torch.bfloat16
76 changes: 74 additions & 2 deletions tests/pytorch/test_ops_grouped_linear_distributed_weight.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ def materialize_group_for_backward(self, **kwargs):
def finalize_group_grads(self, wgrads, **kwargs):
self.calls["finalize"] += 1
wl = list(wgrads) if isinstance(wgrads, (list, tuple)) else [wgrads]
# A real implementer reduce-scatters these as handed over, so their dtype is the
# reduction dtype -- record it before the cast below papers over it.
self.wgrad_dtypes = [g.dtype for g in wl]
for w, g in zip(self._group, wl):
w.main_grad.add_(g.to(w.main_grad.dtype)) # in-place accumulate into main_grad
w.grad_added_to_main_grad = True
Expand All @@ -64,11 +67,12 @@ def grad_buffer(self):
return self.data


def _make_fake_dist_leader(op, num_gemms):
def _make_fake_dist_leader(op, num_gemms, cls=_FakeDistWeight):
"""Replace ``op.weight0`` with a fake distributed leader referencing the whole group."""
w0 = op.weight0
leader = _FakeDistWeight(w0.data)
leader = cls(w0.data)
leader.calls = {"fwd": 0, "bwd": 0, "finalize": 0}
leader.wgrad_dtypes = []
leader._group = [leader] + [getattr(op, f"weight{i}") for i in range(1, num_gemms)]
op.weight0 = leader
return leader
Expand Down Expand Up @@ -129,3 +133,71 @@ def test_ops_grouped_linear_distributed_weight_dispatch(num_gemms):
# lives in main_grad). Nobody writes the real grad into op.weight.grad by design.
assert w.grad_added_to_main_grad is True
assert w.grad is not None, f"weight{i} should receive a dummy .grad"


class _FakeDistWeightPlain(_FakeDistWeight):
"""``_FakeDistWeight`` without the observability scales.

``module.GroupedLinear`` reads ``requires_grad`` off the MATERIALIZED weight, and a tensor
built inside ``autograd.Function.forward`` (grad mode off) has none -- so a scaling hook makes
it skip the wgrad GEMM entirely. Hand back the group itself to keep both paths in play.
"""

def materialize_group_for_forward(self):
self.calls["fwd"] += 1
return list(self._group)

def materialize_group_for_backward(self, **kwargs):
self.calls["bwd"] += 1
return list(self._group)


@pytest.mark.parametrize("num_gemms", [2, 4])
@pytest.mark.parametrize("fused_ops", [True, False], ids=["ops", "module"])
def test_grouped_linear_distributed_weight_wgrad_dtype(num_gemms, fused_ops):
"""The wgrad handed to finalize must already carry ``main_grad``'s dtype.

A distributed weight reduces its wgrad across the sharding axis BEFORE accumulating, so a
compute-dtype wgrad rounds on every rank -- silently defeating fp32 grad accumulation. The
weight cannot accumulate into a sharded ``main_grad``, so the GEMM epilogue is the only place
to widen. Runs the module in bf16 while ``main_grad`` stays fp32, so the two dtypes differ.
"""
if not torch.cuda.is_available():
pytest.skip("requires CUDA")
torch.manual_seed(0)
in_f, out_f, total_tokens = 32, 64, num_gemms * 8
dtype, device = torch.bfloat16, "cuda"

if fused_ops:
op = te.ops.GroupedLinear(num_gemms, in_f, out_f, bias=False, device=device, dtype=dtype)
else:
# fuse_wgrad_accumulation=False is the branch with no grad_buffer to take the dtype from.
op = te.GroupedLinear(
num_gemms,
in_f,
out_f,
bias=False,
device=device,
params_dtype=dtype,
fuse_wgrad_accumulation=False,
)

leader = _make_fake_dist_leader(op, num_gemms, cls=_FakeDistWeightPlain)
for i in range(num_gemms):
w = getattr(op, f"weight{i}")
w.main_grad = torch.zeros((out_f, in_f), dtype=torch.float32, device=device)
w.grad_added_to_main_grad = False

m_splits = [total_tokens // num_gemms] * num_gemms
m_splits[-1] += total_tokens - sum(m_splits)
split_sizes = torch.tensor(m_splits, dtype=torch.int64, device=device)

x = torch.randn(total_tokens, in_f, dtype=dtype, device=device, requires_grad=True)
op(x, split_sizes).sum().backward()

assert leader.calls["finalize"] > 0, "finalize_group_grads never fired"
assert leader.wgrad_dtypes, "no wgrads were handed to finalize"
assert set(leader.wgrad_dtypes) == {torch.float32}, (
f"wgrad reduced in {set(leader.wgrad_dtypes)}, expected main_grad's torch.float32; "
"the reduce-scatter would round on every rank"
)
19 changes: 19 additions & 0 deletions transformer_engine/pytorch/distributed_weight.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"materialize_weight_for_forward",
"materialize_weight_for_backward",
"finalize_weight_grads",
"weight_grad_dtype",
]


Expand Down Expand Up @@ -101,6 +102,24 @@ def materialize_weight_for_backward(weights: Any) -> List[Any]:
return list(weights)


def weight_grad_dtype(weights: Any, compute_dtype: torch.dtype) -> torch.dtype:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can also get rid of this helper?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I'd keep this one. It's the actual fix, and without it the same condition gets copied into 8 places

  • It's the whole fix now that weight_grad_buffers is gone.
  • Inlining it would copy the same condition into 8 call sites.
  • It keeps Megatron's main_grad convention in distributed_weight.py, not spread across TE's ops code.
  • It handles a missing main_grad by falling back to the compute dtype. Inlined, each site would need that guard or could raise AttributeError.

"""Dtype for a wgrad buffer the caller allocates itself.

A distributed weight reduces its wgrad before accumulating, so the GEMM must already emit
``main_grad``'s dtype -- otherwise the reduction rounds on every rank. Falls back to
``compute_dtype`` for plain weights, and for an implementer whose ``main_grad`` the framework
has not attached yet.
"""
if not isinstance(weights, (list, tuple)):
weights = [weights]
leader = weights[0]
if is_distributed_weight(leader):
main_grad = getattr(leader, "main_grad", None)
if main_grad is not None:
return main_grad.dtype
return compute_dtype


def finalize_weight_grads(weights: Any, wgrads: List[Any]) -> List[Any]:
"""Finalize a weight group's grad(s), mirroring :func:`materialize_weight_for_backward`.

Expand Down
8 changes: 5 additions & 3 deletions transformer_engine/pytorch/module/grouped_linear.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
materialize_weight_for_forward,
materialize_weight_for_backward,
finalize_weight_grads,
weight_grad_dtype,
)
from ..cpp_extensions import (
general_grouped_gemm,
Expand Down Expand Up @@ -655,7 +656,7 @@ def _grouped_linear_backward_impl(
num_gemms,
out_features,
in_features,
dtype=args.activation_dtype,
dtype=weight_grad_dtype(origin_weights, args.activation_dtype),
device=device,
)
wgrad_list = [wgrad_packed[i] for i in range(num_gemms)]
Expand Down Expand Up @@ -1188,13 +1189,14 @@ def _grouped_linear_graph_safe_backward(
wgrad_output = main_grads
wgrad_list = main_grads
else:
wgrad_dtype = weight_grad_dtype(origin_weights, args.activation_dtype)
if args.single_grouped_weight:
grouped_wgrad = GroupedTensor.make_grouped_tensor_with_shapes(
num_tensors=N,
shapes=[(args.out_features, args.in_features)] * N,
quantizer=None,
device=grad_output.device,
dtype=args.activation_dtype,
dtype=wgrad_dtype,
)
wgrad_output = grouped_wgrad
wgrad_list = [
Expand All @@ -1205,7 +1207,7 @@ def _grouped_linear_graph_safe_backward(
N,
args.out_features,
args.in_features,
dtype=args.activation_dtype,
dtype=wgrad_dtype,
device=grad_output.device,
)
wgrad_output = [wgrad_packed[i] for i in range(N)]
Expand Down
11 changes: 7 additions & 4 deletions transformer_engine/pytorch/ops/basic/grouped_linear.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
is_distributed_weight,
materialize_weight_for_backward,
materialize_weight_for_forward,
weight_grad_dtype,
)
from ...tensor import GroupedTensor, GroupedTensorStorage
from ...triton.grouped_dbias_dscales import (
Expand Down Expand Up @@ -1620,7 +1621,7 @@ def _fuser_backward_split_quantize(
accumulate_into_main_grad = get_accumulate_flag_in_param(weights[0])
else:
final_weight_grads[0] = torch.empty(
grouped_shape, dtype=ctx.dtype, device=device
grouped_shape, dtype=weight_grad_dtype(weights, ctx.dtype), device=device
)
grad_weights = [final_weight_grads[0][idx] for idx in range(num_groups)]
else:
Expand All @@ -1632,7 +1633,7 @@ def _fuser_backward_split_quantize(
else:
grad_weights_packed = torch.empty(
grouped_shape,
dtype=ctx.dtype,
dtype=weight_grad_dtype(weights, ctx.dtype),
device=device,
)
grad_weights = [grad_weights_packed[i] for i in range(num_groups)]
Expand Down Expand Up @@ -1914,7 +1915,7 @@ def _fuser_backward_grouped_tensor(
shapes=[weight_shape] * num_groups,
quantizer=None,
device=device,
dtype=dtype,
dtype=weight_grad_dtype(weights, dtype),
)
final_weight_grads[0] = grouped_wgrad.rowwise_data.view(num_groups, *weight_shape)
wgrad_output = grouped_wgrad
Expand All @@ -1927,7 +1928,9 @@ def _fuser_backward_grouped_tensor(
accumulate_into_main_grad = get_accumulate_flag_in_param(weights[0])
else:
final_weight_grads = [
torch.empty(weight_shape, dtype=dtype, device=device)
torch.empty(
weight_shape, dtype=weight_grad_dtype(weights, dtype), device=device
)
for _ in range(num_groups)
]
wgrad_output = final_weight_grads
Expand Down
5 changes: 3 additions & 2 deletions transformer_engine/pytorch/ops/fused/grouped_mlp.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
materialize_weight_for_forward,
materialize_weight_for_backward,
finalize_weight_grads,
weight_grad_dtype,
)
from ...module.base import _2X_ACC_WGRAD
from ...quantization import Recipe, get_fp8_torch_dtype
Expand Down Expand Up @@ -873,7 +874,7 @@ def _compute_grad_params(
shapes=[weight_shape] * num_groups,
quantizer=None,
device=device,
dtype=dtype,
dtype=weight_grad_dtype(weights, dtype),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont understand how this will help, since we will never enter this else condition of allocating using torch.empty, if main_grad is attached to the parameter.

@vthumbe1503 vthumbe1503 Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually we will, if accumulate_into_main_grad is False, but why are we attaching main_grad to parameter in that case in Megatron?

@fanshiqing fanshiqing Aug 24, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @vthumbe1503 , for you first question why this line's change is required: we need to follow main_grad's dtype instead of the compute_dtype for this scratch_buffer allocation.

Details:

  • The bug: for a GTP-sharded (distributed) weight whose scratch wgrad buffer isn't fused into main_grad, the buffer was allocated in the forward/backward compute dtype (e.g. bf16) — not in main_grad's dtype. That buffer is what gets reduced across the weight's GTP group before being accumulated into main_grad.
  • Result: whenever main_grad was fp32 (fp32 grad accumulation enabled by set --grad-reduce-in-fp32 from MLM for a best accuracy loss baseline), the reduce-scatter still ran in bf16 — every rank's contribution got rounded to bf16 before it ever reached the fp32 accumulator. fp32 grad accumulation was silently defeated for GTP-sharded weights, with no error or warning.
  • The fix: weight_grad_dtype(weights, dtype) returns main_grad.dtype instead of the plain compute dtype whenever the weight is distributed and main_grad is already attached, so the scratch buffer — and therefore the reduction — happens at the same precision main_grad actually uses. Falls back to compute_dtype for plain weights or when main_grad isn't attached yet.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if accumulate_into_main_grad is False, but why are we attaching main_grad to parameter in that case in Megatron

for MLM:

  • .main_grad attachment is unconditional.
  • accumulate_into_main_grad is a separate, opt-in performance flag.

@vthumbe1503 vthumbe1503 Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the explaination @fanshiqing! Why is this problem not present for discrete weights? Is it because in case discrete weights, we always write directly into the main_grad buffer?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @vthumbe1503 , Mostly yes — what matters is whether the reduction runs before or after the wgrad lands in main_grad:

  • Regular weights: the GEMM writes into the fp32 main_grad (or DDP adds .grad into it), and DDP reduces later, from main_grad, so the sum is in fp32.
  • Distributed weights: main_grad is only the local shard, but the GEMM produces the full unsharded wgrad. It must be reduce-scattered first, from a scratch buffer.
  • The bug: that scratch buffer used the compute dtype, so the RS summed in BF16 even under fp32 grad accumulation.
  • The fix: the scratch buffer now takes main_grad's dtype, so the RS runs in fp32.

)
wgrad_output = grouped_wgrad
w_list = [grouped_wgrad.rowwise_data.view(num_groups, *weight_shape)]
Expand All @@ -889,7 +890,7 @@ def _compute_grad_params(
wgrad_packed = torch.empty(
num_groups,
*weight_shape,
dtype=dtype,
dtype=weight_grad_dtype(weights, dtype),
device=device,
)
w_list = [wgrad_packed[i] for i in range(num_groups)]
Expand Down
Loading