Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/api/pytorch.rst
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ Operation fuser

.. autoapifunction:: transformer_engine.pytorch.ops.register_backward_fusion

.. autoapifunction:: transformer_engine.pytorch.ops.register_forward_backward_fusion

.. autoapiclass:: transformer_engine.pytorch.ops.Linear

.. autoapiclass:: transformer_engine.pytorch.ops.AddExtraInput
Expand Down
69 changes: 66 additions & 3 deletions docs/examples/op_fuser/op_fuser.rst
Original file line number Diff line number Diff line change
Expand Up @@ -317,9 +317,10 @@ of context objects for all the corresponding ``BasicOperation`` s.

.. warning::

Remember the contract that the fused operation must produce outputs
that are interchangeable with the corresponding basic operation
outputs.
Forward-only and backward-only fused operations must produce outputs
that are interchangeable with the corresponding basic operations,
since the opposite pass is fused independently. Joint forward-backward
fusions (described below) relax this contract.

In order to make these fused operations useful, they should be
registered with the operation fuser. To do this, first implement a
Expand Down Expand Up @@ -351,3 +352,65 @@ and then register it with the ``register_forward_fusion`` or

# Register fusion with operation fuser
te.ops.register_forward_fusion(fuse_axpy_ops)

Joint forward-backward fusions
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

The fusions above replace operations in either the forward or the
backward pass, and they must remain interchangeable with the unfused
operations because the opposite pass is fused independently. Some
optimizations, however, benefit from co-designing the two passes. For
example, a fused operation's forward pass might skip saving a tensor if
it knows that its backward pass can recompute it.

A *joint* forward-backward fusion expresses this coupling. It is a single
``FusedOperation`` that implements both ``fuser_forward`` and
``fuser_backward``, registered with ``register_forward_backward_fusion``.
Joint fusions are applied before the forward-only and backward-only
fusion passes, so the same fused operation is seen by both passes. Unlike
forward-only or backward-only fusions, the two halves need not be
individually interchangeable with the unfused operations; only the
forward/backward pair must be jointly equivalent.

.. code-block:: python

class LinearSiLU(te.ops.FusedOperation):

def __init__(self, linear: te.ops.Linear, silu: te.ops.SiLU) -> None:
super().__init__((linear, silu)) # Equivalent basic ops

def fuser_forward(self, basic_op_ctxs, input_, **unused):
weight = self.basic_ops[0].weight
out = torch.nn.functional.silu(torch.matmul(input_, weight.T))
# Save reduced state: the backward recomputes the SiLU input
# rather than saving it.
basic_op_ctxs[0].save_for_backward(input_, weight)
return out, [(), ()]

def fuser_backward(self, basic_op_ctxs, grad_output, **unused):
x, w = basic_op_ctxs[0].saved_tensors
y = torch.matmul(x, w.T) # Recompute SiLU input
s = torch.sigmoid(y)
dy = grad_output * s * (1 + y * (1 - s)) # SiLU backward
return (
torch.matmul(dy, w), # Grad input
[(torch.matmul(dy.T, x),), ()], # Grad params for each basic op
[(), ()], # Grad extra inputs for each basic op
)

def fuse_linear_silu(ops, **unused):
"""Sliding window scan to perform LinearSiLU fusion"""
out = []
window, ops = ops[:2], ops[2:]
while len(window) == 2:
if isinstance(window[0], te.ops.Linear) and isinstance(window[1], te.ops.SiLU):
window = [LinearSiLU(window[0], window[1])]
else:
out.append(window[0])
window = window[1:]
window, ops = window + ops[:1], ops[1:]
out.extend(window + ops)
return out

# Register joint fusion with operation fuser
te.ops.register_forward_backward_fusion(fuse_linear_silu)
145 changes: 145 additions & 0 deletions tests/pytorch/test_fusible_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -5117,6 +5117,151 @@ def fuse_ops(
torch.testing.assert_close(dx_test, x_ref.grad, **tols)
torch.testing.assert_close(dw_test, w_ref.grad, **tols)

def test_custom_forward_backward_fused_op(
self,
*,
shape: Iterable[int] = (7, 11),
dtype: torch.dtype = torch.float32,
device: torch.device = "cuda",
):
"""Custom joint forward-backward fused op

A single fused op implements both ``fuser_forward`` and
``fuser_backward``. Because the same op owns both passes, the
forward saves reduced state (just the linear input and weight)
and lets its own backward recompute the SiLU input rather than
saving it.

"""

class CustomLinearSiLU(te.ops.FusedOperation):
"""Custom joint fused op for GEMM + SiLU"""

_enabled = True

def __init__(self, *, linear, silu) -> None:
super().__init__((linear, silu))

def fuser_forward(
self,
basic_op_ctxs: list[OperationContext],
input_: torch.Tensor,
**unused,
) -> torch.Tensor:
weight = self.basic_ops[0].weight
dtype = weight.dtype

# Forward compute
y = torch.matmul(input_, weight.T)
out = torch.nn.functional.silu(y)

# Save reduced state for the joint backward. Note that we
# do not save the SiLU input ``y``; the backward recomputes
# it from the linear inputs.
linear_op_ctx = basic_op_ctxs[0]
linear_op_ctx.save_for_backward(input_, weight)
linear_op_ctx.dtype = dtype

return out, [(), ()]

def fuser_backward(
self,
basic_op_ctxs: list[OperationContext],
grad_output: torch.Tensor,
**unused,
) -> torch.Tensor:

# Load reduced state from the joint forward
linear_op_ctx = basic_op_ctxs[0]
x, w = linear_op_ctx.saved_tensors
dtype = linear_op_ctx.dtype

# Recompute SiLU input and its gradient in FP64
x = x.double()
w = w.double()
dout = grad_output.double()
y = torch.matmul(x, w.T)
s = torch.sigmoid(y)
dsilu = s * (1 + y * (1 - s))
dy = dout * dsilu

# Linear backward
dx = torch.matmul(dy, w).to(dtype=dtype)
dw = torch.matmul(dy.T, x).to(dtype=dtype)

# grad_input, grad params per basic op, grad extra inputs per basic op
return dx, [(dw,), ()], [(), ()]

Comment on lines +5185 to +5194

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 _enabled flag silently drops the joint fusion on re-fuse

CustomLinearSiLU._enabled is set to False after the first call to fuse_ops, so if maybe_fuse_ops is triggered a second time (e.g. recipe type changes, first_op_requiring_backward shifts, or amax-history length changes), the fusion function returns the ops list unchanged. On that re-run _forward_ops and _backward_ops would revert to the two unfused basic ops, causing the assert isinstance(forward_ops[0][0], CustomLinearSiLU) assertions below to fail silently or with a confusing error rather than a clear "joint fusion was not reapplied" message. The current test is safe because it only calls the model once, but the pattern is fragile: any future extension that adds a second forward call (e.g., to test different recipe configurations) will break without an obvious explanation. Consider either resetting _enabled at the start of each maybe_fuse_ops-triggering call, or restructuring the fuse function to be idempotent (fuse only if the first op is not already a CustomLinearSiLU).

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.

The test is quick and hacky. The right fix would be a way to unregister fusions, but that's outside the scope of this PR.

@staticmethod
def fuse_ops(
ops: list[FusibleOperation],
**unused,
) -> list[FusibleOperation]:
"""Apply fusion the first time this function is called"""
if CustomLinearSiLU._enabled:
CustomLinearSiLU._enabled = False
op = CustomLinearSiLU(linear=ops[0], silu=ops[1])
return [op] + ops[2:]
Comment on lines +5196 to +5204

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 fuse_ops indexes ops without bounds or type guards

When _enabled is True, the function unconditionally accesses ops[0] and ops[1] and constructs CustomLinearSiLU(linear=ops[0], silu=ops[1]) without checking len(ops) >= 2 or that the ops are the expected types. If a future pipeline change reduces the number of basic ops to fewer than two (or the joint-fusion pass is called earlier in a different context), this raises an uncaught IndexError with no diagnostic message. Compared to the documented sliding-window pattern in op_fuser.rst — which uses isinstance checks before fusing — the test's fuse_ops skips these guards entirely, making it a less reliable reference for users adapting this code.

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.

The test is quick and hacky. This function prioritizes simplicity over robustness.

return ops

# Random data
x_ref, x_test = make_reference_and_test_tensors(
shape,
test_dtype=dtype,
test_device=device,
)
w_ref, w_test = make_reference_and_test_tensors(
(shape[-1], shape[-1]),
test_dtype=dtype,
test_device=device,
)
dy_ref, dy_test = make_reference_and_test_tensors(
shape,
test_dtype=dtype,
test_device=device,
requires_grad=False,
)

# Plain PyTorch implementation
y_ref = torch.nn.functional.linear(x_ref, w_ref)
y_ref = torch.nn.functional.silu(y_ref)
y_ref.backward(dy_ref)

# Implementation with joint fusible operation
te.ops.register_forward_backward_fusion(CustomLinearSiLU.fuse_ops)
model = te.ops.Sequential(
te.ops.Linear(shape[-1], shape[-1], bias=False),
te.ops.SiLU(),
)
with torch.no_grad():
model[0].weight.copy_(w_test)
del w_test
y_test = model(x_test)
y_test.backward(dy_test)

# Check that operations have been fused in both passes, using the
# same fused op object
forward_ops = model._module_groups[0]._forward_ops
backward_ops = model._module_groups[0]._backward_ops
assert len(forward_ops) == 1
assert isinstance(forward_ops[0][0], CustomLinearSiLU)
assert len(backward_ops) == 1
assert isinstance(backward_ops[0][0], CustomLinearSiLU)
assert forward_ops[0][0] is backward_ops[0][0]

# Expected numerical error
tols = dtype_tols(dtype)
if dtype == torch.float32:
tols = dtype_tols(torch.float16) # TF32 GEMM

# Check results
y_test = y_test.to(dtype=torch.float64, device="cpu")
dx_test = x_test.grad.to(dtype=torch.float64, device="cpu")
dw_test = model[0].weight.grad.to(dtype=torch.float64, device="cpu")
torch.testing.assert_close(y_test, y_ref, **tols)
torch.testing.assert_close(dx_test, x_ref.grad, **tols)
torch.testing.assert_close(dw_test, w_ref.grad, **tols)


class TestTrainingLoops:

Expand Down
6 changes: 5 additions & 1 deletion transformer_engine/pytorch/ops/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
"""

from .basic import *
from .fuser import register_backward_fusion, register_forward_fusion
from .fuser import (
register_backward_fusion,
register_forward_backward_fusion,
register_forward_fusion,
)
from .linear import Linear
from .op import BasicOperation, FusedOperation, FusibleOperation
from .sequential import Sequential
Expand Down
Loading
Loading