Skip to content

Commit 2e4b876

Browse files
authored
[executorch][cuda] Let the CUDA delegate drive the off-graph KV step
Stack from [ghstack](https://github.com/ezyang/ghstack/tree/0.15.0) (oldest at bottom): * #22820 * #22819 * #22818 * #22817 * __->__ #22989 * #22816 * #22815 The runner currently calls prepare_step/commit_step on CudaKVCache, which puts a backend face in the runner's hands. The delegate is the natural owner: it already runs on the host immediately before and after the compiled program, and that is the only point where storage can grow and be rebound before the kernels read it. The one thing the delegate cannot work out for itself is how many tokens a step writes. Lowering compiles the cache op away, so at runtime there is only compiled code over pre-bound memory and nothing calls back out. The export side knows the width from its own signature, so it declares where to find it as an "input_index:dim" compile spec, and the delegate reads that input's extent -- shape metadata only, never contents, which are on device. Committing from here also pairs the two halves on every exit: a run that fails never advances the cache past tokens the kernels did not write. Differential Revision: D121131873 Pull Request resolved: #22989
1 parent 713f1d3 commit 2e4b876

10 files changed

Lines changed: 497 additions & 5 deletions

File tree

‎backends/cuda/CMakeLists.txt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,9 @@ target_link_libraries(
407407
)
408408
if(EXECUTORCH_BUILD_EXTENSION_LLM)
409409
target_link_libraries(aoti_cuda_backend PUBLIC extension_llm_cache)
410+
target_compile_definitions(
411+
aoti_cuda_backend PRIVATE EXECUTORCH_BUILD_EXTENSION_LLM
412+
)
410413
endif()
411414

412415
if(_cuda_is_msvc_toolchain)

‎backends/cuda/cuda_backend.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@
2525
trim_host_memory,
2626
)
2727
from executorch.backends.cuda.passes.lower_offgraph_kv import (
28+
CheckOffGraphKVStepWidthPass,
2829
LowerOffGraphKVPass,
2930
OFFGRAPH_KV_COMPILE_SPEC,
31+
OFFGRAPH_KV_STEP_WIDTH_COMPILE_SPEC,
3032
parse_offgraph_kv_manifest,
3133
)
3234
from executorch.backends.cuda.passes.move_cond_predicate_to_cpu import (
@@ -782,6 +784,9 @@ def get_custom_passes(cls, compile_specs: List[CompileSpec]) -> List[typing.Any]
782784
)
783785
triton_kernel_mode = mode
784786
passes = [MoveCondPredicateToCpuPass(), ReplaceInt64FloorDivWithFloatPass()]
787+
for spec in compile_specs:
788+
if spec.key == OFFGRAPH_KV_STEP_WIDTH_COMPILE_SPEC:
789+
passes.append(CheckOffGraphKVStepWidthPass(spec.value))
785790
for spec in compile_specs:
786791
if spec.key == OFFGRAPH_KV_COMPILE_SPEC:
787792
passes.append(

‎backends/cuda/passes/lower_offgraph_kv.py‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,84 @@ def fn(q, k, v, position, k_storage, v_storage):
219219
return fn
220220

221221

222+
# Ops that pass a tensor's values through unchanged, which the export of a
223+
# position input may place between the method input and the cache op.
224+
_VALUE_PRESERVING_OPS = (
225+
"view",
226+
"reshape",
227+
"unsqueeze",
228+
"squeeze",
229+
"expand",
230+
"clone",
231+
"alias",
232+
"_to_copy",
233+
)
234+
235+
236+
def _source_input(node):
237+
while node.op == "call_function" and any(
238+
name in str(node.target) for name in _VALUE_PRESERVING_OPS
239+
):
240+
node = node.args[0]
241+
return node
242+
243+
244+
class CheckOffGraphKVStepWidthPass:
245+
"""Check the declared step width against the delegated program.
246+
247+
The runtime reads each step's token count from input ``input`` at ``dim``
248+
of the delegate. That index is the delegate's own input order, which
249+
partitioning decides, so it is checked here, after partitioning: every
250+
cache op's position must come from that input, whose ``dim`` must be the
251+
position's length.
252+
"""
253+
254+
requires_exported_program = True
255+
256+
def __init__(self, value: bytes) -> None:
257+
text = value.decode("utf-8")
258+
fields = text.split(":")
259+
if len(fields) != 2 or not all(f.isdigit() for f in fields):
260+
raise ValueError(
261+
f"{OFFGRAPH_KV_STEP_WIDTH_COMPILE_SPEC} must be "
262+
f'"input_index:dim", got {text!r}'
263+
)
264+
self._input, self._dim = (int(f) for f in fields)
265+
266+
def __call__(self, exported_program: ExportedProgram) -> ExportedProgram:
267+
graph = exported_program.graph_module.graph
268+
user_inputs = set(exported_program.graph_signature.user_inputs)
269+
inputs = [
270+
n for n in graph.nodes if n.op == "placeholder" and n.name in user_inputs
271+
]
272+
target = exir_ops.edge.kvcache.update_and_attend.default
273+
for node in graph.nodes:
274+
if node.op != "call_function" or node.target != target:
275+
continue
276+
if self._input >= len(inputs):
277+
raise ValueError(
278+
f"off-graph KV step width names input {self._input}, but "
279+
f"the delegate takes {len(inputs)}"
280+
)
281+
declared = inputs[self._input]
282+
position = node.args[3]
283+
if _source_input(position) is not declared:
284+
raise ValueError(
285+
f"off-graph KV step width names input {self._input} "
286+
f"({declared.name}), but the cache op's position comes "
287+
f"from {_source_input(position).name}"
288+
)
289+
shape = declared.meta["val"].shape
290+
width = position.meta["val"].shape[0]
291+
if self._dim >= len(shape) or shape[self._dim] != width:
292+
raise ValueError(
293+
f"off-graph KV step width names dim {self._dim} of "
294+
f"{declared.name} {tuple(shape)}, which is not the step's "
295+
f"{width} positions"
296+
)
297+
return exported_program
298+
299+
222300
class LowerOffGraphKVPass:
223301
requires_exported_program = True
224302

‎backends/cuda/runtime/cuda_backend.cpp‎

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,13 @@
4545
#include <executorch/backends/aoti/utils.h>
4646
#include <executorch/backends/cuda/runtime/cuda_allocator.h>
4747
#include <executorch/backends/cuda/runtime/cuda_delegate_handle.h>
48+
#include <executorch/backends/cuda/runtime/cuda_kv_cache.h>
4849
#include <executorch/backends/cuda/runtime/cuda_mutable_state.h>
4950
#include <executorch/backends/cuda/runtime/cuda_weight_cache.h>
5051
#include <executorch/backends/cuda/runtime/platform/platform.h>
5152
#include <executorch/backends/cuda/runtime/shims/memory.h>
5253
#include <executorch/backends/cuda/runtime/utils.h>
54+
#include <executorch/extension/llm/cache/cache_registry.h>
5355

5456
namespace executorch::backends::cuda {
5557

@@ -87,6 +89,17 @@ constexpr int kCudaGraphWarmupSteps = 3;
8789
constexpr char kWeightSharingAcrossMethods[] = "weight_sharing_across_methods";
8890
} // anonymous namespace
8991

92+
// Advances the off-graph KV cache past a step's tokens. Every successful exit
93+
// of execute() ends here, and only those: a run that failed never wrote its
94+
// tokens, and committing them would desynchronise the cache's length from the
95+
// history the kernels actually hold.
96+
Error commit_offgraph_kv_step(CudaDelegateHandle* handle, int64_t width) {
97+
if (handle->kv_cache == nullptr) {
98+
return Error::Ok;
99+
}
100+
return handle->kv_cache->commit_step(width);
101+
}
102+
90103
class ET_EXPERIMENTAL CudaBackend final
91104
: public ::executorch::runtime::BackendInterface {
92105
private:
@@ -306,7 +319,14 @@ class ET_EXPERIMENTAL CudaBackend final
306319
ArrayRef<CompileSpec> compile_specs // This will be my empty list
307320
) const override {
308321
std::string method_name;
322+
std::optional<OffGraphKVStepWidth> kv_step_width;
309323
for (const CompileSpec& spec : compile_specs) {
324+
if (std::strcmp(spec.key, kOffGraphKVStepWidthSpec) == 0) {
325+
auto parsed = parse_offgraph_kv_step_width(std::string_view(
326+
static_cast<const char*>(spec.value.buffer), spec.value.nbytes));
327+
ET_CHECK_OK_OR_RETURN_ERROR(parsed.error());
328+
kv_step_width = parsed.get();
329+
}
310330
if (std::strcmp(spec.key, "method_name") == 0) {
311331
method_name.assign(
312332
static_cast<const char*>(spec.value.buffer),
@@ -443,6 +463,47 @@ class ET_EXPERIMENTAL CudaBackend final
443463

444464
handle->container_handle = container_handle;
445465

466+
// Runtime-owned off-graph buffers must capture their AOTI names before
467+
// the serialized constants update installs the ordinary weight set.
468+
//
469+
// The cache is found by the key the runner published as a runtime spec.
470+
// No key means an in-graph model, whose KV state is ordinary mutable
471+
// buffers -- that is the whole of the off-graph/in-graph detection, and it
472+
// needs nothing from the user.
473+
//
474+
// The key alone does not decide it, though: a program whose storage was
475+
// lowered off-graph carries no bytes for it, so running one without a cache
476+
// would hand its kernels unbound memory. That requirement is read from the
477+
// program's own constants and checked whether or not a key was passed.
478+
const char* cache_key = nullptr;
479+
if (auto spec = context.get_runtime_spec<const char*>(
480+
::executorch::extension::llm::cache::kCacheKeyOption);
481+
spec.ok() && spec.get() != nullptr && *spec.get() != '\0') {
482+
cache_key = spec.get();
483+
}
484+
const auto needs_offgraph_kv = requires_offgraph_kv_storage(*handle);
485+
ET_CHECK_OK_OR_RETURN_ERROR(needs_offgraph_kv.error());
486+
if (cache_key != nullptr) {
487+
#if defined(EXECUTORCH_BUILD_EXTENSION_LLM)
488+
ET_CHECK_OK_OR_RETURN_ERROR(
489+
attach_offgraph_kv_cache(*handle, cache_key, kv_step_width));
490+
#else
491+
ET_LOG(
492+
Error,
493+
"init: an off-graph KV cache was supplied, but this CUDA backend was "
494+
"built without EXECUTORCH_BUILD_EXTENSION_LLM");
495+
return Error::NotSupported;
496+
#endif
497+
}
498+
ET_CHECK_OR_RETURN_ERROR(
499+
!needs_offgraph_kv.get() || handle->kv_cache != nullptr,
500+
InvalidArgument,
501+
"init: method '%s' keeps its KV cache off-graph but no cache was "
502+
"installed under the '%s' backend option (a CUDA backend built without "
503+
"EXECUTORCH_BUILD_EXTENSION_LLM cannot run it)",
504+
method_name.c_str(),
505+
::executorch::extension::llm::cache::kCacheKeyOption);
506+
446507
// Versioned artifacts load each (device, FQN) through the same process-wide
447508
// cross-method cache model used by the legacy path. The payload only adds
448509
// the tensor metadata needed to reconstruct independently named PTD blobs.
@@ -591,6 +652,23 @@ class ET_EXPERIMENTAL CudaBackend final
591652
}
592653
}
593654

655+
// The cache has to grow, and its storage be rebound, before the compiled
656+
// program reads it -- and nothing inside that program can call back out, so
657+
// the step is driven from here rather than by the runner.
658+
int64_t kv_step_width = 0;
659+
if (handle->kv_cache != nullptr) {
660+
const auto step_width = read_offgraph_kv_step_width(
661+
handle->kv_step_width, Span<EValue*>(args.data(), n_inputs));
662+
ET_CHECK_OK_OR_RETURN_ERROR(step_width.error());
663+
kv_step_width = step_width.get();
664+
// Growth is ordered on the stream this step runs on, which was just
665+
// installed as current above.
666+
const Result<cudaStream_t> step_stream = getCurrentCUDAStream(0);
667+
ET_CHECK_OK_OR_RETURN_ERROR(step_stream.error());
668+
ET_CHECK_OK_OR_RETURN_ERROR(
669+
handle->kv_cache->prepare_step(kv_step_width, step_stream.get()));
670+
ET_CHECK_OK_OR_RETURN_ERROR(handle->kv_cache->rebind_for_execute(handle));
671+
}
594672
ET_CHECK_OK_OR_RETURN_ERROR(mutable_state_rebind_for_execute(handle));
595673

596674
// ---------------------------------------------------------------
@@ -641,7 +719,7 @@ class ET_EXPERIMENTAL CudaBackend final
641719
}
642720
ET_CUDA_CHECK_OR_RETURN_ERROR(cudaStreamSynchronize(cs));
643721

644-
return Error::Ok;
722+
return commit_offgraph_kv_step(handle, kv_step_width);
645723
}
646724

647725
// ---------------------------------------------------------------
@@ -934,7 +1012,7 @@ class ET_EXPERIMENTAL CudaBackend final
9341012
// Last failure point is behind us, so the captured state is now the state
9351013
// the next call should replay from.
9361014
capture_guard.disarm();
937-
return Error::Ok;
1015+
return commit_offgraph_kv_step(handle, kv_step_width);
9381016
}
9391017

9401018
// ----- Normal / WARMUP execution continues here -----
@@ -980,7 +1058,7 @@ class ET_EXPERIMENTAL CudaBackend final
9801058
slim_outputs[i] = nullptr;
9811059
}
9821060

983-
return Error::Ok;
1061+
return commit_offgraph_kv_step(handle, kv_step_width);
9841062
}
9851063

9861064
void destroy(DelegateHandle* handle_) const override {
@@ -990,6 +1068,9 @@ class ET_EXPERIMENTAL CudaBackend final
9901068
cuda::CudaDelegateHandle* handle = (cuda::CudaDelegateHandle*)handle_;
9911069

9921070
mutable_state_forget_handle(handle);
1071+
if (handle->kv_cache != nullptr) {
1072+
handle->kv_cache->forget_handle(handle);
1073+
}
9931074

9941075
// NOTE: AOTInductorModelContainerDelete does not work correctly with
9951076
// multiple .so files. Deleting one container frees shared resources,
@@ -1465,7 +1546,7 @@ class ET_EXPERIMENTAL CudaBackend final
14651546
namespace executorch::backends {
14661547
namespace {
14671548
auto cls = cuda::CudaBackend();
1468-
executorch::runtime::Backend backend{"CudaBackend", &cls};
1549+
executorch::runtime::Backend backend{cuda::kCudaBackendId, &cls};
14691550
static executorch::runtime::Error success_with_compiler =
14701551
register_backend(backend);
14711552

‎backends/cuda/runtime/cuda_delegate_handle.h‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,26 @@
1818
#include <vector>
1919

2020
namespace executorch {
21+
22+
// Forward declarations for the off-graph KV cache the delegate may be given.
23+
// The concrete types live in cuda_kv_cache.h, which includes this header.
24+
namespace extension::llm::cache {
25+
class Cache;
26+
} // namespace extension::llm::cache
27+
2128
namespace backends {
2229
namespace cuda {
2330

31+
class CudaKVCache;
32+
33+
// Where a method's inputs carry the number of tokens a step writes: an index
34+
// into execute()'s inputs and a dimension of that tensor. Declared by the
35+
// export side as the "offgraph_kv_step_width" compile spec, "input_index:dim".
36+
struct OffGraphKVStepWidth {
37+
int input{0};
38+
int dim{0};
39+
};
40+
2441
using AOTInductorModelContainerGetConstantDtypeFunc =
2542
aoti::AOTIRuntimeError (*)(
2643
aoti::AOTInductorModelContainerHandle container_handle,
@@ -199,6 +216,22 @@ struct CudaDelegateHandle : public aoti::AOTIDelegateHandle {
199216
// SlimTensor handles alive for as long as AOTI may reference their views.
200217
std::vector<std::shared_ptr<CudaWeightStorage>> fqn_weight_storages;
201218
std::vector<std::unique_ptr<aoti::slim::SlimTensor>> fqn_weight_tensors;
219+
220+
// The off-graph KV cache the runner installed for this model, resolved from
221+
// the registry at init. Null for an in-graph model, which is the signal that
222+
// this program owns its KV state as ordinary (mutable) buffers.
223+
//
224+
// The shared_ptr is the handle's own claim on the cache, so the cache
225+
// outlives the delegate even if the runner drops its guard first; the raw
226+
// pointer is the backend face of that same object. Forward-declared rather
227+
// than included: cuda_kv_cache.h includes this header.
228+
std::shared_ptr<::executorch::extension::llm::cache::Cache> kv_cache_shared;
229+
CudaKVCache* kv_cache{nullptr};
230+
231+
// Where this method's inputs carry the step width. The compiled program
232+
// cannot report it, because lowering replaced the cache op with kernels over
233+
// pre-bound memory.
234+
OffGraphKVStepWidth kv_step_width;
202235
};
203236

204237
} // namespace cuda

0 commit comments

Comments
 (0)