Let delegate backends declare memory-planned scratch buffers - #21985
Draft
rascani wants to merge 1 commit into
Draft
Let delegate backends declare memory-planned scratch buffers#21985rascani wants to merge 1 commit into
rascani wants to merge 1 commit into
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/21985
Note: Links to docs will display an error until the docs builds have been completed. ✅ You can merge normally! (1 Unrelated Failure)As of commit 358cba6 with merge base 869b5ef ( FLAKY - The following job failed but was likely due to flakiness present on trunk:
This comment was automatically generated by Dr. CI and updates every 15 minutes. |
This PR needs a
|
A delegate that needs scratch memory at execute() time has to take it from the runtime temp allocator, and that has two costs. The size is invisible: an integrator has to run the model, watch the high-water mark, and hard-code the result, because nothing in the program says how much the delegate needs. And the temp allocator is a single pool, so a mixed CPU/NPU design has to back it with whatever memory the NPU's large scratch requires; a system that would put NPU scratch in DDR and CPU kernel temporaries in fast TCM cannot, and the CPU kernels pay for it. Making the requirement part of the memory plan addresses both. It becomes a property of the model rather than a number discovered by measurement, and because planned buffers carry a mem_id it can be pinned to one pool while the CPU's temporaries stay in another. Reuse against unrelated planned tensors is a bonus rather than the point: the temp allocator is already reset every instruction, so it too gives max-not-sum across delegate calls. Op-library backends already work this way. An out-variant kernel declares its scratch through get_scratch_metas, gets a memory.alloc node, and the planner allocates it in the arena like any other intermediate. This gives delegates the same thing: a backend returns scratch_specs from preprocess, which is the point where the partition has been compiled and its requirement is known, and InsertDelegateScratchPass turns each spec into a memory.alloc node appended to that delegate call's arguments. The scratch reaches the backend as the trailing arguments of the delegate call, emitted after the inputs and the outputs. The runtime attaches no roles to delegate arguments at all: it hands over a flat list, and every backend already works out which entries are inputs and which are outputs from counts baked into its own blob. Scratch is no different, so it needs no runtime support, and putting it last means the inputs and outputs keep the positions existing backends already read. Passing it as a keyword argument instead was not an option; the call_delegate higher-order op rejects unexpected kwargs. The pass records the count on the delegate node and the emitter reads it from there rather than trusting the lowered module, because the two can disagree: emit paths such as LoweredBackendModule.program() never run the pass, and would otherwise slice a real input into the scratch position. A disagreement is now an error rather than silent corruption. to_executorch() writes its lowered graph back into the edge program, so a second call would retrace a delegate call carrying arguments the lowered module's signature does not have. strip_delegate_scratch_pass runs first and clears them, keeping repeated calls working as they do today. A spec carries an optional mem_id and nothing else. Placement is a property of the target rather than of the backend, so a backend should take the id from a compile spec instead of hardcoding one; pinning to a pool the integrator does not supply fails at load time, and a non-default id is rejected outright by share_mutable_buffers and by enable_non_cpu_memory_planning. There is deliberately no alignment field: the planner aligns to 16, which is what every in-tree backend asks for, and honoring anything stricter would require aligning offsets in the planner and the arena base in the runtime, neither of which is true today. A backend with a stricter requirement should check the pointer it receives. Serialization carries scratch_specs through exir.save/exir.load; dropping it would emit a program without the arguments the backend's blob still expects. With an event tracer enabled, the delegate-argument logging in method.cpp records scratch buffers along with everything else, which is the same limitation that already applies to delegate inputs. Authored with Claude Code.
rascani
force-pushed
the
delegate-scratch-specs
branch
from
August 20, 2026 22:54
4820c6d to
358cba6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lets a delegate backend declare scratch memory that the AOT memory planner allocates in the arena, instead of taking it from the runtime temp allocator at
execute()time.Why
A delegate that needs scratch today pays two costs. The size is invisible: an integrator has to run the model, watch the temp allocator's high-water mark, and hard-code the result, because nothing in the program says how much the delegate needs. And the temp allocator is a single pool, so a mixed CPU/NPU design has to back it with whatever memory the NPU's large scratch requires — a system that would put NPU scratch in DDR while keeping CPU kernel temporaries in fast TCM cannot, and the CPU kernels pay for it.
Making the requirement part of the memory plan addresses both: it becomes a property of the model rather than a number found by measurement, and because planned buffers carry a
mem_idit can be pinned to one pool while the CPU's temporaries stay in another.Reuse against unrelated planned tensors is a bonus rather than the point — the temp allocator is already reset every instruction, so it too gives max-not-sum across delegate calls.
Op-library backends already work this way: an out-variant kernel declares scratch through
get_scratch_metas, gets amemory.allocnode, and the planner allocates it like any other intermediate. This gives delegates the same thing.What a backend writes
preprocess()runs per partition, after that partition is compiled, so the size is known.InsertDelegateScratchPassturns each spec into amemory.allocnode appended to that delegate call's arguments.The runtime contract
Scratch arrives as the trailing arguments of the delegate call, after the inputs and the outputs, and there is no runtime or program-schema change. The runtime attaches no roles to delegate arguments at all — it hands over a flat list, and every backend already derives its input and output counts from its own blob (Ethos-U reads
handles.inputs->count; NXP readscfg->numInputs). Scratch is the same kind of positional slot, and putting it last means existing input and output indices are unchanged.Recommended adoption pattern, correct in both directions of version skew:
Design notes
LoweredBackendModule.program()) now raise instead of slicing a real input into the scratch slot.strip_delegate_scratch_passruns first in the pipeline becauseto_executorch()writes its lowered graph back over the caller's edge program, so a second call would otherwise retrace a delegate call with arguments its signature does not have.mem_idshould come from a compile spec, not be hardcoded. Which id means which pool is a property of the target; on the Arm runner the fast SRAM pool ismem_id=3and is compiled out by default, and a non-default id is rejected byshare_mutable_buffersand byenable_non_cpu_memory_planning.TensorSpec(becauserealign()overwrites), which then broke the greedy planner's sort invariant, and it never aligned the buffer's start anyway — offsets are running sums. The planner's 16 bytes is what Ethos-U and NXP ask for; a backend needing more should check the pointer it receives.scratch_specs, since a round trip that dropped it emitted a program without the argument the backend's blob still expects.Tests
exir/backend/test/test_delegate_scratch.py(12): argument placement, per-call sizing, disjoint-lifetime sharing, two buffers keeping declaration order and their own pools, scratch inside atorch.condsubmodule, emitting without the pass erroring, survival through serde anddeepcopy,to_executorch()twice with and without scratch, and declaration-time validation.runtime/executor/test/backend_integration_test.cppasserts what the backend actually receives: one extra argument beyond the inputs and outputs, a 1-D byte tensor of the declared size, a non-null pointer distinct from every input and output, and a planned arena large enough to hold it.test/models/export_delegated_program.pygained--scratch_bytesto produce the fixture. Note the CMake target for that test file is disabled upstream (TODO T191569140), so it runs under Buck rather than in the OSS CMake run.Known gaps
.pte, so attributing arena growth to a delegate needs a tooling follow-up.devtools/inspectoridentifies delegate outputs by counting back from the end of the logged argument list, so an adopting backend breaks AOT-versus-runtime comparison until the Inspector skips the scratch tail. Scratch also shows up in etdump.arm_vela.pyalready computes the scratch size and discards it.Authored with Claude Code.