Arithmetic and Type Conversion#
This guide shows how to compute with the register values loaded by tile
operations. apply selects a native arithmetic or conversion instruction;
ewise applies a scalar TIRx function to each value. We will use an affine
transformation followed by ReLU, then convert float32 values to float16 and
express a SiLU activation as a scalar function.
An Affine Transformation and ReLU#
A common activation step scales each value, adds a bias, and clamps the
result at zero: B = max(2*A + 1, 0). The following kernel assigns four
values to each of 64 threads:
from tvm_tirx.script import tirx as T
from tvm_tirx.tirx.layout import S, TileLayout
from tvm_tirx.backend.cuda.axis import laneid, warpid
register_layout = TileLayout(S[(2, 32, 4) : (1 @ warpid, 1 @ laneid, 1)])
@T.function
def affine_relu(
A: T.Tensor((2, 32, 4), "float32", align=16),
B: T.Tensor((2, 32, 4), "float32", align=16),
):
T.device_entry(launch=T.cuda.LaunchConfig(grid=1, block=64))
registers = T.alloc_local((2, 32, 4), "float32", layout=register_layout)
T.cuda.tile.apply(T.ptx["ld.global.v4.b32"], registers, A, scope="cta")
scalars = registers.view(2, 32, 4, 1)
T.cuda.tile.apply(
T.ptx["fma.rn.f32"], scalars, scalars, T.float32(2), T.float32(1), scope="cta"
)
T.cuda.tile.apply(T.ptx["max.f32"], scalars, scalars, T.float32(0), scope="cta")
T.cuda.tile.apply(T.ptx["st.global.v4.b32"], B, registers, scope="cta")
The load and store each use a four-word atom. The arithmetic view
(2, 32, 4, 1) keeps the warp and lane group axes, but exposes four
repeats of a single-register atom. fma.rn.f32 computes 2*x + 1 with
one rounding per result, and max.f32 applies ReLU. The scalar constants
are reused for every repeat. Both arithmetic calls write back to the same
register storage.
We can check the result with finite float32 inputs:
import torch
import tvm_tirx
a = torch.linspace(-4, 4, 256, device="cuda").reshape(2, 32, 4)
b = torch.empty_like(a)
compiled = tvm_tirx.compile(affine_relu, target="cuda")
compiled(a, b)
torch.testing.assert_close(b, torch.clamp_min(a * 2 + 1, 0))
Other scalar instructions use the same single-register atom:
Operation |
Example PTX forms |
Reference |
|---|---|---|
Addition, subtraction, multiplication |
|
|
Division, reciprocal, square root |
|
|
Elementwise minimum and maximum |
|
|
Fused multiply-add |
|
|
Base-two exponential and logarithm |
|
|
Absolute value and negation |
|
Each repeat produces an independent result; an elementwise max does
not reduce a tile to one value. The instruction spelling selects rounding,
approximation, and subnormal handling. Packed forms such as
add.rn.f16x2 operate on two half values carried in a uint32 register;
the register instruction reference
specifies those representations and the predication options.
Numeric Conversion#
The same flow can produce float16 output. cvt.rn.f16.f32 converts one
float32 value with round-to-nearest-even. Its destination is a 16-bit
register container, exposed below through a uint16 view:
@T.function
def to_float16(
A: T.Tensor((2, 32, 4), "float32", align=16),
B: T.Tensor((2, 32, 4), "float16", align=8),
):
T.device_entry(launch=T.cuda.LaunchConfig(grid=1, block=64))
registers = T.alloc_local((2, 32, 4), "float32", layout=register_layout)
converted = T.alloc_local((2, 32, 4), "float16", layout=register_layout)
words = converted.view("uint16")
T.cuda.tile.apply(T.ptx["ld.global.v4.b32"], registers, A, scope="cta")
T.cuda.tile.apply(
T.ptx["cvt.rn.f16.f32"],
words.view(2, 32, 4, 1), registers.view(2, 32, 4, 1), scope="cta",
)
T.cuda.tile.apply(T.ptx["st.global.v4.b16"], B.view("uint16"), words, scope="cta")
cvt performs the numeric conversion. The uint16 views expose the
resulting bits for the PTX operands and reuse the float16 storage. The
store writes four 16-bit words per thread, with an 8-byte-aligned output
row. With a from the preceding example, we can verify the converted
values:
half_result = torch.empty_like(a, dtype=torch.float16)
tvm_tirx.compile(to_float16, target="cuda")(a, half_result)
torch.testing.assert_close(half_result, a.to(torch.float16))
Packed conversions such as cvt.rn.f16x2.f32 place two converted values
in one uint32 carrier. Their operand order and supported inverse forms
are specified in the conversion reference.
Scalar Function Compositions#
A scalar TIRx function can describe a longer computation. For example, SiLU multiplies a value by its sigmoid activation:
@T.function(private=True)
def silu(value: T.float32) -> T.float32:
return value / (T.float32(1) + T.exp(-value))
Inside affine_relu, replace the two arithmetic calls with the following
call to apply SiLU to the loaded values:
T.cuda.tile.ewise(silu, registers, registers, scope="cta")
ewise uses the original (2, 32, 4) view: the last axis now selects
four scalar invocations per thread. The scalar function is inlined for
each value and may expand to several instructions. Its parameter and
result types must match the register elements. The load and store remain
in the kernel, and the scalar function reference describes the supported function bodies.