TIRx lowering pipeline#
tvm.compile(mod, target, tir_pipeline="tirx") runs an authored TIRx module
through the tirx pipeline — an ordered sequence of IR passes that turns the
high-level constructs you write (tile ops, TileLayout-typed tensors,
CUDA index calls) into split host + device functions, which the CUDA
backend then renders to source. The pipeline is defined in
python/tvm/tirx/compilation_pipeline.py (tirx_pipeline); this page walks the
passes in order.
Where it sits#
tvm.compile first binds the target, runs the tirx pipeline (the module-level
passes below), then applies finalization passes separately to the host and
device functions, and finally hands each device function to the CUDA code
generator:
authored TIRx ──BindTarget──▶ tirx_pipeline ──▶ host func ──host finalize──▶ C/LLVM
│
└──────────▶ device func ──device finalize──▶ CUDA
The passes#
S-TIR represents scheduled thread placement with ordinary T.parallel loops
and a visible thread_binding annotation. S-TIR normalization removes
unannotated extent-one loops, and LowerThreadBinding converts thread-bound
loops to explicit T.launch_thread regions before generic TIRx transforms.
TIRx source can construct these regions directly. The generic pipeline uses
StmtSimplify for arithmetic simplification and UnrollLoop for inherited
loop unroll policies.
The tirx_pipeline module pass applies this sequence (a few passes are gated by
PassContext config):
# |
Pass |
What it does |
|---|---|---|
1 |
|
the core lowering — see Tile dispatch and tensor layouts below |
2 |
|
statement-level arithmetic simplification (the sym analyzer) |
3 |
|
flattens multi-dimensional |
4 |
|
rewrites |
5 |
|
narrows index/loop |
6 |
|
turns |
7 |
|
unrolls loops marked |
8 |
|
simplify again, now that vectorize/unroll exposed constants |
9 |
|
hoists repeated subexpressions into temporaries (skipped if
|
10 |
|
rewrites |
11 |
|
checks no host-side code directly dereferences device memory (a safety gate) |
12 |
|
marks the single Function as the module entry point |
13 |
|
extracts target-annotated device regions into device functions and
leaves launch calls in the host function; the regions originate from
the explicit |
14 |
|
lowers CUDA IKET instrumentation after host/device splitting |
15 |
|
rewrites the host function to the packed-func ABI (the launcher TVM calls) |
16 |
|
legalizes |
17 |
|
legalizes |
Finalization then runs per function kind:
host:
LowerTVMBuiltin(lowertvm_*builtins),LowerIntrin(target-specific intrinsics)device:
StmtSimplify,LowerIntrin
Tile dispatch and tensor layouts#
The generic pipeline explicitly composes two independently usable passes:
Sequential([ TileDispatch(), LowerTensorLayouts() ])
``TileDispatch`` replaces every tensor
Evaluate(Call)with the body emitted by its instruction lowerer, including delayed mathematical composites as described in Tensor Instruction Lowering. In the same pass it convertsdevice_entryto adevice_scoperegion retaining launch values as operands, and lowers the finite CUDA index calls inside ordinaryBindstatements.``LowerTensorLayouts`` then runs the
LayoutApplier: it resolves everyTileLayout-typed tensor access into concrete physical address arithmetic (addr = data + layout.apply(coord, shape=shape)["m"], in elements), flattens the tensors, and accounts for the layout’s memory origin exactly once in the resulting view or its access indices.
A backend can pair TileDispatch with its own layout lowering, such as
Trainium’s LowerTrainiumLayout. The generic pipeline inserts PrintIR
between dispatch and layout lowering when TVM_TIRX_PRINT_AFTER_DISPATCH_OPS
is set.
After both passes the module remains a tvm.tirx.Function, but contains no
tile ops or TileLayout indirection, and CUDA indices read hardware coordinates. Later TIRx passes lower the remaining opaque constructs and
the target code generators consume tirx::Function directly; there is no
conversion to the separate tvm.tir object model.
A worked example#
Take a one-line scale kernel:
@Tx.function
def scale(A: Tx.Tensor((256,), "float32"), B: Tx.Tensor((256,), "float32")):
Tx.device_entry(launch=Tx.cuda.LaunchConfig(grid=(1,), block=(256,)))
bx = Tx.cuda.block_idx("x")
tx = Tx.cuda.thread_idx("x")
B[tx] = A[tx] * Tx.float32(2.0)
After both passes the layout is applied and the device region retains its launch configuration independently of the index bindings:
with Tx.region("tirx.device_scope", [1, 1, 1, 256, 1, 1], attrs={
"cuda.launch_fields": ["grid.x", "grid.y", "grid.z", "block.x", "block.y", "block.z"],
"cuda.kernel_attrs": {},
}):
bx: Tx.let = 0
tx: Tx.let = Tx.cuda.thread_idx("x")
B_1[tx] = A_1[tx] * Tx.float32(2.0)
After ``SplitHostDevice`` and the later ``MakePackedAPI`` pass the one function has become two — a host launcher and a device kernel:
@I.ir_module
class Module:
def main(...): # host: packed-API launcher (computes the grid/block, launches)
...
def scale_kernel(...): # device: the __global__ body, run on the GPU
The CUDA backend then renders scale_kernel to the __global__ function
(B_ptr[threadIdx.x] = A_ptr[threadIdx.x] * 2.0f).
Reproduce it yourself#
You can run any prefix of the pipeline by hand to inspect a stage — this is how the IR snippets across these docs were produced:
from tvm.tirx import transform as TT
target = tvm.target.Target("cuda")
mod = TT.BindTarget(target.with_host("llvm"))(tvm.IRModule({"main": scale}))
mod = TT.TileDispatch()(mod) # tile ops dispatched
mod = TT.LowerTensorLayouts()(mod) # layouts applied
print(mod.script()) # inspect the lowered TIRx IR
Or compile the whole module and read the generated CUDA:
exe = tvm.compile(tvm.IRModule({"main": scale}), target=target, tir_pipeline="tirx")
print(exe.mod.imports[0].inspect_source())