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,Spmd, andGraphfunction, 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 / Graph / 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 tensor returns already name
their parameters and whose return order already matches its Out/InOut
parameter order, and a no-op for any program with no InCore, Group,
Spmd, or Graph function — Step A0 covers all four, so "no InCore
functions" alone no longer implies the pass does nothing.
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/Graph 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 / Graph 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.
Tensor IfStmt merges are traced only when every branch resolves to the same
parameter. Storage consensus is kept separately from merge metadata, so narrowing
inside both branches can be restored after the merge. Merge metadata comes from
the traced branch types, including caller types forwarded by validated calls.
The actual returned tensor
must match the parameter type, including view and valid-shape metadata;
a tensor.set_validshape after the merge cannot discard that metadata by
canonicalizing its result. Conflicting, fresh, or view-changing branches stay
uncanonicalized. Graph functions retain their existing rejection of merge-based
return lineage because their boundary analyses do not track tensor roots through
these merges; accepting them could freeze a call-varying boundary slice on replay.
Variable roots and failures are cached across branch arms and
return values, avoiding repeated work for sequential conditional writebacks.
Writeback chains are checked at their final result: temporarily narrowing valid
shape and restoring it with tensor.assemble still allows a parameter return.
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.
Graph is in this set for the same reason the wrappers are: orchestration
codegen aliases a Graph call's results to call-site tensors through this map. A
Graph body is written out = core(..., out, ...); return out, which the
outliner turns into a TupleGetItem rebind, so the return reaches codegen as an
SSA rename. While Graph was excluded, that map came back all-nullopt, codegen
fell back to a positional heuristic (return j ↦ the j-th Out/InOut
param), and a Graph whose return order differed from its parameter order had
every result bound to the wrong tensor with no diagnostic (#2601).
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:
- For every
AssignStmtwhose RHS is aCall(GlobalVar)orSubmit(GlobalVar)to a reordered function, permutes the expression's tuple element types and creates a resultVarwith that type. It permutes the caller-instantiated types, preserving caller-local shape symbols. Submit's trailingTASK_IDelement, dependencies, and launch metadata are unchanged. - Records the old result
Vartogether with its replacement and permutation inreordered_tuple_vars_. - For bindings defined more than once before SSA conversion, assigns each
reordered call to a fresh physical temporary and writes its logical tuple
back to the original binding. Branches and loops retain a shared identity
that
ConvertToSSAcan merge, including mixed reordered/non-reordered assignments. - 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, uses the replacement tuple and indexpermutation[k]. - For a whole-tuple use (including aliases and forwarding), constructs a tuple of projections in the original logical order. This view is cached per definition and never repeats the call. Flat function returns expand the logical tuple into their declared return positions.
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, and the call, binding, and projection types agree even when outputs have different dtypes or shapes.
Constraints¶
| Constraint | Reason |
|---|---|
Only InCore functions are rewritten in Step A |
Other function kinds (Orchestration / Group / Spmd / Graph / opaque) follow the user's declared return shape; their callers are remapped in Step B. Group/Spmd/Graph returns are still canonicalized to params in Step A0, but never reordered |
| Step A0 requires type-compatible branch consensus outside Graph functions | Every branch must resolve to the same parameter; the final returned tensor must preserve its type/view/valid shape. Intermediate narrowing may be restored. Graph merge returns remain unsupported |
| 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 tracks the original result Var and creates a fresh typed binding |
Projections use the new physical tuple; other consumers retain the old logical order. Existing IR nodes are not mutated |
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 reorderingtest_branch_writeback_and_wrapper_return_reference_param— same-buffer branches and wrapper forwarding (#2966)test_dynamic_branch_call_returns_reference_caller_param— generated phi types retaining callee-local shape Varstest_branch_return_with_unproven_identity_is_not_canonicalized— conflicting, fresh, view, and valid-shape branchestest_writeback_after_branch_merge_preserves_returned_valid_shape— narrowed metadata after a merge, in both return orderstest_restored_writeback_metadata_references_param— restored and narrowed results sharing lineage, in both return orderstest_sequential_branch_writebacks_share_lineage— 48 sequential merges share cached lineage
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 rewritesSkewCrossCorePipelineandLowerPipelineToSlots— run in between, claiming the pipeline loops each one handlesLowerPipelineLoops— consumes the normalized returns when expanding the pipeline scopes those two passes left behindDeriveCallDirections— 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