Skip to content

feat(executorch): let a TensorRT engine write the caller's KV cache in place - #4667

Open
Conarnar wants to merge 15 commits into
pytorch:mainfrom
Conarnar:feat/executorch-zero-copy-kv
Open

feat(executorch): let a TensorRT engine write the caller's KV cache in place#4667
Conarnar wants to merge 15 commits into
pytorch:mainfrom
Conarnar:feat/executorch-zero-copy-kv

Conversation

@Conarnar

@Conarnar Conarnar commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Description

Let a TensorRT engine write a caller's KV cache in place, instead of routing every update through a staging copy and a copy-back.

An engine with aliased I/O writes its aliased output through the aliased input's pointer, so running the engine over the buffer already is the update. Nothing in the ExecuTorch pipeline knows that, so by default the buffer makes a full round trip on every execution: PropagateDevicePass wraps each delegate input in et_copy._h2d_copy, so the engine writes a per-call staging copy rather than the caller's buffer; and the aliased output is threaded back out as a delegate output for ExecuTorch to copy in afterwards.

For a cache-sized buffer that is two copies per execution of something the engine could have written directly. On a 30-layer Gemma4-MoE at prefill 2048 / max_len 2560 it is 576.7 MB in and 577.2 MB out every decode step, at 6.5–7.0 GB/s pageable.

What this changes

export(..., zero_copy_kv=True) opts in. The feature needs two changes at different points in the pipeline, and the export flag performs only the first:

  • before partitioning, the mutation is repointed at the buffer placeholder, so the aliased output has no user, dies to DCE, and leaves the partition entirely;
  • as a to_out_var_pass, installed by zero_copy_backend_config(), the staging is removed so the engine is handed the caller's buffer.

Applying only the first would leave the engine writing a discarded staging copy with the copy-back already gone — the buffer would silently never update. Neither pass is public on its own.

torch_tensorrt.save(output_format="executorch", zero_copy_kv=True) owns both steps and needs only the one flag. It installs zero_copy_backend_config() itself, so the two are alternatives rather than a pair: handing save() a config built with that function applies the un-staging twice, and the second run finds the buffers already wired straight to their delegates and changes nothing.

The discriminator, which everything else depends on

An aliased KV mutation and a #4459 copy-back mutation are identical in the graph: both are BUFFER_MUTATION target=X <- getitem(engine, i) where X is also an engine input. They are told apart only by reading the engine's own serialized aliased_io. Both ways of getting it wrong are silent — rewiring a copy-back loses a real update, and leaving an aliased cache staged has the engine write scratch that is discarded.

A method may hold both kinds at once, and that combination is supported rather than refused. It is measured on a real engine: a model with index_copy_ KV caches plus a ring-shifted conv_state exports with the caches on their own placeholders and conv_state still bound to a delegate output, in one method, with no code change beyond the aliased_io read. #4459's _trt_no_kv_alias makes that signal cleaner still, by excluding non-KV mutable buffers from aliasing at the converter.

Measured

30-layer Gemma4-MoE 26B-A4B, real weights, int4 MoE + bf16, full 262144 vocab, prefill 2048 / max_len 2560, hybrid TensorRT+CUDA on one 80GB A100, TensorRT 11.1.0.106:

staged zero-copy
KV staging memcpy per decode step 171.66 ms 0.099 ms
decode 422.5 ms/token 28.67 ms/token
prefill 1,934 tok/s 4,125 tok/s

The 1,730× collapse in staging is the mechanism; the rest follows. Host-side gaps fall with it, 114.39 → 4.81 ms, and compute is untouched — the int4 MoE GEMV runs at 298.7 µs against 299.5 before. Generated token ids are byte-identical to the staged run.

Reading it commit by commit

The first six commits are the layering, in dependency order; the rest answer the review. Every commit stands alone, with no failure at any of them but the pre-existing #4635 one noted below:

  1. AOT core — the rewiring and un-staging passes (_zero_copy.py).
  2. Backend accepts elided outputs — relaxes the output-binding check (backend.py).
  3. Runtime runs themTensorRTBackend.cpp, +35/−13. The two shapes differ in arity by exactly the aliased-output count, so the argument count identifies which one a .pte is; no serialized flag, and a .pte written before zero-copy still takes the threaded-output branch.
  4. Public surface — the zero_copy_kv opt-in, zero_copy_backend_config(), the save() flag, docs, the KV example, and the CI reference-runner check. The widest one.
  5. The copy-back combination, plus its real-engine test.
  6. Multi-delegate coverage — tests only.
  7. The review round — nine commits, stacked rather than folded so the inline comments stay attached: the caller-stream guard named correctly in the docs; check_zero_copy_kv() moved into the library, walking every method, run by save(), and handed a real finalized program by the engine tests; the elided-output guard run after the dead-code elimination, so a dead chain of any length cannot pass for a surviving output; a repeated aliased_io entry refused when the blob is parsed; the KV decode check run with and without a caller stream; the un-staging keyed on the shape the program ends up in rather than on the edit the pass made, which is what makes a second application a no-op and what refuses any staging copy the pass leaves behind; a prose pass over the comments, docstrings and user guide; and the two refusals below.

Two shapes that exported cleanly and then failed on device are now refused at export. Both came out of review, which could not run anything, so before either was changed both were reproduced by loading the offending .pte through a real ExecuTorch C++ runner with this backend linked in, on TensorRT 11.1.0.106:

  • An aliased buffer that is not planned where the engine writes. Being a direct argument of the delegate was treated as the whole post-condition of the un-staging pass. It is half of it: the other half is that memory planning puts the buffer in a device arena. ExecutorchBackendConfig(enable_non_cpu_memory_planning=False) produces the same graph — PropagateDevicePass inserts no staging copy and writes the delegate's device straight onto the placeholder's spec — and then plans every tensor into the one host arena regardless of any spec. The buffer's spec is therefore DeviceType.CUDA under both configurations, so a spec-device check alone does not discriminate; zero_copy_backend_config() now reads enable_non_cpu_memory_planning off the config it is building from and hands it to the pass, which refuses a direct argument that is off-CUDA or host-planned, naming the buffer whose update would be lost. The .pte that motivated this exported clean and passed check_zero_copy_kv(), then failed its first execute(): aliased input 'buf_k_cache' must be device-resident, Error::InvalidArgument.
  • An engine whose aliased outputs are only partly elided. Export decides elision per binding name; the runtime decides it with one subtraction, so the two agree only while every aliased output of an engine is a rewired buffer mutation. An engine that also aliases onto a plain input has the narrower set elided, passes the output-binding check and check_zero_copy_kv(), and writes a .pte that loads, binds both aliases at init, and fails every execute() on the argument count (expected at least 7 args, got 6, Error::InvalidArgument). TensorRTBackend.preprocess already holds both sets ten lines apart; it now compares them and refuses, so the failure lands where the export can be re-run. Teaching the runtime to read the elided names from the compile spec is the other way out and is deliberately not taken: refusing is far smaller, and refused → supported is a compatible progression later.

Verified

  • Per-commit standalone across the first six: 232 / 237 / 237 / 267 / 270 / 273 collected, with zero skips or deselects anywhere, so the GPU-gated tests actually ran. With the review commits the tip collects 289 (pytest tests/py/dynamo/executorch/, the same selection those six were counted with), of which 288 pass. Every one of those runs has the same single failure, test_runtime_wheel_pins_cuda_13_native_dependencies, which fails on plain main too since bring back cu126 support #4635 made TENSORRT_DISTRIBUTION dynamic without updating it; nothing else fails at any commit.
  • black at the CI-pinned 26.3.1 and mypy at the pinned 1.15.0: clean at every commit. ruff is clean over the changed files and clean relative to origin/main.
  • Two previously uncovered multi-delegate shapes measured on real engines: the aliased caches and the copy-back split across two TensorRT delegates, and a TensorRT delegate beside an ExecuTorch CUDA delegate. Both lower, finalize and come out with the right buffer bound to the right value.
  • Commit 3 compiles clean against TensorRT 11.2.1.2 under both CMake (gnu++17) and bazel (C++20), with no new warnings measured against its own parent commit. At the tip, //tests/cpp/executorch:executorch_backend_tests builds and passes 4/4 on a real device.
  • The reference-runner CI script was merged by hand against ci(executorch): run a coalesced TensorRT + CUDA program in the reference runner gate #4572 (below) and re-checked: bash -n and shellcheck -S warning clean, the workflow parses as YAML, and the argument parser was exercised standalone on three input shapes under set -u. The zero-copy iteration asserts the aliased_io discriminator, and was shown to raise on a program finalized without zero_copy_backend_config(). The lane itself was then run end to end and exits 0, and the zero-copy no-sync branch was instrumented and observed to execute — the guarded mode takes the skip path on all three of its execute() calls, against the unguarded mode in the same binary, which synchronizes every time.

Merge note for #4572

#4572 added the coalesced .pte to verify-executorch-reference-runner.sh as a third positional argument. This stack made the trailing arguments variadic, so it can hand the staged and the zero-copy KV .pte to one run. Those cannot coexist, so the coalesced model is now named — --coalesced=PATH — and the KV models stay variadic. Both sides' behaviour is preserved and only the encoding of #4572's argument changed. The script has two callers, both workflows in this repo. Only the test workflow needed changing, in the same commit as the script; the build workflow passes a single positional model, which the variadic parser takes unchanged.

Known gaps

  • Finalizing a zero_copy_kv=True program without zero_copy_backend_config() does not raise on its own. The engine writes a staging copy that is discarded and the buffer never updates — wrong output, not a crash. The finalized program does carry the evidence, so check_zero_copy_kv() detects it: save(zero_copy_kv=True) runs that check before writing, and a caller assembling export and to_executorch by hand can call it themselves. What remains is that nothing forces them to.
  • The write-back pass crosses the mutation map, and nothing here repairs it. insert_write_back_for_buffers_pass pairs each mutation spec with a value by walking one counter over the copy_ nodes it created followed by every output it copied nothing for. A rewired mutation is one of the latter, so a method holding a zero-copy cache and an ordinary copy-back buffer comes out of to_executorch() with the two specs crossed. It is upstream's — stock ExecuTorch reproduces it with run_reinplace_pass=True and no TensorRT involved — and it is going upstream as a bug there. The emitted .pte is byte-identical either way, and every consumer on the emit path tests membership in the map's values rather than reading the pairing, so nothing on this branch is wrong because of it. What is affected is a reader of the finalized graph_signature.
  • The arity predicate has no unit test. It lives inside execute() and needs a real engine; its automated coverage is the CI reference-runner check. The duplicate-aliased_io rejection is done when the blob is parsed, and is unit-tested there.
  • Multi-method zero-copy is not covered end to end. _apply_zero_copy_kv loops over methods and check_zero_copy_kv() walks every method. The real-engine tests do run the check on a genuinely finalized program, but a single-method one; the multi-method behaviour of both rests on fabricated program shapes, and no two-method .pte was built.
  • Engine metadata is resolved more than once per engine during partitioning. Each read is cheap under metadata_only=True; folding them into the existing resolved-record handoff is a follow-up, deliberately not bundled here.

If AliasKind.USER ever gets a producer

AliasKind.USER is a placeholder that nothing emits, so every aliased_io entry a .pte carries today is a kv_cache_update on a lifted buffer. Neither half of zero-copy reads the kind: the partitioner elides an aliased output only when the input it aliases is a buffer placeholder the rewiring marked, and the runtime derives the elided arity from the count of all aliased_io entries. Those two agree only while every aliased output on an engine is elided together, so a "user" alias on a plain input beside an elided one is a shape neither the binding check nor check_zero_copy_kv() objects to and the runtime cannot execute. preprocess refuses that engine outright rather than letting it reach a .pte, which is what a producer of the kind would meet first. Supporting the mix instead means giving the runtime the elided names to read rather than an arity to subtract; refusing now leaves that open.

Type of change

  • New feature (non-breaking change which adds functionality)
  • This change requires a documentation update

Checklist:

  • My code follows the style guidelines of this project (You can use the linters)
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas and hacks
  • I have made corresponding changes to the documentation
  • I have added tests to verify my fix or my feature
  • New and existing unit tests pass locally with my changes
  • I have added the relevant labels to my PR in so that relevant reviewers are notified

…in place

An engine with aliased I/O writes its aliased output *through* the aliased
input's pointer, so by the time the engine returns the caller's buffer is
already updated. Nothing in the ExecuTorch pipeline knows that, and it pays for
the update twice on every execution: PropagateDevicePass hands the delegate an
`_h2d_copy` staging copy instead of the buffer, and the aliased output is
threaded back out so ExecuTorch can copy it into the buffer afterwards. For a
KV cache that is two cache-sized copies per token.

Add the two passes that remove them. They are separate functions because they
run at different points and there is no single point that works:

  `rewire_aliased_mutations_to_buffers` runs on the exported program, before
  partitioning, because the partition boundary is what fixes the delegate's
  outputs. Pointing each aliased BUFFER_MUTATION at its own buffer placeholder
  says "the buffer is the result", so there is nothing to copy back and the
  aliased output -- now userless -- leaves the graph and the delegate with it.

  `unstage_aliased_buffers_pass` runs as a `to_out_var_pass`, the last hook in
  the window after PropagateDevicePass and before memory planning -- the window
  where the staging copies exist and the buffers' placement is still open.
  (`sym_shape_eval_pass` is a caller-supplied hook in that window too, but it
  runs first.)

Which mutations are aliased is read from the engine's own `aliased_io`, not
inferred from the graph. A copy-back mutation and a mutation of a buffer that is
also an engine input are both a getitem off the engine node, indistinguishable
by shape, and rewiring either would delete a real update with no error.

Both silent-corruption shapes raise instead. Eliding every output of an engine
would leave a delegate with no outputs, which nothing downstream reports: the
runtime infers elision from a single argument count, and a delegate nothing
reads is a pure node a later dead-code elimination can erase. And a marked
buffer whose staging cannot be followed to CUDA would be written by the engine
and then discarded, since its copy-back is already gone.

Neither function is public: they are one feature and applying half of it is
worse than applying none. The next commits reach them through an opt-in on
export().
The output-binding validator assumed every engine output binding is a delegate
output. With zero-copy KV that stops being true: the aliased outputs are gone
from the delegate, because the engine's in-place write through the aliased input
already is the buffer update.

Accept that shape, but only when the caller asked for it. A delegate missing its
aliased outputs because nothing declared them as buffer mutations looks exactly
like a zero-copy one -- here and in the runtime, which reads elision off a single
argument count -- and today that case is a loud export-time error. Relaxing the
check unconditionally would turn it into a .pte that runs and quietly never
updates its cache. So the permission travels down explicitly, on a CompileSpec
that only export() sets, over the partitioner's DelegationSpec: the one channel
from the export call to `preprocess`.

Even with permission, a partial drop stays an error. It would be a buffer update
lost without a word, and the argument count cannot express it in any case.
With zero-copy KV the aliased outputs are not delegate arguments at all: the
engine's write through the aliased input's pointer is the buffer update, so
there is no mutation slot to fill and no copy to reflect.

Detect that from the argument count rather than a serialized flag. Export elides
either all of an engine's aliased outputs or none, so the two shapes differ in
arity by exactly the aliased-output count the handle already knows -- and a .pte
written before zero-copy existed keeps taking the threaded branch with no
version check and no new blob field. (Elision is all-or-nothing only because
every aliased output today is a `kv_cache_update` on a buffer; a future
`kind="user"` alias beside an elided one would break the identity, and the arity
check would reject the .pte at execute.) `setTensorAddress` has already pointed
the binding at the caller's buffer by the point the branch is taken, so eliding
only skips consuming an argument and recording a reflect.

The end-to-end check for this path comes with the next commit, which adds the
export-side option that can produce such a .pte.
…nfig

Until now the only way to get zero-copy KV was to monkey-patch a private
function, because the rewiring has to land between two things export() does
internally -- after the aliased mutations are declared, before the program is
staged -- and there was no hook there. Give it a real one.

    export(..., zero_copy_kv=True)
    edge.to_executorch(zero_copy_backend_config(config))

Opt-in rather than automatic: a .pte whose aliased outputs are elided needs a
runtime that understands that shape, so producing one unasked would break a
runner built before this feature, and the option changes nothing for existing
caller-owned KV users.

Two calls rather than one because to_executorch() is ExecuTorch's, not ours --
export() returns at the Edge boundary and never sees the config the program is
finalized with. zero_copy_backend_config composes onto the caller's config
rather than replacing it, so their memory planning and their own to_out_var_pass
survive. It is the module's only public name; the two passes stay private,
since applying one without the other is worse than applying neither.

The backend's permission to accept an elided delegate is granted per method and
only where a mutation was actually rewired, so a method that lost an output for
some other reason is still rejected.

That leaves one thing the caller must not forget, and it is documented as such
along with the other two quiet contracts of this feature: finalizing without the
config, running the delegates on separate CUDA streams, and expecting one cache
to be shared across methods without a memory-planning pass that says so. All
three produce wrong values rather than an error.

The persistence check gets a zero-copy variant in CI: the existing runner
asserts a decode step sees the KV the previous step wrote, which is exactly the
property zero-copy has to preserve after removing the copy that used to provide
it.

The two-call contract is unavoidable on the direct export()+to_executorch()
path, but not through torch_tensorrt.save(output_format="executorch"): that path
owns both steps -- it calls export() and then to_executorch() itself -- so it
can make zero-copy foolproof. Give save() a single `zero_copy_kv=` flag that
threads the opt-in into export() and installs zero_copy_backend_config before
to_executorch(), so forgetting the second call is not possible there. It is
single-method only, like the rest of save(); multi-method stays on the direct
export path. Wrapping preserves a caller-supplied backend_config, so passing
both is fine. The one exclusion is handing save() a backend_config that already
carries the pass: save() wraps it again and finalization raises. The two entry
points are mutually exclusive.
A method may hold both kinds of mutable buffer: a KV cache the engine writes
through an aliased binding, and a non-KV buffer -- a convolution state -- whose
new value comes back as a trailing delegate output for ExecuTorch to copy back.
The copy-back path predates `zero_copy_kv`, but the combination of the two was
never decided or covered, only mechanically tolerated.

Treat it as supported. `_aliased_buffer_mutations` already discriminates the two
by reading the engine's own `aliased_io` rather than the graph, in which they are
identical -- both a `getitem` off the engine node whose buffer is also an engine
input. The aliased caches go zero-copy; the copy-back buffer keeps its staging
copy and the output that writes it.

Refusing the combination instead would give up the feature for every model
carrying one non-KV mutable buffer beside its cache, and rewiring the copy-back
would delete a real update with no error. Neither is acceptable, and the
discriminator that avoids both is already load-bearing, so pin it: a stub-level
test on one method holding both mutations, and a real-engine export of a decode
step with `k_cache`/`v_cache` beside a ring-shifted `conv_state`, asserting the
caches end up bound to their own placeholders while `conv_state` stays bound to
a delegate output.

That real-engine export runs on both exporters, because they reach the
discriminator by different routes. The legacy exporter (`retrace=False`) declares
all three mutations as it builds the program, leaving
`_declare_aliased_kv_mutations_on_ep` nothing to do. Under `retrace=True`, which
is `save()`'s default, the retraced program arrives with no mutation declared at
all -- `torch.export` drops the aliased outputs at the fx boundary and leaves the
copy-back value as a plain return -- so that post-export pass is what separates
the two kinds, and it is exercised only on that parameter.
`zero_copy_kv` was pinned on one real engine only: a single method, a single
TensorRT delegate, the aliased caches and a `conv_state` copy-back side by side.
Two shapes that shape does not reach are covered here, both on real engines.

A method whose copy-back rides on a *different* TensorRT delegate than the
aliased caches. `TensorRTPartitioner` derives the elided binding names per
engine and stamps `zero_copy_kv` only on the delegate that lost an output, so
the plain compute delegate beside it must carry no spec; if it did,
`_unstage_aliased_buffers`'s cross-check would demand an aliased buffer it never
had and the export would die. The test asserts the stamping directly and then
finalizes, which is where that cross-check runs.

A method that also holds an ExecuTorch CUDA (AOTI) delegate. `erfinv` has no
TensorRT converter, so with a `CudaPartitioner` catch-all the method lowers to
TensorRT, CudaBackend and TensorRT in sequence. Un-staging must reach the
TensorRT delegate only -- `_is_tensorrt_delegate` gates it, because no other
backend promises the in-place write through an aliased binding.

Both models assert their own shape before asserting the behaviour: a partitioner
change that collapsed either back to one delegate would otherwise leave the test
passing while covering nothing. The trailing `Linear` in the CUDA model exists
for that reason -- ending on `erfinv` leaves exactly one TensorRT delegate, and
"only the KV delegate is stamped" is then true no matter what the partitioner
does.

Both of these run on both exporters. The split-delegate one is the only shape
where `_declare_aliased_kv_mutations_on_ep` has to pick the aliased engine out of
several -- it scans every `execute_engine` node and skips the ones whose
`aliased_io` is empty, and the copy-back value it detaches comes off a different
engine than the caches it declares. The legacy exporter declares all of that as
it builds the program, so that scan runs only under `retrace=True`, which is
`save()`'s default and had no real-engine `zero_copy_kv` exercise before.
@meta-cla meta-cla Bot added the cla signed label Sep 2, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation component: tests Issues re: Tests component: api [Python] Issues re: Python API component: api [C++] Issues re: C++ API labels Sep 2, 2026

@shoumikhin shoumikhin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some notes inline.

These are about lines the change does not touch, so they could not be attached to one.

In examples/executorch_reference_runner/kv_cache_decode_check.cpp, around line 166:

The reference runner check covers the argument count branch, but not the new part that skips the stream sync. The KV decode check never installs a caller stream guard, so the caller stream is absent and the code always takes the synchronizing branch no matter what. The zero copy no sync path never runs in CI.

That is the riskier half. A wrong argument count fails loudly and every time. A missing sync fails sometimes, with numbers that look fine.

Could the KV decode check wrap its decode loop in a caller stream guard on its own stream, the way the main runner already does, and run each model both ways? That would make the branch real coverage instead of a comment.

One related point on wording. Before this change a model with aliased outputs always synchronized at the end of execute, because the reflect forced it. The commit message calls the shared stream contract a source of wrong values. The docs in this repo already say that situation is a race that can surface as wrong results or an illegal memory access. Worth saying it at that strength where the contract is written down.

In cpp/src/torch_tensorrt/executorch/TensorRTBackend.cpp, around line 518:

In execute, the aliased output count now decides the delegate arity, but init increments it once per aliased_io entry rather than once per aliased output binding. A blob that lists the same entry twice passes every check in init, because both copies resolve to the same output and the same engine alias, and the count still ends up at two.

Two things then go wrong. If the count is above the number of outputs actually flagged, the elision test can be true while the loops consume more arguments than the span holds, and the span index is not bounds checked. If the count is above the number of outputs, the subtraction underflows, the sum in the length check wraps, and the check passes for any arity.

Your own exporter cannot produce a duplicate, so this needs a crafted or corrupt file. It is worth two lines anyway, because before this change the counter only fed a log. Increment only when the slot was still unset, and reject a repeated entry the way a duplicate weight streaming spec is already rejected. Refusing a count larger than the number of output bindings makes the underflow unreachable too.

# un-staging pass runs after lowering, where the engine's aliased_io is no
# longer reachable from the graph: it has become an opaque delegate blob.
mutation.placeholder.meta["_torch_tensorrt_aliased_buffer"] = True
output_args[spec_index] = mutation.placeholder

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repointing the mutation at the buffer placeholder makes ExecuTorch's write-back pass skip the copy for that buffer, which is the point. But that pass then patches the signature with a counter over mutation specs while indexing a list that only holds the copy nodes it created. A mutation with no copy shifts every later one by one.

For a method holding both kinds this crosses them. The KV cache mutation ends up naming the copy that writes the conv state, and the conv state mutation ends up naming the KV placeholder. A KV only method is fine because there is no copy at all, and the same model without zero copy is fine because both get one, so nothing you have measured so far would show it.

The file itself looks like it still runs, because copy carries its own destination and the emitter records outputs by position. What is wrong is the finalized program's mutation map, which the exir execution path and the ETRecord both read.

This was checked against the ExecuTorch version the workflow installs. Moving the rewired mutations behind the un-rewired ones in the output specs and args gives the correct pairing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by ordering the rewired mutations last, though the mechanism is not quite the one described.

The list upstream indexes is not only the copies — _insert_copy ends with
buffer_output_nodes.extend(user_output_nodes), so it returns the copy_ nodes it created
followed by every output it copied nothing for, in graph order. That matters for the fix rather
than being a detail: under the "only the copies" reading a mutation spec past the number of copies
would index off the end, so reordering would not be what saves it. What happens instead is that
ordering the rewired mutations last gives them spec positions C..M-1 and puts them at the head
of that tail in the same order, so the copy-producing specs index the copy prefix one-for-one and
the rewired specs index the tail entries that are those same outputs.

On stock ExecuTorch with run_reinplace_pass=True and two mutated buffers where only the first is
reinplaced, the first buffer's mutation names the copy_ that writes the second and the second's
names the index_put_ that wrote the first — a swap, not an index error, which only the tail
explains.

That configuration is also the fix's limit: reinplace_pass runs after the ordering and can swap
them again. There is no hook between the two, so it is scoped and stated rather than fixed.

and every cross-boundary dependency is satisfied, while execution stays
asynchronous.
(each backend exposes a caller-stream hook: scope both
``torch_tensorrt::executorch_backend::CudaStreamGuard`` and

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The single stream section now says to scope the Torch-TensorRT CUDA stream guard as well as the ExecuTorch caller stream guard. That first class is gone. The backend README says it was removed on purpose, with no deprecated alias, so this code would not compile. The reason given is also backwards: one caller stream guard already covers every CUDA delegate, because the shared CUDA extension means all of them read the same caller-stream storage, which that README states directly. The old wording, "each backend exposes a caller-stream hook", was correct. Please name only the ExecuTorch guard here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Finalizing a ``zero_copy_kv=True`` program *without* this config does
not raise. The engine writes a per-call staging copy that is then
discarded and the buffer never updates, which for a KV cache is wrong
output rather than a crash. Nothing downstream can detect the omission,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The warning says nothing downstream can detect the omission, so pairing the two calls is the caller's responsibility. The example in this same change detects it: it walks the finalized program for marked buffers that are still staged and refuses to write the file. So the check is possible, and it is already written twice, in the example and in the tests. The single-call path in save holds that finalized program right before it writes, and does not run it, which is the one path that promised the mistake was impossible. Please move that check into the library and call it there, and drop the "nothing downstream can detect" sentence.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. check_zero_copy_kv() is public, save() runs it before writing, and the example calls it
rather than carrying its own copy.

It walks every method, not just forward — the first version took exported_program() with no
name, which raised KeyError on a multi-method program and skipped the other methods where a
forward did exist. "Nothing marked" raises only when no method has marks, matching the warning
_apply_zero_copy_kv already emits; a staged buffer raises per-method and names it.

# single argument count, which a zero-output delegate satisfies, and a
# delegate nothing reads is a pure node that a later graph-wide dead-code
# elimination can erase, taking the computation with it. Stop here instead.
# The eliminate_dead_code() below does not erase this engine node: unlike

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the rewiring pass, the comment above the every output elided check says an execute_engine node is impure to FX and so survives dead code elimination with no users. I do not think that is true. The operator is declared with no write annotation, and the Python fallback registers it with an empty mutates_args, so FX sees a pure op and will erase it once nothing reads it. Nothing in this repository adds it to FX's side effect list either.

That also leaves a small hole in the check above. It only fires when every user of the engine is an elided getitem. If one non elided getitem is still there but is itself dead, dead code elimination erases that getitem first, then the engine, and the computation goes with it.

Either count live users only, or drop the sentence and say plainly that the guard is what keeps the engine alive.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The premise does not hold: EffectHolder._set_default_effect gives it EffectType.ORDERED, so
is_impure() is true and DCE keeps it with no users. The comment was right.

The hole you inferred from it is real, though, and is fixed — the check now counts live users, so a
surviving-but-dead non-elided getitem no longer lets the engine and its computation be erased.

@Conarnar

Conarnar commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

In examples/executorch_reference_runner/kv_cache_decode_check.cpp, around line 166:

The reference runner check covers the argument count branch, but not the new part that skips the stream sync. The KV decode check never installs a caller stream guard, so the caller stream is absent and the code always takes the synchronizing branch no matter what. The zero copy no sync path never runs in CI.

That is the riskier half. A wrong argument count fails loudly and every time. A missing sync fails sometimes, with numbers that look fine.

Could the KV decode check wrap its decode loop in a caller stream guard on its own stream, the way the main runner already does, and run each model both ways? That would make the branch real coverage instead of a comment.

One related point on wording. Before this change a model with aliased outputs always synchronized at the end of execute, because the reflect forced it. The commit message calls the shared stream contract a source of wrong values. The docs in this repo already say that situation is a race that can surface as wrong results or an illegal memory access. Worth saying it at that strength where the contract is written down.

Fixed, and instrumented to confirm it: the guarded mode takes the skip path on all three of its
execute() calls, against the unguarded mode in the same binary, which synchronizes every time.

Two limits worth stating. The staged .pte still takes the synchronizing branch in both modes, so
the new coverage is zero-copy only. And the correctness assertion cannot detect a missing sync here
cudaStreamCreate returns a blocking stream, so the legacy default-stream copy waits anyway.
Deleting the runner's own cudaStreamSynchronize still passes 40/40 and cudaStreamQuery reads
COMPLETE 200/200. So it covers the branch, not the ordering, and the comment says so.

In cpp/src/torch_tensorrt/executorch/TensorRTBackend.cpp, around line 518:

In execute, the aliased output count now decides the delegate arity, but init increments it once per aliased_io entry rather than once per aliased output binding. A blob that lists the same entry twice passes every check in init, because both copies resolve to the same output and the same engine alias, and the count still ends up at two.

Two things then go wrong. If the count is above the number of outputs actually flagged, the elision test can be true while the loops consume more arguments than the span holds, and the span index is not bounds checked. If the count is above the number of outputs, the subtraction underflows, the sum in the length check wraps, and the check passes for any arity.

Your own exporter cannot produce a duplicate, so this needs a crafted or corrupt file. It is worth two lines anyway, because before this change the counter only fed a log. Increment only when the slot was still unset, and reject a repeated entry the way a duplicate weight streaming spec is already rejected. Refusing a count larger than the number of output bindings makes the underflow unreachable too.

Fixed at parse rather than in init, which makes it unit-testable without a GPU.

Reproducing it turned up a second path the num_aliased_outputs <= num_outputs bound does not
cover. With 2 inputs, 3 outputs and one output duplicated, the count reaches 2 while staying within
num_outputs, elision is believed, the length check 3 >= 3 passes, and the output loop then reads
args[3] on a 3-element span. Confirmed with the argument array against a PROT_NONE guard page:
si_addr == args.data() + args.size(). No underflow anywhere, so the bound alone would not have
caught it.

It needs no crafted graph — the same engine, argument list and arity come from a correct
two-aliased-output program that returns Error::Ok; only one output-name string in the header
differs. The bound stays for the wrap case, and its comment now says that is all it covers.

The single-stream section told runners to scope
`torch_tensorrt::executorch_backend::CudaStreamGuard` alongside
`executorch::extension::cuda::CallerStreamGuard`. That class no longer exists --
`cpp/src/torch_tensorrt/executorch/README.md` records its removal, deliberately
with no deprecated alias -- so the snippet named a type that does not compile.

The reason given for scoping both was backwards as well. One caller-stream guard
already reaches every CUDA-capable delegate, because they resolve a single shared
`libextension_cuda` and so read the same caller-stream storage; that is why the
old class could be dropped rather than aliased. Say that instead.
The warning on `zero_copy_backend_config` said nothing downstream could detect a
program exported with `zero_copy_kv=True` and then finalized without it. The
example in this same change detects exactly that: it walks the finalized program
for marked buffers that no longer reach a delegate directly and refuses to write
the `.pte`. So the claim was false, and the check was written twice -- in the
example and in the tests -- while the one path that owned both ends, `save()`,
held the finalized program and did not run it.

Move it into the library as `torch_tensorrt.executorch.check_zero_copy_kv`, call
it from `save(..., zero_copy_kv=True)` before the `.pte` is written, and have the
example call the library version. It refuses two shapes: a marked buffer that is
not a direct argument of a TensorRT delegate (finalized without the config, so
the engine writes a staging copy that is discarded), and nothing marked for
in-place update at all (`zero_copy_kv=True` only warns when it finds no aliased
buffer mutation). Where the un-staging pass does run it has already raised on
the first shape; this catches the case where it never ran.

Only a TensorRT delegate counts as that direct argument, which is the same
filter the un-staging pass applies. A buffer is marked because a TensorRT engine
writes it in place, so another backend's delegate holding it says nothing about
whether the engine got it un-staged: a program can hand the buffer straight to a
`CudaBackend` delegate while the TensorRT engine beside it still reads a staging
copy that is thrown away.

Every method is read, not only `forward`.
`ExecutorchProgramManager.exported_program()` defaults to `forward`, and
`export()` rewires each method separately, so a check that took the default
would raise a bare `KeyError: 'forward'` on the prefill/decode program the user
guide's own zero-copy example builds, and on a program that does have a
`forward` beside other methods it would pass one whose decode had degenerated to
staged. The failure names the method the buffer is in.

The "nothing marked" refusal stays about the program rather than about each
method, matching the warning `export()` emits for the same condition: a method
with no aliased buffer mutation of its own is not an error, so a model that
rewires only its decode step is accepted, and only a program where no method
rewired anything is refused.

The docs and the docstring now point at it instead of asserting the mistake is
undetectable.

The three real-engine tests hand it their finalized program before running
their own stricter graph assertion. Every other test of it builds the program
itself -- a `SimpleNamespace` for the check's own cases, a monkeypatch for
`save()`'s -- so nothing else puts `methods` or `exported_program(name)` in
front of a real `ExecutorchProgramManager`, and an upstream rename would leave
all of them green while `save(zero_copy_kv=True)` raised `AttributeError` for
every caller.
…liminated

The guard against eliding every output of an engine fired only when *every* user
of the engine was one of the elided getitems. A non-elided getitem that is itself
dead defeated it: the check saw an output, the `eliminate_dead_code()` right
below erased that getitem anyway, and the partition got the zero-output delegate
the guard exists to refuse.

Counting only the users that something reads does not settle it either, because
it looks one step past the engine and a dead chain can be longer than that. The
first link of a two-node dead chain does have a user, so the guard stays silent
and the elimination then erases the whole chain. Measured on hand-built graphs
with chains of 1, 2 and 3 nodes: only the one-node chain raised, and at 2 and 3
the rewiring returned normally leaving the engine node with no users at all.

So run the elimination first and then ask whether the engine still has a user:
what survives it is what the delegate will have, whatever the chain length. The
test is parametrized over a one- and a two-node chain, the second being the
length a rule reading only the engine's immediate users misses.

The comment above it claimed an `execute_engine` node is impure to FX and
survives DCE with no users. That is true, and it now says why, because the reason
is not local to this repository: PyTorch defaults any operator taking a
ScriptObject argument to an ORDERED effect
(`torch._library.effects.EffectHolder._set_default_effect`), and `execute_engine`
takes the engine as one. Confirmed on the op this code builds against --
`_get_effect(torch.ops.tensorrt.execute_engine.default)` is `EffectType.ORDERED`,
`Node.is_impure()` is `True`, and a userless engine node survives
`Graph.eliminate_dead_code()`; the delegate that replaces it after lowering has
no effect registered and does not. The comment also no longer leaves the reader
to infer that the guard, and not the DCE, is what stops the bad shape.
…parsed

`init` incremented the aliased-output count once per `aliased_io` entry rather
than once per output binding it claimed. A blob listing the same entry twice
passed every check -- both copies resolve to the same output and the same engine
alias -- and left the count at two for one aliased output.

`execute` reads that count to decide the delegate arity, so an inflated one is
not just a log line. With the count above the number of flagged outputs, the
elision test can be true while the output loop consumes more arguments than the
span holds, and nothing bounds its index into `args`. With the count above the
number of output bindings, `num_outputs - num_aliased_outputs` underflows, the
sum in the length check wraps, and the check passes for any arity.

A repeat is malformed for every reader of the blob, not only for `init`, and it
is visible from the bytes alone -- so refuse it in `TensorRTBlobHeader::parse`,
where the blob-header unit tests reach it without a GPU or a real engine. It is
refused the way the parser refuses any other malformed metadata, which means the
diagnostic is `init`'s generic parse failure rather than a message naming the
output.

`execute` keeps the `num_aliased_outputs <= num_outputs` bound. The parser cannot
establish it: a blob may carry an empty `io_bindings` array, and then the output
bindings are inferred from the deserialized engine, which the parser has not
seen. Both subtractions there are unsigned and the length check is the only thing
bounding how far the loops index into `args`, so the bound is still worth
checking for a header that reached the backend some other way.

Our own exporter cannot emit a duplicate; this needs a crafted or corrupt file.
…tream

The KV persistence check never installed a caller stream guard, so
`getCallerStream()` was always empty and the backend always took the
synchronizing branch at the end of `execute()`. The skip-the-sync path zero-copy
KV depends on -- no host staging, no aliased reflect, a caller stream set -- had
therefore never run under this check, and neither had the machinery that exists
to make it safe: the `inflight_event` record, the wait on it at the top of the
next `execute()`, and the drain in `~EngineHandle`.

Run both scenarios twice, once with no caller stream and once with a
`CallerStreamGuard` scoped over the decode loop on a stream the check owns, the
way the main runner does. The guard is constructed only in the second mode: an
explicitly null selection is still a selection (see
`tests/cpp/executorch/test_caller_stream.cpp`), so an unconditional guard over a
null stream would cover one branch twice. The guarded run synchronizes and
destroys its stream before reading the logits.

What that buys is branch coverage, and only on the zero-copy `.pte`.
Instrumenting the branch decision shows the zero-copy model in the guarded mode
taking the skip path on all three of its `execute()` calls, waiting on the
previous enqueue at the top of the second `execute()` of the two-step scenario,
and draining a pending enqueue at each of the two teardowns; the unguarded mode
reproduces the old always-synchronize behaviour in the same binary. The staged
`.pte` still synchronizes in both modes, because its aliased outputs are delegate
output args and so an aliased reflect is always pending -- running it under the
guard moves the engine onto a caller-supplied stream and nothing else.

What it does not buy is a check that can fail when a synchronization is missing.
The stream comes from `cudaStreamCreate`, which is a blocking stream, and the
logits are read with a synchronous `cudaMemcpy` on the legacy default stream,
which implicitly waits for every blocking stream in the context. Two controls
say so on this model: deleting the check's own `cudaStreamSynchronize` still
passes, and a `cudaStreamQuery` placed where that sync was reports the stream
already complete every time. So repeated runs agreeing to the last bit are not
evidence that the ordering is right. The check exercises the path and would fail
on a hard error in it -- a rejected event record, a context reconfigured under a
live enqueue, a mis-elided argument -- but it is not a race detector. Making it
one would mean a `cudaStreamNonBlocking` stream and dropping the explicit sync
that is redundant with the default-stream copy.

`verify-executorch-reference-runner.sh` now greps for both modes by name, so
dropping one cannot leave the lane green with the branch uncovered.

Also: where the shared-stream contract is written down, the zero-copy section
described getting it wrong as failing quietly. The coalesced-`.pte` section, a
few paragraphs down, already calls it a race that can surface as wrong results or
an illegal memory access. Say it at that strength in both places.
…ng like

Two defects in `_unstage_aliased_buffers`, both from deciding by the edit it
made rather than by the shape it has to leave behind.

**It keyed success on having deleted a staging copy.** The property zero-copy
needs is that the marked buffer is a direct argument of a TensorRT delegate;
removing an `_h2d_copy` is only the usual route there.
`ExecutorchBackendConfig(enable_non_cpu_memory_planning=False)` is a supported
configuration -- the field defaults to True and `zero_copy_backend_config`
preserves whatever the caller set -- and under it `PropagateDevicePass` inserts
no staging copies at all. Measured against that pass directly: with the flag
True the delegate's argument is an `_h2d_copy` and the placeholder stays on the
host; with it False there is no copy node, the placeholder *is* the argument,
and its spec is already `cuda:0`. That program is in the shape zero-copy wants
and the pass rejected it, naming a caller two causes -- the buffer never reached
a delegate, or the pass was installed twice -- neither of which had happened,
and telling them to install it once. `check_zero_copy_kv` accepted the same
graph, so the two halves disagreed about one program.

The pass now counts a marked buffer as satisfied when a TensorRT delegate takes
it, whether or not this pass is what put it there, and refuses only when no
TensorRT delegate takes it at all -- directly or through a staging copy the pass
can remove. That is the condition `check_zero_copy_kv` reads, so the two accept
and refuse the same graphs. A consequence is that installing the pass twice is
now a no-op rather than an error: the second run finds the first run's work in
place. The docstring, the `save()` comment and the user guide each promised that
finalization raises there, and now describe what it does.

**It allowed a second staging copy to the same GPU to survive the move.** A
marked buffer feeding two same-GPU `_h2d_copy` nodes, one to a TensorRT delegate
and one to another backend, had the TensorRT staging erased and its own spec
flipped host->CUDA, leaving the other copy reading a source that is now device
memory. `_h2d_copy_out` requires a host source and fails `InvalidArgument` on a
device one (portable kernel; the ATen branch has no such check). The rule is now
that any staging copy this pass does not itself remove blocks the move,
same GPU or not, and a copy is removed exactly when every user of it is a
TensorRT delegate whose argument gets rewired.

That reverses a decision earlier in this stack.
`test_unstage_leaves_another_backends_same_gpu_staging_in_place` was rewritten to
pin the allowance -- that the buffer still moves and the other backend's copy
stays -- and now pins the refusal, under a name that says so. Two neighbouring
tests describe which comparison catches which shape and are corrected with it:
the different-GPU-other-backend shape is no longer refused by the device index
alone, and the two-TensorRT-GPUs shape, where every staging does feed a TensorRT
delegate, now asserts that nothing moved rather than only that something raised
-- without that, dropping the index comparison would still leave it green.
Four pieces of prose that a reader can check against the code and find
disagreeing. All four sit in commits already pushed, so they are collected here
rather than folded, to keep the inline review comments attached.

`_aliased_inputs_by_output_index` said the skipped case is "an output binding
absent from the delegate's inputs". What the two `continue`s skip is an
`aliased_io` entry whose *input* does not resolve -- a name that is not one of
the engine's input bindings, or an index past the delegate's argument list.
`_declare_aliased_kv_mutations_on_ep` warns on both, which is the sentence's
point and is why neither is reported twice.

`rewire_aliased_mutations_to_buffers` said "an engine mixing the two is caught".
An engine carrying a rewired aliased output beside an un-rewired user alias is
not caught and must not be: it exports cleanly, because the un-rewired output is
still a delegate output and that is what the binding check wants. What is caught
is a delegate that dropped the un-rewired one as well.

The user guide's zero-copy example passed `CudaPartitioner([])` for each of two
methods. `export()`'s own docstring says a partitioner whose specs name no
method leaves a backend that reads its method name from them -- the CUDA backend
-- unable to find it, so the snippet as written raises during lowering. Nothing
in the example needs a second backend; drop the argument.

`test_validate_output_binding_order_still_accepts_aliased_outputs_threaded` was
described as covering "the pre-existing shape", which tells a later reader
nothing: there is no before. It covers that naming a binding elidable permits
the drop without requiring it.

Also two smaller ones: a comment in `test_edge_cases.py` explained the
`zero_copy_kv=False` assertion in terms of a KV buffer that model does not have;
and the `**ExecuTorch lowering options**` heading had no blank line above it, so
it ran into the paragraph before. That last one is on `main` and predates this
stack, but the paragraph it runs into is one this stack rewrote.
@Conarnar
Conarnar force-pushed the feat/executorch-zero-copy-kv branch from 60b008b to aa7fe51 Compare September 6, 2026 04:02
…emory

`_unstage_aliased_buffers` counted a marked buffer as satisfied the moment a
TensorRT delegate took it directly, on the strength of the mark and the delegate
edge alone. Being a direct argument is half of what zero-copy needs. The other
half is that memory planning puts the buffer in a device arena, and nothing
checked it.

`ExecutorchBackendConfig(enable_non_cpu_memory_planning=False)` produces exactly
that shape and does not satisfy it. `PropagateDevicePass` inserts no staging
copy under that flag and writes the delegate's device straight onto the
placeholder's own spec, so the buffer looks placed; memory planning with the
flag off then ignores every spec device and puts the whole program in one host
arena. Measured on a real export, the marked placeholders carry
`DeviceType.CUDA` either way, and land in a CUDA arena with the flag on and in
the single host arena with it off. The `.pte` that came out exported clean,
passed `check_zero_copy_kv`, and failed its first `execute()` on the runtime's
alias-target guard -- "aliased input 'buf_k_cache' must be device-resident",
`Error::InvalidArgument`. That was run on device rather than inferred.

The spec's device is therefore not on its own the answer either. Two things
decide where the buffer is planned and only one of them is in the graph, so
`zero_copy_backend_config` reads `enable_non_cpu_memory_planning` off the
configuration it is building from and hands it to the pass. The pass refuses a
direct argument that is either off-CUDA or host-planned, naming the buffer,
since that buffer is the one whose update is lost.

The docstring named that configuration as one of the two supported ways to
reach the direct-argument shape. It is not one, and the sentence now says what
being a direct argument is and is not enough for; the pass's own second run over
its output is the route that remains. Both tests pinning the shape set the spec
to CUDA by hand, which is what the real configuration does too, so neither could
have caught this. The new ones cover each conjunct on its own and the wiring
that carries the planning mode in.
…ly elided

Export decides elision per binding name; the runtime decides it with one
subtraction. The two agree only while every aliased output of an engine is a
rewired buffer mutation. An engine that also aliases onto a plain input -- one
buffer KV cache beside an argument cache whose updated value is returned -- has
the narrower set elided, passes the output-binding check, passes
`check_zero_copy_kv`, and writes a `.pte` that cannot run: the runtime reads
elision by subtracting the engine's whole aliased-output count from the argument
count it was handed, so a delegate short of only some of them reads as not
elided at all. Loaded through the ExecuTorch runtime with this backend, such a
file opens, binds both aliases at init, and then fails every `execute()` on the
argument count -- "expected at least 7 args, got 6" for the two-alias engine
measured -- with `Error::InvalidArgument`. That was run on device rather than
inferred from the arity code.

`preprocess` already holds the elidable names and the engine's whole
`aliased_io` ten lines apart. It now compares them and refuses, so the failure
lands where the export can still be re-run instead of on the device, and it says
plainly that partial elision is not expressible by the runtime.

Teaching the runtime to read the elided names from the compile spec it is
already handed is the other way out of this, and is deliberately not taken:
refusing is much smaller, and going from refused to supported later is a
compatible progression.

Deriving the narrower set is not what is wrong here, so the test that pins that
derivation keeps its assertion and gains the refusal beside it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla signed component: api [C++] Issues re: C++ API component: api [Python] Issues re: Python API component: tests Issues re: Tests documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants