Simplify Pass¶
Folds arithmetic expressions, type-embedded shape expressions, and scalar constant bindings using algebraic rewrite rules and bound analysis.
Overview¶
Simplify is a function-level pass that rewrites the IR in place using arith::Analyzer. It performs three kinds of work:
- Arithmetic folding at every expression leaf (e.g.
x + 0 → x,x * 1 → x,min(a, a) → a, comparisons that the analyzer can decide). - Type rebuild — re-walks shape expressions embedded in
TensorType,TileType, andTupleTypeso the in-memory IR matches what a fresh parse would produce. - Scalar binding for folding + DCE — a scalar
Varassigned once is registered with the analyzer. A constant assigned at function-body top level is bound fully so its literal propagates into every downstream use; a symbolic value, or a constant inside a loop/branch, contributes only aConstIntBound— enough to fold dead branch guards likeif expr == 0without inlining the scalar. Bindings left dead are dropped by a conservative scalar DCE.
The pass runs twice in the Default strategy of pass_manager.py:
- Post-SSA (after
ConvertToSSA, beforeFlattenCallExpr): propagates closure-captured constants such asCHUNK_K: Scalar[INDEX] = 512into shape expressions and types so subsequent tile-lowering passes see literals instead of variables. - End of tile pipeline (after
DeriveCallDirections): final cleanup of folds exposed by memory-space inference, layout resolution, and other late lowering.
Requires: nothing.
Produces: nothing.
Invalidates: nothing.
The empty PassProperties contract (kSimplifyProperties in include/pypto/ir/transforms/pass_properties.h) is intentional: Simplify is conservative enough to preserve every property its callers may have established (SSAForm, NormalizedStmtStructure, IncoreTileOps, ...) — it only rewrites expressions and prunes scalar bindings, never restructures statements.
When to Use¶
- After SSA conversion to propagate scalar constants into types/shapes before the tile pipeline inspects them.
- At the end of the tile pipeline as a cleanup pass so that downstream artifacts (printed IR, codegen) are not littered with
K + 0oridx * 1residue. - Anywhere else a pass produces fresh expressions that may be foldable; Simplify is cheap and idempotent so it is safe to insert defensively. The one bounded exception is a chain of more than 16 nested single-trip loops, where a second run folds further — see Substitution-depth cap. No pipeline input reaches that depth.
API¶
| C++ | Python | Level |
|---|---|---|
pass::Simplify() |
passes.simplify() |
Function-level |
Factory function:
Python usage:
from pypto.pypto_core import passes
simplify_pass = passes.simplify()
program_simplified = simplify_pass(program)
Algorithm¶
Implemented by TransformSimplify in src/ir/transforms/simplify_pass.cpp in five phases:
- Multi-assign collection —
MultiAssignCollectorwalks the function body and records every scalarVarassigned more than once. These are excluded from analyzer binding so a stale value cannot be used past a later reassignment. AVarassigned exactly once — even inside a loop body or branch — is safe to bind:SimplifyMutatorscopes every binding to the region the assignment lives in (see phase 2), unbinding it on region exit. Under SSA everyVaris single-assigned, so nothing is collected. SimplifyMutatortraversal — extendsarith::IRMutatorWithAnalyzer. The analyzer carries a constraint stack (loop-var bounds, if-branch conditions, scalar bindings). Folding happens at the leaves rather than only at top-level expressions because the analyzer's top-levelSimplifydoes not recurse into non-arithmetic containers (Call,MakeTuple):VarPtr: substitute via the var-remap table, then run through the analyzer.BinaryExpr/UnaryExpr: visit children, then fold the rebuilt node.CallPtr: refresh the resulttype_so a Call whose shape arguments folded ends up structurally equal to a freshly parsed Call.AssignStmt: for a scalar LHSVarnot inmulti_assigned_, register the simplified RHS with the analyzer. AConstInt/ConstFloat/ConstBoolRHS at function-body top level is bound fully (the literal is substituted into later uses); a symbolic RHS — or a constant inside a loop/branch — contributes only aConstIntBound, so dead branch guards fold without the scalar being inlined. Every binding is logged so the enclosing region's visitor can unbind it on exit.ForStmt: rebuilditer_args_before visiting the body so body references pick up the remapped identity; if bothstart_andstop_fold toConstIntwithstop > start, bind the loop var to that range while visiting the body and unbind on exit; scalars bound inside the body are unbound after the visit; rebuildreturn_vars_after the body so folds discovered inside are visible in return types. Pure single-trip and zero-trip loops are also collapsed in-place — see "Control-flow folding" below.IfStmt: enterAnalyzer::GetConstraintContext(cond)for the then branch andNot(cond)for the else branch; scalars bound inside each branch are unbound after that branch so they do not leak into the other branch or past theIfStmt. Conditions the analyzer can prove are also folded — see "Control-flow folding" below.WhileStmt: same asForStmtminus the bounds — rebuilditer_args_before the condition and body, snapshotvar_remap_around the body visit, then rebuildreturn_vars_after it, with the same scoped scalar unbinding. Rebuildingiter_args_first is required, not cosmetic: anIterArguse is the same node as its declaration and carriesinitValue_, so the baseIRMutatormints a freshIterArgat the first use whose init the analyzer rewrote. Seedingvar_remap_from the header makes every reference resolve to one node; skipping it leaves the header on the staleIterArgwhile all body uses point at an undefined clone (aUseAfterDeffailure).SpmdScopeStmt: visit the body with the same scoped scalar unbinding, and additionally foldcore_num_(closure arithmetic such asMAX // TILEmay need one pass of simplification after SSA conversion).- Type rebuild —
SimplifyTyperecurses throughTensorType,TileType, andTupleType, callingSimplifyExpron every embedded expression (shape, stride, valid_shape, start_offset, view fields). Identity is preserved when nothing changes so the round-trip identity check stays cheap. - Scalar DCE + dead yield-slot prune — after the mutator finishes,
dce::EliminateDeadScalarAssignmentswalks the flattened body and drops scalarAssignStmts whose only uses were folded away. The DCE is conservative: it never removes call-backed assignments because the IR has no purity annotations yet and aCallmay have observable side effects. Between the two scalar-DCE runs,dce::EliminateDeadYieldSlotsdrops the yielded slots nobody reads — anIfStmtphi whosereturn_vars_[i]has no user, and aForStmt/WhileStmtcarry whoseiter_args_[i](read inside the body) andreturn_vars_[i](read after the loop) are both unused — together with the matchingYieldStmtslot. Reusing one Python local across two scopes produces exactly that dead carry: SSA seeds the second loop with the first scope's value, every trip overwrites it, and nothing reads either end. Left in place it makes the earlier scope's value live-out, which for a device scope would force aScalarinto the outlined kernel's return set — see 08-outline_incore_scopes.md. - Loop-state repair — if DCE removed any statements,
loop_repair::MakeBodyreassembles the function body so loop-carried metadata (yield/return mappings) stays consistent.
Control-flow folding¶
Two folds run inside the SimplifyMutator traversal so they share the analyzer's constraint stack with the surrounding expression-level work:
- Fold A — constant-condition
IfStmtcollapse. After the condition is simplified, query the analyzer withCanProve(cond)andCanProve(Not(cond)). On a proof of either polarity, drop the dead branch and lift the kept branch into the parent scope. Whenreturn_vars_is non-empty, the kept branch's trailingYieldStmtis stripped and eachreturn_vars[i]is bound invar_remap_to the corresponding yielded value so subsequent siblings (and the functionReturnStmt) read the value directly. Symmetric for true / false; the only edge case is "always-false with no else and empty return_vars," which collapses to an empty body. - Fold B — pure single/zero-trip
ForStmtcollapse. Fires only on pure sequential loops:attrs_empty,kind_ == ForKind::Sequential. For these, query the analyzer for the trip count usingCanProveGreaterEqual(step, 1)plusCanProve(stop <= start)(zero trips) orCanProve(start < stop && stop <= start + step)(one trip). On zero trips, emit oneAssignStmt(return_vars[i], iter_args[i].initValue_)per return var and drop the body. On one trip,DeepClonethe body withloop_var → startanditer_args[i] → init_values[i]substitutions, re-visit the cloned body so further folds happen in the same pass, then strip the trailingYieldStmtand bind eachreturn_vars[i] → yielded_value[i]invar_remap_(same propagation mechanism as Fold A's lift). The one-trip path is capped atkMaxNestedSingleTripFolds(16) levels of nested single-trip loops per run — see Substitution-depth cap.
DeepClone with clone_def_vars=true is used (rather than an in-place var_remap_ override on the body) so the unrolled body gets fresh Var identities at every DefField, matching LoopUnrollMutator. This keeps the lifted copy structurally independent of the original (discarded) loop body and lets the re-visit bind the body's scalars on identities distinct from the surrounding scope.
The choice to substitute return_vars via var_remap_ rather than emit a literal AssignStmt(rv, yielded) is deliberate: the orchestration codegen's role-aware name disambiguation (role == "out" etc.) collapses several role-tagged SSA versions to the same C++ identifier, so an out__rv_v2 = out__co_l0_rv_v3 alias would lower to the ill-formed auto out = out;. Substituting at use sites side-steps the disambiguation entirely.
Escaping return vars¶
A substitution only reaches uses visited while its var_remap_ entry is live, and ForStmt, WhileStmt and IfStmt each rebase var_remap_ to a pre-body baseline on the way out so a body-internal remap cannot rewrite siblings or post-loop code. A use that outlives that restore would keep the original Var — which the fold has just stripped the only definition of, a dangling reference UseAfterDefCheck reports.
ReturnVarEscapeIndex (a pre-pass in simplify_pass.cpp) decides this per fold site. It walks the function body once, numbering the restoring scopes in pre-order so a scope owns the contiguous id range [id, end) of its subtree; "every use of v sits inside scope S" is then two integer comparisons. A monotonic tick orders uses against the fold site, so a use preceding it inside the same scope counts as escaping too. One walk plus O(1) lookups per fold keeps Simplify within its O(N log N) budget.
Statements the index has never seen answer "does not escape", keeping the substitution. That covers folds nested inside a body Fold B DeepCloned, whose Var identities are minted after indexing. A clone's Vars are unreachable from outside it, so the only unhandled case is a restore-scope within a clone standing between such a fold and a later use of its return var — pre-SSA only, and no worse than the behaviour before this index existed. Re-indexing each clone would close it. It is left open because the gap is pre-SSA-only and the pipeline runs Simplify only after ConvertToSSA, so nothing but a direct pre-SSA caller can reach it.
For an escaping return_vars[i], LiftBodyToReturnVars emits AssignStmt(return_vars[i], yielded_value[i]) at the fold site instead of recording the remap. The assignment stays inside the region being lifted — the yielded value may name body-local Vars, so it cannot be hoisted past the loop, and in leak-mode semantics the last iteration writing last is exactly what a post-loop read expects.
Nothing escapes in SSA form: a value defined inside a region is never referenced outside it, and every use is dominated by its definition. Since the pipeline runs Simplify only after ConvertToSSA (positions 5 and 46), the materializing path never fires there — it exists for callers that run Simplify directly on pre-SSA IR, where the alias-assignment concern above does not apply because SSA conversion still runs afterwards.
The two folds compose in a single pass: when Fold B substitutes loop_var → 0 in a body, predicates like if loop_var == 0 reduce to if 0 == 0 → ConstBool(true), which Fold A then collapses without a second Simplify run.
Examples¶
Algebraic identity¶
Before:
After:
x + 0 → x and x * 1 → x apply at every arithmetic leaf. The two scalar bindings are then dropped by the DCE phase and the body collapses to the return.
Loop-bound aware folding¶
Before:
After:
While visiting the loop body the analyzer is told that i ∈ [0, 8). The condition i < 16 therefore folds to True, the IfStmt collapses to its then branch, and the surrounding for is preserved unchanged.
Scalar constant propagation + DCE¶
Before (post-ConvertToSSA, closure value CHUNK_K = 512):
CHUNK_K__ssa_v0: pl.Scalar[pl.INDEX] = 512
acc: pl.Tile[[CHUNK_K__ssa_v0, 64], pl.FP32] = tile.zeros(...)
for k in pl.range(0, K, CHUNK_K__ssa_v0):
body(k)
return acc
After:
CHUNK_K__ssa_v0 is bound to 512 at its AssignStmt. Every downstream reference — including the embedded shape inside the TileType of acc — folds to the literal during the type-rebuild phase. The now-dead binding is dropped by the DCE phase. This is the primary motivation for the post-SSA scheduling point: tile-lowering passes such as FlattenTileNdTo2D and InferTileMemorySpace see concrete shape literals instead of opaque scalar Vars.
Constant-condition branch (Fold A)¶
Before:
After:
The analyzer binds i ∈ [0, 8) while visiting the loop body. CanProve(Not(i == -1)) succeeds — the comparison is statically false — so Fold A drops the then branch and lifts the else branch into the surrounding for-body. The same path runs for always-true conditions (drops else, lifts then). When the IfStmt has return_vars_, the kept branch's trailing YieldStmt is rewritten into AssignStmts on the return vars.
Dead branch guard through a scalar bound¶
Before:
for ob in pl.range(0, 68, 2):
off: pl.Scalar[pl.INDEX] = ob * 256 + 256
if off == 0:
first_chunk(off)
else:
later_chunk(off)
After:
The analyzer binds ob ∈ [0, 68) while visiting the loop body, so off's AssignStmt registers a ConstIntBound of [256, 17408] for off. CanProve(Not(off == 0)) then succeeds and Fold A drops the dead then branch. off is bound for analysis only — it is not substituted — so the surviving later_chunk(off) still references the scalar. (If off becomes unused after the fold, scalar DCE removes its binding.)
Where an index bound comes from¶
INDEX is the dtype of every index computation, and it is signed — codegen
emits arith.cmpi slt and arith.maxsi for it. So the dtype alone proves
nothing about a variable's sign, and the analyzer treats an unbound INDEX Var
as [-inf, +inf]. Non-negativity has to be established, never assumed:
| Source | Bound | Established by |
|---|---|---|
| Assigned scalar | its RHS's range | BindScalarBound, from the value being produced |
| Loop variable | [start, stop), or [start, +inf) when stop is symbolic |
IRMutatorWithAnalyzer on ForStmt, for a positive step |
| Branch condition | the constraint's range | EnterConstraint for the arm's scope |
| Block / subblock builtin | [0, +inf); a block count is [1, +inf) |
the op's own semantics, in ConstIntBoundAnalyzer |
| Whole shape / valid-shape dimension | [0, +inf) |
DimensionSymbolScope, around the write-union proofs |
| Runtime scalar parameter | [-inf, +inf] |
nothing — the caller chooses the value |
The last three rows are the distinction that matters, and none of them is a fact
about INDEX. tile.get_subblock_idx() is non-negative because of what the op
returns. A dimension is non-negative because it is a count of elements.
That second rule stops at the dimension. DimensionSymbolScope binds a
dimension that is a bare symbol, and never descends into one that is a
compound expression. Being a valid_shape makes the field an extent; it does not
make every variable that computes it one:
Assuming x >= 0 folds that to a constant 0, which reads as an empty region
and silently shrinks the result. Offsets are never bound at all — max(x, 0) in
an offset is a deliberate clamp whose whole point is that x can be negative,
and folding it to x would move the region a store writes.
Assuming it everywhere silently changes what a kernel computes:
pos: pl.Scalar[pl.INDEX] = base - 1 # [-1, +inf)
if pos >= 0: # live guard, kept
if pos < 8:
read_row(pos)
Under a blanket [0, +inf) default, pos >= 0 proves statically true and Fold
A drops the outer guard, leaving the upper-bound check standing alone — which a
negative pos passes, reaching read_row with an index that is then clamped to
row 0 (issue #2500). The same held for a bare Scalar[pl.INDEX] parameter: the
caller can pass -1.
Everything a pass still needs to know about a symbol's sign now comes from somewhere that can prove it — the value assigned, the loop, the branch, the op, or the symbol's use as a whole dimension.
Single-trip loop collapse (Fold B)¶
Before:
After:
The trip count proof start < stop && stop <= start + step succeeds for pl.range(0, 128, 128), so Fold B substitutes ko → 0 (via DeepClone) and lifts the body. The substitution turns the inner if ko == 0 into if 0 == 0, which analyzer_->Simplify reduces to ConstBool(true). Fold A then drops the dead else branch — both folds compose in the same Simplify pass. The same path handles zero-trip loops by emitting AssignStmts for each return_vars[i] = iter_args[i].initValue_ and dropping the body entirely.
Loops with attrs_ or non-Sequential kind_ are skipped — those forms participate in execution-model contracts (Parallel/Unroll/Pipeline scheduling) that downstream passes may depend on observing as a ForStmt.
Substitution-depth cap (Fold B)¶
DeepClone copies the whole loop body. When that body is itself a pure single-trip loop, the re-visit folds it by cloning its body, and so on — clone sizes run N, N-1, …, 1, so a nest of N single-trip loops costs O(N²), above the O(N log N) ceiling .claude/rules/pass-complexity.md sets.
kMaxNestedSingleTripFolds (16, in simplify_pass.cpp) caps how many nested single-trip loops one run collapses. fold_b_depth_ counts the Fold B clones currently on the stack; past the cap the loop falls through to the general path and stays a ForStmt. Folds at one depth sit at disjoint positions and so clone at most N nodes between them, which bounds the run's total cloning at O(N).
Declining is sound, not a missed obligation: the survivors are ordinary single-trip ForStmts, and the next Simplify run takes the next 16 levels. No kernel in the pipeline nests provably one-trip pure loops anywhere near 16 deep, so the cap never fires on real input.
| Nest depth | Loops left after one run |
|---|---|
| ≤ 16 | 0 |
| 19 | 3 |
Implementation¶
Header: include/pypto/ir/transforms/passes.h
Properties: include/pypto/ir/transforms/pass_properties.h
Implementation: src/ir/transforms/simplify_pass.cpp
MultiAssignCollector— IRVisitor that flags scalarVars assigned more than once (unsafe to bind).SimplifyMutator— extendsarith::IRMutatorWithAnalyzer; folds expressions at leaves and rebuildsVar/IterArgtypes when their embedded shape exprs simplify.TransformSimplify— orchestrates the five phases (collect → mutate → type-rebuild → DCE → repair) and returns a newFunctiononly when the body actually changed.
Underlying analyzer: src/ir/arith/analyzer.cpp, src/ir/arith/ir_mutator_with_analyzer.cpp. The analyzer composes a rewrite simplifier, a constant-interval bound analyzer, a transitive comparison analyzer, and a constraint stack.
Python binding: python/bindings/modules/passes.cpp
passes.def(
"simplify", &pass::Simplify,
"Create a pass that simplifies expressions and statements using algebraic rules and bound analysis");
Type stub: python/pypto/pypto_core/passes.pyi
def simplify() -> Pass:
"""Create a pass that simplifies expressions and statements using algebraic rules and bound analysis."""
Tests: tests/ut/ir/transforms/test_simplify_pass.py
- Pass metadata (name
"Simplify", empty required/produced properties). - Identity simplifications (
x + 0,x * 1,min(a, a), ...). - Constant folding through
Callarguments and embedded shape expressions. - Loop-bound aware folding via
ForStmtanalyzer binding. - If-branch constraint propagation via
Analyzer::GetConstraintContext. - Scalar constant propagation through SSA-form bindings.
- Dead branch guards folded via loop-affine scalar
ConstIntBounds. - Conservative scalar DCE — dropped only when every use is foldable.