Skip to content

Commit 2880873

Browse files
committed
feat(executorch): caller-owned KV-cache for the TensorRT delegate
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.
1 parent 456f3ca commit 2880873

20 files changed

Lines changed: 1341 additions & 20 deletions

File tree

cpp/include/torch_tensorrt/executorch/TensorRTBackend.h

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,21 @@ struct EngineHandle {
5959
std::vector<size_t> cached_output_sizes;
6060
size_t num_inputs = 0;
6161
size_t num_outputs = 0;
62+
// Per output binding [0..num_outputs): index into input_binding_names of the
63+
// input it aliases (in-place KV-cache / user alias), or -1 for a normal output.
64+
// Built at init from the blob's aliased_io. The KV buffers are threaded by
65+
// ExecuTorch as caller-owned mutable-buffer delegate args (input AND aliased
66+
// output): execute() binds each aliased TRT output binding to its aliased
67+
// input's caller-provided pointer (in-place) and reflects the result into the
68+
// delegate output EValue (a no-op when the memory planner already aliased the
69+
// two -> zero-copy).
70+
std::vector<int> output_aliased_input_idx;
71+
// Per input binding [0..num_inputs): true if any output aliases this input, so
72+
// its in-place (KV/user) update must land in the caller-owned storage. Built at
73+
// init from aliased_io; execute() uses it to reject a non-device-resident
74+
// aliased input instead of silently staging its update into delegate scratch.
75+
std::vector<bool> input_is_alias_target;
76+
size_t num_aliased_outputs = 0;
6277
int device_id = 0;
6378
bool unified_memory = false;
6479
std::mutex mu;

cpp/include/torch_tensorrt/executorch/TensorRTBlobHeader.h

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,24 @@
88
namespace torch_tensorrt {
99
namespace executorch_backend {
1010

11+
// One aliased output->input binding pair (KV-cache in-place update, or a
12+
// user-declared alias). The engine's output binding shares device memory with
13+
// the named input binding; the runtime binds the output to the input's tensor
14+
// so the update lands in-place in the caller-owned buffer.
15+
struct AliasedBinding {
16+
std::string output; // output binding name
17+
std::string input; // input binding name it aliases
18+
std::string kind; // "kv_cache_update" (TRT-enforced) or "user"
19+
};
20+
1121
struct TensorRTBlobHeader {
1222
uint32_t metadata_offset = 0;
1323
uint32_t metadata_size = 0;
1424
uint32_t engine_offset = 0;
1525
uint64_t engine_size = 0;
1626
std::vector<std::string> input_binding_names;
1727
std::vector<std::string> output_binding_names;
28+
std::vector<AliasedBinding> aliased_io;
1829
bool hardware_compatible = false;
1930
int device_id = 0;
2031

cpp/src/torch_tensorrt/executorch/TensorRTBackend.cpp

Lines changed: 215 additions & 10 deletions
Large diffs are not rendered by default.

cpp/src/torch_tensorrt/executorch/TensorRTBlobHeader.cpp

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ bool parse_int_after_key(const std::string& json, std::size_t search_from, const
136136
bool parse_metadata_json(const std::string& json, TensorRTBlobHeader& out) {
137137
out.input_binding_names.clear();
138138
out.output_binding_names.clear();
139+
out.aliased_io.clear();
139140
out.hardware_compatible = false;
140141
out.device_id = 0;
141142

@@ -229,6 +230,84 @@ bool parse_metadata_json(const std::string& json, TensorRTBlobHeader& out) {
229230
}
230231
}
231232

233+
// Optional aliased_io array: [{"output":..,"input":..,"kind":..}, ...].
234+
// Absent in older blobs -> leave empty (backward compatible). Mirrors the
235+
// io_bindings walk above using the same string helpers.
236+
const std::size_t alias_key = json.find("\"aliased_io\"");
237+
if (alias_key != std::string::npos) {
238+
std::size_t apos = json.find('[', alias_key);
239+
if (apos == std::string::npos) {
240+
return false;
241+
}
242+
++apos;
243+
while (true) {
244+
apos = skip_ws(json, apos);
245+
if (apos >= json.size()) {
246+
return false;
247+
}
248+
if (json[apos] == ']') {
249+
++apos;
250+
break;
251+
}
252+
if (json[apos] == ',') {
253+
++apos;
254+
continue;
255+
}
256+
if (json[apos] != '{') {
257+
return false;
258+
}
259+
++apos;
260+
261+
AliasedBinding ab;
262+
while (true) {
263+
apos = skip_ws(json, apos);
264+
if (apos >= json.size()) {
265+
return false;
266+
}
267+
if (json[apos] == '}') {
268+
++apos;
269+
break;
270+
}
271+
if (json[apos] == ',') {
272+
++apos;
273+
continue;
274+
}
275+
std::string key;
276+
apos = parse_string(json, apos, key);
277+
if (apos == std::string::npos) {
278+
return false;
279+
}
280+
apos = skip_ws(json, apos);
281+
if (apos >= json.size() || json[apos] != ':') {
282+
return false;
283+
}
284+
apos = skip_ws(json, apos + 1);
285+
if (key == "output") {
286+
apos = parse_string(json, apos, ab.output);
287+
} else if (key == "input") {
288+
apos = parse_string(json, apos, ab.input);
289+
} else if (key == "kind") {
290+
apos = parse_string(json, apos, ab.kind);
291+
} else {
292+
apos = skip_value(json, apos);
293+
}
294+
if (apos == std::string::npos) {
295+
return false;
296+
}
297+
}
298+
if (!ab.output.empty() && !ab.input.empty()) {
299+
// A missing "kind" key means an older blob (the Python serializer omits
300+
// it for KV aliases); default to the TRT-enforced kind so init()'s kind
301+
// validation treats an absent key the same as the Python runtime rather
302+
// than rejecting it as unknown.
303+
if (ab.kind.empty()) {
304+
ab.kind = "kv_cache_update";
305+
}
306+
out.aliased_io.push_back(std::move(ab));
307+
}
308+
}
309+
}
310+
232311
return parse_bool_after_key(json, pos, "\"hardware_compatible\"", out.hardware_compatible) &&
233312
parse_int_after_key(json, pos, "\"device_id\"", out.device_id);
234313
}

examples/executorch_reference_runner/BUILD

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ filegroup(
77
srcs = [
88
"CMakeLists.txt",
99
"README.md",
10+
"kv_cache_decode_check.cpp",
1011
"main.cpp",
1112
],
1213
)
@@ -20,3 +21,14 @@ cc_binary(
2021
"@executorch//:executorch_file_data_loader",
2122
],
2223
)
24+
25+
cc_binary(
26+
name = "kv_cache_decode_check",
27+
srcs = ["kv_cache_decode_check.cpp"],
28+
deps = [
29+
"//cpp:tensorrt_executorch_backend",
30+
"@cuda//:cudart",
31+
"@executorch//:executorch_core",
32+
"@executorch//:executorch_file_data_loader",
33+
],
34+
)

examples/executorch_reference_runner/CMakeLists.txt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,18 @@ target_link_libraries(
6161
executorch::extensions
6262
executorch::kernels
6363
torchtrt::executorch_backend)
64+
65+
# Caller-owned KV-cache persistence check (see kv_cache_decode_check.cpp). It
66+
# cudaMalloc's the device-tagged planned arenas that hold the KV buffers and
67+
# copies the logits back to host, so it links the CUDA runtime directly.
68+
find_package(CUDAToolkit REQUIRED)
69+
add_executable(kv_cache_decode_check kv_cache_decode_check.cpp)
70+
target_link_libraries(
71+
kv_cache_decode_check
72+
PRIVATE
73+
executorch
74+
executorch::backends
75+
executorch::extensions
76+
executorch::kernels
77+
torchtrt::executorch_backend
78+
CUDA::cudart)

examples/executorch_reference_runner/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,30 @@ Loading the method initializes the TensorRT ExecuTorch backend for any
103103
Torch-TensorRT delegate subgraphs embedded in the `.pte`. The Python
104104
`torch_tensorrt` package is needed when exporting the `.pte`; it is not needed
105105
by this native runner at inference time.
106+
107+
## Caller-Owned KV-Cache Persistence Check
108+
109+
`kv_cache_decode_check` is a small self-asserting runner for a caller-owned
110+
KV-cache decode `.pte` (its aliased KV output is bound in place to the caller's
111+
mutable buffer, which persists across `execute()` calls).
112+
113+
Export a minimal single-layer decode model:
114+
115+
```bash
116+
python examples/torchtrt_executorch_example/export_kv_cache_decode.py \
117+
--model_path=kv_cache_decode.pte
118+
```
119+
120+
The same CMake build produces the check runner (`kv_cache_decode_check`
121+
target). Run it:
122+
123+
```bash
124+
./build-executorch-reference-runner/kv_cache_decode_check --model_path=kv_cache_decode.pte
125+
```
126+
127+
It loads the method twice (each starting from a zeroed cache) and runs a decode
128+
at `input_pos=1` once with no prior step and once after a step at `input_pos=0`.
129+
Because the causal attention at position 1 covers positions 0..1, the two logits
130+
differ only if the KV written at position 0 persisted across `execute()` calls.
131+
The runner prints `[kv-check] PASS` and returns 0 on success, or fails if the
132+
two are identical (the update did not persist). It requires a CUDA device.

0 commit comments

Comments
 (0)