Skip to content

Commit 5054b1b

Browse files
[executorch][cuda] Recapture off-graph KV decode graphs (#23297)
This PR was created by the merge bot to help merge the original PR into the main branch. ghstack PR number: #22819 by @Gasoonjia ^ Please use this as the source of truth for the PR details, comments, and reviews ghstack PR base: https://github.com/pytorch/executorch/tree/gh/gasoonjia/189/base ghstack PR head: https://github.com/pytorch/executorch/tree/gh/gasoonjia/189/head Merge bot PR base: https://github.com/pytorch/executorch/tree/gh/gasoonjia/188/orig Merge bot PR head: https://github.com/pytorch/executorch/tree/gh/gasoonjia/189/orig Differential Revision: [D120091513](https://our.internmc.facebook.com/intern/diff/D120091513/) @diff-train-skip-merge --------- Co-authored-by: gasoonjia <gasoonjia@icloud.com> Co-authored-by: Gasoonjia <gasoonjia@meta.com>
1 parent 0bf741c commit 5054b1b

6 files changed

Lines changed: 378 additions & 62 deletions

File tree

‎.ci/scripts/test_model_e2e.sh‎

Lines changed: 58 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -454,8 +454,9 @@ EOF
454454
fi
455455
RUNNER_ARGS="$RUNNER_ARGS --tokenizer_path ${MODEL_DIR}/$TOKENIZER_FILE --prompt_file \"$PROMPT_FILE\" --max_new_tokens 512"
456456
if [ "$MODE" = "solo-text-offgraph" ]; then
457-
# The off-graph KV cache does not support CUDA graph yet.
458-
RUNNER_ARGS="$RUNNER_ARGS --cuda_graph=false"
457+
# Exercise the same artifact on both decode paths: eager, then the
458+
# captured CUDA graph, which is recaptured whenever the cache grows.
459+
RUNNER_VARIANTS="--cuda_graph=false --cuda_graph"
459460
else
460461
RUNNER_ARGS="$RUNNER_ARGS --cuda_graph"
461462
fi
@@ -481,50 +482,69 @@ EOF
481482
;;
482483
esac
483484

484-
OUTPUT=$(eval $RUNNER_BIN $RUNNER_ARGS 2>&1)
485-
EXIT_CODE=$?
486-
set -e
485+
# A mode that needs more than one runtime configuration sets RUNNER_VARIANTS to
486+
# a space-separated list of extra args; each is run and validated on its own.
487+
# Everything else runs once with no extra args.
488+
run_and_validate() {
489+
VARIANT_ARGS="$1"
490+
if [ -n "$VARIANT_ARGS" ]; then
491+
echo "--- Runner pass: $VARIANT_ARGS ---"
492+
fi
487493

488-
echo "Runner output:"
489-
echo "$OUTPUT"
494+
set +e
495+
OUTPUT=$(eval $RUNNER_BIN $RUNNER_ARGS $VARIANT_ARGS 2>&1)
496+
EXIT_CODE=$?
497+
set -e
490498

491-
if [ $EXIT_CODE -ne 0 ]; then
492-
echo "Unexpected exit code: $EXIT_CODE"
493-
exit $EXIT_CODE
494-
fi
499+
echo "Runner output:"
500+
echo "$OUTPUT"
495501

496-
# Validate output for models that have expected output
497-
if [ -n "$EXPECTED_OUTPUT" ]; then
498-
if ! echo "$OUTPUT" | grep -iq "$EXPECTED_OUTPUT"; then
499-
echo "Expected output '$EXPECTED_OUTPUT' not found in output"
500-
exit 1
502+
if [ $EXIT_CODE -ne 0 ]; then
503+
echo "Unexpected exit code: $EXIT_CODE"
504+
exit $EXIT_CODE
505+
fi
506+
507+
# Validate output for models that have expected output
508+
if [ -n "$EXPECTED_OUTPUT" ]; then
509+
if ! echo "$OUTPUT" | grep -iq "$EXPECTED_OUTPUT"; then
510+
echo "Expected output '$EXPECTED_OUTPUT' not found in output"
511+
exit 1
512+
else
513+
echo "Success: '$EXPECTED_OUTPUT' found in output"
514+
fi
501515
else
502-
echo "Success: '$EXPECTED_OUTPUT' found in output"
516+
echo "SUCCESS: Runner completed successfully"
503517
fi
504-
else
505-
echo "SUCCESS: Runner completed successfully"
506-
fi
507518

508-
# Validate GPU peak memory usage for models with known memory budgets.
509-
# The runner prints "GPU peak memory usage: XXXX.X MiB" at the end.
510-
case "$MODEL_NAME" in
511-
qwen3_5_moe)
512-
MAX_MEMORY_MIB=20480 # 20 GB — must fit on a single GPU (e.g. 4090)
513-
PEAK_MEM=$(echo "$OUTPUT" | grep -oP 'GPU peak memory usage: \K[0-9.]+' || true)
514-
if [ -n "$PEAK_MEM" ]; then
515-
# Compare as integers (truncate decimals)
516-
PEAK_MEM_INT=${PEAK_MEM%%.*}
517-
if [ "$PEAK_MEM_INT" -gt "$MAX_MEMORY_MIB" ]; then
518-
echo "FAIL: GPU peak memory ${PEAK_MEM} MiB exceeds budget ${MAX_MEMORY_MIB} MiB"
519-
exit 1
519+
# Validate GPU peak memory usage for models with known memory budgets.
520+
# The runner prints "GPU peak memory usage: XXXX.X MiB" at the end.
521+
case "$MODEL_NAME" in
522+
qwen3_5_moe)
523+
MAX_MEMORY_MIB=20480 # 20 GB — must fit on a single GPU (e.g. 4090)
524+
PEAK_MEM=$(echo "$OUTPUT" | grep -oP 'GPU peak memory usage: \K[0-9.]+' || true)
525+
if [ -n "$PEAK_MEM" ]; then
526+
# Compare as integers (truncate decimals)
527+
PEAK_MEM_INT=${PEAK_MEM%%.*}
528+
if [ "$PEAK_MEM_INT" -gt "$MAX_MEMORY_MIB" ]; then
529+
echo "FAIL: GPU peak memory ${PEAK_MEM} MiB exceeds budget ${MAX_MEMORY_MIB} MiB"
530+
exit 1
531+
else
532+
echo "Success: GPU peak memory ${PEAK_MEM} MiB within budget (max ${MAX_MEMORY_MIB} MiB)"
533+
fi
520534
else
521-
echo "Success: GPU peak memory ${PEAK_MEM} MiB within budget (max ${MAX_MEMORY_MIB} MiB)"
535+
echo "WARNING: GPU peak memory usage not found in output"
522536
fi
523-
else
524-
echo "WARNING: GPU peak memory usage not found in output"
525-
fi
526-
;;
527-
esac
537+
;;
538+
esac
539+
}
540+
541+
if [ -z "${RUNNER_VARIANTS:-}" ]; then
542+
run_and_validate ""
543+
else
544+
for VARIANT in $RUNNER_VARIANTS; do
545+
run_and_validate "$VARIANT"
546+
done
547+
fi
528548
echo "::endgroup::"
529549

530550
if [ "$DEVICE" = "cuda" ] && [ "$MODEL_NAME" = "qwen3_5_moe" ]; then

‎backends/cuda/runtime/cuda_backend.cpp‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,13 @@ class ET_EXPERIMENTAL CudaBackend final
206206
res.ok() ? reinterpret_cast<name##Func>(res.get()) : nullptr; \
207207
} while (0)
208208

209+
{
210+
auto res =
211+
get_function(so_handle, "AOTInductorModelContainerRunSingleThreaded");
212+
handle->run_single_threaded = res.ok()
213+
? reinterpret_cast<AOTInductorModelContainerRunFunc>(res.get())
214+
: nullptr;
215+
}
209216
LOAD_OPTIONAL_SYMBOL(
210217
get_num_constants, AOTInductorModelContainerGetNumConstants);
211218
LOAD_OPTIONAL_SYMBOL(
@@ -924,14 +931,19 @@ class ET_EXPERIMENTAL CudaBackend final
924931
end_capture_guard.arm(cuda_stream);
925932
}
926933

927-
AOTIRuntimeError error = handle->run(
928-
handle->container_handle,
929-
reinterpret_cast<Tensor**>(slim_inputs.data()),
930-
n_inputs,
931-
reinterpret_cast<Tensor**>(slim_outputs.data()),
932-
n_outputs,
933-
static_cast<void*>(cuda_stream),
934-
nullptr);
934+
const auto run =
935+
handle->cuda_graph_state.phase != CudaGraphPhase::Disabled &&
936+
handle->run_single_threaded != nullptr
937+
? handle->run_single_threaded
938+
: handle->run;
939+
AOTIRuntimeError error =
940+
run(handle->container_handle,
941+
reinterpret_cast<Tensor**>(slim_inputs.data()),
942+
n_inputs,
943+
reinterpret_cast<Tensor**>(slim_outputs.data()),
944+
n_outputs,
945+
static_cast<void*>(cuda_stream),
946+
nullptr);
935947
run_called = true;
936948

937949
// Delete orphaned pre-created outputs that run() replaced.
@@ -971,6 +983,7 @@ class ET_EXPERIMENTAL CudaBackend final
971983
Internal,
972984
"cudaGraphInstantiate failed: %s",
973985
cudaGetErrorString(gerr));
986+
handle->cuda_graph_state.note_graph_allocations();
974987

975988
// Record static output pointers (stable under graph replay). Releasing
976989
// them from slim_outputs here, before the copies below, keeps the cleanup

‎backends/cuda/runtime/cuda_delegate_handle.h‎

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#include <executorch/backends/aoti/slim/core/slim_tensor.h>
1313
#include <executorch/extension/cuda/device_guard.h>
1414
#include <executorch/extension/cuda/runtime_api.h>
15+
#include <algorithm>
1516
#include <cstdint>
1617
#include <cstdlib>
1718
#include <memory>
@@ -125,21 +126,94 @@ struct CudaGraphState {
125126
std::vector<size_t> static_input_nbytes;
126127
std::vector<size_t> static_output_nbytes;
127128

129+
// Allocations the graph makes and never frees -- the outputs AOTI allocates
130+
// while being captured. AutoFreeOnLaunch frees one launch's before the next
131+
// launch of the same graph, so the last launch's outlive a graph that is
132+
// dropped unless released with it. Outputs that alias other memory are not
133+
// graph allocations and are not listed.
134+
std::vector<void*> graph_allocations;
135+
128136
CudaGraphState() = default;
129137

130138
~CudaGraphState() {
139+
release();
140+
}
141+
142+
// Records the graph's outstanding allocations: every memory-allocation node
143+
// without a matching free node. Call once the graph is captured.
144+
void note_graph_allocations() {
145+
graph_allocations.clear();
146+
#if !defined(EXECUTORCH_USE_HIP)
147+
size_t count = 0;
148+
if (graph == nullptr ||
149+
cudaGraphGetNodes(graph, nullptr, &count) != cudaSuccess) {
150+
return;
151+
}
152+
std::vector<cudaGraphNode_t> nodes(count);
153+
if (cudaGraphGetNodes(graph, nodes.data(), &count) != cudaSuccess) {
154+
return;
155+
}
156+
std::vector<void*> allocated;
157+
std::vector<void*> freed;
158+
for (cudaGraphNode_t node : nodes) {
159+
cudaGraphNodeType type;
160+
if (cudaGraphNodeGetType(node, &type) != cudaSuccess) {
161+
continue;
162+
}
163+
if (type == cudaGraphNodeTypeMemAlloc) {
164+
cudaMemAllocNodeParams params{};
165+
if (cudaGraphMemAllocNodeGetParams(node, &params) == cudaSuccess) {
166+
allocated.push_back(params.dptr);
167+
}
168+
} else if (type == cudaGraphNodeTypeMemFree) {
169+
void* ptr = nullptr;
170+
if (cudaGraphMemFreeNodeGetParams(node, &ptr) == cudaSuccess) {
171+
freed.push_back(ptr);
172+
}
173+
}
174+
}
175+
for (void* ptr : allocated) {
176+
if (std::find(freed.begin(), freed.end(), ptr) == freed.end()) {
177+
graph_allocations.push_back(ptr);
178+
}
179+
}
180+
#endif
181+
}
182+
183+
// Frees the captured graph, the static inputs pinned for it, and the
184+
// allocations its last launch left outstanding (see graph_allocations).
185+
void release() {
131186
if (graph_exec) {
132187
(void)cudaGraphExecDestroy(graph_exec);
188+
graph_exec = nullptr;
133189
}
134190
if (graph) {
135191
(void)cudaGraphDestroy(graph);
192+
graph = nullptr;
136193
}
137-
// Only free input buffers — output buffers are owned by the AOTI runtime
138-
// (allocated during graph capture via the caching allocator).
139194
for (auto* ptr : static_input_ptrs) {
140195
if (ptr)
141196
(void)cudaFree(ptr);
142197
}
198+
// cudaFree synchronises, so the last replay's reads of these are done.
199+
for (auto* ptr : graph_allocations) {
200+
(void)cudaFree(ptr);
201+
}
202+
graph_allocations.clear();
203+
static_input_ptrs.clear();
204+
static_output_ptrs.clear();
205+
static_input_nbytes.clear();
206+
static_output_nbytes.clear();
207+
}
208+
209+
// Drops the captured graph so a new one is captured, for when memory the
210+
// graph baked in has moved. One eager step first: rebinding constants resets
211+
// AOTI's constant-fold state, and the fold the next run performs cannot run
212+
// inside a stream capture. The kernels are already loaded, so one is enough.
213+
void recapture() {
214+
release();
215+
phase = CudaGraphPhase::Warmup;
216+
warmup_remaining = 1;
143217
}
144218

145219
// Non-copyable: prevent double-free of CUDA resources
@@ -155,22 +229,15 @@ struct CudaGraphState {
155229
static_input_ptrs(std::move(other.static_input_ptrs)),
156230
static_output_ptrs(std::move(other.static_output_ptrs)),
157231
static_input_nbytes(std::move(other.static_input_nbytes)),
158-
static_output_nbytes(std::move(other.static_output_nbytes)) {
232+
static_output_nbytes(std::move(other.static_output_nbytes)),
233+
graph_allocations(std::move(other.graph_allocations)) {
159234
other.graph = nullptr;
160235
other.graph_exec = nullptr;
161236
}
162237

163238
CudaGraphState& operator=(CudaGraphState&& other) noexcept {
164239
if (this != &other) {
165-
// Clean up existing resources
166-
if (graph_exec)
167-
(void)cudaGraphExecDestroy(graph_exec);
168-
if (graph)
169-
(void)cudaGraphDestroy(graph);
170-
for (auto* ptr : static_input_ptrs) {
171-
if (ptr)
172-
(void)cudaFree(ptr);
173-
}
240+
release();
174241

175242
phase = other.phase;
176243
warmup_remaining = other.warmup_remaining;
@@ -180,6 +247,7 @@ struct CudaGraphState {
180247
static_output_ptrs = std::move(other.static_output_ptrs);
181248
static_input_nbytes = std::move(other.static_input_nbytes);
182249
static_output_nbytes = std::move(other.static_output_nbytes);
250+
graph_allocations = std::move(other.graph_allocations);
183251

184252
other.graph = nullptr;
185253
other.graph_exec = nullptr;
@@ -190,6 +258,14 @@ struct CudaGraphState {
190258

191259
// CUDA-specific delegate handle that extends AOTIDelegateHandle.
192260
struct CudaDelegateHandle : public aoti::AOTIDelegateHandle {
261+
// AOTI's run() records a completion event on the stream and queries it at
262+
// the start of the next run. Captured into a CUDA graph, that record belongs
263+
// to the graph, so the first run() after the graph is dropped (to recapture
264+
// against moved KV storage) fails the query. The single-threaded entry point
265+
// skips the event, and a CUDA-graph method runs single-threaded by design.
266+
// Null when the .so does not export it; run() is used then.
267+
aoti::AOTInductorModelContainerRunFunc run_single_threaded{nullptr};
268+
193269
// Extra AOTI metadata used to validate per-FQN weights before binding.
194270
AOTInductorModelContainerGetConstantDtypeFunc get_constant_dtype{nullptr};
195271
// Bytes a constant's compiled shape spans; the off-graph KV cache checks its

‎backends/cuda/runtime/cuda_kv_cache.cpp‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,25 @@ class CudaSequenceKVCache final : public cache::SequenceCache,
503503
}
504504
metrics_.flat_capacity = new_rows;
505505
metrics_.growth_count++;
506+
// Every program sharing this cache now points at freed storage: drop the
507+
// bindings so each rebinds before its next run, and any captured CUDA
508+
// graph so it is captured again against the new storage. prefill usually
509+
// grows the cache while decode's graph sits idle, so this reaches every
510+
// handle, not only the one stepping now.
511+
//
512+
// Rebinding also resets AOTI's constant-fold state, which must be run
513+
// eagerly, so every graph-enabled handle gets at least one eager step
514+
// before it captures -- including one that was about to capture for the
515+
// first time, and without shortening a longer warmup still outstanding.
506516
bound_.clear();
517+
for (auto& entry : descriptors_) {
518+
CudaGraphState& graph = entry.first->cuda_graph_state;
519+
if (graph.phase == CudaGraphPhase::Replay) {
520+
graph.recapture();
521+
} else if (graph.phase == CudaGraphPhase::Warmup) {
522+
graph.warmup_remaining = std::max(graph.warmup_remaining, 1);
523+
}
524+
}
507525
ET_LOG(
508526
Info,
509527
"offgraph_kv: grew flat_capacity=%lld->%lld allocated_bytes=%lld "

0 commit comments

Comments
 (0)