TIRx Lowering Pipeline#
tvm.compile(mod, target, tir_pipeline="tirx") takes a TIRx module and
eventually produces two pieces of code: a CPU-side launcher that prepares the
arguments and launches a GPU kernel, and the GPU kernel that performs the
computation. The compiler reaches that result through an ordered series of
passes. Each pass performs a particular transformation, validation, or
annotation on the IR.
The exact pass order is defined in Apache TVM’s python/tvm/tirx/compilation_pipeline.py.
The overall compilation path#
The target identifies the hardware and code-generation backend. The example
below uses CUDA for the device and LLVM for the host. tvm.compile first
attaches that target information to the module and then runs the module-level
tirx pipeline. Once the pipeline has separated the CPU-side host function
from the GPU-side device function, each follows a target-specific finalization
path before code generation:
authored TIRx
│ BindTarget
▼
tirx_pipeline
(SplitHostDevice creates the two paths)
├── host PrimFunc ──host finalization──▶ C/LLVM
└── device PrimFunc ─device finalization─▶ CUDA
A PrimFunc is TIR’s representation of a function. The host PrimFunc above is
the CPU-side launcher, while the device PrimFunc is the GPU kernel.
Finalization refers to the last target-specific transformations performed
before code generation.
Pass order inside tirx_pipeline#
The table lists the 19 pipeline steps in execution order. An ABI is the calling
convention between functions; the ABI passes below adapt ordinary TIR functions
to forms that the runtime can invoke. PassContext holds compiler options:
common-subexpression elimination can be disabled, and it also controls aspects
of vectorization and unrolling.
# |
Category |
Pass |
What it does |
|---|---|---|---|
1 |
TIRx lowering |
|
the core lowering — see Inside LowerTIRx below |
2 |
TIR normalization |
|
merges equivalent thread-axis bindings so each |
3 |
TIR normalization |
|
simplifies arithmetic expressions in the IR |
4 |
TIR normalization |
|
converts thread-binding loops, eliminates unannotated unit loops, and normalizes loop pragmas |
5 |
TIR normalization |
|
flattens the remaining multi-dimensional TIR |
6 |
Compute legalization |
|
when the target lacks native |
7 |
TIR normalization |
|
narrows index expressions and loop variables to 32 bits where provably safe |
8 |
Loop lowering |
|
lowers |
9 |
Loop lowering |
|
unrolls loops marked |
10 |
TIR normalization |
|
simplifies again after vectorization and unrolling expose more constants |
11 |
TIR normalization |
|
hoists repeated subexpressions into temporaries (skipped if
|
12 |
Compute legalization |
|
when the target lacks native |
13 |
Validation and ABI |
|
ensures that host-side code does not directly dereference device memory |
14 |
Validation and ABI |
|
marks the sole function, or the sole externally visible PrimFunc in a multi-function module, as the entry point |
15 |
Validation and ABI |
|
identifies device regions, splits host and device PrimFuncs, and lowers host-to-device calls to the kernel-launch ABI |
16 |
Validation and ABI |
|
removes NVIDIA IKET annotations in normal builds, or lowers them for tracing when IKET is enabled |
17 |
Validation and ABI |
|
rewrites the host function to the packed-function ABI used by the TVM runtime |
18 |
Storage legalization |
|
when the target lacks native |
19 |
Storage legalization |
|
when the target lacks native |
Host and device finalization#
The 19 listed steps form tirx_pipeline. After that module-level pipeline,
tvm.compile runs a different finalization sequence for each function kind:
host:
LowerTVMBuiltin(lowerstvm_*builtins),LowerIntrin(lowers target-specific intrinsics)device:
LowerWarpMemory(lowers warp-scoped buffers to shuffles),StmtSimplify,LowerIntrin
Inside LowerTIRx#
LowerTIRx has two main jobs: choosing concrete implementations for tile-level
operations, and turning logical data layouts into physical memory indices. Its
core transformation is the following two-pass sequence, defined in Apache TVM’s
src/tirx/transform/lower_tirx.cc:
LowerTIRx = Sequential([ TilePrimitiveDispatch, LowerTIRxCleanup ])
``TilePrimitiveDispatch`` chooses concrete implementations for tile operations. TIRx represents operations such as
copy,gemm, andreductionasTilePrimitiveCallnodes; this pass selects a backend implementation for each one. It also turns abstract execution-scope identifiers such asT.cta_idandT.thread_idinto kernel-launch parameters and thread bindings.``LowerTIRxCleanup`` maps logical coordinates to physical indices. It applies supported logical layouts to buffer accesses so later passes can work directly with concrete index expressions.
After LowerTIRx, tile operations have been replaced by their selected
implementations, logical layouts have become physical indices, and abstract
identifiers such as T.cta_id and T.thread_id have become thread
bindings. Thread-binding loops and TIRx-specific loop annotations may still
remain; LowerTIRxOpaque normalizes those structures before
tirx.transform.FlattenBuffer flattens ordinary TIR buffer accesses.
Compiling a Simple Kernel to CUDA#
The following scale kernel illustrates two transformations: how T.cta_id
and T.thread_id become concrete thread identifiers, and how one TIRx
function is split into a CPU-side launcher and a GPU kernel. The kernel processes
1,024 elements using 4 CUDA thread blocks (CTAs), with 256 threads per CTA.
1. TIRx source uses abstract thread identifiers.
import tvm
from tvm.script import tirx as T
@T.prim_func
def scale(A_ptr: T.handle, B_ptr: T.handle):
A = T.match_buffer(A_ptr, (1024,), "float32")
B = T.match_buffer(B_ptr, (1024,), "float32")
T.device_entry()
bx = T.cta_id([4])
tx = T.thread_id([256])
B[bx * 256 + tx] = A[bx * 256 + tx] * T.float32(2.0)
T.device_entry() marks the entry into GPU code. LowerTIRx uses the
marker to establish the corresponding thread bindings; the later
SplitHostDevice pass extracts the resulting device region into a separate
kernel. T.cta_id([4]) specifies 4 CTAs along x, while
T.thread_id([256]) specifies 256 threads per CTA. At this point, bx and
tx are still abstract TIRx identifiers.
2. ``LowerTIRx`` lowers the abstract identifiers to TIR thread bindings. It
binds bx to blockIdx.x and tx to threadIdx.x. Omitting buffer
declarations, the core computation is equivalent to:
with T.launch_thread("blockIdx.x", 4) as bx:
tx = T.launch_thread("threadIdx.x", 256)
B[bx * 256 + tx] = A[bx * 256 + tx] * T.float32(2.0)
This is still TIR, not CUDA source code. The excerpt retains only the important mapping; the next section shows how to print the complete compiler output.
3. Later passes split host/device code and generate CUDA. The compiler starts
with one TIRx function. After LowerTIRx establishes thread bindings and a
device region, SplitHostDevice produces two TIR functions (PrimFuncs):
host launcher (generated from scale)
`-- launch scale_kernel with gridDim.x = 4 and blockDim.x = 256
device scale_kernel
`-- each GPU thread multiplies one input element by 2
The host function retains the kernel-launch logic, while the device function
retains the elementwise computation. MakePackedAPI then adapts the host
function to the uniform calling convention used by the TVM runtime. The device
function proceeds to the CUDA backend, which generates code equivalent to:
__global__ void scale_kernel(float* A, float* B) {
int i = blockIdx.x * 256 + threadIdx.x;
B[i] = A[i] * 2.0f;
}
In short, TIRx describes the thread organization and computation,
LowerTIRx turns abstract identifiers into TIR thread bindings,
SplitHostDevice separates CPU-side launch logic from GPU-side computation,
and the CUDA backend finally emits CUDA source code.
No bounds check is needed here because 4 * 256 is exactly 1,024. For a
general length N, choose the CTA count with ceiling division and guard the
kernel body with i < N.
Inspecting intermediate IR and generated code#
To inspect an intermediate IR, run only the first few passes and stop before the
rest of the pipeline. The following code first places scale in an
IRModule under the global name main. The CUDA target selects the GPU
backend, while with_host("llvm") selects LLVM for the CPU-side launcher.
BindTarget attaches both choices to the module, after which we run only
LowerTIRx:
from tvm.tirx import transform as TT
target = tvm.target.Target("cuda").with_host("llvm")
mod = tvm.IRModule({"main": scale})
mod = TT.BindTarget(target)(mod)
mod = TT.LowerTIRx()(mod) # run LowerTIRx to lower abstract thread IDs
print(mod.script()) # inspect the IR after LowerTIRx
The output should contain thread bindings for blockIdx.x and threadIdx.x;
the original T.cta_id and T.thread_id calls should be gone.
To inspect the final CUDA, run the complete pipeline. The host module in this
example imports exactly one device module, so imports[0] is the generated
CUDA module, and inspect_source() returns its source code:
exe = tvm.compile(tvm.IRModule({"main": scale}), target=target, tir_pipeline="tirx")
cuda_mod = exe.mod.imports[0]
print(cuda_mod.inspect_source())
The generated code should contain blockIdx.x, threadIdx.x, and the
elementwise multiplication that doubles each input value.