| name | compile-trace-inductor |
| description | Debug PyTorch Inductor compiler backend - IR lowering, scheduler/fusion, loopbody ops, and Triton/C++ codegen. Covers TORCH_LOGS for fusion/schedule/ir_post_fusion/output_code, config.trace.enabled for IR dumps, interpreting Inductor IR nodes (Pointwise/Reduction/etc), fusion decisions, kernel generation, and performance optimization. Load after compile-bisect indicates backend='inductor'. |
Tracing Inductor Stage - Lowering Through Codegen
Complete guide to tracing PyTorch Inductor: IR lowering, scheduler, fusion, loopbody, and Triton codegen.
Table of Contents
- Stage Overview
- Inductor Pipeline Summary
- Stage 1: Inductor Lowering
- Stage 2: Fusion & Scheduling
- Stage 3: LoopBody Creation
- Stage 4: Triton Codegen
- Stage 5: Triton Compiler
- Stage 6: Execution
- IR Level Comparison
- Debugging Workflows
Stage Overview
Inductor = PyTorch's deep learning compiler backend for torch.compile
What it does:
- Lowers ATen ops to loop-level IR
- Fuses operations into efficient kernels
- Generates Triton (GPU) or C++ (CPU) code
- Compiles to machine code
For previous stages:
- Dynamo capture: See
compile-trace-dynamo skill
- AOT Autograd: See
compile-trace-aot skill
Key Location: torch/_inductor/
Inductor Pipeline Summary
Inductor receives: FX graph with ATen ops (from Dynamo or AOT Autograd)
Inductor produces: Compiled GPU/CPU kernels
ATen FX Graph โ Lowering โ Scheduler โ LoopBody โ Codegen โ Triton โ Execution
(IR Nodes) (Wrappers) (ops.*) (Triton) (PTX/CUBIN)
Inductor Stages:
- Lowering: ATen ops โ Inductor IR nodes (Pointwise, Reduction, etc.)
- Scheduling: IR nodes โ SchedulerNode wrappers โ Fusion decisions
- LoopBody: Fused nodes โ ops.load/store/index_expr operations
- Codegen: ops.* โ Triton/C++ source code
- Compilation: Triton โ PTX โ CUBIN (executable)
Key Insight: Two-level IR design:
- IR nodes (Pointwise, Reduction) define WHAT to compute
- SchedulerNode wrappers define HOW and WHEN to compute
Stage 1: Inductor Lowering
Location: torch/_inductor/lowering.py, torch/_inductor/ir.py
What Happens:
- Lowers aten ops to Inductor IR nodes
- Creates Buffer, Pointwise, Reduction nodes
- Each node contains an
inner_fn that defines computation
Example Input (from Stage 2):
%arange = torch.ops.aten.arange.start_step(0, 11, ...)
%mul = torch.ops.aten.mul.Tensor(%arange, 1000000000)
Example Output (Inductor IR):
buf0 = Pointwise(
device=device('cuda:0'),
dtype=torch.int64,
inner_fn=lambda index: ops.index_expr(
index[0],
torch.int64
),
ranges=[11]
)
buf1 = Pointwise(
device=device('cuda:0'),
dtype=torch.int64,
inner_fn=lambda index: ops.mul(
ops.load('buf0', index[0]),
1000000000
),
ranges=[11]
)
How to View:
import torch._inductor.config as config
config.trace.enabled = True
config.debug = True
Key Concepts:
- Buffer: Represents a tensor buffer
- Pointwise: Element-wise operation
- Reduction: Reduction operation (sum, max, etc.)
- inner_fn: Python function defining the computation
- ranges: Iteration space (e.g., [11] means iterate 0-10)
When to Debug at This Level:
- Missing lowerings (operation not implemented)
- Understanding pre-fusion IR structure
- Decomposition issues
Stage 2: Fusion & Scheduling
Location: torch/_inductor/scheduler.py
What Happens:
- Wraps IR nodes in scheduler data structures
- Analyzes dependencies between operations
- Fuses compatible operations into single kernels
- Determines execution order and kernel launch parameters
Two-Level Design
The scheduler creates wrapper objects around IR nodes to track dependencies, fusion opportunities, and execution state:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Scheduler Wrappers (metadata) โ
โ SchedulerNode, SchedulerBuffer, Fused... โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ wraps โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ IR Nodes (computation semantics) โ
โ Pointwise, Reduction, ComputedBuffer โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Why this separation?
- IR nodes define WHAT to compute (semantics)
- Scheduler wrappers define HOW and WHEN to compute (optimization)
- IR nodes remain immutable; scheduler creates new fusion wrappers
SchedulerBuffer - Tracks Buffer Lifetime
Definition (simplified):
@dataclasses.dataclass
class SchedulerBuffer:
scheduler: Scheduler
node: ir.Buffer
defining_op: BaseSchedulerNode
users: list[NodeUser]
mpi_buffer: MemoryPlanningInfo
Purpose:
- Tracks which operation creates a buffer
- Tracks which operations use the buffer
- Enables buffer lifetime analysis and memory reuse
Example:
SchedulerBuffer(
node=buf0,
defining_op=snode_relu,
users=[
NodeUser(node=snode_add)
]
)
BaseSchedulerNode - Base Class
Definition (key fields):
class BaseSchedulerNode:
node: ir.Operation | None
outputs: list[SchedulerBuffer]
read_writes: ReadWrites
unmet_dependencies: OrderedSet[Dep]
ancestors: OrderedSet[str]
min_order: int
max_order: int
Subclasses:
SchedulerNode - Wraps single IR operation
FusedSchedulerNode - Represents multiple fused operations
ExternKernelSchedulerNode - External operations (matmul, conv)
SchedulerNode - Wraps Single IR Operation
Definition (key fields):
class SchedulerNode(BaseSchedulerNode):
node: ir.ComputedBuffer | ir.TemplateBuffer
_sizes: tuple[Sequence[sympy.Expr], ...]
_body: LoopBody
Example - Wrapping x.relu():
buf0 = ir.ComputedBuffer(
name="buf0",
data=ir.Pointwise(
inner_fn=lambda i: ops.maximum(ops.load("x", i), 0.0),
ranges=[100]
)
)
snode_relu = SchedulerNode(
scheduler=scheduler,
node=buf0,
_sizes=([100],),
outputs=[
SchedulerBuffer(
node=buf0,
defining_op=snode_relu,
users=[snode_add]
)
],
read_writes=ReadWrites(
reads={Dep("x")},
writes={Dep("buf0")}
),
unmet_dependencies={Dep("x")},
group=(cuda:0, ((100,),))
)
FusedSchedulerNode - Represents Fused Operations
Definition:
class FusedSchedulerNode(BaseSchedulerNode):
"""
Combines multiple operations into a single kernel.
Maintains union of constituent nodes' dependencies.
"""
snodes: list[BaseSchedulerNode]
Example - Fusing x.relu().add(1):
snode_relu = SchedulerNode(node=buf0_relu, ...)
snode_add = SchedulerNode(node=buf1_add, ...)
fused = FusedSchedulerNode(
scheduler=scheduler,
snodes=[snode_relu, snode_add],
outputs=[
SchedulerBuffer(
node=buf1,
defining_op=fused,
users=[]
)
],
read_writes=ReadWrites(
reads={Dep("x")},
writes={Dep("buf0"), Dep("buf1")}
),
unmet_dependencies={Dep("x")},
)
Fusion Decision Process
Step 1: Group Compatible Operations
Operations can fuse if they have:
- Same device (cuda:0)
- Same grouping key (iteration space structure)
- Producer-consumer or consumer-consumer relationship
- No dependency cycles
Step 2: Score Fusion Opportunities
score = (
memory_saved
- locality_penalty
)
Step 3: Create FusedSchedulerNode
Merge compatible operations into FusedSchedulerNode while maintaining correct dependencies.
Complete Example: x.relu().add(1).sum()
Stage 4 Output (Inductor IR):
buf0 = ir.ComputedBuffer(
name="buf0",
data=ir.Pointwise(
inner_fn=lambda idx: ops.maximum(ops.load("x", idx), 0.0),
ranges=[10, 100]
)
)
buf1 = ir.ComputedBuffer(
name="buf1",
data=ir.Pointwise(
inner_fn=lambda idx: ops.add(ops.load("buf0", idx), 1.0),
ranges=[10, 100]
)
)
buf2 = ir.ComputedBuffer(
name="buf2",
data=ir.Reduction(
inner_fn=lambda outer, reduction: ops.load("buf1", outer*100 + reduction),
ranges=[10],
reduction_ranges=[100],
reduction_type="sum"
)
)
Stage 5 Step 1 - Create SchedulerNodes:
snode0 = SchedulerNode(node=buf0, group=(cuda:0, ((10, 100),)))
snode1 = SchedulerNode(node=buf1, group=(cuda:0, ((10, 100),)))
snode2 = SchedulerNode(node=buf2, group=(cuda:0, ((10,), (100,))))
buf0_buffer = SchedulerBuffer(
node=buf0,
defining_op=snode0,
users=[snode1]
)
buf1_buffer = SchedulerBuffer(
node=buf1,
defining_op=snode1,
users=[snode2]
)
Stage 5 Step 2 - Fusion Analysis:
Can fuse snode0 + snode1?
โ Same device: cuda:0
โ Same group: ((10, 100),)
โ Producer-consumer: buf0 โ buf1
โ No cycles
โ YES, create FusedSchedulerNode
Can fuse with snode2?
โ Different group: ((10,), (100,)) - has reduction dimension
โ NO, keep separate
Stage 5 Step 3 - Create Fused Node:
fused_pw = FusedSchedulerNode(
snodes=[snode0, snode1],
outputs=[SchedulerBuffer(node=buf1, users=[snode2])]
)
How to View:
TORCH_LOGS="ir_pre_fusion" python script.py
TORCH_LOGS="fusion,schedule" python script.py
TORCH_LOGS="ir_pre_fusion,fusion,schedule" python script.py
Example Console Output:
FusionDecision: buf0 (Pointwise) <- producer
FusionDecision: buf1 (Pointwise) <- consumer
โ Ranges match: [10, 100]
โ Vertical fusion (producer-consumer)
โ Fused into 1 kernel
FusionDecision: fused_pw (Fused Pointwise) <- producer
FusionDecision: buf2 (Reduction) <- consumer
โ Cannot fuse: Different iteration structure
โ Separate kernel
Fusion Types
Vertical Fusion (Producer-Consumer):
x.relu()
.add(1)
Horizontal Fusion (Consumer-Consumer):
a = x.relu()
b = a.add(1)
c = a.mul(2)
Reduction Fusion:
x.sum(dim=-1)
.add(bias)
Fusion Constraints
Required for fusion:
- Same device
- Compatible iteration spaces
- No dependency cycles
- Satisfies memory constraints
Blockers:
- Cross-device operations
- Mismatched iteration ranges (unless broadcast)
- Circular dependencies
- Extern kernel boundaries (matmul, conv)
When to Debug at This Level
- Operations not fusing as expected
- Understanding why fusion was/wasn't applied
- Performance regression from missed fusion
- Too many kernels launched
- Memory usage from intermediate buffers
Stage 3: LoopBody Creation
Location: torch/_inductor/loop_body.py
What Happens:
- Traces
inner_fn into FX graph (Inductor IR level, NOT aten)
- Creates
ops.load, ops.store, ops.index_expr nodes
- This is the graph that gets code-generated
Example Input (from Stage 5):
def inner_fn(index):
idx_val = ops.index_expr(index[0], torch.int64)
result = ops.mul(idx_val, 1000000000)
return result
Example Output (LoopBody FX Graph):
class triton_poi_fused_arange_mul_0_loop_body:
var_ranges = {p0: 11}
index0 = p0
index1 = 1000000000*p0
def body(self, ops):
get_index = self.get_index('index1')
index_expr = ops.index_expr(get_index, torch.int64)
get_index_2 = self.get_index('index0')
store = ops.store('buf0', get_index_2, index_expr, None)
How to View:
TORCH_LOGS="ir_post_fusion" python script.py
Key Operations:
ops.load(buffer, index):
- Loads from
buffer at index
index: Used for memory addressing
- Result: The loaded value
ops.store(buffer, index, value, mode):
- Stores
value to buffer at index
index: Memory address (argument position 2)
value: Data to store (argument position 3)
mode: Optional (None, 'atomic_add', etc.)
ops.index_expr(expr, dtype):
- Converts index expression to value with dtype
- Critical for when iteration variables are used in computations
- Example:
ops.index_expr(p0, torch.int64) โ iteration var as int64 value
Arithmetic ops: ops.add, ops.mul, ops.maximum, etc.
- Standard arithmetic operations
- All arguments are values (not indices)
When to Debug at This Level:
- Dtype handling issues
- Understanding operation semantics
- Data flow analysis
- Index vs value usage
Stage 4: Triton Codegen
Location: torch/_inductor/codegen/triton.py
What Happens:
- Interprets LoopBody FX graph via
InterpreterShim
- Generates Triton kernel code (string)
- Each
ops.* call becomes Triton code
- Applies optimizations (tiling, vectorization)
Example Input (from Stage 6):
get_index = self.get_index('index1')
index_expr = ops.index_expr(get_index, int64)
store = ops.store('buf0', index_pos, index_expr, None)
Example Output (Generated Triton Code):
import triton
import triton.language as tl
@triton.jit
def triton_poi_fused_arange_mul_0(out_ptr0, xnumel, XBLOCK: tl.constexpr):
xnumel = 11
xoffset = tl.program_id(0) * XBLOCK
xindex = xoffset + tl.arange(0, XBLOCK)
xmask = xindex < xnumel
x0 = xindex
tmp0 = 1000000000*x0
tl.store(out_ptr0 + x0, tmp0, xmask)
How to View:
TORCH_LOGS="output_code" python script.py
Key Triton Concepts:
Index Variables:
xindex, x0, x1, etc. - Iteration variables
r0, r1, etc. - Reduction variables
- Named to match iteration dimension
Tiling Parameters:
XBLOCK - Tile size (auto-tuned)
tl.program_id(0) - Block/workgroup ID
tl.arange(0, XBLOCK) - Thread indices within block
Masking:
xmask = xindex < xnumel - Bounds checking
- Ensures threads don't access out-of-bounds
Memory Operations:
tl.load(ptr + offset, mask) - Load from memory
tl.store(ptr + offset, value, mask) - Store to memory
When to Debug at This Level:
- Performance issues
- Understanding generated kernel structure
- Memory access patterns
- Tiling/vectorization problems
Stage 5: Triton Compiler
What Happens:
- Compiles Triton code to PTX (NVIDIA assembly)
- PTX compiled to CUBIN (binary) via
ptxas
- Binary cached for fast warmup on reruns
Example Input (from Stage 7):
@triton.jit
def triton_poi_fused_arange_mul_0(...):
x0 = xindex
tmp0 = 1000000000*x0
tl.store(out_ptr0 + x0, tmp0, xmask)
Example Output:
PTX (NVIDIA assembly) โ CUBIN (binary)
Cached at: /tmp/triton_cache/...
When to Debug at This Level:
- Triton compilation failures
- PTX/CUBIN generation issues
- Usually handled by Triton itself
Stage 6: Execution
What Happens:
- Wrapper code allocates tensors
- Launches compiled kernel on GPU
- Returns result to Python
Example:
buf0 = torch.empty([11], dtype=torch.int64, device='cuda')
triton_poi_fused_arange_mul_0[grid](buf0, 11, XBLOCK=256)
return buf0
Result:
tensor([0, 1000000000, 2000000000, ..., 10000000000], device='cuda:0')
IR Level Comparison
| Level | Language | Example | When to Use |
|---|
| Python | Python | torch.arange(0, 11) | User code |
| FX/Aten | FX nodes | aten.arange.start_step(...) | Graph breaks |
| Inductor IR | IR nodes | Pointwise(inner_fn=...) | Pre-fusion, lowering |
| Scheduler | Wrappers | SchedulerNode(node=buf0) | Fusion analysis |
| Fused Scheduler | Wrappers | FusedSchedulerNode(snodes=[...]) | Fusion decisions |
| LoopBody | ops.* | ops.index_expr(...) | Dtype/semantics |
| Triton | Triton | tl.store(...) | Performance |
| PTX | Assembly | st.global.u64 [%rd1], %rd2 | Low-level debug |
Common Patterns Across Stages
Pattern 1: Simple Pointwise Operation
x.relu()
%relu = aten.relu.default(%x)
buf = Pointwise(inner_fn=lambda idx: ops.maximum(ops.load('x', idx), 0.0))
tmp0 = ops.load('x', xindex)
tmp1 = ops.maximum(tmp0, 0.0)
ops.store('buf', xindex, tmp1)
tmp0 = tl.load(x_ptr + xindex, xmask)
tmp1 = tl.maximum(tmp0, 0.0)
tl.store(out_ptr + xindex, tmp1, xmask)
Pattern 2: Reduction Operation
x.sum(dim=-1)
%sum = aten.sum.dim_IntList(%x, [-1])
buf = Reduction(
inner_fn=lambda idx, reduction_idx: ops.add(
ops.load('x', idx + reduction_idx),
reduction_accumulator
)
)
tmp0 = ops.load('x', xindex + r0)
tmp1 = ops.add(accumulator, tmp0)
for r0 in range(0, rmax):
tmp0 = tl.load(x_ptr + xindex*stride + r0)
tmp1 = tmp1 + tmp0
tl.store(out_ptr + xindex, tmp1)
Key Takeaways
-
Each stage serves a specific purpose: Dynamo captures, AOT transforms, Inductor lowers, Scheduler fuses, LoopBody defines, Codegen generates
-
Two-level design (IR + Scheduler):
- IR nodes (Pointwise, Reduction) define WHAT to compute
- Scheduler wrappers (SchedulerNode, FusedSchedulerNode) define HOW and WHEN
- This separation enables fusion without modifying IR nodes
-
Multiple IR levels: Understanding which IR level is relevant for your issue is critical
-
Progressive lowering: Each stage takes higher-level representation and lowers it
-
FX graphs appear twice:
- Dynamo FX graph (aten ops)
- LoopBody FX graph (ops.* operations)
These are different!
-
Index vs Value semantics: In LoopBody, distinguish between:
- Indices (for memory addressing)
- Values (for computation)
-
Fusion is key: Most performance comes from fusing operations into fewer kernels
- SchedulerBuffer tracks buffer lifetime and users
- FusedSchedulerNode combines multiple operations
- Intermediate buffers eliminated in fused kernels
-
Scheduler operates between lowering and codegen: It's the bridge that decides which IR nodes get combined before LoopBody generation
Debugging Workflows
Issue โ Inductor Stage:
- Missing lowerings โ Inductor Lowering (Stage 1)
- Fusion not happening โ Scheduler (Stage 2)
- Why specific fusion decision โ Scheduler fusion logs (Stage 2)
- Wrong dtype/semantics โ LoopBody (Stage 3)
- Slow kernel performance โ Triton Codegen (Stage 4)
- Compilation errors โ Triton Compiler (Stage 5)
For issues before Inductor:
- Graph breaks โ See
compile-trace-dynamo skill
- AOT Autograd issues โ See
compile-trace-aot skill
General approach:
- Identify symptom
- Choose relevant IR level from pipeline
- Enable appropriate logging (
TORCH_LOGS, config.trace.enabled)
- Analyze IR output files or console logs
- Trace transformation through pipeline stages
- Understand what changed between stages