My Triton From Scratch Part 9: AST Frontend
In Part 1: Symbolic Tracing, mytriton learned how to trace a Python function into an expression-tree IR.
In Part 2: Typed SSA, it learned how to infer types and lower that tree into typed SSA-style operations.
In Part 3: CUDA Lowering, it learned how to turn SSA into CUDA C++ and launch the generated kernel.
In Part 4: Elementwise Ops, the language grew enough elementwise operations to write ReLU, leaky ReLU, and sigmoid.
In Part 5: Verification, the middle of the compiler became stricter: SSA is now verified, optimized, and verified again before code generation.
In Part 6: Reductions, vectors learned how to cooperate inside one CUDA block: row-wise sum, max, min, softmax, and a first naive matmul.
In Part 7: Minimal MLIR, the compiler learned that optimized SSA can feed more than one backend.
In Part 8: Rank-2 Tiles, distributed values gained logical rows and columns instead of pretending every block was one flat vector.
Version 9 comes back to the very beginning of the pipeline.
The practical motivation is matrix multiplication. A matmul kernel needs to
walk over its inner dimension K, load one slice from A and one slice from
B, and accumulate their product into an output tile:
for k in tl.static_range(0, K):
a_values = tl.load(a + offs_m * K + k)
b_values = tl.load(b + k * N + offs_n)
acc += a_values * b_values
I want that loop to be part of the kernel language, with semantics controlled
by mytriton. Relying on CPython to execute it happens to unroll a constexpr
K, but it leaves the compiler unable to see that the source contained a loop.
An AST frontend gives the compiler an explicit place to recognize iteration
over the inner matmul dimension today and to lower richer loop forms later.
Until now, tracing meant calling the kernel as an ordinary Python function with symbolic arguments:
with Builder() as builder:
fn(*symbolic_args, **symbolic_kwargs)
This technique carried mytriton surprisingly far. Python executed assignments,
function calls, operators, indexing, and compile-time loops. The symbolic
objects only had to overload operations such as +, <, &, and [] to
build the expression tree.
But that also meant Python itself was the frontend.
mytriton could see the operations that happened while the function ran, but it
could not see the syntax that caused them. A for loop was already gone by the
time symbolic values reached the builder. An assignment was only a Python local
variable update. Unsupported syntax failed wherever the Python interpreter or
one of the symbolic objects happened to notice it.
Version 9 inserts an explicit frontend before symbolic tracing:
Python source
-> Python AST
-> AST frontend
-> expression-tree IR
-> typed SSA
-> verification and optimization
-> CUDA C++ or MLIR
The generated expression tree, SSA, and backend source stay the same. The new layer changes who is responsible for understanding the kernel source.
The code for this milestone is here: https://github.com/pbelevich/mytriton/tree/ver9.
The kernel still looks the same
The AST frontend is not introduced with a new kernel operation. Existing kernels are the test.
For example, the rank-2 matmul from Part 8 still contains assignments, attributes, calls, indexing, a compile-time loop, augmented assignment, and a masked store:
@triton.jit
def matmul_2d_kernel(
a,
b,
c,
M,
N,
K: tl.constexpr,
BM: tl.constexpr,
BN: tl.constexpr,
):
pid_m = tl.program_id(0)
pid_n = tl.program_id(1)
offs_m = pid_m * BM + tl.arange(0, BM)[:, None]
offs_n = pid_n * BN + tl.arange(0, BN)[None, :]
c_offsets = offs_m * N + offs_n
c_mask = (offs_m < M) & (offs_n < N)
acc = c_offsets * 0.0
for k in tl.static_range(0, K):
a_offsets = offs_m * K + k
b_offsets = k * N + offs_n
a_values = tl.load(a + a_offsets, mask=offs_m < M, other=0.0)
b_values = tl.load(b + b_offsets, mask=offs_n < N, other=0.0)
acc += a_values * b_values
tl.store(c + c_offsets, acc, mask=c_mask)
Earlier versions asked CPython to execute this function. Version 9 asks the
standard-library ast module to
parse it, then evaluates the supported nodes deliberately.
That is an important constraint: this is not a second Python interpreter. It is a small language frontend whose source syntax happens to be a carefully chosen subset of Python.
Finding the function in its source
The frontend begins with the original function object. Python function objects contain code objects, globals, defaults, annotations, and closure information, but they do not directly contain a high-level syntax tree.
So the first step recovers and parses the source:
def _find_function_def(fn) -> ast.FunctionDef:
source = inspect.getsource(fn)
tree = ast.parse(textwrap.dedent(source))
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name == fn.__name__:
return node
raise ASTFrontendError(
f"could not find function definition for {fn.__name__}"
)
inspect.getsource returns the source lines that define the function.
textwrap.dedent matters for kernels nested inside tests or helper functions:
their source is indented in the file, but ast.parse expects the snippet to be
valid at its new top level.
The result is an ast.FunctionDef. Its body is an ordered list of statement
nodes. The frontend does not visit the decorator or reconstruct a Python
call. It starts directly with those statements.
This MVP therefore needs source to be available. A function created with
eval, recovered only from bytecode, or defined in an environment where
inspect.getsource cannot find its file is outside the current boundary.
That is a reasonable boundary for a source-language frontend. More importantly, it is now an explicit one.
Two environments, two kinds of names
Evaluating a kernel AST requires resolving names such as:
a
K
acc
tl
range
Those names do not all come from the same place.
Kernel parameters and local assignments live in the frontend’s symbolic environment:
self.env
Globals, nonlocals, and builtins live in an external environment:
self.external_env
The initial symbolic environment preserves the runtime/constexpr split from the old tracer:
def _make_symbolic_env(signature, bound_args, runtime_params):
env = {}
params_by_name = {param.name: param for param in runtime_params}
for name, parameter in signature.parameters.items():
value = bound_args[name]
if is_constexpr_annotation(parameter.annotation):
env[name] = value
continue
param = params_by_name[name]
if isinstance(param.ty, PointerType):
env[name] = Ptr(param)
else:
env[name] = Value(param)
return env
A compile-time parameter such as K: tl.constexpr stays an ordinary Python
integer. A pointer parameter becomes Ptr. Another runtime scalar becomes
Value.
That distinction is why this loop can be unrolled:
for k in tl.static_range(0, K):
K is already a concrete integer in the frontend environment.
The external environment comes from the function object:
closure_vars = inspect.getclosurevars(fn)
external_env = {
**closure_vars.builtins,
**closure_vars.globals,
**closure_vars.nonlocals,
}
This is how tl, a module-level helper, a captured nonlocal, or builtin
range can be found without pretending they are kernel parameters.
Name lookup checks locals first:
def visit_Name(self, node):
if isinstance(node.ctx, ast.Load):
if node.id in self.env:
return self.env[node.id]
if node.id in self.external_env:
return self.external_env[node.id]
raise NameError(node.id)
That matches the useful part of Python’s name-shadowing behavior while keeping the frontend implementation small.
Statements update the frontend environment
The AST visitor processes a function body in source order:
def visit_stmt_list(self, body):
for stmt in body:
self.visit(stmt)
A simple assignment evaluates its right-hand side and records the result:
def visit_Assign(self, node):
if len(node.targets) != 1:
raise ASTFrontendError(
"only single-target assignment is supported"
)
target = node.targets[0]
if not isinstance(target, ast.Name):
raise ASTFrontendError(
"only assignment to a simple name is supported"
)
self.env[target.id] = self.visit(node.value)
For:
offsets = pid * BLOCK + tl.arange(0, BLOCK)
the right-hand side produces a symbolic Value, and offsets becomes a name
for that value in self.env.
Assignment itself does not create a new expression-tree operation. That is the same semantic result as before: Python local names are handles for symbolic expressions, not mutable storage in the kernel IR.
Annotated assignment is also accepted:
value: something = expression
The MVP records the value but does not interpret or enforce the local
annotation. An annotation without a value creates a local bound to None.
Augmented assignment is made explicit too:
acc += a_values * b_values
becomes conceptually:
self.env["acc"] = self.env["acc"] + rhs
The frontend supports +=, -=, *=, and /= on simple names. Attribute,
subscript, tuple-unpacking, and chained assignment targets are rejected instead
of acquiring accidental semantics.
Expression statements are evaluated and their return value is ignored. That is
exactly what a side-effecting call such as tl.store(...) needs: the call adds
a Store node to the active builder even though there is no local result to
remember.
Expressions reuse the symbolic language
The AST frontend does not rebuild the expression-tree IR itself. It evaluates
syntax into the same Value, Ptr, and language functions used by the old
tracer.
For a binary operation, it first evaluates both operands:
lhs = self.visit(node.left)
rhs = self.visit(node.right)
Then it applies the corresponding Python operator:
if isinstance(node.op, ast.Add):
return lhs + rhs
if isinstance(node.op, ast.Sub):
return lhs - rhs
if isinstance(node.op, ast.Mult):
return lhs * rhs
if isinstance(node.op, ast.Div):
return lhs / rhs
if isinstance(node.op, ast.BitAnd):
return lhs & rhs
If lhs or rhs is symbolic, its overload creates the same BinOp or
AddPtr node as before. If both are constexpr values, Python computes an
ordinary compile-time result.
This is a useful division of responsibility:
AST frontend: what syntax did the user write?
symbolic values: what expression-tree node does that operation mean?
Unary + and -, simple < and is comparisons, tuples, lists, and constants
follow the same pattern.
Calls are similarly direct:
def visit_Call(self, node):
fn = self.visit(node.func)
args = [self.visit(arg) for arg in node.args]
kwargs = {
keyword.arg: self.visit(keyword.value)
for keyword in node.keywords
}
return fn(*args, **kwargs)
For tl.load(...), visit_Attribute first resolves tl, then getattr
resolves load. The evaluated function receives symbolic pointers, masks, and
values and builds a normal Load expression.
**kwargs are deliberately unsupported. The frontend wants each supported
source construct to be visible in its own implementation instead of delegating
arbitrary Python call behavior back to CPython.
Indexing is narrow on purpose
Part 8 added exactly two symbolic indexing forms:
x[:, None]
x[None, :]
The AST for these expressions contains Subscript, Tuple, Slice, and
Constant(None) nodes. The frontend turns them back into ordinary Python index
objects:
def _eval_index(self, node):
if isinstance(node, ast.Tuple):
return tuple(self._eval_index(elt) for elt in node.elts)
if isinstance(node, ast.Slice):
if node.lower is None and node.upper is None and node.step is None:
return slice(None)
raise ASTFrontendError("only ':' slices are supported")
if isinstance(node, ast.Constant) and node.value is None:
return None
return self.visit(node)
Then visit_Subscript applies that index:
value = self.visit(node.value)
index = self._eval_index(node.slice)
return value[index]
The symbolic Value.__getitem__ from Part 8 still decides that [:, None]
means ExpandDims(axis=1) and [None, :] means ExpandDims(axis=0).
This is another useful separation. The frontend understands Python’s spelling of an index. The symbolic language understands which IR operation that index means.
General slicing, computed slice bounds, ellipsis, and arbitrary symbolic indexing remain unsupported.
Compile-time loops are interpreted, not captured
The most compiler-shaped statement in Version 9 is for.
It is also the main reason for switching frontends. The inner dimension of
matmul is naturally expressed as a loop over K. Version 9 still unrolls that
loop because K is constexpr, but the decision is now made by mytriton after
it recognizes an ast.For, rather than being an invisible side effect of
calling the Python function.
The MVP accepts only a simple induction variable and one of two iterators:
for k in range(...):
or:
for k in tl.static_range(...):
The range can use one, two, or three positional arguments. Every bound and the
step must evaluate to an exact Python int:
for name, value in (
("start", start),
("stop", stop),
("step", step),
):
if type(value) is not int:
raise ASTFrontendError(
"dynamic range bounds are not supported by "
f"AST frontend MVP; {name} is {value!r}"
)
The frontend then performs ordinary compile-time unrolling:
for value in loop_range:
self.env[name] = value
self.visit_stmt_list(node.body)
For K = 3, the matmul body is visited three times. Each visit adds another
set of loads, multiplication, and accumulator update to the expression tree.
There is still no loop operation in SSA or generated CUDA.
This matches the behavior of the previous tl.static_range helper, which
returned a Python range. The difference is that unrolling now belongs to the
frontend and can eventually evolve independently from Python execution.
The induction variable is scoped carefully. If the same name existed before the loop, its old value is restored. Otherwise the temporary name is removed after unrolling.
The current frontend rejects for/else, non-name targets, arbitrary iterables,
keyword arguments to ranges, and dynamic bounds. Those constructs would need
new language semantics, not just more visitor methods.
Compile-time conditional expressions
Version 9 supports Python’s conditional expression:
value = lhs if CONDITION else rhs
but only when CONDITION evaluates to an ordinary Python Boolean:
def visit_IfExp(self, node):
test = self.visit(node.test)
if not isinstance(test, bool):
raise ASTFrontendError(
"only constexpr conditions in IfExp are supported"
)
return self.visit(node.body) if test else self.visit(node.orelse)
Only the selected branch is visited. This is compile-time specialization, not runtime control flow.
A symbolic condition cannot silently become Python control flow. The frontend rejects it because representing that condition would require branch operations, blocks, and merge semantics in the IR.
There is also no statement-form if yet. Supporting a constexpr if statement
would be a small extension. Supporting a runtime symbolic if would be a much
larger compiler milestone.
Unsupported Python becomes a frontend error
ast.NodeVisitor normally walks unknown nodes recursively. That default is
convenient for analysis tools, but dangerous for a language frontend: a new
syntax form could appear to work while important semantics are skipped.
Version 9 reverses the default:
def generic_visit(self, node):
raise ASTFrontendError(
f"unsupported AST node: {type(node).__name__}"
)
Every accepted syntax node therefore needs an explicit visitor method.
Examples of honest failures include:
unsupported AST node: If
unsupported AST node: While
unsupported AST node: BoolOp
return statements are not supported in kernels
only assignment to a simple name is supported
dynamic range bounds are not supported by AST frontend MVP
The exact supported subset is small:
- expression, assignment, annotated assignment, and augmented assignment statements;
- compile-time
forloops overrangeortl.static_range; - names, constants, tuples, lists, attributes, calls, and narrow subscripts;
+,-,*,/, and&;- unary
+and-; - simple
<andiscomparisons; - constexpr conditional expressions.
That list is not an attempt to describe all of Python. It is a language contract for the kernels mytriton can currently compile.
This also improves precedence diagnostics. A chained comparison is rejected
with a reminder to parenthesize both sides of &:
only simple comparisons are supported; when combining comparisons with &,
wrap each comparison in parentheses
The frontend sees the malformed syntax shape directly. It no longer has to
wait for symbolic __bool__ to fail somewhere during Python execution.
The rest of the compiler does not change
The public compilation path changes in one important import:
from .ast_frontend import trace
instead of:
from .trace import trace
The old trace function in trace.py is removed. Its symbolic classes,
expression nodes, builder, parameter construction, and language operations all
remain.
The new frontend ends with the same builder protocol:
with Builder() as builder:
ASTTracer(env, external_env).visit_stmt_list(function_def.body)
return builder.ops, runtime_params
So every later stage receives the same interface:
list of expression-tree operations + runtime parameters
Type inference does not need to know whether CPython or ASTTracer produced
the tree. SSA lowering does not change. The verifier and optimizers do not
change. CUDA and MLIR code generation do not change.
That lack of downstream churn is part of the result. The expression-tree IR was already a real boundary. Version 9 replaces the implementation on one side of that boundary without rewriting the other side.
Existing kernels become frontend tests
Version 9 mostly moves test imports from:
from mytriton.trace import trace
to:
from mytriton.ast_frontend import trace
The expected expression trees and SSA stay unchanged.
That means the existing kernel suite now exercises the AST frontend across a fairly useful source subset:
- vector and matrix elementwise kernels cover assignments, calls, arithmetic, comparisons, masks, and stores;
- rank-2 tile kernels cover tuple subscripts, full slices,
None, attributes, and&; - reduction and softmax kernels cover nested symbolic expressions;
- naive and rank-2 matmul cover constexpr parameters,
tl.static_range, loop induction variables, and repeated accumulator updates; - long-row sum covers compile-time
range-style unrolling.
This is a compatibility test for the new frontend architecture. If the AST visitor gives an existing source construct different meaning, the exact tree, SSA, or generated-source assertions expose it.
There are many dedicated negative frontend tests still worth adding: unknown
names, multiple assignment targets, while, dynamic ranges, for/else,
symbolic conditional expressions, unsupported slices, and return statements.
The explicit errors are already implemented; a focused test file would make
that language boundary even more visible.
Current boundaries
Version 9 is intentionally not a full Python frontend.
It depends on inspect.getsource, so source must be recoverable for the kernel
function.
Only ordinary def functions are found. Async functions and lambdas are not
part of the kernel language.
Assignments target simple local names. Destructuring, attribute assignment,
subscript assignment, chained assignment, break, continue, exceptions,
context managers, and nested definitions are not supported.
Loops are compile-time only. There is no loop representation in expression IR or SSA, and runtime scalar values cannot control iteration counts.
Conditional expressions are constexpr only. Statement if and runtime control
flow are not represented.
Calls still invoke resolved Python callables. That is useful for the small
mytriton.language API and compile-time helpers, but it is not yet a restricted
or sandboxed execution model. An AST frontend makes syntax explicit; it does
not automatically make arbitrary external calls safe or compilable.
The frontend uses Python values as its compile-time value system. There is no separate constant evaluator yet.
These boundaries are real, but the failure mode is now much better. Unsupported syntax is rejected at the frontend with a local explanation instead of being partially executed and failing in an unrelated symbolic overload.
What changed conceptually
Before Version 9, mytriton traced a kernel by doing this:
run the Python function and observe symbolic operations
That is a powerful prototype technique. It made Version 1 tiny and let the project focus first on expression nodes, types, SSA, verification, optimization, and backends.
But it left one compiler layer implicit. Python decided how statements and expressions executed, and mytriton only participated when symbolic objects were involved.
For matmul, that meant Python owned the loop over the inner dimension. The compiler saw only the operations left after unrolling and had no source-level loop to inspect, validate, transform, or eventually lower without unrolling.
After Version 9, tracing means:
parse the Python function and interpret a supported kernel-language AST
The symbolic value machinery still builds the expression tree. The important change is that mytriton now owns the walk from source syntax to those symbolic operations.
That creates a real place for the next frontend semantics:
- runtime matmul loops over the inner dimension that survive into IR instead of being fully unrolled;
- constexpr
ifstatements; - better source-position diagnostics;
- explicit compile-time evaluation;
- runtime control flow once the IR can represent blocks and branches;
- language-specific restrictions before arbitrary Python executes;
- transformations that need to see syntax before it disappears.
Version 9 does not implement those next steps yet. It makes compile-time loop unrolling explicit and creates the layer where runtime loops and the other features belong.
The conceptual shift is small enough to fit in one line:
Python is now the syntax of the kernel language, not its hidden frontend.
All code for this milestone is available at https://github.com/pbelevich/mytriton/tree/ver9.
Next: Part 10: Runtime For Loops.