-
Notifications
You must be signed in to change notification settings - Fork 796
[PyTorch] Add joint forward-backward op fusion pass #3080
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,), ()], [(), ()] | ||
|
|
||
| @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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_enabledflag silently drops the joint fusion on re-fuseCustomLinearSiLU._enabledis set toFalseafter the first call tofuse_ops, so ifmaybe_fuse_opsis triggered a second time (e.g. recipe type changes,first_op_requiring_backwardshifts, or amax-history length changes), the fusion function returns the ops list unchanged. On that re-run_forward_opsand_backward_opswould revert to the two unfused basic ops, causing theassert 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_enabledat the start of eachmaybe_fuse_ops-triggering call, or restructuring the fuse function to be idempotent (fuse only if the first op is not already aCustomLinearSiLU).There was a problem hiding this comment.
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.