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 expressions embedded in
TensorType,TileType, andTupleType, including the slot index of a pinned declared MemRef, so 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 proven constant is bound fully so its literal propagates into downstream uses, including inside loops, branches, and SPMD scopes. A symbolic value contributes only aConstIntBound— enough to fold dead branch guards likeif expr == 0without inlining the scalar. Bindings are scoped to their defining region, and 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
MaterializeDistTensorCtx): final cleanup of folds exposed by memory-space inference, layout resolution, and other late lowering.
InitMemRef also calls the shared internal helper in constant-only mode at entry, before normalizing statement structure and building addresses. This mode propagates proven constants through expressions and type metadata and removes dead scalar assignments, while preserving control-flow, phi, and loop-carry structure. It skips identity tensor.view folding, nonconstant algebraic rewrites, and dead yield-slot pruning. This folds constants introduced by intervening lowering passes even when InitMemRef is invoked directly, without adding a separate pipeline pass or a new precondition. The named Simplify pass retains the full behavior described below.
Requires: nothing.
Produces: nothing.
Invalidates: nothing.
The empty PassProperties contract (kSimplifyProperties in include/pypto/ir/transforms/pass_properties.h) is intentional: Simplify preserves established properties such as SSAForm, NormalizedStmtStructure, and IncoreTileOps while rewriting expressions, pruning dead bindings, and collapsing proven branches or pure zero-trip/single-trip loops. The constant-only helper used by InitMemRef preserves those control-flow statements and their yielded slots.
Integer and INDEX rewrites preserve the expression's declared result dtype. If simplification selects an operand of a different integer dtype, it retains the conversion with a cast (or folds it into a typed constant). For example, an INT32 (index + 1) - 1 becomes cast(index, INT32). The printer preserves non-INDEX integer constant dtypes with pl.const(value, dtype), including constants propagated into nested call arguments.
Boolean bindings use value substitution without numeric ordering analysis; floating-point bindings also substitute known values while leaving floating-point algebraic rewrite rules disabled, preserving dtype and signed zero.
Floating-point comparisons remain explicit until rounding to the declared format is modeled; raw ConstFloat doubles must not decide a branch. Scoped unbinding clears symbolic intervals as well as concrete bounds and substitutions. Single-trip cloning carries the original multi-assignment exclusions onto fresh variables, and scalar/yield-slot DCE preserves communication-domain scope fields when rebuilding their bodies.
When to Use¶
- After SSA conversion to propagate scalar constants into types/shapes before the tile pipeline inspects them.
- Before MemRef initialization, through the shared helper's constant-only mode, so constant coordinates and declared slot indices produce concrete address metadata without changing storage-related control flow.
- 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/IterArgPtr: substitute via the var-remap table, then run through the analyzer. Loop-carried uses are leaves: only the loop header rebuilds their type and initial value. Revisiting a carry's MemRef offset at a use site can otherwise create a fresh, undefined variable, especially across separately scoped branches.BinaryExpr/UnaryExpr: visit children, then fold the rebuilt node. AnINDEXloop_var OP constantcomparison that the loop's static range decides becomes aConstBool— see Provable comparison in an operand.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 is bound fully (the literal is substituted into later uses), at both function-body top level and inside nested regions. A symbolic RHS 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. This does not bind a loop-carried value to its initial value or assume that different branch results agree.ForStmt: simplify every carry's initial value in the enclosing scope. If any header value or type changes, create and register the whole carry group before rebuilding its types, so an allocated offset or slot index can reference a later scalar carry. Unchanged headers retain their original identities. Then visit the body using the complete remap; 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. Retained branches rebuildreturn_vars_under the common baseline mapping so result types follow enclosing binder changes without importing branch-local definitions. Conditions the analyzer can prove are also folded — see "Control-flow folding" below.WhileStmt: same asForStmtminus the bounds — use the same two-phase carry-group rebuild before the condition and body, snapshotvar_remap_around the body visit, then rebuildreturn_vars_after it, with the same scoped scalar unbinding. Seeding the complete header remap first keeps every condition/body and storage-metadata use attached to the declared node regardless of carry order.SpmdScopeStmt: foldcore_num_and rewrite expression-valued dispatch attributes, including task dependencies and runtime predicates, under the enclosing carry mappings before visiting the body. Body-local scalar bindings use the same scoped unbinding. This keeps dispatch uses attached to the rebuilt For/While headers without turning runtime predicates into constants.CommDomainScopeStmt: keep slot identities fixed while visiting the body, then resolve slot sizes using the body's unconditional scalar bindings. One non-evaluating sweep updates all dependent window references before DCE; branch-local facts never specialize a domain slot. Symbolic size dependencies remain live even when referenced only by a slot.- Type rebuild —
SimplifyTyperecurses throughTensorType,TileType, andTupleType, callingSimplifyExpron embedded expressions (shape, stride, valid_shape, start_offset, view fields).RemapPinnedMemRefExprsalso simplifies the slot index of pinned declared allocations before it is converted to a byte offset. Non-pinned MemRefs retain their pointer identity unless an embedded variable is remapped, such as a rebuilt loop carry. Their base, byte offset, and retained slot index then follow that remap without analyzer folding, and a shared cache keeps variable and Call types on the same rebuilt MemRef. Scalar definitions used only by address metadata remain live during DCE. Type 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))before visiting either branch body. On a proof of either polarity, skip the unreachable body entirely, simplify only the surviving body, and lift it into the parent scope when permitted. If a call in the surviving yield prevents lifting, retain theIfStmtand remap enclosing variable references and dependent types in the unreachable body without evaluating its arithmetic. Constants-only mode retains its existing traversal behavior. This prevents constant folding from diagnosing invalid arithmetic such as integer division or modulo by zero inside unreachable code. If neither polarity can be proven, simplify both branches under their respective constraints; errors in potentially reachable code still propagate. 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.
Only definitions inside the cloned subtree receive fresh identities. A reference to an enclosing loop's IterArg must retain that exact object unless explicitly substituted; its defining loop is not being cloned. Local ForStmt and WhileStmt carries are registered from their DefFields before visiting their uses. This prevents folding two sibling single-trip loops from creating two unbound copies of the same outer carry (issue #2955), which would otherwise become invalid SPMD call arguments during outlining.
Retained IfStmt results are new definitions: earlier value aliases and scalar facts for reused pre-SSA variables are cleared before subsequent uses are rewritten.
Window views cloned by Fold B also retain an enclosing domain's slot identity. When cloning or explicit substitution changes the storage base or size, definition and Call/Submit result types share the same remapped slot.
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.
Trailing yields may be nested inside RuntimeScopeStmt (AUTO or MANUAL) or
SplitAivScopeStmt. Folding and dead-slot pruning follow that same trailing
path, preserving scope metadata and the ordering of all other statements.
Removing the only yield retains an empty scope body, printed as pass.
Constant-branch and single-trip folding retain the control flow when a yielded
expression contains a Call or Submit, including a nested call, so substitution
cannot duplicate, delete, or move the operation. Single-trip folding also retains
loops with call-containing carry initializers. A call already assigned to a
variable before yielding that variable does not prevent folding.
FlattenCallExpr, which runs later, can materialize call results before a
subsequent Simplify folds the control flow.
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, 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.
Provable comparison in an operand¶
Fold A decides IfStmt conditions, but a comparison passed as an operand — a call argument such as tile.matmul_acc's init_cond, or an assignment value — only gets expression rewriting, which cannot use loop-variable ranges to reach a literal. VisitBinaryExpr_ therefore decides the static-bound case and replaces it with a ConstBool — the node the analyzer's own constant folding produces, which Fold A also recognizes:
# Before (after LowerPipelineLoops replicates a stage=2 split-K loop, ko in [0, 512);
# the rewriter has already turned ko + 128 == 0 into ko == -128)
acc = pl.tile.matmul_acc(acc, a, b, init_cond=ko == -128)
# After Simplify
acc = pl.tile.matmul_acc(acc, a, b, init_cond=False)
A comparison is decided only when it is loop_var OP constant (either order, OP one of ==, !=, <, <=, >, >=), where loop_var is an INDEX loop variable of an enclosing loop with constant INDEX start/stop and no other definition in the function, and the constant is an INDEX literal. The decision compares the loop's header range [start, stop - 1], which contains every value the variable takes, with the constant: no arithmetic and no analyzer bound is involved. Everything else is left exactly as written, because the analyzer's bounds are unsound at the int64 edges and under narrow-dtype wrap; a missed fold is always safe, a wrong literal is a miscompile.
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.
Constants inside nested regions¶
Before:
for i in pl.range(0, 8):
base: pl.Scalar[pl.INDEX] = 32
fixed_row: pl.Scalar[pl.INDEX] = base * 2
view = pl.tile.slice(pool, [16, 16], [fixed_row, 0])
consume(view, i)
After:
The analyzer proves base * 2 is 64 within the loop body. Substitution works through ordinary call arguments for every op; it is not special handling for slice. For a row-major FP32 pool with 16 columns, InitMemRef can now compute a ConstInt(4096) byte offset, just as if the author had written [64, 0]. A constant declared slot index is simplified through its type metadata in the same way. Runtime-dependent coordinates and loop carries remain symbolic unless independently proven constant.
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.
- Constant propagation in loops, branches, and SPMD scopes, including typed call arguments and pinned MemRef slot indices.
- Dead branch guards folded via loop-affine scalar
ConstIntBounds. - Conservative scalar DCE — dropped only when every use is foldable.