TileFoundry Spec — passes¶
The pass framework: Pass / ModulePass / FunctionPass /
PrimFuncPass / PassManager. A Pass is the unit the compiler
schedules over a Module; lowering is one stage in this pipeline,
not a free function. After the framework, this spec lists the
implemented passes and their per-pass contracts (HIR → TIR
lowering rules, buffer planning, …).
flowchart TB
Pass["<b>Pass</b> (ABC)"]
ModulePass["<b>ModulePass</b> (Pass)"]
FunctionPass["<b>FunctionPass</b> (Pass)"]
PrimFuncPass["<b>PrimFuncPass</b> (Pass)"]
PassManager["<b>PassManager</b><br/>holds list[Pass]"]
Pass --> ModulePass
Pass --> FunctionPass
Pass --> PrimFuncPass
PassManager -. schedules .-> Pass
1. Role¶
A Pass abstracts every Module-reading or Module-rewriting compile
step; PassManager runs registered passes in registration order. The
pass framework is:
- a linear pipeline — passes run sequentially in registration order;
- three pass granularities:
ModulePass/FunctionPass/PrimFuncPass; - explicit registration — passes are added via
PassManager.add(...).
2. Pass base class¶
from abc import ABC, abstractmethod
from tilefoundry.ir.core import Module
class Pass(ABC):
"""A Module → Module pure function.
Side-effect logging is allowed, but the input Module MUST NOT be
mutated — Module is a frozen dataclass, so a mutator returns a new
instance.
Attributes:
name: attribute; Stable dump and log name.
requires: attribute; Ordered dependency assertion.
"""
name: str = ""
requires: tuple[str, ...] = ()
@abstractmethod
def run(self, module: Module) -> Module: ...
- constraints:
run(module)returns a newModule; it does not mutate input.- Passes MUST NOT depend on global state. All configuration enters through constructor parameters or pass-local attributes.
- Pass failures raise named exceptions (e.g.
VerifyError); they do not swallow errors.
3. Three pass granularities¶
3.1 ModulePass¶
Runs over the whole Module and may add / remove / reorder
functions. Examples: module-level inline, dead-function elimination,
HIR → TIR replacement (substitute tir.PrimFunction for
hir.Function).
class ModulePass(Pass):
"""Run over a complete Module."""
@abstractmethod
def run(self, module: Module) -> Module: ...
- constraints:
runreturns a newModule; inherits thePassno-mutation / no-global-state contract (§2).
3.2 FunctionPass¶
Visits each hir.Function. The framework supplies a default run
that walks module.functions, calls run_function for HIR
entries, and reassembles the Module.
from tilefoundry.ir.hir import Function as HirFunction
class FunctionPass(Pass):
"""Run independently over each HIR Function."""
@abstractmethod
def run_function(self, fn: HirFunction, module: Module) -> HirFunction: ...
def run(self, module: Module) -> Module: ...
- constraints:
- inherits the
Passcontract (§2); the defaultrunreassembles theModulefromrun_functionresults.
3.3 PrimFuncPass¶
Same shape as FunctionPass, but visits tir.PrimFunction.
from tilefoundry.ir.tir import PrimFunction
class PrimFuncPass(Pass):
"""Run independently over each TIR PrimFunction."""
@abstractmethod
def run_prim_func(self, fn: PrimFunction, module: Module) -> PrimFunction: ...
def run(self, module: Module) -> Module: ...
- constraints:
- inherits the
Passcontract (§2); same shape asFunctionPassovertir.PrimFunction.
4. Transform pass idiom¶
Transform passes use the visitor / mutator base classes from
visitor-mutator rather than hand-written
isinstance dispatch.
A transform PrimFuncPass wraps a StmtExprMutator subclass: run_prim_func
runs the mutator over fn.body, returns fn unchanged when the mutator
preserves identity (the returned body is the same object), and otherwise
returns replace(fn, body=new_body). The inner mutator matches on Evaluate
and dispatches on type(stmt.callable).
The visit-and-rewrite contract — including the visit_Evaluate
entry form for TIR effect Ops — is owned by
visitor-mutator §7.
5. PassManager¶
A linear scheduler that runs passes in registration order and
optionally drops per-pass IR dumps when wrapped in a
tilefoundry.dump.DumpScope.
class PassManager:
"""Ordered pass pipeline.
Attributes:
passes: attribute; the registered passes, run in registration order.
"""
passes: list[Pass] = field(default_factory=list)
def add(self, p: Pass) -> "PassManager": ... # register a pass; returns self for chaining
def run(self, module: Module) -> Module: ... # run passes in registration order
- constraints:
requiresis an ordering assertion checked before the run, not a topological sort.
PassManager does not own a dump destination. When the caller
wraps the run in a DumpScope and DumpFlags.PASS_IR is enabled,
each pass writes before.txt / after.txt under
<scope-root>/{NN}_{pass_name}/. Without an active scope or with
the flag off, dump(...) is a no-op.
6. Top-level API¶
Three public verbs operate on Module (no bare HirFunction /
PrimFunction):
class CompilerOptions:
"""Carry deterministic compiler configuration.
Attributes:
target: attribute; Constructed compilation Target.
"""
target: Target
def canonical_text(self) -> str: ...
def lower(mod: Module, /, *, target: Target | None = None) -> Module: ...
def build(mod: Module, /, *, target: Target | None = None) -> RuntimeModule: ...
def compile(mod: Module, /, *, target: Target | None = None) -> RuntimeModule: ...
lower runs the default pipeline (HirToTirPass → BufferizePass →
…) and returns a lowered TIR Module. Mesh bindings come from
the HIR body's MeshScope regions — the verbs do not accept
cta_mesh / thread_mesh kwargs.
lower uses the Module-owned Target when present. At an undeclared root it
attaches either the explicit Target instance or default_target() before any
pass runs. An explicit Target that disagrees with a declared Module Target is
an error. build requires the lowered Module to own its Target and applies the
same explicit-conflict rule. Internally it
runs codegen → toolchain link → loader and returns a
RuntimeModule (runtime).
compile is build(lower(mod, ...)). The jit convenience and cache contract
are owned by runtime §1.3.
Dirty-scope retype / verify¶
typeinfer and verify are not standalone pipeline stages. The
parser already runs eager typeinfer (parser), so a
Module entering the pipeline has Expr.type filled. After each
pass runs, PassManager re-runs the relevant analysis on that
pass's dirty scope:
- HIR-side: changed
Functions reruntypeinfer. - TIR-side: changed
PrimFunctions rerunverify, which recursively retriggerstypeinferon the embedded Expr fields and refreshes their.type. - A
ModulePasswhose effect crosses functions (e.g. rewriting anEvaluate(SymbolRef)callee) MUST report its changed-function set or conservatively trigger a whole-module fallback.
Consequence: there is no separate TypeInferPass, and verify is
not inserted as a public ModulePass. Passes own rewrite work; the
unified retype / verify is scheduled by PassManager.
7. Implemented passes¶
7.1 HirToTirPass¶
class HirToTirPass(ModulePass): # replaces every hir.Function with a tir.PrimFunction
name = "hir_to_tir"
- constraints:
- TIR has no return-tensor form; HIR outputs become trailing params. The
per-op / mesh /
Reshard/ dispatch lowering rules are in the subsections below.
ModulePass. Replaces every hir.Function with a
tir.PrimFunction, materialising the HIR Function(params) →
tensor calling convention into the TIR explicit-output-param form
PrimFunction(params=(inputs..., outputs...), body=...). TIR has
no return-tensor form. After this pass, PassManager reruns HIR
typeinfer / TIR verify on the dirty scope.
Per-op lowering dispatch¶
Per-op lowering is registry-dispatched, not a hand-written isinstance
chain (§4): each HIR op registers its lowering handler keyed by op class
(register_hir_lowering(OpClass)), and the pass looks the handler up by
type(call.target). An op with a lowering-specific contract (e.g. HIR
Reshard) registers its own lowering, so the pass core depends on the registry
contract, not on importing target-specific op classes.
A handler is a free function handler(ctx, target, expr) -> Var, where
ctx is the lowering context and target is the dispatched Op
instance. A handler SHOULD interact with ctx only through its public ABI:
ctx.lower(expr) -> Var— recursively lower a nested sub-expression;ctx.fresh(type, hint) -> Var— a fresh Var name (no binding emitted);ctx.alloc(type, hint) -> Var— a fresh Var bound to a newAllocTensor;ctx.emit(stmt)— append a raw Stmt to the pending lowered sequence;ctx.emit_bind(var, value)— append a let-bindingvar = value.
A target-owned op's handler MUST use only this public ABI — it has no
standing to reach into pass-internal state. A core op whose lowering
depends on pass-internal cooperation (dispatch-group resolution, the
reshard cross-CTA sync fence, tuple-carry field lookup) MAY call other
_Lowerer methods directly; this is the exception, not the norm.
A unit-stride HIR Slice lowers to a TensorView at the per-axis absolute
element starts; InsertSlice uses the same coordinate convention. Neither
multiplies an already-absolute coordinate by a window extent. A start that is dim
arithmetic over literals and scalar Vars is an address the emitter computes, so
lowering MUST carry it to the coordinate site rather than lower it as a value. A
coordinate that an op computes MUST be refused: materializing it produces a
buffer, and a buffer is not a scalar index. A
non-divisible tile loop whose body consumes such a fixed-shape window MUST raise
until a handwritten residual-tail lowering is supplied; a window moved by a
compile-time offset is still that loop's window for this rule.
Mesh structure derivation¶
HirToTirPass MUST preserve the HIR execution regions when deriving TIR
structure. It MAY derive target-specific mesh information from
ShardLayout.mesh references in the body, but it MUST NOT fabricate a
synthetic MeshScope for a missing reference. Each HIR MeshScope lowers to
the corresponding TIR scope around its body.
Reshard lowering — dual semantics¶
The rewrite of Reshard(x, layout, storage) selects a different
TIR shape based on whether storage is provided:
- No storage (
storage == ""): emit aTensorView(x, layout)— a pure shard tensor view, no allocation, no copy. The result is aVarcarrying the newShardLayouttype. - With storage (
storage != ""): emitdst = AllocTensor(plain_type, storage=...)followed byEvaluate(Copy, (TensorView(x, layout), dst))— allocate a plain tensor (noShardLayout) and copy from the shard view into the plain storage.
Dispatch lowering¶
The pass lowers each Module.functions entry by its shape
(hir.md §1.1):
- A normal function (
variants == ()) lowers on the default single-body path. - A dispatch prototype (
variants != (),body is None) lowers through the dispatch path: - Each variant lowers to its own
tir.PrimFunctionunder the mangled symbolf"{name}${dim_var}${lo}_{hi}". Variant params keep the originalTensorTypeenvelope; the dispatched range is carried by the variant'sspecializationsand the mangled symbol, not by narrowed param types. - The prototype emits one entry
tir.PrimFunctionunder the unmanglednamewhose body is a singletir.DispatchCall(see tir.md §1.6):subjects = (ShapeOf(param, axis),)for the canonical(param, axis)of the dispatchDimVar;case_patternscarries each variant's pattern in source order;case_callsis a parallel tuple ofEvaluate(SymbolRef, args)invoking the mangled variants;fallback = Sequential((Abort(),)).
A Call(target=hir_fn) whose callee is a dispatch prototype
(variants != ()) lowers to a tir.DispatchCall covering the
reachable set — callee variants whose specialization range
intersects the caller-side range carried by the call argument at the
callee's canonical (param_index, axis). The caller-side range is
derived from call.args[param_index].type.shape[axis]:
- a static integer
k→ singleton half-open[k, k+1); - a
DimVar(name, lo, hi)→ caller half-open range[lo, hi)read directly from the dim; - any other form → compile-time error.
An empty reachable set is a compile-time error. Coverage and
disjointness of the variants over the dispatch envelope are verified
statically (the partition rule, hir.md §1.1),
so an in-envelope shape always selects exactly one variant. The
tir.DispatchCall.fallback (Abort) is reached only by an
out-of-envelope shape — a call-contract violation.
Each lowered PrimFunction that references ShapeOf(param, axis)
gains a hidden scalar parameter named <param.name>_shape_<axis> of
TensorType((), i32). The CUDA host wrapper extracts the value from
the runtime tensor's shape; the parameter is invisible at the user
FFI surface (see target).
7.2 BufferizePass¶
PrimFuncPass. Runs after HirToTirPass, before codegen. The input
is already an explicit-buffer-param PrimFunction; this pass does
not perform an MLIR-style value → buffer IR conversion.
Responsibility: collect logical-buffer lifetimes and run the placement-policy
hook. The independent-allocation policy is already represented by one
AllocTensor per logical buffer, so the pass returns the PrimFunction
unchanged and does not write placement records into tir.memory.* descriptors.
class BufferizePass(PrimFuncPass):
"""Collect lifetimes and validate independent allocation placement.
Attributes:
collector: attribute; lifetime collector, defaulted during initialization.
scheduler: attribute; placement scheduler, defaulted during initialization.
name: attribute; pass name.
requires: attribute; required predecessor passes.
"""
collector: LifetimeCollector = None
scheduler: BufferScheduler = None
name: str = "bufferize"
requires: tuple[str, ...] = ("hir_to_tir",)
- constraints:
- every logical buffer gets an independent physical allocation; no reuse, pool, or lifetime overlap. Buffer planning is not a codegen responsibility.
- The scheduler's placement result is advisory under this policy;
run_prim_funcMUST return the input function unchanged.
7.3 insert_default_host_entry¶
def insert_default_host_entry(module: Module) -> Module:
"""Return a Module whose entry is host-callable.
Args:
module: Lowered Module to normalize.
Returns:
The unchanged or host-entry-normalized Module.
"""
...
- constraints:
- A CPU entry MUST pass through unchanged.
- A dispatch entry MUST be retargeted to CPU while its launched variants remain device functions.
- With no CPU entry and exactly one CUDA device function, the transform MUST synthesize a CPU entry that mirrors the parameters and launches that function. It MUST reject ambiguous device-function sets, a non-entry CPU function, or a launch-provided dynamic CTA extent.
8. Directory layout¶
File layout is implementation-owned. Analysis registry ownership is defined by visitor-registry.