fix(executorch): support KV-cache aliased I/O in the TensorRT delegate - #4445
fix(executorch): support KV-cache aliased I/O in the TensorRT delegate#4445Conarnar wants to merge 2 commits into
Conversation
52d316b to
c5ab1e4
Compare
|
Thanks for adding aliased-I/O metadata to the ExecuTorch blob. This fixes a real missing capability, and the serialization changes look reasonable. I found a blocking issue that applies even when only one method is exported. The patch removes every aliased input from the delegate arguments and replaces it with a private, zero-initialized buffer. If the caller supplies the cache tensor, its existing contents are ignored and the caller cannot observe the update. For example, the expected behavior is: The new behavior is: Checking only Runtime-owned storage also needs a defined lifetime. The current buffer is initialized once, has no reset operation, and is shared by every call using that loaded method. Two conversations, or concurrent requests, would therefore use the same cache. Could caller-owned aliases stay explicit delegate inputs, with the output bound to the same pointer? If runtime-owned storage is genuinely needed, please add explicit ownership and a stable state identity, plus reset or session-selection behavior. On testing, it would help to pin the runtime behavior directly rather than through serialization round-trips: caller-visible mutation, repeated calls on one loaded method, a fresh load starting from a known state, and sequence isolation. For the multi-method case: multi-method Torch-TensorRT ExecuTorch export is still in flight (#4440), so a natural next step is to build a two-method prefill/decode test on top of it and assert that decode observes cache state written by prefill. I want to flag the expected outcome up front: with the current design I believe that test fails, because each method loads as its own delegate with its own private cache, so there is no shared object for the two methods to write through. That is really why I think the cache has to be owned above the delegate and bound into both methods; the shared-cache test is the acceptance criterion for that ownership change rather than something stacking alone will make pass. For mixed TensorRT and CUDA execution there are two independent requirements worth testing separately: (1) both methods bind the same KV-cache storage, and (2) dependent GPU work from the two delegates is ordered. Ordering needs a shared caller stream (there is separate in-flight work for that, #4421), so a mixed test should run on top of it, but note the shared stream only provides (2). Property (1), shared storage across the TensorRT and CUDA delegates, cannot come from a delegate-private buffer, so it also depends on moving cache ownership above the delegate. |
|
Following up with something concrete I should have led with, plus a correction to my own comment. The existing runtime already defines the expected behaviorThe non-ExecuTorch C++ runtime binds an aliased output to the caller's pointer ctx->setTensorAddress(name.c_str(), in_it->second.data_ptr());and So this is not only a question of which design is nicer. It is that the ExecuTorch Correction: the alias kind is never consultedI said checking for (int in_idx : handle->output_aliased_input_idx) {
if (in_idx >= 0) {
handle->input_is_self_owned[in_idx] = true; // no kind check
}
}That matters for On the constraint you hitYour comment in the header explains the real obstacle, and I do not think I gave it My concern is where it gets solved. Working around it inside the delegate means the Two smaller things
The serialization work (carrying Composition noteRe-checking my earlier point about multi-method, in #4440 each method gets its own |
…ng for hybrid graphs
torch_tensorrt.save(retrace=False) uses the legacy dynamo exporter, which inlines the
partitioned _run_on_gpu (non-TensorRT) submodules back into the graph before building an
ExportedProgram. For a hybrid graph interleaving TensorRT engines with a CUDA/pytorch
delegated op, inline_torch_modules wired each submodule's inputs by MATCHING placeholder
names to graph nodes (get_duplicate_nodes). Name matching binds an input to a same-named
but unrelated node on a collision (e.g. a submodule input placeholder name-matching a
different engine's getitem), which:
- rewires a consumer to the wrong producer and orphans the real one; the orphan is then
pruned by dead-code elimination, leaving a delegate short an output at runtime (an
aliased engine reports "expected N args, got N-1"); and
- for a submodule mixing graph-input and computed-intermediate inputs, leaks the
computed intermediates as spurious graph placeholders (misclassified USER_INPUTs).
Wire submodule inputs POSITIONALLY from the call_module args (gm_node.args, which is
authoritative) instead of by name: let graph_copy create a fresh placeholder for each
submodule input, then rewire each to submodule_inputs[i] by position and erase it. Drop
get_duplicate_nodes (now unused).
Also fix two torch-version-compat gaps this path hits on recent torch:
- lift(): pass an explicit persistent= flag on BUFFER InputSpecs (required since 2.3).
- create_trt_exp_program(): an inlined GraphModule may carry a plain fx.CodeGen (no
pytree_info); fall back to specs rebuilt from the example inputs + graph outputs.
With these, retrace=False export of a hybrid TensorRT+CUDA program is bit-identical to
retrace=True (validated on a 2-layer int4 MoE decode: per-step argmax + logits match).
Tests: tests/py/dynamo/models/test_exporter_inlining.py -- positional input wiring under a
name collision, and multi-output preservation (GPU-free fx unit tests).
c5ab1e4 to
40e0486
Compare
What changed vs the earlier (delegate-owned) versionThe earlier revision made the delegate own the KV cache: because ExecuTorch This revision makes the cache caller-owned, above the delegate. Export / lowering (new):
Runtime (changed):
|
40e0486 to
2312f42
Compare
|
@cehongwang Please review usage of the aliased i/o feature |
| engine_node, | ||
| _split_binding_names(_get_str(engine_info, INPUT_BINDING_NAMES_IDX)), | ||
| ) | ||
| output_names = _split_binding_names( |
There was a problem hiding this comment.
line 223:
Only inputs need this. Outputs are also bound positionally by the runtime,
but they aregetitem(engine_node, idx)nodes whose index order equals the
engine output-binding order. ExecuTorch lowering can reorder delegate outputs
(arrange_graph_outputsmoves buffer-mutation outputs ahead of user
outputs), but a TensorRT delegate partition is a functional inference engine
with no mutation outputs, so that pass is a no-op here and the output order is
preserved. If a TRT partition ever produced mutation outputs, outputs would
need the same node-identity reordering as inputs.
We need to account for the output order, or otherwise there is a mismatch
There was a problem hiding this comment.
The engine appends the buffer mutation at the end of output, while the delegate prepends it
There was a problem hiding this comment.
Runtime walkthrough
Assume:
TRT inputs: [tokens, k_cache_in]
TRT outputs: [logits, k_cache_out]
ExecuTorch passes:
args[0] = tokens
args[1] = k_cache_in
args[2] = k_cache_mutation
args[3] = logits
After consuming the inputs, arg_idx == 2.
First output iteration
o = 0
name = output_binding_names[0] = logits
arg = args[2] = k_cache_mutation
The backend binds TensorRT's logits output to the cache-mutation output storage.
Second output iteration
o = 1
name = output_binding_names[1] = k_cache_out
out_arg = args[3] = logits
TensorRT correctly binds k_cache_out to k_cache_in for the in-place update, but the backend treats the logits EValue as its mutation output slot. Its reflect copy therefore writes the cache result into the logits output.
The result is effectively:
| TensorRT output | Lands in |
|---|---|
logits |
cache mutation slot |
| cache update | logits slot |
If shapes or capacities differ, execution may fail during resize/binding/enqueue. If they are compatible, it can run successfully while returning incorrect logits and corrupting the observable cache state.
The required fix is to reorder serialized output_binding_names into actual delegate-output order, analogous to _reorder_input_names_for_executorch.
There was a problem hiding this comment.
I was not able to reproduce this. Seems like arrange_graph_outputs reorders the submodule outputs, output_specs, and the parent getitems, but it doesn't touch the call node's meta["val"]. node.args is permuted by fusion (so _reorder_input_names_for_executorch is still needed), but outputs go through meta["val"], which arrange_graph_outputs leaves alone.
If meta["val"] is being rearranged somewhere that would be a problem, but the fix would be different from how _reorder_input_names_for_executorch handles it.
There was a problem hiding this comment.
Ok I spend a ton of time on this and it is quite surprising that the order was actually correct. But here is some findings:
_keep_mutated_buffers_above_delegate(exported_program)
https://github.com/Conarnar/TensorRT/blob/b9dbb306a4597eb4fb0c4ef0fdb546235d3924b1/py/torch_tensorrt/executorch/partitioner.py#L165
This function lifes mutated buffer to the executorch program level, therefore arrange_graph_outputs did not have any buffer_mutation, and therefore the order is perserved.
| @@ -0,0 +1,157 @@ | |||
| """Export-side coverage for caller-owned KV-cache buffer mutations. | |||
There was a problem hiding this comment.
These tests verify that mutations get prepended in the ExportedProgram, which is one half of the contract. The other half — that the serialized output_binding_names line up with the delegate arg order the C++ backend indexes positionally — isn't covered here or anywhere else, because to_edge, arrange_graph_outputs, and preprocess are all outside the mocked boundary.
Could you add a test at that seam? Roughly:
- Build a KV model with both a user output and an aliased cache output.
- Run it through
to_edge_transform_and_lowerwith the TRT partitioner. - Read the delegate's output-arg order from the lowered module's output specs.
- Assert
deserialize_engine(...).io_bindingsoutput names match that order.
This needs no GPU and would fail today.
|
One testing gap worth closing before merge: there's currently no test anywhere that exercises a full That means the export side and the blob side are each tested in isolation and each is individually correct, while the bug lives in their composition. Could we get:
(2) is what RFC 0003 §7.4 asked for, and it would also cover the device-residency and reflect-path questions raised elsewhere in this review. Happy to gate it on GPU availability, but it should exist as a runnable target. |
|
In The comment says output order is "stable by construction (getitem index order == engine output-binding order)." That was true when a TRT partition was purely functional. This PR introduces The fixture here has no mutation outputs, so it passes — but it asserts "preprocess must pass output names through unchanged," which is the behavior that needs to change. Could you update this test to cover the mutation case? Something like a fixture with one aliased output and one user output, asserting the serialized |
| engine->cached_input_sizes[i] = 1; | ||
| } | ||
| bind_ptr = engine->cached_input_ptrs[i]; | ||
| } else if (engine->unified_memory || is_cuda_accessible_ptr(et_in.const_data_ptr())) { |
There was a problem hiding this comment.
Aliased inputs can still take the host-staging path, which breaks caller-owned semantics.
If an aliased input's data pointer isn't CUDA-accessible, this falls through to the staging branch and binds engine->cached_input_ptrs[i] — a delegate-owned scratch buffer. The aliased output then binds to input_bind_ptrs[alias_in], i.e. that same scratch buffer, so the in-place KV update lands in delegate scratch rather than the caller's storage. On the next execute() the staging copy re-reads the caller's unchanged host buffer, so the update is silently lost. Recovery depends entirely on the reflect copy, which has its own problem (see the reflect comment).
There was a problem hiding this comment.
Can you clarify what RFC 0003 §6.4 (and §7.4) is? I was not able to find any references for that.
There was a problem hiding this comment.
Oh, here it is:
Require the caller's tensor itself to be device-resident. Aliasing only
works when the pointer bound to the input is the caller's real storage. Two
existing branches break that: the H2D fallback copies a host tensor into a
device staging buffer and binds that, and the zero-byte branch binds a
cached scratch allocation. Either would make the engine write the update
somewhere the caller never reads. For an alias-source input, gate on the
caller tensor being device/unified memory (is_cuda_accessible_ptr) with
non-zero size, and otherwise return Error::InvalidArgument ("aliased input
'%s' must be on GPU or unified memory") — i.e. reject before both the
staging and zero-byte paths, not only the H2D one.
| @@ -590,7 +724,7 @@ | |||
| const bool must_sync = output_staged_to_host || input_staged_from_host || !g_user_stream_set; | |||
There was a problem hiding this comment.
Do we need to account for aliase I/O? Is there a race possible?
There was a problem hiding this comment.
Yes, but only on the non-zero-copy reflect path: with a caller stream active and no end sync, a pending reflect into the delegate output could still be in flight when ExecuTorch's buffer-mutation copy_ reads it. Will handle it.
| // execute() can bind it to that input's device pointer (in-place). | ||
| // Non-aliased models have an empty header.aliased_io -> all -1, unchanged path. | ||
| handle->output_aliased_input_idx.assign(handle->num_outputs, -1); | ||
| for (const auto& ab : header.aliased_io) { |
There was a problem hiding this comment.
This doesn't cross-check the persisted alias map against the engine, and it accepts unknown kind values.
The Python runtime's _TRTEngine._reconcile_aliased_io treats getAliasedInputTensor as the source of truth for kv_cache_update aliases and preserves user ones as metadata-trusted. Here, a kind that is neither "kv_cache_update" nor "user" — a typo in the wire format, or a future kind written by a newer exporter — skips the shape check and gets registered as if it were a KV alias, which then binds two tensors to the same storage.
Could you mirror the Python behavior:
- Reject unknown kinds with
Error::InvalidProgram. - For
kv_cache_update, compare the persistedab.inputagainstengine->getAliasedInputTensor(ab.output.c_str())and error on disagreement. - Keep
useras metadata-trusted after the shape check (TRT can't see those aliases).
Related: the parser leaves ab.kind empty when the "kind" key is absent, while the Python side defaults to "kv_cache_update". Once unknown kinds are rejected, that mismatch turns an old blob into a hard failure — worth defaulting to "kv_cache_update" in the parser to match.
2312f42 to
b9dbb30
Compare
| new_mutation_outputs: List[torch.fx.Node] = [] | ||
| for oi, out_name in enumerate(out_names): | ||
| if out_name not in aliased_io: | ||
| continue |
There was a problem hiding this comment.
This'd better be warnings or errors
There was a problem hiding this comment.
Guessing you mean the two continues below.
| cuda_err = cudaMemcpyAsync(std::get<0>(r), std::get<1>(r), std::get<2>(r), cudaMemcpyDeviceToDevice, stream); | ||
| if (cuda_err != cudaSuccess) { | ||
| ET_LOG( | ||
| Error, "TensorRTBackend::execute: aliased-output reflect D2D copy failed: %s", cudaGetErrorString(cuda_err)); |
There was a problem hiding this comment.
Missing stream drain if a reflect copy fails, so the next call can touch a live context.
enqueueV3() has already succeeded by the time we get here, so the engine is running on the stream. If one of these cudaMemcpyAsync calls fails we return straight away, and engine->inflight_pending is only set further down (line 811), so it stays false.
Both later guards are gated on that flag:
- the next
execute()(line 470) waits onlyif (engine->inflight_pending), and it does that before callingsetInputShape/setTensorAddress - the destructor (line 83) waits only
if (inflight_pending)before destroying the context and freeing the staging buffers
So the flag says "nothing in flight" while TensorRT is still working. The next call is then free to reconfigure the context, which the comment at line 466 correctly says TensorRT forbids.
You already have exactly the right pattern a few lines below, at 802-808:
// Could not arm the completion marker; drain now so a later execute() or the
// destructor never reconfigures or frees exec_ctx while this enqueue runs.
(void)cudaStreamSynchronize(stream);
engine->inflight_pending = false;
return Error::InvalidProgram;Same two lines would fix this branch.
Worth considering the more robust shape too: set the pending marker as soon as the first async work is submitted, rather than at the end of the happy path. Then any early return added later between enqueue and the end of the function inherits the protection instead of quietly reintroducing this.
Note the D2H branch at 786-793 looks like it has the same gap, but that one pre-dates this PR, so it is probably a separate fix.
|
|
||
| # retrace=True: torch.export truncates the engines' aliased KV | ||
| # outputs, so declare them as buffer mutations before lowering. | ||
| exp_program = _declare_aliased_kv_mutations_on_ep(exp_program) |
There was a problem hiding this comment.
Aliased models saved in the other two formats get no equivalent handling, and no warning.
This normalization runs only in the output_format == "executorch" branch. The exported_program branch (1116) and aot_inductor branch (1126) receive the same retrace=True program and save it as-is.
if output_format == "exported_program": # no alias handling
elif output_format == "aot_inductor": # no alias handling
elif output_format == "executorch":
exp_program = _declare_aliased_kv_mutations_on_ep(exp_program) # only hereI understand why it lives here: ExecuTorch has tag_constant_data, which freezes an undeclared buffer, and that pass is the reason this function exists. The other formats have no equivalent, so the harm is not the same.
But the outcome still differs per format in a way a user cannot see:
exported_program: the engine still mutates the buffer in place at runtime, so the cache probably does update, but the saved signature does not declare the mutation. Anything downstream that trusts the signature gets a wrong answer.aot_inductor: this goes through inductor, and whether an in-place mutation on a custom op survives functionalization is not obvious. I could not find test coverage for that combination.
If aliased I/O is only intended to be supported for executorch, could the other two reject or warn when the module has aliased engines? A user who saves as aot_inductor today gets no signal either way, which is the part that seems worth closing regardless of which behavior you pick.
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).
Tests cover serialization round-trip, the exposure-flag dispatch across both
retrace modes, the buffer-mutation declaration, and the partitioner un-tagging.
b9dbb30 to
8db164f
Compare
Description
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 through as mutable-buffer delegate args (both the input and the engine's aliased output), so a TensorRT engine updates them in place and the cache persists across decode steps — matching the contract the non-ExecuTorch TensorRT runtime already exposes.
Runtime + serialization (delegate)
serialization.py,backend.py,TensorRTBlobHeader.{h,cpp}).TensorRTBackend.{h,cpp}).kindmust be one we understand (kv_cache_update/user);kv_cache_updateentries are cross-checked against the engine's owngetAliasedInputTensor(TensorRT is the source of truth), anduserentries are shape-checked before two tensors are bound to the same storage. A caller-owned aliased input must be device-resident — otherwise its in-place update would be staged through host scratch and silently lost, so it's rejected loudly.execute()returns, since ExecuTorch's buffer-mutationcopy_reads the delegate output EValue afterward; the zero-copy caller-owned KV fast path records no reflect and is untouched.Export / lowering (torch_tensorrt)
BUFFER_MUTATIONs so ExecuTorch keeps the KV buffers as caller-owned mutable buffers instead of freezing them: at transform time for the legacy exporter (retrace=False), and via a post-export pass (_declare_aliased_kv_mutations_on_ep) fortorch.export(retrace=True), which otherwise drops the aliased outputs at the fx boundary.TensorRTPartitioner(tag_constant_datawould otherwise freeze them as constants).Dependency
This PR is stacked on #4446 and must land after it — #4446 fixes the legacy (
retrace=False) submodule inlining that the composable ExecuTorch export path depends on.Follow-up tests (gated on other PRs)
A cross-delegate prefill/decode acceptance test — decode consuming the KV cache that prefill wrote through a separate per-method delegate — will be added once #4440 (per-method
TensorRTPartitioner→ separate delegate instances) and #4454 (shared caller CUDA stream, for ordering the dependent GPU work between the two) land. That configuration is what exercises cross-delegate cache sharing, which single-delegate tests cannot cover.Testing
aliased_ioserialization round-trip; blob-header parse (present / empty / missing-key); exposure-flag dispatch across both retrace modes; theBUFFER_MUTATIONdeclaration; partitioner keeps only mutation-target buffers above the delegate.examples/executorch_reference_runner/kv_cache_decode_check(exported byexport_kv_cache_decode.py) drives multi-step decode against the delegate and asserts the KV cache persists in place across steps.