Uh oh!
There was an error while loading. Please reload this page.
fix(executorch): lift nested and partitioner-excluded mutable buffers - #4470
fix(executorch): lift nested and partitioner-excluded mutable buffers#4470shoumikhin wants to merge 16 commits into
Conversation
197acc4 to
0c52ff5Compareshoumikhin
commented
Aug 12, 2026
Pushed an amendment to this PR. What changedA copy-back value is now paired with its buffer by name rather than by position. The old code assumed those values were always the last outputs: copyback_getitems=list(out_args[-num_copyback:])That assumption does not always hold. How it showed upOn a transformer whose cache write stays inside the engine, it declared the model's Export then failed at runtime lowering: 16 is the sequence length and 4096 is the cache length, which is the shape mismatch you The fixReclassify only the buffers the program does not already declare as mutated: already_mutated= {
spec.targetforspecinorig_specsifspec.kind==OutputKind.BUFFER_MUTATIONandisinstance(spec.target, str)
}
pending_copyback= [bufforbufincopyback_buffersifbufnotinalready_mutated]This makes the pass idempotent with respect to what After the change the same model exports and produces four mutation specs for four |
032acc5 to
2bd9dbcCompare15e0a87 to
8029187Compareshoumikhin
commented
Aug 19, 2026
Rebased onto your updated #4459 (5e7b37d). Two add/add conflicts, both in the copy-back remap comment, resolved in favor of your wording since #4459 now carries that fix itself. One collision worth your call. Your new commit raises So we now have two contracts for the same situation. Your My read is that the raise is still correct for the KV path your guard watches, and the graceful skip is correct for the |
Adds end-to-end caller-owned KV-cache support to the ExecuTorch TensorRT
delegate: the KV buffers are owned by the caller above the delegate and threaded
in as mutable-buffer delegate args, instead of being self-allocated inside a
(stateless) TensorRT engine.
Runtime + serialization (delegate):
- serialize each engine's aliased (KV-cache / in-place) I/O into the delegate blob
(serialization.py, backend.py, TensorRTBlobHeader.{h,cpp});
- at runtime bind each aliased TRT output binding to its aliased input's
caller-provided pointer (in-place) and reflect the result into the delegate
output EValue -- a no-op when the memory planner already aliased the two
(TensorRTBackend.{h,cpp}).
Export/lowering (torch_tensorrt):
- expose each engine's aliased outputs as graph-level BUFFER_MUTATIONs so
ExecuTorch keeps the KV buffers as caller-owned mutable buffers: at transform
time for the legacy exporter (retrace=False), and via a post-export pass
(_declare_aliased_kv_mutations_on_ep) for torch.export (retrace=True), which
otherwise truncates the aliased outputs at the fx boundary;
- keep delegate-mutated buffers above the delegate in TensorRTPartitioner
(tag_constant_data would otherwise freeze them as constants).
The retrace=True pass runs for exported_program as well as executorch. The
truncation happens at the fx boundary for every output format, so declaring only
on the executorch path left an exported_program saved with the mutation absent
from its signature while the engine still updated the cache in place. It is
declared before _normalize_engine_constants_to_python, which rewrites the engine
constants the pass reads aliased_io from. retrace=False was already correct for
every format via create_trt_exp_program. aot_inductor stays undeclared and
warns: whether an aliased in-place mutation survives functionalization under
inductor is unverified.
Tests cover serialization round-trip, the exposure-flag dispatch across both
retrace modes, the buffer-mutation declaration, and the partitioner un-tagging.…mutations
_declare_aliased_kv_mutations_on_ep rebuilds the ExportedProgram to attach the
new output specs, but reconstructed only root/graph/signature/state_dict/
range_constraints/module_call_graph/constants. example_inputs and verifiers are
not recoverable from the graph and reset to their defaults when omitted, so the
pass was not a drop-in replacement for the program it rewrites.
That was invisible while the pass ran only on the executorch path, since to_edge
does not read either. Declaring mutations for exported_program as well makes it
reachable: torch.export.save then persists a program whose example inputs are
silently gone. AOTI refuses such a program outright ("exported_program.
example_inputs is required to be set in order for AOTInductor compilation"), so
this also has to be fixed before that format can ever declare mutations.
Carry both through. The stub programs in test_kv_cache_export.py now model them,
and the capturing test asserts they reach the constructor.
Reported by cehongwang in review.Whether an aliased KV output gets declared a BUFFER_MUTATION is decided in two places: the exporter (the legacy one declares at transform time, via create_trt_exp_program) and save()'s per-output-format branch. Nothing reconciles them, so a program that arrives already declared is declared a second time. The duplicate spec then fails the ExportedProgram verifier's output ordering check. Seed already_exposed from the incoming signature's BUFFER_MUTATION targets rather than an empty set, so the pass skips buffers that are already declared and returns the program untouched when nothing new remains. That covers every combination that reaches it, including exported_program with use_legacy_exporter=True, which the preceding commit made reachable. The added test drives the pass on an already-declared program with ExportedProgram monkeypatched to raise, so a regression fails on "rebuilt the program" rather than on some later verifier complaint. The no-op fixture grows an output_specs field, which a real ExportGraphSignature always has. Reported by shoumikhin in review.
Four comments describe a zero-copy path where the memory planner places the delegate's output slot on the aliased input, so the reflect is skipped. That never happens: the delegate input and its aliased output are live at the same time, so the planner cannot co-locate them. Instrumenting the branch over a 30-layer export confirms it -- dst == bind_ptr was false for all 120 aliased outputs across a prefill and a decode step. Two of them go further and call the skipped case the common fast path, which inverts what the code does: every aliased output reflects, and a model with aliased outputs therefore always syncs before returning. Say what actually happens in all four (TensorRTBackend.h, and the reflect list, the reflect loop and the must_sync rationale in TensorRTBackend.cpp). The dst != bind_ptr check itself stays -- it is unreachable today, but it is what stops a self-copy if planning ever changes -- and its comment now says so instead of advertising a fast path. Also two comment fixes found in the same pass: "These two" in _exporter.py referred forward to checks the reader had not reached yet, and a test comment said "previously-dropped" where it meant the output torch.export truncates. No behaviour change. Reported by shoumikhin in review.
The aliased-output branch discarded the resize_tensor result and skipped the resize altogether when the rank was out of range, while the sibling non-aliased branch treats both as fatal. et_alias_out.nbytes() is read on the next line and sizes both the reflect D2D copy and, through the delegate output EValue, ExecuTorch's write-back copy_. A dynamic aliased output that outgrows its planned size would therefore move the stale planned byte count in both, silently truncating the cache update rather than failing. Mirror the sibling branch: reject an out-of-range rank and propagate a resize_tensor error. Reported by shoumikhin in review.
…th too Whether an aliased KV output is declared a BUFFER_MUTATION depends on two independent switches. save() picks the exporter (retrace, plus an optional use_legacy_exporter override) and only the legacy exporter exposes the mutations, at transform time; save()'s per-format branch declares them for everything else. The retrace=False branch did neither, so retrace=False with use_legacy_exporter=False produced a program that silently omits an update the engine performs -- no declaration and no diagnostic. Run the declaration pass on the exported_program and executorch branches there as well. The preceding commit made the pass skip buffers that already carry a spec, so this is correct for either exporter: the legacy one keeps declaring at transform time and the pass returns its program untouched. aot_inductor stays undeclared on both paths, as before. The added test drives save() over both formats and both exporters and asserts the pass runs exactly once; against the previous commit all four parametrizations fail. Reported by shoumikhin in review.
The runtime binds delegate output i to output_binding_names[i]. That holds because the partition's outputs are getitem(engine_node, i) in index order, and nothing verifies it: inputs are checked in _reorder_input_names_for_executorch, outputs were not. arrange_graph_outputs moves buffer mutations ahead of user outputs and is a no-op here only while the mutated buffers stay above the delegate, so a regression there would swap the serialized names silently. Validate the correspondence in preprocess. A single-output engine returned unwrapped is accepted -- one binding has no order to get wrong -- and anything else must be one getitem per binding, in index order. _build_edge_program only ever emitted `output((engine_node,))`, including for its three-output-binding case, which is not a shape that can occur: a three-tuple cannot be consumed as one value. It now emits one getitem per output binding, so the fixtures model what the backend actually receives. Reported by shoumikhin in review.
…d_io aliased_io changes what a blob means. A parser that predates it binds each aliased output to its own allocation instead of the input it aliases, so it does not fail -- it returns wrong results. The magic is the only field that parser validates, so it is the only thing that can make the skew fail closed. Emit TR02 when metadata.aliased_io is non-empty and keep TR01 otherwise, rather than bumping unconditionally: a blob with no alias map means exactly what it meant before, so it stays loadable by an older runtime. Both magics are accepted on read, so new runtimes still load existing artifacts. Verified against a real older build rather than a simulated one: a TR02 blob loads and produces the expected KV-persistence result on a runtime built from this branch, and a runtime built before the change rejects the same blob with "failed to parse TensorRT blob". Reported by shoumikhin in review.
…r verify The caller-owned KV path had no CI coverage: kv_cache_decode_check ships in the release tarball and is defined in the packaged CMake project, but nothing built or ran it, so a regression in the aliased binding would only surface downstream. Export a decode .pte alongside the static-shape one and pass it to the verify script as an optional second argument. When present the script builds kv_cache_decode_check from the unpacked tarball, runs it, and requires the persistence assertion to pass; the same no-libtorch link check the example runner gets is applied to it. Without the argument the script behaves as before. Also assert the tarball ships kv_cache_decode_check.cpp, next to the existing entries, so the packaging contract is checked rather than assumed. Reported by shoumikhin in review.
…ering claim Four `continue`s in _declare_aliased_kv_mutations_on_ep left an aliased output undeclared without saying so, and the mismatch only surfaced later as a delegate arity error at execute. Log at each, at the level the case warrants: warn when the persisted alias map disagrees with the engine's bindings (unknown input name, or an index past the delegate args) and when the aliased input is not a registered buffer, since all three leave the engine with an output binding the delegate cannot satisfy; debug when the buffer already carries a spec, which is the expected idempotent skip. The non-aliased output path stays silent -- it is the common case, not a fault. _reorder_input_names_for_executorch's docstring also justified skipping the output reordering by claiming a TensorRT partition has no mutation outputs. That has not been true since aliased I/O landed. The order does survive lowering, but for a different reason: _keep_mutated_buffers_above_delegate keeps mutated buffers out of the delegate, so ExecuTorch records the mutation as a USER_OUTPUT and arrange_graph_outputs computes the identity permutation. Say that, and note the guarantee is conditional -- _validate_output_binding_order is what enforces it. Reported by cehongwang in review.
…T delegate A mutable buffer with no engine aliasing -- a convolution-state ring buffer, say -- is mutated outside the engine, so the KV aliasing path does not cover it. lift_mutated_buffers appends its new value as a trailing user output; reclassifying that output as a BUFFER_MUTATION of its buffer makes ExecuTorch copy it back after the delegate runs. Thread the buffer FQNs from gm.meta['_copyback_mutation_buffers'] into _declare_aliased_kv_mutations_on_ep on the retrace=True executorch path, and have create_trt_exp_program do the equivalent at transform time on the legacy path. The reclassification is positional: it takes the trailing len(copyback_buffers) outputs. That cannot skip an already-declared buffer the way the KV path's already_exposed set does, because after a first declaration the mutations sit at the front and the trailing outputs are the user's -- a second pass would retarget those, dropping a user output and leaving the buffer with two mutation specs. Reject that combination instead of silently corrupting the signature.
…program Copy-back was threaded only on the executorch branch, so saving as exported_program produced a signature that omits an update the program performs. Hoist _copyback_bufs to the retrace=True block and pass it on both format branches. retrace=False is left alone deliberately. Only the legacy exporter declares copy-back there, at transform time, and the pass cannot pick it up afterwards: its reclassification is positional, so running it on an already-declared program would retarget the wrong outputs. That leaves retrace=False with use_legacy_exporter=False unable to declare copy-back at all, which now warns instead of silently dropping it, mirroring the aot_inductor warning above.
Four problems that together stop a model from exporting when its caches live in
submodules or when part of the graph runs on another backend. The later ones only
become visible once the earlier ones are fixed.
1. A get_attr target is fully qualified, so a buffer owned by a submodule arrives
as "layers.0.self_attn.kv_cache.k_cache". hasattr and getattr do not walk a
dotted path, so every nested buffer is reported missing and skipped, and the
copy-back path never runs. Use get_buffer, which resolves through submodules.
2. register_buffer rejects dots, so a nested buffer is renamed to lifted_buf_*,
but the recorded mutation targets keep the original dotted names. The verifier
then rejects the program:
SpecViolationError: Buffer output getitem_1 does not point to a buffer
that exists
Remap the recorded targets through the same mapping, and keep the mapping in
meta so a later consumer does not have to reconstruct the renaming rule.
3. The aliasing prediction never sees torch_executed_ops, so a write the caller
excluded from TensorRT is still predicted to alias and its copy_ is dropped.
compile() then fails its own cross-check, reporting that the write-back would
be silently dropped. An op that never reaches a converter cannot emit an
IKVCacheUpdateLayer, so honor the exclusion list, matched the way the
partitioner matches it so the two cannot disagree.
4. Even with the prediction corrected, lifting an excluded write turns the
mutation into a delegate output that feeds the other delegate. ExecuTorch
cannot express a buffer mutation consumed across a delegate boundary:
RuntimeError: Tried to erase Node getitem_145 but it still had 1 users
in the graph: {executorch_call_delegate_1: None}
If TensorRT is not converting the write, leave the buffer alone entirely so
the copy_ stands for whichever backend claims it.
Pair a copy-back with its buffer by name rather than by position. torch.export
usually leaves those values as the last outputs, but when it recognises the mutation
itself it declares them BUFFER_MUTATION and moves them to the front, because the
verifier requires mutations to precede user outputs. Taking the tail unconditionally
then reclassified a real user output as a buffer mutation. On a transformer whose
cache write is kept inside the engine, that declared the model's logits as a cache
update and the export failed with:
RuntimeError: expand: attempting to expand a dimension of length 16 -> 4096
Only the buffers the program does not already declare as mutated are reclassified
now, so the pass is idempotent with respect to what torch.export already recorded.The conflict resolution when rebasing left a line-wrapped condition that black wants on one line. Python Linting failed on it.
An op the caller lists in torch_executed_ops never reaches a TensorRT converter, so it cannot emit an IKVCacheUpdateLayer and the engine will not alias the buffer. The aliasing prediction did not know that, so it classified such a write as engine-aliased and dropped its copy_, and compile() then failed its own cross-check reporting that the write-back would be silently lost. The prediction now honors the exclusion list, matched the way the partitioners match it so the two agree about the op. The write is still lifted and still copied back, like any other write the engine does not alias. An earlier version of this change skipped such a buffer entirely, leaving the get_attr and the copy_ in place on the theory that another backend would claim the write. That is wrong outside ExecuTorch: lift_mutated_buffers runs for every compile() caller, and post_lowering's remove_num_users_is_0_nodes erases the orphaned copy_ because nothing reads it, so the buffer froze at its compile-time contents and the cache silently stopped updating. The copy-back route already handles this case correctly, and the mutation value becomes a real graph output, so the dead-node pass cannot remove it. The delegate-boundary error that motivated the skip has a different cause. ExecuTorch only rejects a mutation consumed after the delegate when the delegate also absorbed the buffer, and tag_constant_data already keeps mutated buffers above the delegate, which _keep_mutated_buffers_above_delegate reinforces for the case it misses. Returning a mutation value out of a delegate is a supported, tested path. Also drop gm.meta["_lifted_buffer_attr_names"], which had no reader. Test: test_torch_executed_write_is_copyback_not_kv asserts the excluded write is lifted, recorded as copy-back rather than predicted-aliased, and left with no dead nodes for post_lowering to erase. It fails on the previous behavior.
…s to lift_mutated_buffers appends each copy-back buffer's new value as a trailing output, in copyback_buffers order. The exporter reclassified those outputs as BUFFER_MUTATION by taking the last N and zipping them against the buffer list. That pairing breaks once torch.export recognises a mutation itself, because it then records its own BUFFER_MUTATION and moves it ahead of the user outputs. Filtering the buffer list first and slicing the same number of trailing outputs shifts the two lists against each other, so one buffer gets another buffer's value: buffers [a, b, c], b already declared pending [a, c] -> takes the last two values, which are b's and c's result a is declared as the mutation of b's value With different shapes the runtime fails copying that value into the buffer; with matching shapes it silently writes the wrong state. Detach the full trailing run first and pair it positionally with the full buffer list, then keep only the buffers the program has not already declared. Those appended values are internal plumbing either way, so the run always leaves the user outputs. Previously, when every buffer was already declared, nothing was detached and the values stayed as user outputs, changing the saved model's output arity and exposing internal buffer state to the caller. The out_spec rebuild is keyed on the same count for that reason. Tests: test_declare_aliased_kv_mutations_pairs_copyback_by_position covers the mixed case and test_declare_aliased_kv_mutations_skips_already_declared_copyback covers the all-declared case. Both fail on the previous behavior.
8029187 to
266a494Compareshoumikhin
commented
Aug 20, 2026
Closing this in favour of #4459, which is the same work with one owner. This branch was #4459 plus four commits on top, so there is nothing here that #4459 cannot carry directly. I rebased it onto today's The branches stay up so the commits remain fetchable:
One note for whoever rebases next: the commit |
What this fixes
Two problems that stop a model from exporting when part of its graph runs on a different
backend, plus the nested-buffer follow-ups that only become visible once the graph gets
that far.
1. An op excluded from TensorRT was predicted to alias
_kv_write_will_aliasdecides whether a buffer write becomes engine-level aliasing bylooking at the op, and never saw the ops the caller excluded via
torch_executed_ops. Anexcluded write was still predicted to alias, its
copy_was dropped, and the cross-checkin
compile()then failed:An op that never reaches a converter cannot emit an
IKVCacheUpdateLayer, so theprediction now honors the exclusion list, matched with
ConverterRegistry.qualified_name_or_strthe same way the partitioners match it. Thewrite is still lifted and still copied back, like any other write the engine does not
alias.
That guard is worth calling out: it turned a cache that would silently stop updating into
a clear error naming every affected buffer.
2. A copy-back output could be paired with the wrong buffer
lift_mutated_buffersappends each copy-back buffer's new value as a trailing output, incopyback_buffersorder, and the exporter reclassified them by taking the last N.That breaks once
torch.exportrecognises a mutation itself, because it then records itsown
BUFFER_MUTATIONand moves it ahead of the user outputs. Filtering the buffer listfirst and slicing the same number of trailing outputs shifts the two lists against each
other:
With different shapes the runtime fails copying that value into the buffer; with matching
shapes it silently writes the wrong state. On a transformer whose cache write stays inside
the engine, an earlier form of this bug declared the model's logits as a cache update and
export failed with
expand: attempting to expand a dimension of length 16 -> 4096.The fix detaches the full trailing run first and pairs it positionally with the full
buffer list, then keeps only the buffers not already declared. When every buffer was
already declared, nothing used to be detached and the appended values stayed as user
outputs, changing the saved model's output arity.
3. Renamed nested buffers left the mutation targets dangling
register_bufferrejects dots, so a nested buffer is renamed tolifted_buf_*. Therecorded mutation targets kept the original dotted names, which no longer resolve:
They are remapped through the same mapping.
What changed since the first version
The first version of this PR also skipped an excluded buffer entirely, leaving the
get_attrandcopy_in place, on the theory that another backend would claim the write.That was wrong outside ExecuTorch.
lift_mutated_buffersruns for everycompile()caller, and
post_lowering'sremove_num_users_is_0_nodeserases the orphanedcopy_because nothing reads it, so the buffer froze at its compile-time contents and the cache
silently stopped updating. Worse, that input used to fail loudly at the cross-check above,
so the skip turned a clear error into wrong numbers.
The delegate-boundary error that motivated the skip has a different cause. ExecuTorch only
rejects a mutation consumed after a delegate when the delegate also absorbed the
buffer; returning a mutation value out of a delegate is a supported, tested path
(
exir/backend/test/test_partitioner.py::test_not_delegate_mutable_buffers). Absorption isalready prevented, by
tag_constant_dataskipping mutated buffers and by_keep_mutated_buffers_above_delegatecovering the case it misses. So the copy-back routehandles this correctly and no skip is needed.
Also dropped
gm.meta["_lifted_buffer_attr_names"], which had no reader.Testing
tests/py/dynamo/lowering/test_buffer_lifting.pyandtests/py/dynamo/executorch/test_kv_cache_export.py, plus three new regression tests,each of which fails on the behavior it replaces:
test_torch_executed_write_is_copyback_not_kv: an excluded write is lifted, recorded ascopy-back rather than predicted-aliased, and leaves no dead nodes for
post_loweringtoerase.
test_declare_aliased_kv_mutations_pairs_copyback_by_position: with[a, b, c]and onlybalready declared, each buffer is paired with its own value.test_declare_aliased_kv_mutations_skips_already_declared_copyback: an already-declaredbuffer is not declared twice, and its trailing value still leaves the user outputs.
Note the nested-buffer lookup itself (
gm.get_buffer) landed separately in #4472 and isinherited here through this branch's base, so it is no longer part of this PR's changes.