Skip to content

[PyTorch] Declare fusible ops as custom ops + HOP proof of concept - #28

Closed
pggPL wants to merge 11 commits into
linear_compile_on_mainfrom
ops_custom_ops_on_main
Closed

[PyTorch] Declare fusible ops as custom ops + HOP proof of concept#28
pggPL wants to merge 11 commits into
linear_compile_on_mainfrom
ops_custom_ops_on_main

Conversation

@pggPL

@pggPL pggPL commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Description

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

The plan is to 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 does the per-operation half of that: make operations declarable as custom ops, with
tests, plus a proof of concept for the pipeline-level HOP. The fuser is untouched, so nothing in the
eager path changes yet.

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

Changes

  • register_op_halves (dynamo/custom_op.py): registers an operation's forward and backward as two
    independent two-tier custom ops and returns callables for both, without registering autograd. The
    caller wires the halves into its own autograd.Function, so the forward and backward passes can be
    grouped differently -- which is what OperationFuser does. Reuses the existing
    schema/adapter/TensorSpec machinery; register_custom_op is untouched.
  • Declaring an operation. BasicOperation gained the plumbing, so an operation only declares its two
    argument containers and implements four compute classmethods (forward_compute, forward_fake,
    backward_compute, backward_fake) plus resolve_fwd_args / resolve_bwd_args.
    __init_subclass__ registers the custom ops; op_forward / op_backward are written once in the base.
  • Bias and the activation family converted. The activations share one implementation and dispatch
    to their per-class kernel through cls, so all ten subclasses get their own registered op without a
    factory. They are also the first operations here that hand back a quantized tensor: with a
    next-operation input quantizer the kernel writes FP8 directly.
  • compile_unsupported_reason on BasicOperation: refuses an operation without the compute halves,
    and any quantizer torch.compile cannot specialize on. It sits on the operation rather than on the args
    (where Linear keeps it) because in ops/ the compile boundary is the fuser group, not the operation.
  • Tests. test_ops_custom_ops.py is driven by a single _OP_CASES list -- adding an operation means
    adding one entry -- and checks fake-vs-real conformance, numerics against eager, and fullgraph=True,
    each with and without an FP8 output. test_ops_hop_poc.py is the proof of concept.

Proof of concept for the higher-order op

test_ops_hop_poc.py checks the assumption the whole approach rests on, before the fuser is touched.
A pipeline-level autograd.Function running Bias -> GELU -> Bias through the registered custom ops:

  • traces as autograd_function_apply, so both its forward and its backward end up in the graph;
  • walks a different op grouping in the backward than in the forward, which an operation with per-op
    autograd could not express;
  • creates context objects inside the forward and reads them in the backward, so they never cross an
    op schema;
  • carries an FP8 tensor from one operation to the next inside the traced region.

Forward and backward match eager under fullgraph=True.

Notes on two contracts that the implementation had to respect

A custom op may not return one of its own inputs. A backward that passes its gradient through therefore
returns None for that slot and the caller substitutes; cloning would cost a full-size copy on the
common path. The same trick does not work for saved tensors: the fake cannot see strides, so it cannot
predict whether contiguous() will be a no-op, and the resulting metadata mismatch surfaces as an
inductor assertion. Those rules have to be static, which is why the activations keep their input only
when cache_quantized_input is set.

An FP8 output crosses the boundary as its inner buffers (_data, _scale_inv) and is rebuilt on the far
side from the fake's TensorSpec; the subclass itself never crosses the schema.

Testing

test_fusible_ops.py, test_ops_custom_ops.py and test_ops_hop_poc.py: 1574 passed, 1163 skipped.
Lint: 10.00/10. Run on an RTX Ada workstation, so the FP8 coverage is current scaling only.

Not covered yet, and gated: BasicLinear and the fused operation classes, 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 added 11 commits August 5, 2026 18:24
… glue

Registers an op's forward and backward as two independent two-tier custom
ops and returns callables for both, leaving autograd to the caller. This
lets a pipeline-level autograd.Function (which Dynamo traces as a
higher-order op) group the forward and backward passes differently, as
ops.OperationFuser does.

Reuses the existing schema/adapter/TensorSpec machinery; register_custom_op
is untouched.

Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
Splits Bias into config resolution, pure compute and ctx saving, then
registers the compute halves via register_op_halves. resolve_fwd_args reads
module config and global FP8 state, so it stays in the traced region where
Dynamo guards those reads; the impls take everything as arguments and never
touch self.

op_forward/op_backward keep their signatures and drive the same impls, so
the eager path is unchanged.

Adds tests/pytorch/test_ops_custom_ops.py with the fake-vs-real conformance
harness every subsequent op will reuse.

Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
A custom op may not return one of its own inputs, which Bias did whenever
the grad input is grad_output unchanged. Cloning would cost a full-size
copy on the common unquantized path, so the impl returns None for that slot
and the caller substitutes grad_output.

Pass-through grads are common (Identity, Reshape, ConstantScale, Quantize),
so this is the convention those ops will follow too.

Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
Each activation subclass registers its own pair of compute halves, keyed by
the class: the kernel pair is fixed by the class and a callable has no place
in an op schema, so per-class registration keeps the op registry bounded by
the op zoo rather than by the model.

The subclass dispatch methods become staticmethods, which is what 'the
compute half must not depend on an instance' means in practice; existing
self._activation_forward_impl(...) call sites are unaffected.

Activations are the first ops here that hand back a quantized tensor: with a
next-operation input quantizer the kernel writes FP8 directly, so an FP8
tensor crosses the custom-op boundary instead of being dequantized at it.

The forward's saved tensor is the input itself whenever dequantize and
contiguous are both no-ops, so it follows the same None convention as the
Bias backward. The conformance harness now checks the invariant the compiled
path actually relies on -- the flat Tensor[] slot layout, via the framework's
own helpers -- rather than approximating it.

Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
Checks the assumption the whole approach rests on, before the fuser is
touched: Dynamo traces a pipeline-level autograd.Function as the
autograd_function_apply higher-order op, so the forward and the backward can
walk different op groupings -- which is what OperationFuser does, and what an
op with per-op autograd could not express.

The pipeline runs Bias -> GELU -> Bias through the real registered custom
ops, creates context objects inside the forward and reads them in the
backward, and carries an FP8 tensor from one op to the next inside the traced
region. Forward and backward match eager under fullgraph=True.

Also makes the activation's saved tensor a static choice. It used to be
returned as None when dequantize and contiguous were both no-ops, but the
fake cannot see strides and so cannot predict that; the resulting metadata
mismatch surfaced as an inductor assertion on the sentinel's rank. The op now
keeps the input only when cache_quantized_input is set, and the backward
rebuilds its input from the operation's input otherwise.

Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
The checks were written per operation, so each one restated the same three
questions in its own shape. They are now driven by a single _OP_CASES list:
adding an operation means adding one entry, and every operation gets the same
fake-conformance, matches-eager and fullgraph checks, each run with and
without an FP8 output.

resolve_fwd_args takes the same arguments on every operation to make that
possible; Bias accepts next_op_input_quantizer and ignores it, as its
op_forward already did.

Compile cases reset Dynamo between runs: the compiled helpers are closures
over one operation, so the parametrized runs otherwise walk into the
recompilation limit and fall back to eager, which fullgraph=True rejects.

Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
The impls and fakes lived outside the class they belong to -- module-level
functions for Bias, closures from a factory for the activations -- and each
operation then repeated the same op_forward/op_backward plumbing.

They are now classmethods on the operation, and BasicOperation does the rest:
__init_subclass__ registers the custom ops for any operation that declares
fwd_args_type/bwd_args_type, and op_forward/op_backward are written once in
the base. Declaring an operation means two dataclasses, four compute halves,
resolve_fwd_args and resolve_bwd_args -- no registration or plumbing.

Classmethods rather than static functions because the binding is load
bearing: the whole activation family shares one implementation and dispatches
to its per-class kernel through cls, so it needs no factory.

Two hooks cover what genuinely differs: saved_for_backward, for an operation
whose backward needs its input but whose forward produces no distinct tensor
for it, and resolve_bwd_args, since each backward container names its own
fields.

Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
Linear puts compile_unsupported_reason on its forward args, because the
module is the compile boundary and the check sits next to the choice between
the custom op and eager. In ops/ the boundary is the fuser group, not the
operation, so the check belongs elsewhere:

- a pipeline compiles as a whole, so the decision is one per group, aggregated
  over its operations;
- the args only exist after resolve_fwd_args, while most reasons are known
  earlier and more cheaply from the operation itself;
- recipe-level limits belong to whoever reads the recipe, which is the fuser.

So the hook goes on BasicOperation. The default refuses an operation without
the compute halves, and any quantizer torch.compile cannot specialize on --
delayed scaling holds live scale/amax tensors, so baking its quantizer into
the graph would silently freeze stale scales.

__init_subclass__ now also checks that the argument containers are
dataclasses, which is what the framework actually requires of them: the op
schema is built from their fields.

Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
register_op_halves had grown as a copy of register_custom_op's body minus the
autograd wiring -- about two thirds of it was the same code. Both now build on
_register_two_tier_pair, which owns everything they genuinely share: schemas
from the argument containers, the base kernels, the wrapper ops that flatten
QuantizedTensor subclass inputs, and the passthrough registrations. What is
left in each entry point is only what they differ on.

Renamed to register_custom_op_without_autograd. 'Halves' said nothing about
why the function exists; the one thing a reader needs is that, unlike
register_custom_op, it leaves autograd to the caller.

Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
register_custom_op_without_autograd named a thing by what it lacked, which had
it backwards: after the two entry points were factored onto a shared
registration, the forward/backward pair is the primitive and autograd is what
the other one adds on top.

So the pair takes the plain name and the wired variant becomes
register_custom_op_with_autograd. The file follows the same order, primitive
first, and the pair's docstring no longer defines itself by negation.

Signed-off-by: Pawel Gadzinski <pgadzinski@nvidia.com>
Wires the fuser's compiled path end to end, so a group whose operations
declare their compute halves 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 lets its forward and backward walk
different op groupings later on.

Four things blocked tracing, all of them side effects reaching 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 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;
- warnings.warn from the gate, which is not traceable, so the reason is now
  reported from the eager path only.

These 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, which means the tracing constraints hold on both
paths.

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 be converted.

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

pggPL commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Split into #29 (fuser support for a single-operation group, no operations converted) and #30 (Bias and the activations).

@pggPL pggPL closed this Aug 5, 2026
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