Adding a new op¶
A new op means writing code in the six places below, and the table is in the order to work through them.
The spec goes first: it decides what the other five files contain, and in the end it is what they are checked against. The spec is this pipeline's input, and the other five are written from it.
| # | File | Named in the spec by | Contents |
|---|---|---|---|
| 1 | src/tileops/manifest/<family>.yaml |
the key is the op's class name | the spec itself |
| 2 | src/tileops/ops/<family>/… |
source.op |
the op class, subclassing Op |
| 2 | src/tileops/ops/__init__.py |
— | the op's name, exported |
| 3 | src/tileops/kernels/<family>/… |
source.kernel |
the kernel class, subclassing Kernel |
| 4 | tests/ops/test_<name>.py |
source.test |
the comparison against ref_api |
| 5 | benchmarks/ops/bench_<name>.py |
source.bench |
the benchmark |
GemmFwdOp — the plainest matmul there is — runs through all six below.
Step 1: write the spec¶
What the fields mean and how to write them is in writing a spec. Two things are specific to a new op.
The first is the status. A new op starts at status: spec-only, meaning the interface is
settled and there is no implementation yet, so validation runs L0 — the structure check —
and does not fail over the missing code.
The second is source.kernel_map, the one field in the spec that nothing can derive.
An op may have more than one kernel behind it: GEMM uses a matmul kernel at general
shapes, and at M = 1 the problem degenerates to a matrix-vector product that a different
kernel does faster. kernel_map is the roster of those kernels — a name for each, against
the Kernel class that implements it:
GemmFwdOp:
ref_api: torch.matmul
family: gemm
status: spec-only
signature:
inputs:
a: {dtype: "float16 | bfloat16"}
b: {dtype: "same_as(a)"}
outputs:
d: {dtype: "same_as(a)"}
params:
trans_a: {type: bool, default: false}
trans_b: {type: bool, default: true}
shape_rules:
- "d.shape == ((a.shape[1] if trans_a else a.shape[0]), (b.shape[0] if trans_b else b.shape[1]))"
source:
kernel: tileops/kernels/gemm/dense.py
kernel_map:
gemm_kernel: GemmKernel
gemv_kernel: GemvKernel
op: tileops/ops/gemm/gemm.py
test: tests/ops/test_gemm.py
bench: benchmarks/ops/bench_gemm.py
Those names are how a kernel is asked for at runtime: _eager_forward picks one, passes
the name to get_or_build_kernel, and the op layer looks the class up in kernel_map and
builds it (see step 2). An external backend registers against the same roster
— whichever name it registers a build_kernel for is the kernel of the op it takes over.
The names are yours to choose, they should say what the kernel is for, and once the op code uses one it should not change: it is the word the spec, the op and any backend all agree on. That is also why nothing can derive it — only whoever writes the kernels knows how many cases the op splits into.
Step 2: write the op class¶
The op class subclasses Op and sits between the spec and the kernel: it validates the
arguments against the spec, infers the output shapes, then fetches a kernel and launches
it. It comes first because the spec dictates all of it, and the line where it builds a
kernel is what fixes that kernel's constructor signature.
The class, and its four members¶
GemmFwdOp's skeleton, with the parts of each body that are beside the point elided:
class GemmFwdOp(Op):
def __init__(self, trans_a=False, trans_b=True, kernel_map=None, tune=False):
self.trans_a, self.trans_b, self.tune = trans_a, trans_b, tune
self.dispatch_kernel(kernel_map) # establishes this instance's kernel_map
@property
def default_kernel_map(self): # the spec's source.kernel_map
return {"gemm_kernel": GemmKernel, "gemv_kernel": GemvKernel}
def _infer_output_shapes(self, a_shape, b_shape):
m = a_shape[1] if self.trans_a else a_shape[0]
n = b_shape[0] if self.trans_b else b_shape[1]
return {"d": (m, n)} # the spec's shape_rules
def forward(self, a, b):
self._validate_dtypes(a, b) # generated by the base class
m, n, k = self._infer_mnk(a, b)
a, b = a.contiguous(), b.contiguous() # handed over as the spec declares it
slot = "gemv_kernel" if m == 1 else "gemm_kernel"
kernel = self.get_or_build_kernel(
slot, # a name from kernel_map
(a, b), # the memo key's tensors, and what a backend receives
key=(m, n, k, a.dtype), # the cache key on the in-tree side
build=lambda: self.kernel_map[slot](m, n, k, a.dtype, tune=self.tune),
)
return kernel(a, b)
Four members to write, each of them from the spec:
| # | Member | Written from |
|---|---|---|
| 1 | __init__ |
the names and defaults in signature.params, plus kernel_map and tune, closing with self.dispatch_kernel(kernel_map) to establish this instance's kernel_map |
| 2 | default_kernel_map |
source.kernel_map: the same names, against the Kernel classes themselves |
| 3 | _infer_output_shapes |
the rules in signature.shape_rules that derive an output's shape |
| 4 | forward |
signature.inputs — its order and defaults, optional inputs last — plus the validation, the contiguity, fetching the kernel and launching it |
Two more members arrive on their own. When the subclass is defined, the base class
synthesises _validate_dtypes and eval_roofline from the spec's dtype declarations and
its roofline, so they are there to call — and worth overriding only where the op needs
something the spec cannot say.
get_or_build_kernel¶
A kernel is a compiled artefact, hundreds of milliseconds to seconds to build, while an op
instance is called over and over at different shapes and dtypes. The op layer therefore
keeps a memo table: a kernel this call needs and has built before comes straight back,
and only otherwise is one built and stored. get_or_build_kernel is that table's only
entrance, and the point where the in-tree implementation and an external backend part
ways — the second layer of selection in the backend protocol.
Its four arguments:
name — which kernel this call wants, as a name from kernel_map.
The in-tree side looks up the Kernel class under that name; a backend looks up the
build_kernel it registered under it. An op has as many names as it has cases.
inputs — the tensors the kernel is about to be handed, in signature.inputs order,
one slot per input.
self.get_or_build_kernel(slot, (a, b), ...) # GEMM: two required inputs
self.get_or_build_kernel("group_norm", (x, weight, bias), ...) # an absent optional input is None
The external path keys on it — the device, plus each slot's (dtype, shape). The device
counts because an artefact compiled for one card may hold resources on it. A backend's
build_kernel receives the same tensors as TensorSpecs: device, dtype, shape, no
data.
An optional input that was not passed keeps its slot, as None; that is what a backend
reads presence off, rather than counting slots. Squeeze the empty slots out, and a clamp
with only a lower bound looks exactly like one with only an upper bound.
Omitting inputs raises nothing until a backend is installed, and then
OpNotAvailableError: the op stays in-tree only, out of reach of any target (see after
install: two states).
key — what the in-tree kernel specializes on; the in-tree path only. What becomes of
these last two once a backend serves the op is in how one call reaches
build_kernel.
key=(m, n, k, a.dtype) # GEMM: three dimensions and the dtype
key=(self._cache_key(*input_shapes), x.dtype) # the general form
The default _cache_key takes the sizes of every non-static axis across the inputs —
always correct, but it can over-fragment: one compile per distinct shape. Where the kernel
depends on fewer quantities, override it to project the shape onto those, flattening the
leading dims to one product when the kernel treats its input as 2-D.
build — how that in-tree kernel is constructed; the in-tree path only.
Called once per key, which is why compiling belongs here. It may return one Kernel, a
sequence of Kernels built together, or a dataclass carrying them — the last two suit an op
that launches several kernels per call.
An op with no in-tree implementation at all, one written to depend on a backend, may leave
build out; a call on a device no target claims then raises OpNotAvailableError.
Finishing: the compile boundary, and registering¶
Two things to finish, a few lines each:
- To support
torch.compile, declare a compile boundary as well:forwardonly calls the opaque operator, and the validation, the kernel lookup and the launch move into_eager_forward. The op above declares none, so itsforwardholds all the work. How to declare it is in bringing an op into torch.compile. - Add the op's name to the imports and
__all__insrc/tileops/ops/__init__.py, orfrom tileops.ops import ...will not find it.
Step 3: write the kernel¶
A kernel class subclasses Kernel, lives under src/tileops/kernels/, is written in
TileLang, compiles at construction and launches on __call__. Its constructor and call
signatures are the ones step 2 just used — the build lambda and the kernel(a, b) that
follows it.
This is the one place of the six the spec does not constrain: a kernel neither reads the spec nor is checked against it, and the spec records only its path and class name.
How the constructor and the call divide their arguments is a hard requirement: only
values compiled into the generated code go in the constructor. GemmKernel divides them
like this:
class GemmKernel(Kernel):
def __init__(self, m, n, k, dtype, config=None, tune=False, trans_a=False, trans_b=False):
self.kernel = _gemm_kernel(m, n, k, trans_a, trans_b, self.dtype_str) # this line compiles
self.init_config(config, tune) # block_m / block_n / block_k / num_stages
def __call__(self, a, b): # a call passes tensors, nothing else
...
m, n, k, the dtype and the two layout flags are constructor arguments because the
generated code treats them as constants: loop bounds, TMA descriptors and the WGMMA shape
all unroll from them, as do the tile sizes (block_m and the rest). The tensors belong to
__call__, where each call swaps pointers.
Dividing them wrong costs a recompile. A decode step advances one token at a time, so
seq_len grows by one every step and batch changes with the running set:
# wrong: seq_len in the constructor — every step is a new kernel
kernel = AttnKernel(batch, seq_len, num_heads, dtype)
# right: compile-time constants in the constructor, the varying sizes per call
kernel = AttnKernel(num_heads, head_dim, dtype)
out = kernel(q, k, v) # seq_len is read off the tensor shapes
With the first form, seq_len ends up in get_or_build_kernel's key, every step misses,
every step compiles, and decode goes nowhere.
Step 4: write the test¶
Tests live in tests/ops/, and what they compare against is the spec's ref_api, point
by point, over the shapes and dtypes the spec declares — small shapes marked smoke for
the PR checks, large ones full for the nightly.
The scaffolding is TestBase and FixtureBase from
tests/test_base.py, with the cases in PARAMS.
Where the op has an optional input, both sides need a case — passed and not passed often run different kernels.
Step 5: write the benchmark¶
Benchmarks live in benchmarks/ops/ and subclass ManifestBenchmark. The shapes are not
written here: they come from the spec's workloads through load_workloads(<op>), and
hand-written shapes fail L4 validation:
from benchmarks.benchmark_base import ManifestBenchmark, workload_params
from tileops.manifest import load_workloads
_OP_NAME = "GemmFwdOp"
Record at least one non-TileOPs baseline as well, or the row has nothing to compare against. Where a baseline needs its input converted, that conversion stays inside its own timed region. What the reported numbers mean is in how a benchmark is timed.
Step 6: flip the status, and let CI take over¶
With the other five written, check your own work with the three commands below:
python scripts/validate_manifest.py --check-op GemmFwdOp # spec and code agree, all five levels
python -m pytest tests/ops/test_gemm.py -v # numerics match ref_api
python -m pytest benchmarks/ops/bench_gemm.py # the benchmark produces numbers
With all three passing, flip the spec's status from spec-only to implemented. That
one edit takes validation from L0 to all five levels and puts the op inside CI's reach:
every later change is held against the spec by the validator, the tests and the nightly
benchmark.
Afterwards¶
Once the op runs, two optional things remain:
- Let the op into a user's compiled graph — bringing an op into torch.compile.
- Let someone else's kernels serve it on other hardware — adding a hardware backend.