Metaprogramming#

Note

All the code in this guide is in examples/programming_guides/metaprogramming.py.

This guide shows how to use Python to generate, reuse, and specialize TIRx kernels. TIRx functions are written in Python, so ordinary Python code runs before compilation and decides what each kernel contains: which dtypes and sizes it uses, which helper code it includes, and which values are fixed.

The examples use the following imports:

from __future__ import annotations

import torch

import tvm_tirx
from tvm_tirx.script import tirx as T

Parameterizing Kernels with Captured Values#

A Python function can build kernels. The inner @T.function can read any value from the enclosing Python scope, such as a shape variable, a dtype, a constant, or a launch size. Each call to the outer function produces a new kernel with those values filled in:

def make_add(dtype, value, block=256):
    n = T.dynamic("n")

    @T.function
    def add(A: T.Tensor((n,), dtype), B: T.Tensor((n,), dtype)):
        T.device_entry(launch=T.cuda.LaunchConfig(grid=T.ceildiv(n, block), block=block))
        i = T.cuda.block_idx("x") * block + T.cuda.thread_idx("x")
        if i < n:
            B[i] = A[i] + value

    return add


add_two_f16 = make_add(T.float16, 2, block=128)
add_two_f16_compiled = tvm_tirx.compile(add_two_f16, target="cuda")
a = torch.arange(10, dtype=torch.float16, device="cuda")
b = torch.empty_like(a)
add_two_f16_compiled(a, b)
print(b)

The parser reads n, dtype, value, and block from the enclosing scope when it parses add. In add_two_f16, the tensors have element type T.float16, the constant becomes T.float16(2.0), and each block has 128 threads. n stays a symbolic dimension, so the kernel still accepts tensors of any length. The script prints:

tensor([ 2.,  3.,  4.,  5.,  6.,  7.,  8.,  9., 10., 11.], device='cuda:0',
       dtype=torch.float16)

Calling make_add(T.float32, 1) instead produces a separate float32 kernel from the same definition. The parser needs the function’s source code, so define kernel generators in a Python file rather than in an interactive session that has no source to read.

Reusable Code Fragments#

Kernels often repeat the same few statements. A function marked with @T.inline holds such a fragment, and a kernel calls it like a regular function. In the following kernel, each thread handles two neighboring elements and calls add_one_if_valid once for each:

@T.inline
def add_one_if_valid(A, B, i, n):
    if i < n:
        B[i] = A[i] + 1


n = T.dynamic("n")


@T.function
def add_one_pairs(A: T.Tensor((n,), T.float32), B: T.Tensor((n,), T.float32)):
    T.device_entry(launch=T.cuda.LaunchConfig(grid=T.ceildiv(n, 512), block=256))
    i = (T.cuda.block_idx("x") * 256 + T.cuda.thread_idx("x")) * 2
    add_one_if_valid(A, B, i, n)
    add_one_if_valid(A, B, i + 1, n)

During parsing, TVMScript expands each add_one_if_valid(...) call into the helper’s statements, with the arguments substituted. The body of add_one_pairs is therefore equivalent to:

i = (T.cuda.block_idx("x") * 256 + T.cuda.thread_idx("x")) * 2
if i < n:
    B[i] = A[i] + 1
if i + 1 < n:
    B[i + 1] = A[i + 1] + 1

No function call remains in the compiled kernel, and other kernels can call add_one_if_valid in the same way.

Compile-Time Specialization#

A symbolic dimension such as n varies at runtime. When a size should instead be fixed before compilation, @T.jit lets us declare it as a T.constexpr parameter and set it later through .specialize(...):

@T.jit
def add_one_fixed(
    A: T.Tensor((N,), T.float32),
    B: T.Tensor((N,), T.float32),
    *,
    N: T.constexpr,
):
    T.device_entry(launch=T.cuda.LaunchConfig(grid=T.ceildiv(N, 256), block=256))
    i = T.cuda.block_idx("x") * 256 + T.cuda.thread_idx("x")
    if i < N:
        B[i] = A[i] + 1


add_one_256 = add_one_fixed.specialize(N=256)

Unlike @T.function, which parses its definition immediately, @T.jit waits until .specialize supplies the constexpr values. add_one_256 is an ordinary TIRx function with 256 fixed in its parameter types, launch configuration, and bounds check, and it compiles with tvm_tirx.compile like any other function. The annotations refer to N before it has a value, which is why the file starts with from __future__ import annotations.