Adding a new op¶
Adding an op means writing code in the six places below. The order of the table is also the recommended order of writing.
The spec is written first, because it decides what the other five files contain, and in the end they are checked against it. The spec is the input to this workflow, and the other five places are written from it.
| # | File | Held to the spec by | Contents |
|---|---|---|---|
| 1 | src/tileops/manifest/spec/<family>.yaml |
the validator's schema and signature levels |
the spec itself |
| 2 | src/tileops/ops/<family>/… |
the validator, against __init__, forward and the declared kernels; the checks generated around every call |
the op class, subclassing Op |
| 2 | src/tileops/ops/<family>/__init__.py and src/tileops/<family>.py |
the validator: the family's __all__ agrees with the manifest |
the op's name, exported by its family and on the public path tileops.<family>.<Op> |
| 3 | src/tileops/kernels/<family>/… |
— | the kernel classes, subclassing Kernel |
| 4 | tests/ops/test_<name>.py |
the contract tests, which run every workload row | the numerical comparison against the reference ref_program |
| 5 | benchmarks/ops/bench_<name>.py |
the validator's bench level |
the benchmark, and the op's case entry in benchmarks/_cases/ |
The steps below take GemmFwdOp, the simplest matmul, through all six places.
Step 1: write the spec¶
What each field means and how to write it is in
Reading and writing the manifest. A new op starts at
status: spec-only: the interface is settled and the implementation is not written
yet. The checks that read code are skipped at this status, so they do not fail on the
missing class.
GemmFwdOp's spec, with one workload row:
GemmFwdOp:
ref_api: torch.matmul
family: gemm
status: spec-only
signature:
types:
Mat:
params: {t: Bool, R: Dim, C: Dim}
match: t
cases:
- {when: false, is: "[R, C]"}
- {when: true, is: "[C, R]"}
forall: {M: Dim, N: Dim, K: Dim, T: "DType[float16 | bfloat16]"}
params:
trans_a: {type: bool, default: false}
trans_b: {type: bool, default: true}
inputs:
a: {dtype: T, shape: "Mat[trans_a, M, K]"}
b: {dtype: T, shape: "Mat[trans_b, K, N]"}
outputs:
d: {dtype: T, shape: "[M, N]"}
workloads:
- {M: 4096, N: 4096, K: 7168, trans_a: false, trans_b: true,
dtype_cases: [{T: float16}, {T: bfloat16}], label: ds-v3-prefill-mlp-up}
roofline:
flops: "2 * M * N * K"
The spec names no file and no kernel. Which kernels serve the op belongs to the code, and is declared on the op class in step 2.
Step 2: write the op class¶
The op class subclasses Op and sits between the spec and the kernel. The checks
around every call (dtypes, shapes, the refinements and output shape inference) are
generated from the signature when the class is defined, and the class writes none of
them. What the class writes is how a call reaches a kernel.
The class, and its members¶
GemmFwdOp's skeleton, with docstrings elided:
class GemmFwdOp(Op):
kernel_types: ClassVar[Mapping[str, type[Kernel]]] = {
"gemm_tma": GemmTMAKernel,
"gemm_cp_async": GemmCpAsyncKernel,
"gemv": GemvKernel,
}
interfaces: ClassVar[Mapping[str, type[KernelInterface]]] = {"gemm": GemmFwdInterface}
def __init__(self, trans_a=False, trans_b=True, *, target=None):
self.trans_a = trans_a
self.trans_b = trans_b
super().__init__(target=target) # checks the params, installs kernel_types
def forward(self, a, b): # the generated checks have run
a, b = a.contiguous(), b.contiguous() # handed over as the spec declares it
m, k = (a.shape[1], a.shape[0]) if self.trans_a else a.shape
call = GemmCall( # what this call is
m=m,
n=b.shape[0] if self.trans_b else b.shape[1],
k=k,
dtype=a.dtype,
trans_a=self.trans_a,
trans_b=self.trans_b,
device=a.device,
)
return self.kernel_for("gemm", call)(a, b)
def roof_key(self): # FLOPs priced on tensor cores
return tensor_core_roof(self.last_call.indices["T"])
| # | Member | Written from |
|---|---|---|
| 1 | __init__ |
the names, order and defaults in signature.params, then the keyword-only target; it assigns each manifest parameter to the attribute of the same name, then calls super().__init__(target=target) |
| 2 | kernel_types |
the Kernel classes that can serve the op, each under a key |
| 3 | interfaces |
one entry per kernel call the op makes, mapping the name kernel_for uses to the KernelInterface class that the implementations serving that call inherit |
| 4 | forward |
the parameters: the order of signature.inputs, with optional inputs last and defaulting to None; the body: making the inputs contiguous, building the call spec, fetching the kernel and calling it |
| 5 | roof_key |
optional: the hardware unit whose peak prices the op's FLOPs; the default is fp32 on CUDA cores, and the member is written only when the op uses another unit |
_infer_output_shapes and eval_roofline are generated from the spec and are not
written by hand.
forward is the op's computation and the only method that carries it. A caller writes
op(a, b), never op.forward(a, b): Op.__call__ runs the generated checks and then
forward. An op whose spec has a call-time tensor input and no composition gets a
compile boundary generated for it, and __call__ runs forward behind the generated
operator. How that works is in Bringing an op into torch.compile.
The constructor takes no tuning parameter. To tune, construct the op, then call
op.request_tune().
kernel_for, and choosing among kernels¶
A kernel is a compiled artefact that takes hundreds of milliseconds to seconds to
build, while an op instance is called many times at different shapes and dtypes. The
op layer therefore keeps a cache table: a kernel this call needs that has been built
before is returned from the table, and otherwise the kernel is built and stored.
kernel_for is the only way in-tree implementations reach that table; a
target serves the whole op and does not go through kernel_for.
kernel_for takes two arguments:
interface: a key ofinterfaces, naming one kernel call the op makes.GemmFwdOpmakes one kernel call, so it declares one interface,"gemm". A second kernel interface is added only where the semantics or the call contract changes:BatchNormFwdOphasbatch_norm_fwd_trainandbatch_norm_fwd_infer, which return different things. A kernel that is faster on some shape range or some architecture is another implementation of the existing kernel interface.call: a frozenCallSpecsubclass carrying the facts needed to select an implementation and build the kernel: shapes, the dtype, the op's semantic parameters, and the device. It must be the type the kernel interface names inrequest. The dispatcher derives the device facts (arch,sm_count,calibration,smem_budget) fromcall.deviceon a cache miss; the caller does not fill them in.
The op calls the returned kernel with the parameters of the kernel interface's
abstract forward, in that order.
A kernel interface is a class in
src/tileops/kernels/<family>/call_spec.py,
next to the call spec it names in request; a family with one kernel file keeps both
in that file. Its name has the form {Name}{Fwd|Bwd}Interface, with variant words
before the direction, and interface-names-lint checks it. Its abstract forward is
the only contract every implementation, in-tree or from a backend, is written against,
so its docstring states each tensor's shape, dtype, memory layout, device, and whether
it is written in place:
class GemmFwdInterface(KernelInterface):
"""Dense matmul under the ``(trans_a, trans_b)`` layout the call states."""
request = GemmCall
@abstractmethod
def forward(self, a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
"""Multiply the two matrices; nothing is written in place.
Both operands are contiguous on ``call.device`` in ``call.dtype``, which is
``float16`` or ``bfloat16``; the contraction accumulates in ``float32``.
Args:
a: ``(call.m, call.k)``, or ``(call.k, call.m)`` when ``call.trans_a``.
b: ``(call.n, call.k)`` when ``call.trans_b``, else ``(call.k, call.n)``.
Returns:
A new ``(call.m, call.n)`` tensor in ``call.dtype``.
"""
An implementation is a class that inherits both Kernel and one kernel interface, and
is listed in kernel_types under a key. Which implementation serves a call follows
from the availability, applicability and precedence each implementation declares about
itself; the op takes no part in the selection. A kernel interface with one
implementation needs no declaration beyond the inheritance. The selection rules, how to
write each declaration, and the common errors are in
How an op selects a kernel and
Adding a kernel to an op.
An op does not write entry_for and keeps no kernel cache of its own. An op with no
in-tree implementation, which depends only on an external backend, declares neither
kernel_types nor interfaces; when no target claims the call's device, the call
raises OpNotAvailableError. How a backend adds an implementation or serves the whole
op is in How a backend joins TileOPs.
Registering¶
Add the op's name to the imports and __all__ in two places:
- its family's
src/tileops/ops/<family>/__init__.py, where the class is implemented; src/tileops/<family>.py, the public path.
Without the second, from tileops.<family> import ... does not find the op, and the
API reference does not include it.
Step 3: write the kernel¶
A kernel class subclasses Kernel and the kernel interface it implements, lives under src/tileops/kernels/, is written in
TileLang. Construction obtains the TileLang builder; the program compiles the first
time a call runs it with a given config. It implements forward, which the base
class's __call__ runs. Its constructor is called by the builder its own entry_for
returns, and its forward takes the kernel interface's parameters, the kernel(a, b)
of step 2.
The kernel is the only one of the six places the spec does not constrain: a kernel neither reads the spec nor is checked against it.
The split of arguments between the constructor and the call is a hard requirement:
only values compiled into the generated code go in the constructor. GemmTMAKernel
splits them like this:
class GemmTMAKernel(Kernel, GemmFwdInterface):
def __init__(self, m, n, k, dtype, config=None, trans_a=False, trans_b=False, ...):
self.kernel = _gemm_kernel(m, n, k, trans_a, trans_b, self.dtype_str, ...) # the TileLang builder
self.init_config(config) # tile sizes and pipeline depth
def forward(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 are all unrolled from them, and so are the tile sizes. The tensors are passed to
forward, and each call only changes the pointers.
A wrong split causes recompilation. Decode advances one token per step, so seq_len
grows by one every step, and the 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, the build identity entry_for returns contains seq_len, so
every step misses the cache and compiles once, and decode cannot run at a usable
speed.
Step 4: write the test¶
Tests live in tests/ops/ and compare against ref_program, the reference
implementation the workload (or the test class) defines. Each test chooses its own
shapes to cover the kernel's branches. Cases fall into three groups by when they run:
- cases marked
smokerun on every PR; - cases marked
fullrun on any PR that changes their test file, and in the nightly; - long-running cases are marked
nightlyand run only in the nightly.
The workload rows are not part of unit-test coverage, because the contract tests already run each row through the op.
The test scaffolding is TestBase from
tests/workload_test_base.py, with the cases in PARAMS.
When the op has an optional input, it needs at least one case with the input passed and one without, because the two often run different kernels.
An op with a compile boundary, that is, one whose spec has a call-time tensor input and
no composition, also needs a cold torch.compile(op, fullgraph=True) test, registered
through register_compile_contract in
tests/compile_contract.py.
A test compares the registered ops with the implemented ones that have a compile
boundary, and fails on any difference.
Step 5: write the benchmark¶
Benchmarks live in benchmarks/ops/. The cases are not written by hand:
bench.cases(<Op>) turns each workload row, with each of its dtype cases, into one
bench.Case named by its case id, and bench.Runner(op, case).compare() checks the op
and every implementation it is compared with against the case's reference, then times
them and records the results. The validator's bench level checks that every benchmark
file calls both:
import pytest
from benchmarks import api as bench
from tileops.ops import GemmFwdOp
@pytest.mark.parametrize("case", bench.cases(GemmFwdOp), ids=lambda case: case.id)
def test_gemm_bench(case) -> None:
op = GemmFwdOp(**case.arguments)
bench.Runner(op, case).compare({"torch-cublas": case.reference})
bench.cases() builds a case through the op's entry in
benchmarks/_cases/,
which says how a manifest call becomes a workload. A new op adds its entry to its
family's module there; without one, pytest fails while collecting the cases.
A benchmark also records at least one non-TileOPs implementation; without one, the row has nothing to compare against. Where an implementation needs its input converted, the conversion stays inside its own timed region. How to pass implementations that need private arguments, and the order in which they are checked and timed, are in Writing benchmarks; 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 places written, run the three commands below to check the work:
python scripts/validate_manifest.py --check-op GemmFwdOp # spec and code agree
python -m pytest tests/ops/test_gemm.py -v # numerics match ref_program
python -m pytest benchmarks/ops/bench_gemm.py # the benchmark produces numbers
When all three pass, change the spec's status from spec-only to implemented.
That one edit turns on the checks that read code and brings the op under CI: from then
on, the validator, the tests and the nightly benchmark check every change against the
spec.
Afterwards¶
Once the op runs, letting other kernels serve it on other hardware is optional; see Adding a hardware backend. How the op behaves inside a user's compiled graph is in Bringing an op into torch.compile.