MemoryReuse Pass¶
Uses dependency analysis to identify memory reuse opportunities and removes redundant alloc operations.
Overview¶
This pass analyzes variable lifetimes and dependencies to enable memory sharing. Variables with non-overlapping lifetimes in the same memory space can share MemRef objects, reducing memory footprint.
After applying MemRef sharing, the pass also removes redundant tile.alloc statements for MemRefs that are no longer referenced by any TileType variable.
Key insights:
- Variables that don't overlap in lifetime can reuse memory
- Only variables in the same memory space can share MemRef
- Lifetime is determined by def-use analysis
- After sharing, MemRefs that become unreferenced are cleaned up along with their alloc statements
When to use: This is the opportunistic reuse stage for
MemoryPlanner.PYPTO. It runs after
MaterializeSemanticAliases and before
AllocateMemoryAddr. MemoryPlanner.DSA_RP
skips it so independent buffers remain visible to the DSA-RP solver;
MemoryPlanner.PTOAS skips it because ptoas owns lifetime reuse.
Semantics-required loop-carry and in-place aliases are already materialized by
MaterializeSemanticAliases in all three modes.
Planner boundary¶
MemoryReuse selects shared MemRef identities before address assignment.
DSA_RP instead keeps those identities separate and jointly chooses their
addresses under capacity and reuse penalties in AllocateMemoryAddr. Running
both would erase alternatives before DSA-RP can evaluate them. Correctness
facts collected for this pass—lifetime interference, semantic no-alias rules,
and target hazards—remain hard constraints in the DSA-RP problem.
API¶
| C++ | Python | Level |
|---|---|---|
pass::MemoryReuse() |
passes.memory_reuse() |
Function-level |
Factory function:
Python usage:
from pypto.pypto_core import passes
reuse_pass = passes.memory_reuse()
program_optimized = reuse_pass(program)
Algorithm¶
- Lifetime Analysis: Walk the full IR tree (including nested control flow bodies) to compute variable lifetimes via def-use analysis. Variables defined outside a loop but used inside have their lifetime extended to the end of the loop (loop-aware extension)
- Interference Check: Identify variables with overlapping lifetimes
- MemRef Sharing (global first-fit-decreasing packing,
IdentifyReuseOpportunities): Within each memory space, intervals are packed largest-first; every later interval joins the first existing buffer all of whose members it can share with (non-overlapping lifetime + hazard/no-alias safe — seecan_share). A buffer's allocated size is fixed by its first (largest) member, so admitting a smaller member afterwards costs nothing — and a later, larger interval can now host an earlier, smaller one. (The previous definition-order greedy had a one-directional size gatesource.size >= target.size, so two lifetime-disjoint tiles whose small one was defined first were never coalesced.) The representative each member is rebased onto is the buffer's largest member; itstile.allocdominates the whole function because InitMemRef hoists every alloc to the function-body head, so a representative defined after some of its members is safe. Because the packer no longer runs in program order, every pairwise gate (hazard, no-alias) is checked in both directions.
Capacity-overflow subrange fallback (PYPTO only). The pass first computes the legacy whole-buffer packing above and keeps it byte-for-byte when its exact allocator footprint fits the backend capacity. Only when that footprint overflows does it retry packing smaller roots into aligned subranges of an existing largest-member arena. For example, after a dead 64 KiB root has reserved [0, 65536), two later 32 KiB roots that are live together may occupy [0, 32768) and [32768, 65536). The 64 KiB lifetime must not overlap either smaller root; the two smaller roots must occupy disjoint byte ranges because they interfere. The retry selects the lowest aligned gap not blocked by a member that cannot share bytes with the candidate. It never grows an arena, so two 32 KiB arenas do not become a new 64 KiB arena; the 64 KiB representative must already exist. If subdivision still does not fit, the existing pipeline-depth shed and legacy fallback run unchanged.
Subdivision moves a complete root allocation; it never packs into holes outside an alias/view's accessed window. The candidate and arena roots must therefore have constant, non-negative allocation offsets. A symbolic offset is supported only on a direct internal tile.slice, whose runtime address PTO reconstructs from the translated source tile and the slice operands. If a memory space contains another dynamic-offset member, such as a reshape, reinterpret, or transpose view of that slice, subdivision is disabled for the complete space so compacting unrelated roots cannot turn a legacy overflow into wrong code. Constant member offsets are still checked to be non-negative and in bounds; supported symbolic slices keep the exact relative expression arena_offset + placement + (member_offset - root_offset). A dynamic root offset remains ineligible, and pinned/declared allocations remain closed. Lifetime interference, pipeline separation, target hazards, and op no-alias rules still call can_share; if any such rule prevents byte overlap, the two root address intervals must be disjoint. AllocateMemoryAddr sizes the physical slot from the arena root's complete allocation capacity; every constant subrange was already checked to stay within that capacity. A pure dynamic tile.slice MemRef then retains the legacy bare-base address because PTO lowers its actual window as pto.subview of the already placed source tile using the slice operands. Downstream reshape folding compares base plus offset and tile signature, while PTO DPS aliasing also compares the extent. Sibling root subranges therefore cannot be mistaken for the same tile merely because their base pointers match.
4. Loop-carry re-alignment (AlignLoopCarriesToInitMutator): Sharing (step 3) only retypes AssignStmt-defined vars (producers/init); loop-carried iter_arg/return_var nodes are excluded from the lifetime/sharing maps and keep their original MemRef. This step walks ForStmts top-down and retypes each loop's iter_arg/return_var to its (now-reused) initValue MemRef, seeding var_remap_ before recursing so a nested loop observes the corrected outer iter_arg as its init. Without it, a reused nested pipelined matmul_acc accumulator splits across two Acc buffers and step 7 emits invalid acc→acc tile.move ops that ptoas rejects on Ascend 910B (#1352)
5. Accumulator if-phi coalescing (TopDownRetargeter::CoalesceAccumulatorIfPhis): LowerPipelineLoops peels a stage-2 K-loop into if-phis whose live branch is an in-place matmul_acc (on the accumulator buffer) and whose dead if k==0 branch is a fresh matmul seed on a different Acc buffer. Left alone, step 7 would try to reconcile them with an acc→acc tile.move — a second co-live L0C buffer (overflow) that ptoas also rejects (no legal Acc→Acc tmov). This step identifies the in-place-accumulator branch by its reuses_input producer and retargets the other branch's seed onto the accumulator buffer, so both branches share it and no move is emitted (matching mad_acc's shared-%dst semantics). Scoped to Acc; the retarget is mandatory (a declined retarget is an INTERNAL_CHECK, never a move — no legal Acc→Acc move exists). It bypasses the global dead-at-assign liveness check (which would false-decline on the legitimate post-if phi consumer), but only after verifying the two preconditions branch exclusivity actually needs: (a) the seed producer is a Call lexically inside the branch (a pre-if value yielded through the branch runs unconditionally and would clobber the accumulator the sibling in-place branch reads), and (b) a branch-scoped IsTargetDeadAtAssign (bounded to stop at the enclosing if) finds no same-branch tail read of the accumulator buffer. When either fails, the phi remains uncoalesced and step 7 fails loudly rather than manufacturing unsupported Acc→Acc IR
Direction of travel (long horizon). The shape this step repairs is the hand-peeled split-K idiom. tile.matmul_acc(acc, lhs, rhs, init_cond=(k == 0)) expresses the same reduction on one buffer and needs no repair, and AutoTileMatmulL0 now generates that form (head-peeling the bias K-loop, which has no init_cond operand), so no compiler-generated IR reaches this step any more. It is kept for the peels in author source — pypto-lib alone has 95 of them — and retiring it would need that migration to land first. Prefer init_cond in new code; do not expect this step to disappear soon
6. Identity-copy buffer normalization (NormalizeIdentityCopyBuffersMutator, runs on both sides of step 7): after step 5 retargets an accumulator if-phi, a downstream bare-Var SSA identity copy of the (now-moved) return_var can still carry the pre-coalesce buffer (e.g. c: …mem_acc_17 = c_phi after c_phi moved to mem_acc_5). An x = y copy (value a bare Var, not a Call) is a pure rename and must alias y's buffer, so this single forward pass retypes such a copy's LHS to the RHS's MemRef and substitutes the LHS's downstream uses. No-op when no mismatch exists. It runs on both sides of the yield fixup because both strand such a rename, from opposite directions. Before, because a carry rename such as lag2 = lag1 only lands on lag1's buffer here, which step 7 must see to order the carry writebacks against each other — against the pre-normalization buffers every rename still sits on a buffer of its own and no conflict is visible. After, because step 7's own IfStmt fixup repoints a phi return_var onto the canonical branch buffer and strands that phi's downstream rename the same way. The mutator is idempotent, so whichever run finds nothing is a no-op
7. Yield fixup: Fix MemRef mismatches in control flow return variables. Acc→Acc copies are not legal, so a residual mismatched Acc carry is an internal error rather than a tile.move:
- ForStmt: Ensure all 4 loop-carry variables (initValue, iter_arg, yield value, return_var) share the same MemRef. Inserts tile.move before yield if MemRefs differ. pl.yield_ rebinds every carry simultaneously, but the copies realizing it run in sequence, so they are ordered against each other rather than emitted in iter_arg order: a copy that reads a byte range another copy writes runs first. Without that ordering a shift register collapses — for lag2 = lag1; lag1 = v, writing lag1's buffer first makes lag2 read the new lag1 and the loop carries lag2 == lag1 (#2481). A swap (cur, prev = prev, cur) has no valid order at all, so one member is copied into a fresh scratch buffer ahead of both writebacks and its own writeback reads the scratch; that allocation is hoisted onto the function-body head like every other. Carries whose buffers overlap — most often two carries seeded from one tile, which makes them one buffer — cannot be saved by any order and are rejected here with a CHECK, where the carries still have names
- IfStmt: Patch return_vars to the canonical (then-branch) MemRef, and give any arm whose yield value lives on a different buffer a tile.move into that buffer, so every path through the if writes the phi. An arm's yield value may be the enclosing loop's IterArg — if cond: acc = f(acc) leaves a bare yield acc_iter on the other arm — so arms are matched with AsVarLike, not As<Var>: IterArg carries its own ObjectKind, and skipping it left the phi buffer unwritten on that path while the return_var was still patched onto the sibling arm's buffer, feeding the loop carry stale data (#2481)
8. Remove redundant allocs: Collect all MemRefs still referenced by TileType variables, then remove tile.alloc statements whose MemRef is no longer in use
Reuse conditions:
- Non-overlapping lifetimes (no interference). Two variables do NOT overlap when
prev.last_use <= curr.def(i.e., the source's last use can be at the same statement as the target's definition, since inputs are read before outputs are written within a single statement). - Same memory space
- A buffer is sized to its largest member; because packing is largest-first, every member admitted later is no larger than the representative, so no explicit byte-size check is needed (and the reuse direction is no longer constrained to "earlier-and-larger only")
- Subrange placement is attempted only after an exact known-capacity overflow. A candidate must fit wholly inside the existing representative's capacity at an address accepted by the backend alignment policy; every simultaneously live or otherwise non-shareable member blocks its complete byte range.
- No-alias guard (op-semantic): the op that defines the reusing variable may forbid its output from sharing a buffer with one or more of its input operands, because the hardware reads those inputs while writing the output — an in-place write would corrupt the op mid-flight. Three sources feed one per-output forbidden-input set (
ForbidAliasCollector): not_inplace_safe()— the op cannot run withsrc == dst, so its output must not alias any input operand.forbid_output_alias(i)— the op is in-place-safe w.r.t. its value operands but reads a specific operand while writing its output, so the output must not alias that one operand's buffer.- widening
tile.cast(handled directly inForbidAliasCollector) — when the output dtype is wider than the input, the cast cannot run in place: elementiis read ati*in_bytesbut written ati*out_bytes, so the write cursor outruns the read cursor and clobbers not-yet-converted input. Narrowing / same-width casts stay in-place-safe (preserving the cross-dtype reuse below).
MemoryReuse refuses to place the output on a forbidden operand's physical buffer, resolving each operand through both reuse-map coalescing and VIEW inheritance (a reshape/slice shares its source's MemRef base) — so a forbidden operand reached indirectly (its owning tile reused onto another buffer, or occupied via a view) is still caught. For a tuple-returning op, the constraint is propagated from the tuple temporary to every tile-valued TupleGetItem result.
Ops that currently declare a no-alias constraint:
| Op(s) | Constraint | Why the output cannot alias the input |
|---|---|---|
tile.recip, tile.rsqrt |
not_inplace_safe |
high-precision path reads the input and a tmp scratch while writing the output |
tile.row_sum / row_max / row_min |
not_inplace_safe |
TROW* reads the full input row + tmp scratch while writing the reduced [M, 1] output |
tile.mrgsort_format1 |
not_inplace_safe |
merge-sort intrinsic requires src != dst |
tile.move |
not_inplace_safe |
TMOV requires distinct source and destination addresses; baked-address PTO codegen rejects a remaining same-address move rather than silently treating it as a no-op |
tile.rem, tile.rems, tile.fmod, tile.fmods |
not_inplace_safe |
The remainder kernels write intermediate quotient/remainder state into dst while still reading the original dividend; aliasing dst with that source corrupts the final correction/subtraction. In addition, A2/A3 tile.rem / tile.rems codegen rejects a scratch tmp MemRef that overlaps any live tile source (the dividend and, for tile.rem, the divisor), including overlapping subviews; A5 does not consume that scratch operand |
tile.transpose |
not_inplace_safe |
pto.ttrans is not in-place safe: the a2a3 unaligned scalar path writes dst directly from src (no tmp staging), so dst == src corrupts the data mid-write. The output always gets a fresh buffer (also enforced in InitMemRef, which never inherits the input's buffer for it). |
tile.sel |
forbid_output_alias(0) (mask), (3) (tmp) |
TSEL reads the predicate mask + tmp scratch while writing dst |
tile.sels |
target-aware | TSELS keeps dst disjoint from the predicate mask and may reuse src or tmp; A2/A3 writes the scalar into tmp and loads it with set_cmpmask before writing dst, so tmp may alias dst but must remain disjoint from mask/src; A5 retains an unread tmp ABI operand that may alias any operand |
tile.prelu |
target-aware | A2/A3 is not_inplace_safe because TPRELU reads src, slope, and tmp while writing dst; A5 retains the ABI-required tmp operand but does not read it, so dst may reuse tmp but not the active src/slope inputs |
tile.{row,col}_expand{,_mul,_add,_sub,_div} |
forbid_output_alias(1) (broadcast vector) |
the row/col vector (arg 1) is re-read for every output row/col, so an output aliasing it is overwritten after the first row/col |
tile.cast (widening only) |
output ≠ input buffer (conditional, in ForbidAliasCollector) |
wider output's write cursor outruns the read cursor (see above) |
- Pipeline-stage guard (capacity-gated, replication path only):
pl.pipeline(stage=F)replicates a loop bodyFtimes for ping-pong, andLowerPipelineLoopstags each clone's tile-producingCallwith apipeline_membership(group, stage)(see 30-lower_pipeline_loops.md). This whole guard is about that replication path. Undermemory_planner=PTOASan eligible loop is instead claimed earlier byLowerPipelineToSlots, which gives each load apl.MemRef(..., slots=F)indexediv % Fand leaves one un-replicated body — no clones, nopipeline_membership, and MemoryReuse is skipped for that pipeline anyway. Everything that follows describes only the loops that pass declines. TheFclones run concurrently under the scheduler, so their program-order-disjoint lifetimes are not a safe reuse signal — collapsing concurrent clones onto one buffer injects a false write-after-read that serializes the stages (the #1475 cube-matmul-operand collapse). MemoryReuse therefore keeps concurrent clones in distinct buffers in every memory space — including the L0 matmul spaces (Left/Right/Acc/Bias), and regardless of whether a tile is a load or atile.moveresult — up to the max-affordable double-buffering depthF_g = min(depth_g, ⌊C_s / slot_g⌋): a clone at stageklands in residueordinal(k) mod F_g(the dense stage ordinal, so sparse stage IDs like{0, 2}can't collide via raw2 mod 2 == 0 mod 2), so concurrent clones never share (full ping-pong when it fits, maximal spread when the space is tight). Whether the separation fits is decided by the exact per-space allocator footprint (SpaceFootprint, shared withAllocateMemoryAddr— parity by construction), not an estimate. When a space still overflows at every group's affordable depth, a graceful cross-group shed lowers one group's depth by a residue and re-packs (choosing the group by theMaxReliefheuristic — free the most bytes first, ties by lowest group id); if the shed exhausts, the pass re-packs from scratch in a legacy fallback (force_legacy), so it never overflows where the legacy packing would have fit. A space whose capacity is unknown (no backend configured) uses the legacy predicate, so the capacity-gated path is never worse than legacy. When the gate reduces a group below its requestedstage=depth (or a space hits the legacy fallback), MemoryReuse emits a diagnostic through the unified channel — aPH-MR-001perf hint (or a warning for the fallback) naming the requested vs achieved depth and the fix (shrink the per-stage tile to≤ C_s / stage, or lowerstage=to what fits) — so a capacity-forced serialization is never silent. The fallback warning is suppressed when one physical buffer alone exceeds the entire memory space: that is an intrinsically impossible allocation, not a reuse/depth degradation, and an operation-specific check orAllocateMemoryAddrreports it as a hard error. Once the reuse decision is made, MemoryReuse strips the now-consumedpipeline_membershipattr so it does not ride downstream into later passes or codegen. DSA_RPskipsMemoryReuse; it represents requested pipeline depth as hard separations and performs its pipeline-only fallback inAllocateMemoryAddr.
No shape / dtype / TileView compatibility gate: tiles that share a physical MemRef may carry different shapes, dtypes, or TileView attributes. PTO codegen binds a per-variable alloc_tile to each tile, so each alias declares the shared base with its own static shape / dtype / layout / valid_shape. This permits, for example:
- cross-dtype reuse — a BF16 tile reusing a dead FP32 tile's buffer (e.g. across
tile.cast); tile.fillpadoutput reusing its input, and two fillpad outputs with differentpadsharing one buffer;- N-D tiles with divergent
valid_shapesharing a buffer (each keeps its ownvalid_shapeon its ownalloc_tile); - L0 cube-input
Left/Rightsub-tiles of differing shape sharing one slot (e.g. fused-attention QKRight[k, SEQ]reused by PVRight[k', HEAD], halving peak L0B — issue #1595).
Earlier revisions gated reuse on an AreTileTypesCompatible shape / dtype / view match (with a narrow L0 byte-reuse exception); that gate has been removed. Correctness for read-while-write ops is now handled precisely by the no-alias guard above rather than by a coarse whole-tile match.
Alloc cleanup:
After MemRef sharing, some MemRef objects become unreferenced (their variables now point to a different shared MemRef). The pass traverses the surrounding SeqStmts and removes any tile.alloc AssignStmt whose LHS MemRef pointer is not in the set of still-used MemRefs.
Declared allocations¶
Reuse is opportunistic: any two tiles whose lifetimes do not overlap are candidates for one buffer. That is the right default for capacity, but it is not free — two tiles sharing a buffer are ordered by a WAR dependency the source never asked for, and the hardware must serialize work the scheduler could otherwise overlap.
Referencing a declared pl.MemRef("name") in a tile annotation lets the author take an
allocation out of the packer's hands. InitMemRef materializes it as a tile.alloc(..., pinned=True)
(see InitMemRef), and this pass then treats it as
closed: a pinned interval opens its own slot in the first-fit pack and that slot is
skipped when placing every later candidate. (Isolation is a per-slot flag inside the
packing loop rather than another can_share gate — can_share is the innermost step
of an O(M²) pack that re-runs per shed step, so the check is resolved once per interval
instead of once per pair.) Concretely:
- Tiles the author bound to different buffers are never coalesced, however disjoint their lifetimes — the point is to keep them independent.
- Tiles the author bound to the same buffer already share one base from InitMemRef and stay that way.
- Unbound tiles are packed as before, and never pulled into a declared allocation.
The cost is the author's to manage: pinning trades capacity for parallelism, and an
over-pinned kernel surfaces as a hard AllocateMemoryAddr overflow rather than being
silently coalesced back.
Overlap check. Two tiles independently bound to the same slot must not be live at
the same time — that is not reuse, it is the later write destroying data the earlier tile
still needs. The check is per slot, not per allocation: two tiles on different slots of a
pl.MemRef(slots=N) declaration are meant to be live together (that is the ping-pong), and
only tiles landing on one slot can corrupt each other. A runtime slot index (l0c[i % 2])
has no static slot to attribute a tile to, so the check is skipped there — the rotation is
the author's to get right — while isolation from every other allocation still holds. This pass owns the check because it is where lifetimes are computed
(ComputeLifetimes); the rule matches the packer's own var_overlap, so touching is
allowed (one tile's last read may be the statement that produces the next member).
Tiles that land on the allocation by inheritance rather than by binding — views, in-place
results, bare SSA aliases — are excluded: they are the same data as their source, so
overlapping with it is expected.
Because the isolation guarantee lives here, and ptoas replaces this pass wholesale,
A declared allocation under memory_planner=PTOAS is rejected at InitMemRef rather than
silently honored-but-unenforced: allocating them without isolating them would
hand back exactly the coalescing the author wrote the binding to prevent.
memory_planner=DSA_RP also skips this pass, but preserves the contract in its
allocation problem: every declared allocation is hard-separated from every
other allocation in its memory space, and independently bound co-live members
of one declaration are rejected before solving.
Ascend910B load + tpop_from_aic hazard¶
On Ascend910B AIV functions with a non-None SplitMode, a writer that consumes both a tile.load result (or a legal-view descendant of one) and a tile.tpop_from_aic value must not place its output in the same physical buffer as that load result. Allowing the writer's output to in-place-reuse the load buffer produces silently wrong results on this hardware.
MemoryReuse owns every buffer-coalescing decision, so it prevents the hazardous sharing from ever forming rather than relying on a later split. When the guard is active, the reuse decision is blocked exactly when:
- the writer's defining op consumes a
tile.tpop_from_aicvalue, and - the buffer member it would reuse in place (whose last use is the writer's def statement) is load-derived.
Both classifications are keyed on Var identity. An operand takes one extra step: a value can reach the writer through a loop carry, and an IterArg is never itself an AssignStmt def, so Var identity can never classify it. Since MaterializeSemanticAliases has already fused each carry chain — init value, IterArg, yield value — onto one MemRef base, an IterArg operand is classified by the taint of that base instead. Reading operands with AsVarLike (never As<Var>, which does not match IterArg's own ObjectKind) is what makes the carry visible in the first place; without it, down_next = tile.add(down_prev, pipe_carry) with a carried tpop value silently loses the taint and the hazardous in-place reuse is formed.
A carry also breaks program order, so the collector walks the body twice. The producer that taints a carry's buffer may stand after the use it taints — w = tile.add(l, carry); p = tile.tpop_from_aic(); yield p reads a tpop value in w from iteration 1 onwards, but a single forward walk classifies w before it has seen p. A third traversal could add nothing: the base sets are complete after the first walk (a tile.tpop_from_aic def is order-independent, and a view shares its source's base), so only the Var sets grow in the second, and those propagate in program order within it. Both walks are O(N).
The guard is gated by BackendHandler::RequiresSplitLoadTpopWorkaround() (true only for Ascend910B) and the function being split-AIV; on every other backend / function kind the inputs are empty and reuse behaviour is unchanged. The writer is still free to reuse any non-load buffer — only the load + tpop in-place combination is rejected. (This guard previously lived in a dedicated LegalizePTOBufferReuse pass that split the buffer after the fact; it now folds into MemoryReuse.)
Example¶
MemRef Sharing with Alloc Cleanup¶
Before (after InitMemRef):
# SeqStmts [
mem_vec_0: MemRefType = tile.alloc(Vec, -1, 16384, 0)
mem_vec_1: MemRefType = tile.alloc(Vec, -1, 16384, 1)
mem_vec_2: MemRefType = tile.alloc(Vec, -1, 16384, 2)
tile_a: Tile[[64, 64], FP32, memref=mem_vec_0] = tile.load(...)
tile_b: Tile[[64, 64], FP32, memref=mem_vec_1] = tile.add(tile_a, ...)
# tile_a last use ↑
tile_c: Tile[[64, 64], FP32, memref=mem_vec_2] = tile.load(...)
# ]
After (tile_c reuses mem_vec_0 from tile_a, alloc for mem_vec_2 removed):
# SeqStmts [
mem_vec_0: MemRefType = tile.alloc(Vec, -1, 16384, 0)
mem_vec_1: MemRefType = tile.alloc(Vec, -1, 16384, 1)
# mem_vec_2 alloc removed — no longer referenced
tile_a: Tile[[64, 64], FP32, memref=mem_vec_0] = tile.load(...)
tile_b: Tile[[64, 64], FP32, memref=mem_vec_1] = tile.add(tile_a, ...)
tile_c: Tile[[64, 64], FP32, memref=mem_vec_0] = tile.load(...)
# tile_c now shares mem_vec_0 with tile_a
# ]
Producer-Consumer Reuse¶
When a variable's last use is at the same statement that defines a new variable (producer-consumer relationship), the new variable can reuse the old variable's memory because inputs are read before outputs are written:
# Before:
tile_a: Tile[[64, 64], FP32, memref=mem_vec_0] = tile.create(...)
tile_b: Tile[[64, 64], FP32, memref=mem_vec_1] = tile.muls(tile_a, 0.0)
# tile_a.last_use == tile_b.def → reuse allowed
# After:
tile_a: Tile[[64, 64], FP32, memref=mem_vec_0] = tile.create(...)
tile_b: Tile[[64, 64], FP32, memref=mem_vec_0] = tile.muls(tile_a, 0.0)
# tile_b reuses mem_vec_0
Overlapping Lifetimes (No Reuse)¶
When a variable is still alive after another variable's definition (last_use > def), their lifetimes truly overlap and they cannot share memory:
# SeqStmts [
mem_vec_0: MemRefType = tile.alloc(Vec, -1, 16384, 0)
mem_vec_1: MemRefType = tile.alloc(Vec, -1, 16384, 1)
tile_a: Tile[[64, 64], FP32, memref=mem_vec_0] = tile.load(...)
tile_b: Tile[[64, 64], FP32, memref=mem_vec_1] = tile.load(...)
# tile_a.last_use > tile_b.def → tile_a still live when tile_b is defined
# ]
Implementation¶
Header: include/pypto/ir/transforms/passes.h
Implementation: src/ir/transforms/memory_reuse_pass.cpp
LifetimeAnalyzerwalks the full IR tree to compute variable lifetimes (including nested control flow)ComputeLifetimesbuilds MemRef sharing groups and lifetime intervalsIdentifyReuseOpportunitiesfinds reuse candidates, retries with aligned subrange placement on a known-capacity overflow, and records each member's placement offsetApplyMemRefSharingupdates MemRef pointers viaMemRefSharingMutatorTopDownRetargeter::CoalesceAccumulatorIfPhiscoalesces peeled loop-carried accumulatorif-phis by retargeting the dead-branch seed onto the in-place accumulator buffer, soYieldFixupMutatornever emits an illegalacc→acc tile.move(see Algorithm step 5)YieldFixupMutatorfixes ForStmt/IfStmt yield/return_var MemRef mismatches after reuse (insertstile.movewhen legal; rejects residual Acc→Acc mismatches)NormalizeIdentityCopyBuffersMutatorreconciles bare-VarSSA identity copies whose LHS/RHS buffers diverged after accumulator if-phi coalescing (see Algorithm step 6)UsedMemRefCollectorgathers still-referenced MemRef pointers after sharingRemoveUnusedAllocStatementsfilters out redundanttile.allocstatements fromSeqStmts
Python binding: python/bindings/modules/passes.cpp
Tests: tests/ut/ir/transforms/test_memory_reuse.py
- Tests non-overlapping lifetime reuse with MemRef sharing
- Tests producer-consumer reuse (last_use == def at same statement)
- Tests overlapping lifetime no-reuse
- Tests memory space separation
- Tests byte-size compatibility
- Tests cross-dtype / cross-
TileViewreuse (now permitted: BF16↔FP32, fillpad output↔input, divergentvalid_shape) - Tests the no-alias guard (
TestForbidOutputAlias+TestInplaceOps), one case per constraint above: tile.recip/tile.rsqrt/tile.row_sum— output must not alias input (not_inplace_safe)tile.sel— output must not alias the mask / tmp (forbid_output_alias)tile.sels— output never aliases mask; both A2/A3 and A5 permit tmp/output alias, while A2/A3 backend validation still rejects tmp overlap with mask/srctile.prelu— A2/A3 output must not alias any input; A5 output may alias only the unusedtmptile.col_expand_mul— output must not alias the broadcast vector- widening
tile.cast— output must not alias the (narrower) input - a forbidden operand reached through a VIEW is still honored (physical-buffer resolution)
- Tests view operation MemRef sharing preservation
- Tests redundant alloc statement removal
- Tests control flow lifetime analysis (nested IfStmt in ForStmt, branch variable sharing)