Skip to content

[PyTorch] torch.compile for an OperationFuser group holding one operation - #45

Open
pggPL wants to merge 21 commits into
mainfrom
ops_fuser_compile_main
Open

[PyTorch] torch.compile for an OperationFuser group holding one operation#45
pggPL wants to merge 21 commits into
mainfrom
ops_fuser_compile_main

Conversation

@pggPL

@pggPL pggPL commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Description

First step towards torch.compile(fullgraph=True) support for transformer_engine.pytorch.ops.

The approach: keep the pipeline-level _OperationFuserAutogradFunction and let Dynamo trace it as a higher-order op, with each fusible operation calling its own custom op inside. That keeps the forward and backward fusion layouts independent (so the backward-only fusions survive), keeps OperationContext inside the traced scope, and bounds op registration to one entry per op class.

This PR makes that work for an OperationFuser group holding one operation. It converts no real operation: the fuser's path is exercised by a test-only operation, so it does not depend on which operations happen to have a custom op. Converting the operations themselves is a follow-up.

Type of change

  • Documentation change (change only to the documentation, either a fix or a new content)
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Infra/Build change
  • Code refactoring

What an operation implements

A BasicOperation opts in by declaring two argument containers and implementing the methods below. op_forward / op_backward are then inherited from the base class; the operation does not write any custom-op plumbing itself.

Declarations

  • fwd_args_type, bwd_args_type: dataclasses. Their fields define the custom-op schema (tensors, quantized tensors, quantizers, plain values), exactly as LinearFwdArgs / LinearBwdArgs do for te.Linear.

Traced part (runs in the Dynamo-traced region, may read self, module state and ctx)

  • resolve_fwd_args(input_, *, requires_grad, prev_op_grad_output_quantizer, next_op_input_quantizer, <kwargs>) -> fwd_args_type: gathers everything the forward needs into the container. Forward kwargs the operation accepts are declared as additional keyword-only parameters with defaults; fwd_kwarg_names is derived from this signature.
  • resolve_bwd_args(ctx, grad_output) -> bwd_args_type: rebuilds the backward's inputs from the saved context.
  • setup_context(ctx, args, aux) (optional): decides what to save for backward. The default saves aux. An operation whose backward needs an input or a parameter saves it here from args, because a custom op may not return one of its own inputs.

Custom op implementation (classmethods; read nothing but args, mutate nothing)

  • forward_impl(args) -> (output, aux): the forward. aux is a tuple of fresh tensors produced inside the op that the backward needs (norm statistics, a quantized copy of the input). Inputs and parameters are not aux.
  • forward_fake(args) -> (output, aux): the same over TensorSpec, allocation-free.
  • backward_impl(args) -> (grad_input, *grad_params): the backward. grad_input may be None, meaning "grad_output, unchanged".
  • backward_fake(args): its TensorSpec twin.

The *_impl methods are called directly on the eager path and are the bodies of the registered custom ops on the compiled path, so there is one implementation of the math. Because they are custom-op bodies, they may not mutate their arguments: no clear_tensor_data on inputs or saved tensors, no in-place writes to tensors from an enclosing scope.

Derived by the base class

  • compile_ops: the registered (forward_fn, backward_fn) pair, one registration per class, under the lower-cased class name.
  • fwd_kwarg_names: the extra keyword-only parameters of resolve_fwd_args.
  • compile_unsupported_reason(): None, or why the operation must run its eager implementation. The base class rejects operations without a custom op and quantizers that are not value-opaque (delayed scaling).

Changes

  • register_custom_op now defines an operation's forward and backward as two independent custom ops and returns both, leaving autograd to the caller. The variant that wires autograd itself keeps the old behaviour under register_custom_op_with_autograd. Both are built on a single-op primitive (_register_op returning a _RegisteredOp), so the pair is two calls and a forward-only op is possible later. The autograd-free forward returns a plain (output, aux) tuple.
  • BasicOperation gains the contract above; __init_subclass__ registers the custom op once per class.
  • OperationFuser decides once per group whether to run its operations' custom ops. The group runs its eager implementations when: it holds more than one operation, either pass has a fusion, an operation has extra tensor inputs/outputs, a kwarg is undeclared or not a tensor, or an operation reports compile_unsupported_reason. Only tensor kwargs are accepted because a value kwarg becomes a symbolic scalar on its second value, which the opaque value bundle cannot carry; a 0-d tensor is the way to pass a scalar.

Side effects that had to go

All of them reached outside the higher-order op's scope:

  • OperationContext objects are created in the forward, but the backward is a separate subgraph, so writing to them there mutates an enclosing scope. The backward copies them into its own scope.
  • requires_grad_ on an output: AOTAutograd's functionalization drops it anyway, and autograd marks the outputs of an apply() itself.
  • _do_not_clear on inputs and outputs.

They are gated on being traced, not on using the custom ops. Under fullgraph=True there is no leaving the graph, so an unsupported operation does not "fall back": the pipeline is traced either way and only the choice of implementation changes. The gate reports its reason through warn_compile_eager_fallback, which is safe to call from the traced region.

Testing

test_torch_compile.py gains two test-only operations and five tests:

  • a single-operation group compiles and matches eager (output, input gradient, parameter gradient);
  • a group of two operations is gated onto the eager implementations;
  • a backward-only fusion is gated onto the eager implementations;
  • an operation without a custom op still runs, on its eager implementation, under fullgraph=True;
  • a quantized tensor forward kwarg reaches the op through its custom op and changes between calls without a recompilation, while a value kwarg is gated onto eager.

test_torch_compile.py + test_fusible_ops.py (without grouped / userbuffers cases): 1616 passed, 1039 skipped, 1 xpassed. RTX Ada.

Gated out and untouched: multi-operation groups, fused operations, operations with extra tensor inputs/outputs, grouped operations, userbuffers, delayed scaling, FP8 block scaling.

Checklist:

  • I have read and followed the contributing guidelines
  • The functionality is complete
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

pggPL and others added 11 commits September 4, 2026 12:08
register_custom_op now defines an operation's forward and backward as two
independent two-tier custom ops and hands back both, leaving autograd to the
caller. That is what lets a pipeline-level autograd.Function decide how the
two are wired, and so group the forward and backward passes differently --
which is what ops.OperationFuser does.

The variant that wires autograd itself keeps the old behaviour under
register_custom_op_with_autograd, and is now built on the same registration:
the pair is the primitive, autograd is what the other one adds. About two
thirds of the two bodies were the same code before.

BasicOperation gains the plumbing an operation needs to opt in: declare two
argument containers and implement four compute classmethods, and
__init_subclass__ registers the custom ops while op_forward / op_backward are
written once in the base. compile_unsupported_reason lets an operation say why
it cannot be compiled -- it sits here rather than on the args, as Linear has
it, because in ops/ the compile boundary is the fuser group, not the
operation.

Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
A group whose operations declare their compute halves now runs through their
custom ops under torch.compile(fullgraph=True). The pipeline-level
autograd.Function is traced as a higher-order op, which is what will later let
its forward and backward walk different op groupings.

Four side effects reached outside the higher-order op's scope and had to go:

- OperationContext objects are created in the forward, but the backward is a
  separate subgraph, so writing to them there mutates an enclosing scope; the
  backward copies them into its own scope instead;
- requires_grad_ on an output, which AOTAutograd's functionalization drops
  anyway -- autograd marks the outputs of an apply() itself;
- _do_not_clear on inputs and outputs.

They are gated on being traced rather than on using the custom ops. Under
fullgraph there is no leaving the graph, so an unsupported operation does not
fall back: the pipeline is traced either way and only the choice of
implementation changes.

The gate reports why a group runs eagerly through warn_compile_eager_fallback,
which is safe to call from the traced region.

Sequential builds its module groups outside the forward pass, since that
constructs nn.Modules.

Tested with a test-only operation, so the fuser's path does not depend on
which real operations happen to declare their halves.

Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
An operation lists the forward kwargs it takes in fwd_kwarg_names. They are
resolved into its args container like any other config, in the traced Python
where Dynamo guards them, so they reach the custom op through the existing
schema -- a value is guarded, a tensor is lifted into the graph, and a quantized
one crosses as its inner buffers.

An undeclared kwarg still sends the whole group to eager. That is not a schema
limitation, as the old message implied: the kwargs that remain are the grouped
operations' preallocated buffers, which the op writes to, and a custom op may
not mutate a tensor from an enclosing scope.

A kwarg carries no gradient. This matches the eager path, where kwargs never
entered the autograd graph either, and is why only read-only ones are accepted.

The fuser test helper now builds a separate model for the eager and the compiled
pass. Previously both shared one model and the eager pass ran first to produce
the reference, so the compiled pass was always traced on a model whose module
groups, fusions and pre_first_fuser_forward had already run. Those paths are now
traced as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
A value kwarg does not survive a second call. The other fields of an args
container are read off the module and are constant across calls, so they are
baked into the graph; a kwarg changes per call, and on the second value Dynamo
hands over a symbolic scalar, which OpaqueValueBundle cannot carry -- it fails
with AsPythonConstantNotImplementedError, not with a graph break. Measured on
int and float alike; specialize_float=True cures only the float, and is global.

The gate now takes tensor kwargs only, so a value sends the group to eager
deterministically instead of failing on its second call. A 0-d tensor is the way
to pass a scalar: it is a graph input, so it recompiles for no value at all.

The test carries a quantized offset that changes on every call and confirms no
recompilation, then adds a value kwarg to cover the gated path. That last call
keeps its offset unquantized on purpose: a gated group runs the eager
implementation, which is traced directly rather than hidden behind a custom op,
and dequantize() graph-breaks there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
The forward/backward pair was the registration primitive, returned as an
_OpPair carrying every object the function had created, because the
autograd-wired variant finished the registration outside it. Forward and
backward are registered almost identically, so the primitive is now one op:
_register_op returns a _RegisteredOp with the plan and the base/wrapper
handles, and the two entry points register the pair as two calls. This also
makes a forward-only op possible later without a placeholder backward.

ForwardResult is gone: the autograd-free forward returns a plain
(output, aux) tuple, symmetric with the backward.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
num_grad_inputs leaves BasicOperation: on the autograd-free path it only
validated the length of backward_compute's result, and a default of 1 was
wrong for every op with a parameter. register_custom_op takes it as an
optional check. fwd_kwarg_names is read off resolve_fwd_args's keyword-only
parameters instead of being declared twice.

The fuser gate now matches the title: a group of several operations runs the
eager implementations. Multi-op groups without fusions were slipping through.

The saved_tensors reset in backward no longer skips compile; the contexts are
copies local to that subgraph, so the write is allowed, and gating it only
suggested a constraint that is not there.

Drop test_te_ops_setup_context_saves_parameter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
op_forward / compiled_op_forward and op_backward / compiled_op_backward
share one body each, parametrized by the compute callable. Registration
moves into _register_compile_ops. The base resolve_fwd_args no longer
takes **kwargs, which the registration forbids on subclasses. Redundant
checks and long comments removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
forward_compute / backward_compute become forward_impl / backward_impl,
matching module/linear.py and register_custom_op's parameters. The fixed
resolve_fwd_args parameters are read off the base signature instead of a
separate constant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
Skip tensor storage cleanup while tracing eager operation bodies. Reject delayed-scaling state in the fuser, including CustomRecipe, before compiled execution can omit its backward scale update.

Add regression coverage for input preservation and built-in/custom delayed-scaling rejection after eager warmup.

Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
@pggPL
pggPL requested a review from cyanguwa as a code owner September 9, 2026 16:08
pggPL and others added 10 commits September 9, 2026 18:42
Remove the test additions from 2f8582f at the requested scope. Keep both production fixes.

Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
Move argument packing, compute dispatch, and context saving into FusibleOperation. Preserve nested custom-op results for extra outputs and gradients, while retaining existing pipeline compile gates.

Validate shared fused interfaces and custom-op graph nodes. Workstation tests: 1620 passed, 1039 skipped, 1 xpassed; full Python lint reports only existing fused_mla_q_uproj diagnostics.

Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
Make register_custom_op accept one argument type, implementation, and fake implementation. Let FusibleOperation call it independently for forward and backward. Preserve nested tensor results through the same adapter in either direction.

Validation: 13 focused compile tests passed; standalone eager/fullgraph op ran without a backward registration; changed-file pylint passed.
Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
Inline forward/backward registration and forward result reconstruction in the autograd-wired adapter. Preserve registration options and gradient validation while removing three single-use helpers.

Validation: 13 focused ops/Linear compile tests passed; changed-file pylint and formatting checks passed.
Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
Remove flatten_in_body and always give wrapper bodies the same quantized-input slot adapter used by their dispatch rules. Already packed storage and ordinary tensors pass through unchanged.

Validation: test_torch_compile.py 126 passed, 46 skipped, 1 xpassed. Six standalone autograd cases passed for ordinary tensors, FP8 wrappers, and bare FP8 storage in eager and Inductor. Pylint and repository Black checks passed.
Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
Replace duplicated scale operations with one affine op and use a fixed fused contract. Compare outputs and random-gradient backward results against native PyTorch autograd. Parameterize FP32/BF16 pipeline cases and assert custom-op graph selection and reuse across tensor kwargs.

Reduce the ops test section from 497 to 297 lines. Use exactly representable BF16 products to avoid eager versus fused-reduction rounding differences. Validation: test_torch_compile.py 128 passed, 46 skipped, 1 xpassed; repository Black and diff checks passed.

Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
…fely

Raise on duplicate custom-op names instead of replacing registered kernels. Save bare quantized storage as buffers and metadata while compiling, then reconstruct a fresh storage object in backward.

Validation: 8 focused regression tests passed; changed-file Black and pylint passed.
Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant