NormalizeReturnOrder Pass¶
Reorders the return tuple of every InCore function so that return[i]
corresponds to the i-th Out/InOut parameter in declaration order, and
remaps TupleGetItemExpr indices in non-InCore callers accordingly. After
this pass, orchestration codegen can map tuple element indices to output
parameters with a direct out_indices[i] lookup, without tracing through
tile.store / ForStmt yield chains.
Overview¶
User code is free to write tile.store calls in any order — out_b
before out_a, or interleaved with compute. Earlier in the pipeline, the
body order is preserved verbatim, so the InCore ReturnStmt::value_ may
list its outputs in an order that does not match the declared Out/InOut
parameter order. Without normalization, orchestration codegen would have
to follow each return[i] back through assignments and tile.store calls
to discover which parameter it materializes — analysis that belongs in a
pass, not in codegen (see docs/en/dev/codegen/00-pto_codegen.md).
This pass canonicalizes the contract so codegen can rely on
return[k] ↔ out_indices[k] by position alone:
- Step A0 (param-return canonicalization) — for every
InCore,Group, andSpmdfunction, rewrite each tensor return value that is a param writeback to reference the parameter directly (pointer identity), using the sharedreturn_lineageutility. Kernel-allocated outputs (not traceable to any param) and scalar returns are exempt and stay unchanged. - Step A (InCore rewrite) — for every
InCorefunction, compute a permutation that sortsReturnStmt::value_to match the declaredOut/InOutparameter order, then rewrite both the return values andFunction::return_types_accordingly. - Step B (call-site remap) — for every non-InCore function
(Orchestration / Group / Spmd / opaque), rewrite every
TupleGetItemExpr.index_whose tuple operand is the result of a call to a function reordered in Step A. The new index ispermutation[old_index], so observers of the call result still see the same SSA values bound to the same names.
The pass is a no-op for any function whose return order already matches
its Out/InOut parameter order, and a no-op for any program with no
InCore functions.
Pipeline position: slot #20 in the Default strategy — after
SplitVectorKernel (#19) and before LowerPipelineLoops (#21). It runs
late enough that all kernel splitting / tile-structural decisions are made
on the original return order, and early enough that downstream tile-level
passes (LowerPipelineLoops, CanonicalizeIOOrder, InitMemRef,
MemoryReuse, AllocateMemoryAddr) — and ultimately PTO orchestration
codegen — see the canonical order.
API¶
| C++ | Python | Level |
|---|---|---|
pass::NormalizeReturnOrder() |
passes.normalize_return_order() |
Program-level |
Pass Properties¶
| Property | Value |
|---|---|
| Required | SplitIncoreOrch, IncoreTileOps |
| Produced | ReturnParamsExplicit |
| Invalidated | — |
SplitIncoreOrch guarantees that InCore work has been outlined into its
own functions; IncoreTileOps guarantees the body uses tile ops, so the
tile.store(_, _, out_param) signal that drives Step A is present. The
pass produces ReturnParamsExplicit (verified by
verify_return_params_explicit.cpp): every InCore/Group/Spmd tensor
return value that is a param writeback references the param by pointer
identity, so orchestration codegen maps returns to args with a lookup.
It invalidates nothing — SSA form, normalized statement structure, memory
inference, and every other upstream property are preserved.
Algorithm¶
Step A0 — Canonicalize return values to params¶
For each InCore / Group / Spmd function, CanonicalizeReturnValues
calls return_lineage::ReturnedParamIndices (which traces var-to-var
aliases, loop carries, builtin writebacks, TupleGetItem of tuple calls,
and Group/Spmd wrapper calls) and replaces every tensor return value that
traces to a param with the param Var itself. Untraceable values
(kernel-allocated outputs) and scalars keep their original expression.
This step is what makes the return→param map readable without an analysis.
Once it has run, return position j writes back param i exactly when
ReturnStmt->value_[j] is params_[i] by pointer identity — which is what
IRProperty::ReturnParamsExplicit asserts. Consumers at or after this pass
(orchestration codegen, ClassifyIterArgCarry) therefore call
return_lineage::ExplicitReturnedParamIndices(func), a local structural read,
rather than re-running the interprocedural tracer. Reserve
ReturnedParamIndices for callers that run before the property exists
(ExpandMixedKernel, the scope outliner), for this pass itself, and for the
property verifier, which must re-derive independently to have anything to check.
Because it is a codegen precondition, a test that hand-builds IR and calls
orchestration codegen directly must run this pass first (see
tests/ut/codegen/_orchestration_codegen_common.py), exactly as it must run
DeriveCallDirections, MaterializeRuntimeScopes and ClassifyIterArgCarry.
Step A — Compute and apply per-function permutations¶
For each InCore function, BuildReturnToParamMapping walks the body
once (excluding the trailing ReturnStmt) and builds a
Var* → out_param_index map by replaying three rules:
| Rule | Pattern | Action |
|---|---|---|
1. tile.store writes an Out/InOut buffer |
lhs = tile.store(tile, offsets, out_param, ...) |
lhs → param_index_of(out_param) |
| 2. Var-to-var alias | lhs = rhs_var (and rhs_var already mapped) |
lhs → lookup(rhs_var) |
3. ForStmt iter-arg yield |
for_stmt.iter_args[i].initValue_ already mapped |
for_stmt.return_vars_[i] → lookup(initValue) |
Each value of ReturnStmt::value_ is then resolved by looking up its
Var in this map, falling back to direct identity match against
Function::params_. Returning kNoParam for an entry means "no out-param
linkage detected" — that slot keeps its original index.
ComputeReturnPermutation turns the mapping into
permutation[old_index] = new_index, where new_index is the position
of the matching parameter in CollectOutIndices(func). The function
returns the empty permutation in three cases (any of which makes the
pass a no-op for that function):
- The body has no
ReturnStmt(open IR) or no Out/InOut parameters. out_indices.size() > ret_to_param.size()— more declared output parameters than returned values, so the analysis is incomplete and we refuse to construct an out-of-bounds permutation.- The computed permutation is the identity (already canonical).
When the permutation is non-empty, ReorderReturns builds a fresh
Function via MutableCopy, replacing the trailing ReturnStmt with one
whose value_[permutation[i]] = old_value_[i] and permuting
Function::return_types_ in lockstep so the type list stays aligned with
the values.
Step B — Remap TupleGetItemExpr at call sites¶
For each non-InCore function in the (already-Step-A-rewritten) program,
TupleIndexPermutationMutator does a single SSA pass that:
- Tracks every
AssignStmtwhose RHS is aCall(GlobalVar)to a function reordered in Step A, recordingassign.var → permutation_refin areordered_tuple_vars_map. - Removes a tracked entry whenever its
Varis reassigned (call to a non-reordered function, non-call RHS, etc.) so identity-based lookups never read a stale binding. - For every
TupleGetItemExpr(tuple_var, k)whosetuple_varis in the tracked map, rewrites the index topermutation[k].
Because Step A rewrites function signatures and Step B rewrites call-site index access in the same pass invocation, the program is consistent at exit: each tuple element is still bound to the same physical output buffer, just under a new index.
Constraints¶
| Constraint | Reason |
|---|---|
Only InCore functions are rewritten in Step A |
Other function kinds (Orchestration / Group / Spmd / opaque) follow the user's declared return shape; their callers are remapped in Step B. Group/Spmd returns are still canonicalized to params in Step A0, but never reordered |
| Step A0 leaves kernel-allocated outputs and scalars untouched | Only param writebacks must be explicit; a return value with no param lineage has no param to reference |
Skips functions where out_indices.size() > ret_to_param.size() |
An incomplete analysis must not produce an out-of-bounds permutation — leave the function as-is so the verifier can flag the inconsistency |
| Permutation is identity ⇒ no rewrite | Avoids spurious Function clones and keeps the pass idempotent |
Step B only rewrites TupleGetItemExpr whose tuple operand resolves to a tracked Var after VisitExpr |
The mutator preserves Var node identity, so the operand pointer stays valid as a key in reordered_tuple_vars_; if a future change ever returned a fresh node, looking up the post-visit pointer keeps the check correct |
Example¶
Two Out parameters with the InCore body writing them in the wrong
order. The orchestrator picks ret[0] and ret[1] assuming those are
out_a and out_b. After the pass, the InCore return matches the
parameter order and the orchestrator's TupleGetItemExpr indices are
remapped so the same SSA values still flow into a and b.
Before:
@pl.program
class Module:
@pl.function(type=pl.FunctionType.InCore)
def kernel(self, x: pl.Tensor[[16], pl.FP32],
out_a: pl.Out[pl.Tensor[[16], pl.FP32]],
out_b: pl.Out[pl.Tensor[[16], pl.FP32]]) \
-> tuple[pl.Tensor[[16], pl.FP32], pl.Tensor[[16], pl.FP32]]:
x_tile = pl.load(x, [0], [16])
a_tile = pl.tile.add(x_tile, x_tile)
b_tile = pl.tile.mul(x_tile, x_tile)
out_b_store = pl.store(b_tile, [0], out_b)
out_a_store = pl.store(a_tile, [0], out_a)
return (out_b_store, out_a_store) # ← wrong order vs. (out_a, out_b)
@pl.function(type=pl.FunctionType.Orchestration)
def main(self, x, out_a, out_b):
ret = self.kernel(x, out_a, out_b)
a = ret[0] # ← currently materializes out_b
b = ret[1] # ← currently materializes out_a
return (a, b)
After:
@pl.program
class Module:
@pl.function(type=pl.FunctionType.InCore)
def kernel(self, x, out_a, out_b):
x_tile = pl.load(x, [0], [16])
a_tile = pl.tile.add(x_tile, x_tile)
b_tile = pl.tile.mul(x_tile, x_tile)
out_b_store = pl.store(b_tile, [0], out_b)
out_a_store = pl.store(a_tile, [0], out_a)
return (out_a_store, out_b_store) # ReorderReturns: permutation [1, 0]
@pl.function(type=pl.FunctionType.Orchestration)
def main(self, x, out_a, out_b):
ret = self.kernel(x, out_a, out_b)
a = ret[1] # TupleIndexPermutationMutator: 0 → 1
b = ret[0] # TupleIndexPermutationMutator: 1 → 0
return (a, b)
The same SSA assignment (a = ...) is still bound to the value produced
by pl.store(a_tile, ..., out_a); only the path through the tuple has
changed. InOut parameters behave identically.
See tests/ut/ir/transforms/test_normalize_return_order.py for the
full set of cases:
test_swapped_returns_reordered— the two-Out-param example abovetest_already_ordered_noop— pass leaves canonical IR untouchedtest_single_return_noop— single Out param needs no permutationtest_non_incore_unchanged— programs with no InCore functions are no-opstest_three_returns_scrambled— three-way permutationtest_2d_tensor_reorder— 2-D tensors / multi-dim offsetstest_inout_param_reorder—InOutparticipates in reordering
Implementation¶
Header: include/pypto/ir/transforms/passes.h
Implementation: src/ir/transforms/normalize_return_order_pass.cpp
CanonicalizeReturnValues— Step A0 rewriter: replaces traceable tensor return values with the paramVar(viareturn_lineage::ReturnedParamIndices).BuildReturnToParamMapping— Step A analysis: walks the function body to map eachReturnStmtvalue back to an Out/InOut parameter index.CollectOutIndices— collects the parameter positions whoseParamDirectionisOutorInOut.ComputeReturnPermutation— composes the previous two into the finalpermutation[old_index] = new_index; returns empty when no rewrite is needed or the analysis is incomplete.ReorderReturns— builds aMutableCopy(func)with the permutedReturnStmt::value_andFunction::return_types_.TupleIndexPermutationMutator— Step B rewriter: tracks call-result vars and rewritesTupleGetItemExprindices.
Properties: include/pypto/ir/transforms/pass_properties.h
inline const PassProperties kNormalizeReturnOrderProperties{
.required = {IRProperty::SplitIncoreOrch, IRProperty::IncoreTileOps},
.produced = {IRProperty::ReturnParamsExplicit}};
Python binding: python/bindings/modules/passes.cpp
passes.def("normalize_return_order", &pass::NormalizeReturnOrder,
"Create a return order normalization pass\n\n"
"Reorders return tuple values in InCore functions so that return[i]\n"
"corresponds to the i-th Out/InOut parameter in declaration order,\n"
"and updates TupleGetItemExpr indices at call sites accordingly.");
Type stub: python/pypto/pypto_core/passes.pyi
Tests: tests/ut/ir/transforms/test_normalize_return_order.py
Related¶
OutlineInCoreScopes— upstream producer of theInCorefunctions this pass rewritesLowerPipelineLoops— runs immediately after; consumes the normalized returns when expanding pipeline scopesDeriveCallDirections— later inspects call signatures whose return shape this pass canonicalizes- PTO codegen overview and
orchestration codegen —
consumers of the canonical
return[i] ↔ out_indices[i]mapping