the pytorch path · 0/12
start the path

the PyTorch path · Graphs · lesson 03 of 4

ATen, core ATen, prims

One call to gelu is one node, or one node, or five, depending on which vocabulary you ask it to speak. The rewriting between those vocabularies is what decompositions are, and you can watch it happen on one line of code.

the goal Given an op, say which of the three vocabularies it belongs to, predict whether the core decomposition table will rewrite it and into what, and read a decomposed graph well enough to say why the rewrite left the node count higher but the signature count lower.

mastery work · this chapter0/3
  1. go →
  2. go →
manual items are your word; auto items complete from your streaks, labs, and can-you ticks · stored in your browser only
§ 01

One function, three vocabularies deep

make_fx traces a function into an FX graph and takes a decomposition table as an argument, which makes it the cleanest instrument for this. Trace torch.nn.functional.gelu three times, changing only the table, and you get three graphs of the same function at three depths.

Plain, it is one node: aten.gelu.default. With the core table, it is still one node, because gelu is already in the core set and the table has no rule that touches it. Under TorchRefsMode, which routes every torch call through the reference implementations in torch._refs, it becomes five prims nodes and the arithmetic is finally visible: multiply by a half, multiply by one over the square root of two, take the error function, add one, multiply the two halves together.

The constant 0.7071067811865476 in that printout is the whole point of the bottom rung. At the prims level nothing is named after a neural network idea any more. There are only elementwise operations on shaped buffers, which is a small enough vocabulary that a new hardware backend can implement all of it.

run it (verified, torch 2.2.2 CPU): the same gelu at three depths
import torch
from torch.fx.experimental.proxy_tensor import make_fx
from torch._decomp import core_aten_decompositions
from torch._prims.context import TorchRefsMode

def f(x):
    return torch.nn.functional.gelu(x)

x = torch.randn(3)
print("aten:")
print(make_fx(f)(x).code.strip())
print("core aten:")
print(make_fx(f, decomposition_table=core_aten_decompositions())(x).code.strip())
print("prims:")
with TorchRefsMode():
    print(make_fx(f)(x).code.strip())
print("core_aten_decompositions entries:", len(core_aten_decompositions()))
§ 02

Counting the three surfaces

The output of that script, verbatim, is below. Read the last line first: the core decomposition table on this build holds 373 entries, which is the number of rewrite rules, not the size of the core set itself.

Set that against the numbers from the last lesson. There are 817 ATen packets carrying 2124 overloads, and 132 prims packets. A backend author facing 2124 signatures has an impossible job; facing 132 has a finite one, at the cost of writing every fused kernel out of pieces. Core ATen sits between the two, small enough to implement and still coarse enough to keep the fusionSeveral ops compiled into one kernel so intermediates stay in fast memory instead of round-tripping through HBM. XLA’s central optimization, with an exact limit.taught in /l/xla → opportunities the prims level has already thrown away.

Which rung you want depends on what you are building. A compiler that fuses aggressively wants ops small enough to rearrange. A vendor library with a hand-written attention kernel wants the op left whole so it can pattern-match it. Both of those preferences are expressed by choosing a decomposition table, not by changing the model.

the verbatim output of the script above (verified, torch 2.2.2 CPU)
aten:
def forward(self, x_1):
    gelu = torch.ops.aten.gelu.default(x_1);  x_1 = None
    return gelu
core aten:
def forward(self, x_1):
    gelu = torch.ops.aten.gelu.default(x_1);  x_1 = None
    return gelu
prims:
def forward(self, x_1):
    mul = torch.ops.prims.mul.default(x_1, 0.5)
    mul_1 = torch.ops.prims.mul.default(x_1, 0.7071067811865476);  x_1 = None
    erf = torch.ops.prims.erf.default(mul_1);  mul_1 = None
    add = torch.ops.prims.add.default(erf, 1.0);  erf = None
    mul_2 = torch.ops.prims.mul.default(mul, add);  mul = add = None
    return mul_2
core_aten_decompositions entries: 373
vocabularymeasured sizewhat it is for
torch.ops.aten817 packets, 2124 overloadseverything eager PyTorch can dispatch, including every convenience signature
core ATen373 rewrite rules in core_aten_decompositions()the target a backend is expected to cover; the rules map the rest onto it
torch.ops.prims132 packetsthe reference layer, elementwise and shape primitives with no fusion left in them
the three surfaces, counted on this build (verified, torch 2.2.2 CPU)
§ 03

What the core set actually buys

Four ops through the same comparison show the shape of the boundary better than any list. silu is not core, so the table rewrites it into sigmoid plus a mul. hardswish is not core either, and comes out as add, two clamps, a mul and a div. _softmax and native_layer_norm both survive untouched, because both are in the core set and a backend is expected to implement them.

The fourth case is the interesting one, and it is not about neural network ops at all. nn.functional.linear traces to aten.t.default followed by addmm. Under the core table, the addmm is unchanged and the t becomes aten.permute.default with an explicit [1, 0]. Same node count, same work, one fewer signature to implement.

That is the rule the core set is built on. Decomposition is not simplification and it is not always a rewrite into more nodes. It is a reduction in the number of distinct signatures a backend has to understand, and sometimes the cheapest way to reach it is to replace a convenience op with the general one it is a special case of.

written astraced plainlyunder the core table
F.silu(x)aten.silu.defaultaten.sigmoid.default, aten.mul.Tensor
F.hardswish(x)aten.hardswish.defaultadd.Tensor, clamp, clamp, mul.Tensor, div.Tensor
torch.softmax(x, -1)aten._softmax.defaultunchanged, already core
F.layer_norm(x, (3,))aten.native_layer_norm.defaultunchanged, already core
F.linear(x, w, b)aten.t.default, aten.addmm.defaultaten.permute.default, aten.addmm.default
the same trace with and without core_aten_decompositions (verified, torch 2.2.2 CPU)
§ 04

Decomposing an exported program

You do not have to build the trace by hand to get this. An ExportedProgram carries run_decompositions(), which returns a new program with the table applied, and running it on a linear layer followed by a silu shows both rewrites from the table above happening at once on real graph.

Three things change in that printout and only two of them are decompositions. t becomes permute with an explicit permutation. silu becomes sigmoid and mul, and the addmm node picks up a second user because both of them consume it. The third change is cosmetic: the user input, printed as %l_x_ before, comes back as %arg2_1, because the pass renumbers every placeholder into one sequence.

Chapter 10 says an exported program reaches the bridges decomposed toward a core set of ATen ops. This is the call that does it, and the reason the bridge cares is the same reason a backend author cares: on the other side of the crossing there is a compiler whose op list is finite and does not include silu.

run it (verified, torch 2.2.2 CPU): an exported program before and after run_decompositions()
import torch
import torch.nn as nn

class Net(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc = nn.Linear(3, 4)
    def forward(self, x):
        return torch.nn.functional.silu(self.fc(x))

torch.manual_seed(0)
ep = torch.export.export(Net(), (torch.randn(2, 3),))
print(ep.graph)
print(ep.run_decompositions().graph)
the script and its verbatim output · 32 lines
import torch
import torch.nn as nn

class Net(nn.Module):
    def __init__(self):
        super().__init__()
        self.fc = nn.Linear(3, 4)
    def forward(self, x):
        return torch.nn.functional.silu(self.fc(x))

torch.manual_seed(0)
ep = torch.export.export(Net(), (torch.randn(2, 3),))
print(ep.graph)
print(ep.run_decompositions().graph)

# graph():
#     %arg0_1 : [num_users=1] = placeholder[target=arg0_1]
#     %arg1_1 : [num_users=1] = placeholder[target=arg1_1]
#     %l_x_ : [num_users=1] = placeholder[target=l_x_]
#     %t : [num_users=1] = call_function[target=torch.ops.aten.t.default](args = (%arg0_1,), kwargs = {})
#     %addmm : [num_users=1] = call_function[target=torch.ops.aten.addmm.default](args = (%arg1_1, %l_x_, %t), kwargs = {})
#     %silu : [num_users=1] = call_function[target=torch.ops.aten.silu.default](args = (%addmm,), kwargs = {})
#     return (silu,)
# graph():
#     %arg0_1 : [num_users=1] = placeholder[target=arg0_1]
#     %arg1_1 : [num_users=1] = placeholder[target=arg1_1]
#     %arg2_1 : [num_users=1] = placeholder[target=arg2_1]
#     %permute : [num_users=1] = call_function[target=torch.ops.aten.permute.default](args = (%arg0_1, [1, 0]), kwargs = {})
#     %addmm : [num_users=2] = call_function[target=torch.ops.aten.addmm.default](args = (%arg1_1, %arg2_1, %permute), kwargs = {})
#     %sigmoid : [num_users=1] = call_function[target=torch.ops.aten.sigmoid.default](args = (%addmm,), kwargs = {})
#     %mul : [num_users=1] = call_function[target=torch.ops.aten.mul.Tensor](args = (%addmm, %sigmoid), kwargs = {})
#     return (mul,)
§ 05

Where this lands for a backend author

The jax path solves the same problem with the same move and different names, which is worth noticing because it tells you the problem is structural rather than a PyTorch quirk. There, a client-level op either has an HLO counterpart and survives, or it falls through a table of decomposition patterns into spec ops. The lesson on how a jaxprThe traced program: one equation per primitive in single-assignment form, every shape and dtype stated.taught in /l/jaxpr → becomes HLO works one such table line by line.

The difference is where the table lives. PyTorch's decompositions are written in Python, in torch._decomp and torch._refs, and you can swap the table per compilation. The XLAThe compiler: brilliant at fusing along dataflow edges, structurally unable to change your algorithm. That gap is why kernels exist.taught in /l/xla → equivalent is compiled into the compiler. That is why a torch backend can be selective about which ops it wants left whole, and why the same model can reach two backends with two different op sets from one export.

A practical consequence for reading profiles: when a fused kernel you expected does not appear, check whether the op survived decomposition before you go looking at the codegen. An op that got rewritten into five pieces upstream never reached the fusionSeveral ops compiled into one kernel so intermediates stay in fast memory instead of round-tripping through HBM. XLA’s central optimization, with an exact limit.taught in /l/xla → pass as one thing.

before you move on

Check yourself

01 Why did core_aten_decompositions() leave gelu and softmax alone but rewrite silu?

Because gelu and _softmax are in the core ATen set and silu is not. The table only carries rules for ops outside the set, so membership is what decides whether a node survives.

02 The core table turned aten.t.default into aten.permute.default and the node count did not change. What was the gain?

One fewer signature for a backend to implement. Decomposition reduces the number of distinct ops a backend must cover, which sometimes means replacing a convenience op with the general one rather than expanding it.

03 You want a vendor kernel to match a whole attention op, but your graph arrives already broken into elementwise pieces. Where do you intervene?

At the decomposition table, before the backend sees the graph. Which ops survive is chosen by the table passed to tracing or to run_decompositions, not by anything in the model.

assigned

Readings