InlineFunctions Pass¶
Eliminates FunctionType.Inline functions by splicing their bodies at every call site.
Overview¶
Functions decorated as @pl.function(type=pl.FunctionType.Inline) (or via the JIT-side @pl.jit.inline) are source-level utilities: each call site expands into a fresh, alpha-renamed copy of the body, with formal parameters substituted by actual-argument expressions. After this pass, no FunctionType.Inline function and no Call to one survives in the program — subsequent passes treat the spliced code as if it had been written inline at the call site.
Runs as the first pass in OptimizationStrategy.Default so downstream passes (UnrollLoops, OutlineIncoreScopes, …) never observe Inline functions.
Produces: IRProperty.InlineFunctionsEliminated.
Requires: nothing — runs on a freshly parsed program.
When to use: Always, as part of the default pipeline. The pass is a no-op when no Inline functions exist.
API¶
| C++ | Python | Level |
|---|---|---|
pass::InlineFunctions() |
passes.inline_functions() |
Program-level |
Python usage:
from pypto.pypto_core import passes
inline_pass = passes.inline_functions()
program_inlined = inline_pass(program)
Algorithm¶
- Collect all functions with
func_type == FunctionType::Inline. - Cycle-detect the Inline → Inline call graph; raise
pypto::ValueErrornaming the cycle if one is found. - Iterate to fixpoint — each iteration walks every function (including the Inline ones, so that nested Inline-calls-Inline expands transitively):
- For every top-level
LHS = inline_call(args)orEvalStmt(inline_call(args))in a function body:- Build the param-substitution map (formal
Var→ actualExpr). - Alpha-rename every locally-bound
Varin the inlined body to a fresh name (<orig>_inline<counter>, with any trailing_trimmed off<orig>) to avoid collisions across multiple call sites. - Splice the renamed-and-substituted body's statements before the call site.
- Wire up the callee's trailing return value according to the call-site form:
LHS = renamed_return(single-return assign; omitted whenLHSresolves to the sameVaras the substituted value, to avoid a redundant SSA copy), per-elementTupleGetItemExprsubstitution instead of aMakeTuplebinding (multi-return assign), a freshReturnStmt(return inline_call(...)), or a freshEvalStmtwhen the value is discarded but its evaluation is observable (EvalStmtcall site — see Edge cases).
- Build the param-substitution map (formal
- Drop all Inline functions from the program.
The pass uses a single underscore (_inline) in the rename suffix because __ is reserved by the IR's auto-naming convention (see auto_name_utils.h).
A single-underscore suffix is not sufficient on its own: <orig> may itself end in _ — _ is Python's throwaway name and the documented loop variable of for _ in pl.split_aiv(...) — and plain concatenation would then fuse two individually-legal underscores into the reserved delimiter. FreshName therefore joins through auto_name::JoinNameSuffix, which trims that tail: _ renames to _inline7, not __inline7. A <orig> that already contains __ is passed through unchanged, so an author-written a__b stays the user-facing error ValidateBaseName reports rather than being silently normalized.
Example¶
Single call site¶
Before:
@pl.program
class P:
@pl.function(type=pl.FunctionType.Inline)
def helper(self, x):
y = pl.mul(x, x)
return y
@pl.function
def main(self, a):
z = self.helper(a)
return z
After:
@pl.program
class P:
@pl.function
def main(self, a):
y_inline0 = pl.mul(a, a)
z = y_inline0
return z
Multiple call sites¶
Each site is independently alpha-renamed, so locals never collide:
Before:
@pl.function(type=pl.FunctionType.Inline)
def square(self, x):
y = pl.mul(x, x)
return y
@pl.function
def main(self, a, b):
a2 = self.square(a)
b2 = self.square(b)
return pl.add(a2, b2)
After:
@pl.function
def main(self, a, b):
y_inline0 = pl.mul(a, a)
a2 = y_inline0
y_inline1 = pl.mul(b, b)
b2 = y_inline1
return pl.add(a2, b2)
Inline body containing pl.at¶
The scope is preserved verbatim and gets outlined by OutlineIncoreScopes later in the pipeline, exactly as if it had been written at the call site.
Edge cases¶
| Case | Behaviour |
|---|---|
| Inline function with no callers | Silently removed from the program. |
| Inline function as program entry | Not detected as an error here — but no Call to it exists, so it is removed in the cleanup phase like any other no-caller function. |
| Inline calls Inline (transitive) | Iteratively expanded to fixpoint. |
| Recursive Inline (self or mutual) | pypto::ValueError raised before any splicing, with the cycle named (a -> b -> a). |
| Multi-return inline | No LHS = MakeTuple([rets...]) is emitted — orchestration codegen cannot lower MakeTuple. The cloned return values are recorded against the LHS Var and downstream TupleGetItemExpr(LHS, i) uses are rewritten to value i, leaving the LHS binding unreferenced (see SpliceInlineCallAsTupleSub). |
Nested call to Inline (e.g. pl.add(inline_fn(x), y)) |
Not handled in v1 — left as-is. The InlineFunctionsEliminated verifier flags any surviving Call. |
EvalStmt(inline_call(...)) — return value ignored |
The value is discarded, its evaluation is not. See Discarding a return value below. |
Discarding a return value¶
An EvalStmt call site — self.wrapper(x, out) with no LHS — has nowhere to put the callee's trailing return value. Dropping that value is correct; dropping its evaluation is not, because evaluating it can write through Out / InOut arguments, launch a task, block on a signal, or set up hardware. Each discarded value is therefore classified:
| Discarded value | Behaviour |
|---|---|
A Call — any callee, cross-function or builtin |
Re-emitted as an EvalStmt, in return order. The fixpoint loop expands a cross-function one on its next iteration when that callee is also Inline; otherwise it stays an ordinary dispatch, exactly as if the author had written it at the call site. |
A Submit |
Re-emitted as an EvalStmt. A task launch is effectful whatever its callee does. |
Anything else that hides no call — a Var, a constant |
Dropped. |
A value that is not itself call-like but wraps a call — scalar arithmetic such as self.bump(n) + 1, a MakeTuple, a TupleGetItemExpr |
pypto::ValueError. It cannot become an EvalStmt, and deleting it would delete the nested call with it. Return that call directly, or bind the wrapper's result at the call site. |
Why every call, rather than only the ones that write. Nothing in the IR answers "is this call safe to delete". The nearest registry data, OpRegistryEntry::WritesAnyArg, answers whether an operator writes through an argument, and keying deletion on it is wrong in both directions:
- Most operators are simply unclassified — 263 of 315 at the time of writing, among them
tile.tpush_to_aivandsystem.aic_initialize_pipe, whichdce::IsSideEffectOplists as side-effecting.OpRegistryEntry::HasDeclaredArgEffectsexists precisely so an analysis can tell "declared to write nothing" from "nobody looked yet". - A positive
no_arg_writes()verdict does not mean deletable either.pld.system.waitblocks until a signal slot satisfies a threshold,pld.system.defer_waitregisters a completion condition, andsystem.set_fftshands the FFTS unit its workspace pointer — all three declareno_arg_writes()while carrying synchronization or hardware-setup semantics.
So the pass keeps every call. A discarded genuinely pure call survives as a dead EvalStmt, which the pipeline carries harmlessly. Narrowing this needs a real "safely deletable" operator property, declared per operator rather than inferred from writes.
Before:
@pl.function(type=pl.FunctionType.Inline)
def writeout(self, t, out: pl.Out[...]):
return pl.tile.store(t, [0, 0], out) # the write IS the return expression
@pl.function(type=pl.FunctionType.InCore)
def kernel(self, a, out: pl.Out[...]):
t = pl.tile.load(a, [0, 0], [64, 64])
self.writeout(t, out) # return value ignored
return out
After — the store survives:
@pl.function(type=pl.FunctionType.InCore)
def kernel(self, a, out: pl.Out[...]):
t = pl.tile.load(a, [0, 0], [64, 64])
pl.tile.store(t, [0, 0], out)
return out
Verification¶
The InlineFunctionsEliminated PropertyVerifier (registered against IRProperty.InlineFunctionsEliminated) confirms:
- No
Functionwithfunc_type == FunctionType::Inlineremains. - No
Callwhose callee resolves to one survives.
See also¶
python/pypto/jit/decorator.py—@pl.jit.inlineis the user-facing front end (_SubFunctionDecorator("inline", ...)).- 02-unroll_loops — runs immediately after.
- 09-outline_incore_scopes — handles the
pl.atscopes that survive splicing.