perf(executorch): opt-in shared per-device activation-scratch pool - #4600
perf(executorch): opt-in shared per-device activation-scratch pool#4600Conarnar wants to merge 1 commit into
Conversation
A multi-layer model lowered to the ExecuTorch TensorRT delegate becomes N
separate single-layer engines, each with its own execution context. By default
every context allocates its own activation scratch (`getDeviceMemorySizeV2`
bytes) and holds it for as long as the context lives, so device memory scales
with the layer count and multi-layer models OOM at runtime. The scratch of one
engine need not sit alongside the scratch of the next, because the delegates of
a Method are submitted one at a time: `Method::execute()` advances `step_state_`
through the instruction stream one instruction at a time on the calling thread,
and a `DelegateCall` is one instruction. Their enqueues can still overlap on the
device, but that is orderable, whereas N resident copies are not.
Submission order is not stream order, though. That two consecutive delegates
land on the same stream is not a property of `Method`; it holds only because
both read the same thread-local caller stream, and a caller that runs two
Methods under two different `CallerStreamGuard` streams breaks it. So the pool
orders the handoff itself rather than relying on the stream.
Add an opt-in shared pool that backs all contexts on a device from one buffer:
- the `use_shared_activation_scratch` runtime backend option enables it. It is a
boolean, defaults to false, and is delivered with
`executorch::runtime::set_option("TensorRTBackend", options.view())`. With it
unset each context owns its private `kSTATIC` scratch, the delegate emits no
extra log output, and the only work it adds is one relaxed atomic load per
engine init and a bool test or two per `execute()`;
- when enabled, create each execution context with `kUSER_MANAGED` so it
allocates no scratch of its own (`initialize_engine_io`);
- in `execute()`, once the input shapes are bound, query the exact requirement
with `updateDeviceMemorySizeForShapes()`, grow a per-device pool to it, and
point the context at the current buffer via `setDeviceMemoryV2`.
The pool grows monotonically to the largest engine's need and syncs the device
before freeing a replaced buffer. N per-layer scratch copies collapse to one
(the (N-1)x duplication is reclaimed). Measured with TensorRT 11.2.1.2 and CUDA
13 on one 80GB NVIDIA PG509-210, in a CMake reference runner that also loads the
ExecuTorch CUDA/AOTI backend, reading `cudaMemGetInfo` after a `cudaFree(0)`
baseline, on a deterministic non-uniform fp32 input:
- four execution contexts of one engine holding two fp32 8-head attention blocks
over `[1,2048,512]` (285,212,672 B of scratch) go from 1188MB to 372MB,
3 x 272MB reclaimed;
- a single Method holding six one-block engines of the same shape, interleaved
with six CUDA delegates (281,018,368 B each), goes from 1656MB to 316MB,
5 x 268MB reclaimed.
Outputs are identical between the two modes in both cases.
Two consequences a caller feels once the option is on. The pool is never freed,
so a device keeps the largest scratch it was ever asked for until the process
exits, where per-context `kSTATIC` scratch is released with its context. And a
growth allocates the new buffer before releasing the old one, so both are
resident for that moment -- that ordering is what leaves the existing buffer
usable when an allocation fails.
An execution context's allocation strategy is fixed when the context is created,
so each engine captures the setting in effect at its own init and keeps it. A
later `set_option` decides what the engines loaded after it are built with and
changes nothing about the ones already running, so a `kSTATIC` context and a
`kUSER_MANAGED` context coexist in one process.
Why opt-in, not default-on: one buffer serves every context on a device, and a
context holds its scratch for the whole enqueue -- which under a
`CallerStreamGuard` can still be in flight when `execute()` returns -- so two
enqueues must never hold it at once. The pool records each enqueue on a
per-device event and makes the next one wait on it. An event, not the previous
stream: synchronizing on a destroyed stream handle crashes rather than returning
an error; CUDA recycles handle values, so two distinct streams can compare
equal; and the NULL stream is a legal caller stream that no stream-handle
sentinel can tell from "no previous user". Waiting from the stream that recorded
the event is already satisfied, so the single-stream case pays a host call and
no device stall. Not covered: concurrent same-device `execute()` on several
threads, because the pool mutex is released before either enqueue is submitted.
A default-on version needs the scratch keyed per stream instead of one buffer
per device.
This is orthogonal to weight streaming (pytorch#4336), which targets engine *weight*
memory rather than activation scratch, and to export-time OOM.
The grow/reuse/per-device policy and the handoff rule are factored into a
header-only helper (`SharedScratchPool.h`) so they are unit-tested without a
device (fake allocator, fake event factory). The CUDA path supplies the three
callables it takes -- `cudaMalloc`, `cudaFree` and `cudaEventCreateWithFlags`;
the `cudaStreamWaitEvent` and `cudaEventRecord` half of the handoff is the
caller's, issued in response to what the helper returns. The helper needs the
`cudaEvent_t` typedef, which is a compile-time dependency and not a runtime one;
the repo had no headers-only CUDA target, so `third_party/cuda/BUILD` gains one
rather than the helper depending on `cudart` and putting `libcudart` in the
DT_NEEDED of a test that makes no CUDA call. `set_option` itself is not
unit-tested, because no target in `tests/cpp/executorch/` links the backend.
Three behaviours live only there: skipping a key this backend does not read,
storing a valid boolean, and rejecting a non-boolean with
`Error::InvalidArgument` instead of dropping it silently. The store is exercised
by the measurement above, which reaches the pool through `set_option`; the key
skip and the wrong-type rejection are covered nowhere, as `CudaBackend`'s
equivalents also are.
shoumikhin
left a comment
There was a problem hiding this comment.
took a proper look at this, most of it against 11.2.1.2 since that's the pin in MODULE.bazel. the design holds up and the event handoff is doing real work. one blocking thing on the zero path, rest is smaller stuff.
two things i chased that turned out to be fine, noting them so nobody else burns time on them: the pool does cover weight streaming scratch (updateDeviceMemorySizeForShapes tracks getDeviceMemorySizeV2 exactly, scratch included, checked with the budget moved around), and a caller stream from a green context records on the per-device event without complaint and actually orders the work. no concerns on either.
| // called on one. | ||
| bool scratch_from_pool = false; | ||
| if (engine->shared_scratch) { | ||
| const size_t need = ctx->updateDeviceMemorySizeForShapes(); |
There was a problem hiding this comment.
this treats the error sentinel as a real answer, and the argument in the comment only covers the first call.
tested against 11.2.1.2:
- first call with nothing set,
enqueueV3does refuse. so the comment is right about that case. setDeviceMemoryV2(nullptr, 0)is itself rejected ("Cannot set memory to nullptr"), and it returns void, so the failure is invisible to us.- on a later call the context silently keeps its previous pointer and
enqueueV3returns true.
that previous pointer can be freed memory. once another engine grows the pool you cudaFree the old buffer, so a spurious 0 here runs the engine against a dead allocation. i reproduced it: run once with a good buffer, free it the way the release lambda does, let an unrelated cudaMalloc take the address, then hit the zero path. all 4194304 bytes of the unrelated allocation got overwritten, and enqueueV3 still returned true with a correct output.
the zero branch also skips get_or_grow_shared_scratch entirely, so you lose the wait and the in-flight mark in the same step.
simplest fix is to return an error on 0.
| // 4. Enqueue inference on the current CUDA stream | ||
| // 4. Back activation scratch with the shared per-device pool | ||
| // ------------------------------------------------------------------ | ||
| // All input shapes are bound by now, so the exact scratch requirement for this |
There was a problem hiding this comment.
"the exact scratch requirement for this call" is only true with kRUNTIME_ACTIVATION_RESIZE_10_10 on, and nothing here enables it (we only set MULTIDEVICE_RUNTIME_10_16).
measured on 11.2.1.2: preview off, a query at batch 64 under a profile max of 256 returns exactly the profile-max size. preview on, same engine returns 8192 at batch 1 vs 33554432 at batch 4096.
not a safety issue since it oversizes, but the pool ends up sized to the profile max rather than to the call, and that's most of the savings story.
| // cudaMalloc, cudaFree and cudaDeviceSynchronize all act on the *current* device | ||
| // and nothing in here sets it. | ||
| Error get_or_grow_shared_scratch(int device_id, size_t need, cudaStream_t stream, void*& out_ptr, size_t& out_size) { | ||
| std::lock_guard<std::mutex> lk(scratch_pool_mu); |
There was a problem hiding this comment.
scratch_pool_mu is one mutex for all devices and it's held for the whole function, including cudaMalloc and the release lambda's cudaDeviceSynchronize + cudaFree.
so a growth on device 0 blocks a plain claim on device 1, which only needs its own map slot. the README says concurrent execute on different devices is fine, and that stops being true during a growth. the sync is unbounded as well, it waits on everything queued on the device, not just the scratch users.
per-device lock would fix both scopes, or move the cuda calls out from under the map lock.
| backend is registered under that name, which is what a binary that has not linked | ||
| the backend archive gets. | ||
|
|
||
| N per-engine copies collapse to one, so the reclaimed memory is `(N-1)` times the |
There was a problem hiding this comment.
this only holds when every engine needs the same amount. the pool grows to max(s_i) and never shrinks, so the saving is sum(s_i) - max(s_i). for engines needing 1, 2 and 4 units that's 3, not 8.
the numbers in the description used uniform engines so it wouldn't show up there. the commit message has the same claim.
| // it from the stream that recorded it is already satisfied, so the common | ||
| // single-stream case costs a host call and no device stall. | ||
| template <typename CreateEvent> | ||
| SharedScratchHandoff shared_scratch_claim_event( |
There was a problem hiding this comment.
both helpers mutate the caller's map with no locking, and the header never says the caller has to serialize. the backend gets away with it by holding scratch_pool_mu, but this header goes out in executorch_api_headers, so it's API and the next caller won't know.
either document the precondition, or wrap the maps in a type that owns the lock, or keep the header private.
| EXPECT_EQ(out, 1024u); | ||
| } | ||
|
|
||
| TEST(SharedScratchPool, FirstAllocationFailureReturnsNullAndStoresNothing) { |
There was a problem hiding this comment.
name says stores nothing, but pool[device_id] default-inserts before alloc runs, so there is an entry, just one with a null pointer. the test only checks the return value and the retry, so it passes either way.
the header comment ("the slot is then left untouched") says the same thing. EventCreationFailureIsReportedAndRetried has the identical gap on the markers map. either narrow the names or assert pool.empty() and don't insert until the alloc succeeds.
| ], | ||
| ) | ||
|
|
||
| cc_test( |
There was a problem hiding this comment.
this only depends on tensorrt_executorch_shared_scratch_pool, never on the backend, so nothing here covers the wiring. i can revert the context to kSTATIC, or delete the updateDeviceMemorySizeForShapes/setDeviceMemoryV2 pair, or drop the wait/record calls, and all 11 tests stay green.
the description calls out the missing set_option and concurrency tests, but not that the plain single-threaded path has no automated coverage at all.
| ::executorch::runtime::DelegateHandle* handle, | ||
| ::executorch::runtime::Span<::executorch::runtime::EValue*> args) const override; | ||
|
|
||
| // Applies the runtime backend options a caller passes to |
There was a problem hiding this comment.
while you're in this header, the execute() comment just above still ends with "calls on one handle must not overlap each other or its destruction". the pool adds a stronger rule (no two handles on the same device may overlap) and that only lives in the README right now. this is the installed header, so it should carry it.
Description
A multi-layer model lowered to the ExecuTorch TensorRT delegate becomes N separate
single-layer engines, and by default every execution context allocates its own activation
scratch and holds it for as long as the context lives. Device memory therefore scales with
the layer count, and multi-layer models OOM at runtime on the layer count alone.
This adds an opt-in shared per-device pool that backs every context on a device from
one buffer, sized to the largest engine's requirement. It ships disabled: with
use_shared_activation_scratchunset, each context keeps its privatekSTATICscratch,no extra log output is emitted, and the only added work is one relaxed atomic load per
engine init and a bool test or two per
execute().That default path is byte-identical to a binary built from
origin/mainover 18 runs,including canonicalised stderr — the real regression risk, given the feature ships off.
Measured on one 80GB A100 with TensorRT 11.2.1.2 and CUDA 13, reading
cudaMemGetInfoafter a
cudaFree(0)baseline:[1,2048,512]: 1188MB → 372MB, 3 × 272MB reclaimed1656MB → 316MB, 5 × 268MB reclaimed
Outputs are identical between the two modes in both cases. The commit message carries the
full rationale, the ordering argument, and the two consequences a caller feels once the
option is on.
No dependencies; this does not stack on anything. It is orthogonal to weight streaming
(#4336), which targets engine weight memory rather than activation scratch. It composes
with the zero-copy KV work if that lands too — measured together on the same model, with
byte-identical generated ids and no overlapping hunks.
Where I would spend review attention
The enqueue handoff, not the allocation. The pool itself is simple; the ordering is
the part with teeth. Contexts share one buffer while their enqueues can still be in
flight, so each device slot carries a pool-owned
cudaEvent_t: wait on it beforeenqueueing, record after. An earlier revision tracked the last stream instead and was
wrong three ways — synchronizing a destroyed stream segfaults rather than returning an
error, CUDA recycles stream handle values so two distinct streams compare equal, and the
NULL stream is a legal caller stream indistinguishable from "no previous user". All three
are structural with an event, and
~EngineHandlein the same file had already made thischoice and documented why.
The per-handle capture. A context's allocation strategy is fixed at creation, so each
EngineHandlerecords the setting in effect at its owninit()andexecute()consultsthat, never the global. That is what lets a later
set_optiongovern only subsequentengines and lets pooled and private-scratch contexts coexist, with no freeze and no
rejected calls.
third_party/cuda/BUILDgains a target, the one file outside the delegate. The headerneeds the
cudaEvent_ttypedef — a compile-time dependency, not a runtime one — and therepo had no headers-only CUDA target. Depending on
cudartinstead putlibcudartin theDT_NEEDEDof a host-side test that makes no CUDA call, and broke it with exit 127.Known gaps
set_optionis not unit-tested. No target intests/cpp/executorch/links thebackend. Three behaviours live only there — skipping a foreign key, storing a valid
boolean, rejecting a non-boolean. The store is exercised by the memory A/B; the other two
are covered nowhere.
CudaBackend's equivalents are equally uncovered. Closing this needsa new backend-linked
cc_test, which I have not added.execute()on one device is not covered, and the code says so. Therequirement is that all delegate enqueues on a device are ordered on one stream; a
default-on version would need the scratch keyed per stream rather than one buffer per
device.
crash was reproduced through the delegate; handle recycling and the NULL-stream collision
were measured at the CUDA level. Corruption from a missing wait was never reproduced
through a real engine, on either design.
growth policy is exercised), and weight streaming combined with the pool.
Type of change
Checklist: