feat(ir): Add pl.Buffer for manual memory-reuse control - #2171
Conversation
MemoryReuse coalesces any two tiles whose lifetimes do not overlap. That
is right for capacity but not free: the pair is then ordered by a WAR
dependency the source never asked for, and the hardware serializes work
the scheduler could otherwise overlap.
pl.Tile[[...], dtype, pl.Buffer("name"), pl.Mem.Vec] lets the author take
a buffer out of the packer's hands. Tiles naming one buffer share one
allocation; nothing else is ever packed into it. Neither a size nor an
address is written — InitMemRef sizes the buffer to its largest member
and derives the memory space from the bound tiles. Unannotated tiles keep
the existing automatic reuse, so this is opt-in per tile.
The binding rides TileType::memref_ from the parser to InitMemRef, marked
by the size-0 "derive me" MemRef the parser emits. Two passes had to stop
rebuilding the type without it: ConvertToSSA now merges an LHS MemRef into
the RHS-derived authoritative type (op type deduction never produces one,
so it is additional information, not a stale override — this also stops a
re-parsed post-allocation dump from losing its MemRefs), and
FlattenTileNdTo2D carries it through the ND-to-2D rebuild.
Rejected at compile time, each with the offending tile's span: a dynamic
shape, bound tiles disagreeing on memory space, binding a view / in-place
output (it physically is its source's buffer), and two independently
bound tiles that are live at the same time. pl.Buffer under
memory_planner=PTOAS is rejected rather than silently unenforced, since
ptoas replaces the pass that provides the isolation.
Isolation is a per-slot flag in the packing loop rather than a can_share
gate, and the co-liveness check is a sorted sweep, so neither adds work to
the O(M^2) pack for kernels that declare no buffer.
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughAdds ChangesUser buffer support
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant TileAnnotation
participant TypeResolver
participant InitMemRef
participant MemoryReuse
participant AllocateMemoryAddr
TileAnnotation->>TypeResolver: Resolve pl.Buffer("name")
TypeResolver->>InitMemRef: Attach named MemRef binding
InitMemRef->>InitMemRef: Derive size and memory space
InitMemRef->>MemoryReuse: Emit pinned tile.alloc
MemoryReuse->>MemoryReuse: Validate lifetimes and isolate slots
MemoryReuse->>AllocateMemoryAddr: Pass distinct allocation bases
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b10d95e9d0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The binding never reached InitMemRef in a kernel that is actually lowered on-core, and did so silently — the buffer simply was not pinned, with no diagnostic. ConvertToSSA merges a pl.Buffer binding into the *Var*'s type; op type deduction leaves the RHS Call's type MemRef-less. Every carry-through site read call->GetType(), so each one matched nothing — including the ND-flatten carry this feature added. The tests missed it because a plain @pl.function needs no argument substitution: FlattenTileNdTo2D leaves those statements untouched, so the binding survived by accident rather than by the carry. Wrap the body in pl.spmd and the tensor params become partition views, every downstream op is rebuilt, and the binding is gone. Read it from assign->var_ instead, at all three FlattenTileNdTo2D rebuilds (ND flatten, <=2D tile.load, generic tile op). Fixing those exposed the same bug one pass later: InferTileMemorySpace syncs the LHS Var type to the rebuilt Call and preserved only memory_space_, taking the Call's empty memref_. It now keeps the Var's MemRef for the same reason it already keeps the Var's memory space. With the binding intact, an explicit multi-level double-buffer schedule is expressible: two named Acc slots put consecutive TMATMULs on distinct L0C addresses instead of one, so TSTORE is no longer a WAR dependency between them. Also pins the interaction the fix makes reachable: naming a buffer inside a pl.pipeline(stage=2) body is rejected, since the cloned stages would both want the one buffer. Explicit slots replace pipelining at that level rather than stacking on it.
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/ir/transforms/init_memref.cpp (1)
444-524: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winUser-buffer rejection check misses two paths that also discard the binding silently.
The new
CHECK_SPAN(Lines 464-472) only guards the Call-based inherit/reuse-input path. Two structurally identical paths in this same function bypass it entirely, silently dropping apl.Buffer(...)binding with no diagnostic:
- Lines 453-457:
ProducesBufferLessTile→MakeMemRefLessAssignunconditionally clones withstd::nulloptMemRef, discarding any user binding on a cross-core tpop result (or a buffer-aliasing view chained off one).- Lines 511-519: the plain tile-alias branch (
a = b) callsShareMemRefFromunconditionally —t1: pl.Tile[..., pl.Buffer("mine"), ...] = t0has noCallon the RHS, soHasUserBindingis never consulted, andt1's binding is silently replaced byt0's MemRef.Both are exactly the "output physically is its source's buffer, so it cannot be bound to a buffer of its own" scenario the new check exists to reject (per the accompanying docs), just reached via a
VarRHS or a buffer-less producer instead of aCall.🐛 Suggested fix: extend the guard to both paths
if (As<TileType>(op->var_->GetType()) && ProducesBufferLessTile(new_value, [](const Var* v) { return !GetTypeMemRef(v->GetType()).has_value(); })) { + CHECK_SPAN(!HasUserBinding(op->var_->GetType()), op->var_->span_) + << "Tile '" << op->var_->name_hint_ << "' has no general-pool buffer of its own " + "(cross-core tpop result or a view over one), so it cannot be bound to a buffer."; return MakeMemRefLessAssign(op, new_value); } @@ if (auto value_var = As<Var>(new_value)) { if (As<TileType>(op->var_->GetType())) { + CHECK_SPAN(!HasUserBinding(op->var_->GetType()), op->var_->span_) + << "Tile '" << op->var_->name_hint_ << "' is an alias of '" << value_var->name_hint_ + << "', which reuses its source tile's buffer, so it cannot be bound to a buffer of " + "its own. Bind the source tile instead."; auto result = ShareMemRefFrom(value_var, op, new_value); if (result) return result; } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/ir/transforms/init_memref.cpp` around lines 444 - 524, Extend the user-buffer rejection in VisitStmt_ to cover both buffer-less tile assignments and plain tile aliases. Before MakeMemRefLessAssign in the ProducesBufferLessTile branch, and before ShareMemRefFrom in the Var RHS alias branch, validate that HasUserBinding(op->var_->GetType()) is false; otherwise emit the same span-aware diagnostic used for call-based buffer reuse, while preserving valid unbound assignments.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@include/pypto/ir/transforms/utils/op_predicates.h`:
- Around line 49-59: Update the documentation for the predicate covering
OutputMemoryInheritsInput and GetOutputReusesInputArg to replace “same data as
their source” with wording that describes using a source-provided allocation.
Preserve the distinction from IsBufferAliasingViewOp so tile.transpose is
recognized as sharing storage without implying data aliasing.
In `@python/pypto/language/parser/type_resolver.py`:
- Around line 1589-1642: Update _resolve_buffer so Buffer names bypass
scope_lookup and resolve only through resolver-local base-pointer interning. Add
a dedicated helper or an explicit _intern_base_ptr option for this behavior,
while preserving scope_lookup for ordinary MemRef base resolution and ensuring
repeated Buffer names share the same interned Ptr.
In `@python/pypto/language/typing/buffer.py`:
- Around line 10-17: Document the restriction that Buffer declarations are
rejected inside pl.pipeline(stage=2) bodies, explaining that cloned stages would
otherwise share one function-scoped allocation; update the Buffer docstring in
python/pypto/language/typing/buffer.py (lines 10-17), the English guide in
docs/en/dev/language/00-python_syntax.md (lines 72-92), and the corresponding
Chinese guide in docs/zh-cn/dev/language/00-python_syntax.md (lines 72-89).
In `@src/ir/transforms/flatten_tile_nd_to_2d/rewrite.cpp`:
- Around line 68-86: Update the rank>2 tile.create/tile.full rewrite to pass
new_call->GetType() through WithCarriedMemRef using the relevant assignment
before constructing flat_var. Use this carried type for both the recreated call
and replacement variable, preserving existing shape/type metadata while
retaining the assigned variable’s MemRef binding.
---
Outside diff comments:
In `@src/ir/transforms/init_memref.cpp`:
- Around line 444-524: Extend the user-buffer rejection in VisitStmt_ to cover
both buffer-less tile assignments and plain tile aliases. Before
MakeMemRefLessAssign in the ProducesBufferLessTile branch, and before
ShareMemRefFrom in the Var RHS alias branch, validate that
HasUserBinding(op->var_->GetType()) is false; otherwise emit the same span-aware
diagnostic used for call-based buffer reuse, while preserving valid unbound
assignments.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: e2a9702c-411b-40b4-ae4b-fe13b96fcbf7
📒 Files selected for processing (22)
docs/en/dev/language/00-python_syntax.mddocs/en/dev/passes/29-init_memref.mddocs/en/dev/passes/31-memory_reuse.mddocs/zh-cn/dev/language/00-python_syntax.mddocs/zh-cn/dev/passes/29-init_memref.mddocs/zh-cn/dev/passes/31-memory_reuse.mdinclude/pypto/ir/transforms/utils/memref_utils.hinclude/pypto/ir/transforms/utils/op_predicates.hpython/pypto/language/__init__.pypython/pypto/language/op/tile_ops.pypython/pypto/language/parser/ast_parser.pypython/pypto/language/parser/type_resolver.pypython/pypto/language/typing/__init__.pypython/pypto/language/typing/buffer.pysrc/ir/op/tile_ops/memory.cppsrc/ir/transforms/convert_to_ssa_pass.cppsrc/ir/transforms/flatten_tile_nd_to_2d/rewrite.cppsrc/ir/transforms/infer_tile_memory_space_pass.cppsrc/ir/transforms/init_memref.cppsrc/ir/transforms/memory_reuse_pass.cppsrc/ir/transforms/utils/op_predicates.cpptests/ut/ir/transforms/test_user_buffer.py
- Buffer names no longer resolve through scope_lookup. That fallback
exists so pl.MemRef(base, ...) can name an alloc-defined Ptr; a buffer
never has one, so it could only misfire — pl.Buffer("a") alongside a
tensor parameter `a` took that parameter as its allocation base, and
InitMemRef then emitted a Tensor-typed var as a base Ptr.
- Carry the MemRef through the rank>2 tile.create / tile.full rewrite in
FlattenTileNdTo2D. It re-deduces the 2D call like the tile.load and
generic-op paths, so it dropped the binding for the same reason.
- OutputInheritsSourceBuffer no longer counts tile.transpose. Inheriting
the input's memory space is not landing in its buffer: pto.ttrans is
registered not_inplace_safe() and permutes into a fresh one, which
IsBufferAliasingViewOp already accounts for. Binding a transpose output
was rejected with a reason that did not hold.
- Document the pl.pipeline restriction in the Buffer docstring and both
language guides, with the pl.range alternative.
- Correct the UserBufferCollector comment: it does not visit params, and
need not — the parser refuses pl.Buffer in a parameter annotation.
Not changed, with reasons:
- "Collect tile parameters in the user-buffer traversal" — unreachable.
A pl.Buffer in a parameter annotation is rejected at parse time.
- "size_ == 0 collides with a dynamically shaped compiler MemRef" — a
TileType never reaches that state. CreateMemRef INTERNAL_CHECKs every
extent is a positive ConstInt, so a compiler tile MemRef is always >= 1
byte, and the marker check only matches TileType.
Two things were being decided by a string or a sentinel.
Whether a MemRef is an author's binding was inferred from `size_ == 0`.
Nothing guarantees a compiler allocation never holds that value; the
invariant was implicit, and the printer had to emit the binding as a
MemRef with an invented size, so a dump did not say what it was. MemRef
now carries `is_user_buffer_`, and a binding prints as `pl.Buffer("name")`
— the form the author wrote, round-tripping without inventing anything.
InitMemRef consumes the binding: the MemRef it produces is ordinary, and
`pinned=True` on the alloc carries user ownership from there on.
An explicit field is not free the way the sentinel was. Riding `size_`
meant every rebuild that copied the size copied the marker with it; a new
field is dropped by default, so the sites that rebuild the *same* MemRef
now thread it: the deep-clone path (LowerPipelineLoops clones a body per
stage — without this every tile in a pipelined region silently unbinds),
AllocateMemoryAddr's address rewrite, and both deserializers, where an
absent field means the blob predates user buffers and holds only compiler
allocations.
Which buffer a tile means was a string. A typo declared a second buffer
instead of failing. `pl.Buffer()` is now declarable and referenced by
variable:
ping = pl.Buffer()
t: pl.Tile[[64, 64], pl.FP32, ping, pl.Mem.Vec] = ...
A misspelled reference is a NameError. An unnamed buffer takes the name of
the variable it is bound to, so it is named once rather than twice. The
inline `pl.Buffer("name")` form stays valid — it is what the printer emits,
so a dump reparses without a surrounding Python scope.
The authoring examples still spelled buffers inline, which is the form the printer emits for round-tripping, not the one to reach for when writing a kernel. Every example that writes a kernel now declares a pl.Buffer() and references it; the prose that discusses the inline form still names it, since that form is what a dump contains. Covers both language guides, 29-init_memref, 31-memory_reuse, and the Buffer module/class docstrings.
Summary
MemoryReusecoalesces any two tiles whose lifetimes do not overlap. That is the right default for capacity, but it is not free: the pair is then ordered by a WAR dependency the source never asked for, and the hardware serializes work the scheduler could otherwise overlap.This adds an opt-in, per-tile escape hatch. Declare a buffer, then reference it:
Tiles referencing one buffer share one allocation; nothing else is ever packed into it. The author writes neither a size nor an address —
InitMemRefsizes each buffer to its largest member and derives the memory space from the bound tiles. Tiles left unannotated keep the existing automatic reuse.Measured on a three-tile chain:
t0/t2onping,t1onpongTwo things a name should not decide
Which buffer a tile means was a string. A typo in
pl.Buffer("scartch")declared a second buffer rather than failing, and a buffer whose name collided with an in-scope variable silently took that variable as its allocation base. A declaredpl.Buffer()referenced by variable makes a misspelling a PythonNameError, and an unnamed buffer takes the name of the variable it is bound to, so it is named once rather than twice.The inline
pl.Buffer("name")form stays valid, and is not merely legacy: it is what the IR printer emits, because a dumped program has to reparse without a surrounding Python scope. The object form is the source-level spelling; both lower to the same IR.Whether a MemRef is a binding at all was inferred from
size_ == 0. Nothing guarantees a compiler allocation never holds that value — the invariant was implicit — and the printer had to emit a binding as apl.MemRef(...)with an invented size, so a dump did not say what it was.MemRef::is_user_buffer_now records it, and a binding prints aspl.Buffer("name"): the form the author wrote, round-tripping without inventing anything.An explicit field is not free the way the sentinel was. Riding
size_meant every rebuild that copied the size copied the marker with it; a new field is dropped by default. The sites that rebuild the same MemRef now thread it — the deep-clone path (LowerPipelineLoopsclones a body per stage; without this every tile in a pipelined region silently unbinds),AllocateMemoryAddr's address rewrite, and both deserializers, where an absent field means the blob predates user buffers and holds only compiler allocations.How the binding reaches InitMemRef
It rides
TileType::memref_, flagged byis_user_buffer_.The binding lives on the assigned
Var, not on the RHSCall.ConvertToSSAmerges it into the Var's type, and op type deduction never produces a MemRef, so a rebuilt Call's type has none. Any carry-through that readscall->GetType()therefore matches nothing — silently, since a missing binding just means the buffer is not pinned. Three passes rebuild the type and had to carry it:ConvertToSSAmerges an LHS MemRef into the RHS-derived authoritative type. Since deduction never produces one, an LHS MemRef is additional information rather than a stale override. This also fixes a latent gap: a re-parsed post-allocation dump previously lost its MemRefs here.FlattenTileNdTo2Dcarries it through all four rebuilds — ND→2D flatten, ≤2Dtile.load, rank>2tile.create/tile.full, and the generic tile-op re-deduction — each sourcing fromassign->var_. The latter three fire only once arguments are substituted, which is exactly what happens when tensor params become partition views.InferTileMemorySpacesyncs the LHS Var type to the rebuilt Call; it already preserved the Var'smemory_space_and now preserves its MemRef for the same reason.Anything less and the binding survives a plain
@pl.function(no substitution, so those statements pass through untouched) but is dropped in any function actually lowered on-core — the only shape that matters in practice.InitMemRefthen consumes the binding: the MemRef it produces is an ordinary one carrying the derived size, with the flag cleared. From there on the allocation'spinned=Truekwarg is what marks the buffer as user-owned, so re-running the passes over a dump cannot promote compiler allocations into user buffers.Rejected at compile time
Each with the offending tile's span and a stated fix: dynamic shape; bound tiles disagreeing on memory space; binding a view / in-place output (it physically is its source's buffer); two independently bound tiles live at the same time.
pl.Bufferundermemory_planner=PTOASis rejected outright rather than honored-but-unenforced, since ptoas replaces the pass that provides the isolation.tile.transposeis not in that set: it inherits its input's memory space but permutes into a fresh buffer (pto.ttransis registerednot_inplace_safe()), so it owns its storage and may be bound.That last rule also covers
pl.pipeline: naming a buffer inside astage=2body is rejected, because the cloned stages make the tile co-live with itself on one allocation. Explicit slots replace compiler pipelining at a given level rather than stacking on top of it — an author taking over that level writespl.rangeplus their own named slots.What it enables
An explicit multi-level double-buffer schedule (#2131). An outer
pl.pipeline(stage=2)keeps compiler-managed L1 buffers while an unrolled innerpl.rangedrives author-declared L0B/L0C slots:With one accumulator both
TMATMULs emitaddr = %c0_i64, so aTSTOREhas to drain L0C between them. With two slots they land on%c0_i64and%c8192_i64, and the store is no longer a WAR dependency on the critical path.Cost to kernels that do not use it
Isolation is a per-slot flag inside the packing loop rather than another
can_sharegate, and the co-liveness check is a sorted sweep rather than a pairwise scan. Both are short-circuited when no buffer is declared, so the O(M²) pack is unchanged for existing kernels.Testing
tests/ut/ir/transforms/test_user_buffer.py— 27 tests: binding via both spellings, sharing, size/space derivation, packer exemption, survival through the full pipeline (incl. ND flattening,tile.createflattening, and apl.spmdMat/Left/Right/Acc kernel), the rejections, and the negative cases — a zero-sized ordinary MemRef is not a binding, and re-parsed dumps are not treated as user buffers.pl.spmdcube-kernel test is the regression guard for the Var-vs-Call sourcing fix — verified to fail without it.tests/ut/suite: 7999 passed, 2 skipped.29-init_memref.md,31-memory_reuse.md,00-python_syntax.md.Known limitation
Buffers are function-scoped and do not clone per pipeline stage, so a binding inside a
pl.pipelinebody is rejected rather than silently shared across stages (see above). Per-stage privacy for load buffers is already handled automatically by the existing pipeline-stage guard inMemoryReuse; letting user buffers clone per stage would needLowerPipelineLoopsto freshen the identity, and is left out of this PR.There is no slot subscript — no
buffer[i & 1]— so an N-slot rotation is spelled as an N× unrolled body referencing N declared buffers.