Skip to content

perf(executorch): opt-in shared per-device activation-scratch pool - #4600

Open
Conarnar wants to merge 10 commits into
pytorch:mainfrom
Conarnar:perf/executorch-shared-scratch-pool
Open

perf(executorch): opt-in shared per-device activation-scratch pool#4600
Conarnar wants to merge 10 commits into
pytorch:mainfrom
Conarnar:perf/executorch-shared-scratch-pool

Conversation

@Conarnar

@Conarnar Conarnar commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

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 the activation scratch of every context created while the option is on from one buffer, grown to the largest figure any call on that device has asked for. It ships disabled: with use_shared_activation_scratch unset, each context keeps its private kSTATIC scratch, 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(). A context created while the option was off keeps its own scratch and is outside the pool entirely, and so is one whose engine reports needing no activation scratch under any shape.

How much any one engine asks for is decided when the engine is built, not when it runs. Whatever updateDeviceMemorySizeForShapes() answers is binding rather than advisory — setDeviceMemoryV2 refuses a smaller buffer, and an engine backed by less than it asked for writes past the end — but whether an engine reports what the shapes just bound need or reports its profile maximum depends on how TensorRT planned it. The builder's PreviewFeature::kRUNTIME_ACTIVATION_RESIZE_10_10 produces the former; without it either can happen. So the pool can settle well above the live data, and nothing a runtime does changes that.

That default path was compared against a binary built from the commit this PR branches from, over nine runs (three models, three repetitions each): canonicalised stderr identical every time, at 45 / 45 / 17 lines. That stderr is the whole of the evidence — this harness writes nothing to stdout — and the canonicaliser normalises timestamps, pointers, source line numbers and the harness's own device-wide memory reading, which moves by a tenth of a megabyte between two runs of the same binary. The control, the same binary with the option on, differs at 47 lines against 45, so the comparison can fail.

Measured on one 80GB A100 with TensorRT 11.2.1.2 and CUDA 13, reading cudaMemGetInfo after a cudaFree(0) baseline. N per-engine copies collapse to one, so what is reclaimed is the sum of the N per-engine requirements less the largest of them:

  • four execution contexts of one engine holding two fp32 8-head attention blocks over [1,2048,512]: 1188MB → 372MB. Uniform engines, so that sum-less-largest is 3 × 272MB.
  • one Method holding six one-block engines of the same shape interleaved with six CUDA delegates: 1656MB → 316MB. Also uniform: 5 × 268MB.
  • the same shape of Method with its six engines at differing sizes: 792MB → 316MB. Sum 780,140,544 B less the largest 281,018,368 B is 476.0MB, which is what the A/B reads.

Outputs are identical between the two modes in every case.

Two consequences a caller feels once the option is on. The pool never shrinks — a growth does free the buffer it replaces, but the high-water mark never comes down — 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. A third, the device-wide wait that same growth's cudaFree makes, is the first of the known gaps below; it is the one a caller is most likely to feel, and it is documented rather than fixed.

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 the two branches still merge without a conflict.

Where I would spend review attention

This is eight commits: the pool itself; a second answering the first review — the zero-scratch guard, per-device locking in place of one process-wide mutex, and a backend-linked test target; a third answering the second — the device lock held across the enqueue, the retired-buffer handling, and a concurrency regression test; a fourth answering the third — the pool header out of the installed set, and the growth-frequency claims corrected; a fifth — cudaEventBlockingSync on the handoff event and three prose corrections; a sixth — the zero-answer sizing below, the stream-capture refusal, the cudaDeviceReset() note, and a fixture that empties the pool between tests, with a unit test for the hook it does that through; a seventh — the install of that buffer read back so one TensorRT refused never reaches enqueueV3, the capture guard moved ahead of everything a capture cannot take, the pool made a leaked singleton, and the reset hook's disposer taken out from under the locks; and an eighth — the capture advice corrected (it had said loading with the option off is enough to capture, which is not what CUDA does), TORCHTRT_EXECUTORCH_REQUIRE_CUDA=1 actually passed by the CI invocation, the pool's two test-only entry points moved into a testonly translation unit so a release build exports neither, and a discriminating test for each of the three fixes the seventh commit had shipped without one.

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 before enqueueing, 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 ~EngineHandle in the same file had already made this choice and documented why.

A reported zero is ambiguous. updateDeviceMemorySizeForShapes() answers zero for three different things: a query that failed, a call whose bound shapes need no activation scratch — a valid empty input on a dynamic profile, which an earlier revision rejected outright — and an engine that needs none under any shape. Only the last of the three can be told apart before any call is made, and it is told apart once, at init: each engine records whether ICudaEngine::getDeviceMemorySizeV2() is non-zero, and one that reports zero is left out of the pool entirely, so it claims no per-device lock and does not serialize against the engines that do.

The other two arrive at execute() indistinguishable where the query is read, and neither can be handed nothing. setDeviceMemoryV2(nullptr, 0) is itself refused (condition: memory != nullptr || expectedSize == 0), so a context handed a zero keeps whatever buffer it last held, which a pool growth may already have freed — forcing that path reproduces an illegal memory access. And enqueueV3 refuses a kUSER_MANAGED context with no device memory installed as soon as the engine reports needing some under any shape, however little the shapes actually bound need: Parameter check failed, condition: noDeviceMemory. So neither is asked to size the pool: a zero is handed whatever the pool already holds, at its current size, which grows nothing. Only against a pool holding nothing does it allocate, and then one byte, leaving the first call with a real requirement to size the pool. The obvious alternative, standing the engine's own getDeviceMemorySizeV2() in for the zero, satisfies enqueueV3 too but covers the whole profile: on the dynamic engine the tests build that is 4 MiB against the 128 KiB the next call needs, and since the pool never shrinks a single empty batch would pin it at 32x for the process lifetime — on the path the pool exists to keep small.

Only one of those two is safe with a buffer that small, and the install is what separates them. Where the bound shapes genuinely need nothing the engine expects nothing, and one byte is accepted. Where the query failed the engine still expects what it always did, setDeviceMemoryV2 refuses the undersized buffer — and it returns void, so a caller that does not ask cannot tell the two apart. Measured on TensorRT 11.2.1.2 against this branch's own two-softmax net (expecting 8388608 bytes): install one byte and enqueueV3 returns true, cudaStreamSynchronize reports no error, the output matches byte for byte, and the engine writes the buffer the context was last given. Free that buffer, let the next cudaMalloc take the address, and 4090 of 4096 bytes of an unrelated allocation are overwritten with nothing reporting an error anywhere. So the install is read back, through a TensorRT IErrorRecorder attached for the duration of that one call: a correct install records nothing, an undersized one records kINVALID_ARGUMENT naming both sizes, and the genuinely-empty case — expected size zero, one byte installed — records nothing and keeps working. A refusal ends the call with Error::InvalidState and nothing reaches enqueueV3. The check runs on every pooled install, not only on those following a zero, so "no path enqueues on an install TensorRT rejected" is a property of the code rather than of an argument about when a zero can occur — and because the refusal is observable, the engine's profile-wide figure never has to be installed and the pool is still never pinned at the profile maximum.

Two hazards the pool does not absorb. Capturing a CUDA graph from the stream a pooled execute() runs on is refused with Error::NotSupported. The handoff between one enqueue and the next is a wait on an event recorded outside the capture, and cudaStreamWaitEvent on such an event fails with cudaErrorStreamCaptureIsolation and invalidates the capture under every capture mode, cudaStreamCaptureModeRelaxed included — the caller would learn of that only from cudaStreamEndCapture returning 901 and a null graph far from the cause. That wait, not the allocation, is what produced the 901 when the guard was removed and measured: the pool was already large enough, so no growth ran. A growth is mode-dependent by comparison — its cudaMalloc, its cudaEventSynchronize and its cudaFree each return cudaErrorStreamCaptureUnsupported and invalidate the capture under the Global and ThreadLocal modes, while Relaxed permits all three and then runs them uncaptured, leaving a replay pointed at a buffer the pool may since have freed. Measured directly against CUDA 13.0 on this device, one process per cell, rather than read off the documentation. The check is one cudaStreamIsCapturing on the selected stream, and it runs ahead of everything execute() does that a capture cannot take — not merely ahead of the pool's own calls, since the host wait on a previous enqueue and the cudaMalloc that grows a host-input staging buffer come before those and, outside Relaxed, would invalidate the capture first. Only the device query and the device switch run earlier, and a capture takes both. The pageable copy that follows the staging allocation is not one of the calls the guard has to get ahead of: on its own it is captured cleanly at 4 KiB and 64 MiB in all three modes, and replaying execute()'s own order it returns cudaErrorStreamCaptureInvalidated rather than the cudaErrorStreamCaptureUnsupported a prohibited call returns — that is the error for touching a capture the two calls before it had already killed, not for being one of them.

Loading the engine with the option off takes the pool's calls out of the way, and the refusal with them, but not the rest of what a capture cannot take. Under Global and ThreadLocal the wait on a previous enqueue and the staging cudaMalloc are still prohibited, and under every mode, Relaxed included, so is the cudaStreamSynchronize that ends any call staging through host memory, aliasing an output, or running with no caller stream — a capturing stream cannot be synchronized at all. So a call with the option off captures cleanly when it runs on the capturing stream under a CallerStreamGuard, binds only non-empty device-resident tensors, aliases no output, and follows no call that left an enqueue in flight. That is measured through the delegate rather than argued, and so is its consequence: such a call leaves its own enqueue in flight, so under Global and ThreadLocal the next one on the same handle fails the last condition. With the option off a handle is good for one captured call, not for capture. Two tests pin the two outcomes. Moving the guard out of the pooled branch, so that the refusal covered this path too, would refuse exactly the calls that do capture, for contexts that never touch the pool — mutating it to be unconditional fails both of those tests — so it stays where it is and the README carries the rule.

What the check cannot cover is a capture live on some other stream: under Global, and under ThreadLocal from the calling thread, the same calls invalidate that capture and the call is not refused. Asking cudaStreamIsCapturing(cudaStreamLegacy) instead does not close the gap — measured, it reports a capture on another stream only when that stream is a blocking one, and then reports it under Relaxed and under another thread's ThreadLocal too, where the pool's calls are permitted and refusing would be wrong. CUDA offers no "is a capture live in this process" query, so the rule is the caller's to keep, and the installed header and the README say that rather than promising more. cudaDeviceReset() is documented rather than guarded, in the installed header and the README: the pool holds its buffer and its handoff event for the process lifetime and a reset destroys both, and catching that would mean revalidating both on every call, which costs as much as the work it protects.

The lock scope, and how far it now reaches. The registry that finds a device's entry has one lock, held for a single find-or-insert with no CUDA call under it. The device's own lock is held from the claim through setDeviceMemoryV2, the enqueue, and the record of that enqueue on the handoff event — releasing it any earlier leaves an enqueue live in a window the event does not yet cover, and a second claimant entering that window is handed the same buffer with nothing ordering the two. That produced silently wrong output on every trial before the fix. The lock nests inside the per-handle EngineHandle::mu, which already spans the enqueue, and is never taken in the other order. Entries are never erased and std::unordered_map keeps references valid across rehashing, which is what lets the registry lock be dropped before the entry is used.

The per-handle capture. A context's allocation strategy is fixed at creation, so each EngineHandle records the setting in effect at its own init() and execute() consults that, never the global. That is what lets a later set_option govern only subsequent engines and lets pooled and private-scratch contexts coexist, with no freeze and no rejected calls.

SharedScratchPool.h is not installed. It was in executorch_api_headers for no reason: TensorRTBackend.h never included it and named the option key only in comments, so the installed set carried a mutex, a per-device registry and three functions whose only locking rule is a sentence, to deliver one string constant. The key now sits beside set_option, and the header moved to cpp/src/torch_tensorrt/executorch/. Dropping it from the package outright would have broken the standalone archive — libtorchtrt.tar.gz ships TensorRTBackend.cpp as buildable source and that source includes the header — so it moves rather than leaves. Every header remaining in cpp/include/ is installed, which is the invariant the original placement broke. The same question applies to symbols, not just headers: the pool's two test-only entry points — one of which frees the live pool with no wait for work in flight — were defined in TensorRTBackend.cpp and so exported from the archive the README tells a consumer to build. They now live in a SharedScratchPoolTestHooks translation unit that only a testonly Bazel target compiles, and nm/readelf on the release object shows neither symbol.

third_party/cuda/BUILD gains a target, the one file outside the delegate. The header needs the cudaEvent_t typedef — a compile-time dependency, not a runtime one — and the repo had no headers-only CUDA target. Depending on cudart instead put libcudart in the DT_NEEDED of a host-side test that makes no CUDA call, and broke it with exit 127.

Known gaps

  • A growth stalls on everything queued on the device, and that is documented rather than fixed. The wait before freeing a replaced buffer is on the per-device handoff event, but cudaFree performs its own device-wide synchronization, so the free waits for every stream on the device and not only for the enqueues that used that buffer. It is the only call on the growth path that does: with a host function parked on a stream neither the pool nor the growing engine had ever used, the cudaMalloc for the new buffer and the wait on the retired one each returned at once and the cudaFree returned after 3000 ms, exactly when that host function was released. The free is issued outside the device lock, so it does not hold up another engine's claim, but it does fall after the growing call's own enqueue — that one execute() waits for its own engine work. The wait has no upper bound and a caller can make it permanent: if some stream on the device is waiting on work only this thread submits after execute() returns, the growing call never comes back, and the thread that would unblock it is the one inside cudaFree. The backend's own tests deadlocked on exactly that once, when a change of test order made a case that parks a host function on its own stream be the one that grew the pool. Neither fix on offer is local. cudaFreeAsync would bound the wait to the buffer's own users but pairs only with cudaMallocAsync, so it moves every pool allocation onto the stream-ordered allocator — the redesign an earlier round considered and rejected. Deferring the free to a point where the wait is already paid has no such point: the two calls before it on the same path each return immediately, so deferral moves the wait rather than bounding it. So the installed header and the README state it as a caller obligation instead. How often it happens depends on the engine. execute() re-queries the requirement on every call once the shapes are bound, so an engine whose answer does not vary with those shapes grows the pool at most once, on its first run — a program built only from those settles after its largest engine has run once. An engine whose answer does vary can grow it on any call needing more than every call before it, and with one of those in the program nothing bounds the number of allocations. Note this is run order, not load order: loading an engine allocates nothing.
  • The option cannot be enabled from Python or from a .pte. It arrives only through the C++ set_option; there is no load-time runtime spec and no compile-spec fallback of the kind weight_streaming_budget has. Deferred rather than built here.
  • The backend-linked test needs a real GPU, and the job that would give it one rarely runs. tests/cpp/executorch/test_shared_scratch_backend.cpp is the only target in that package that links the delegate, and it is what covers set_option, the per-engine capture, the pooled path, the install check, the capture refusal and the two-thread concurrency regression. Without a device it skips all nineteen of its cases and exits zero, which Bazel reports as a passing target: measured with CUDA_VISIBLE_DEVICES='', 19 of 19 skipped and exit 0. The suite prints how many cases it skipped and why, and TORCHTRT_EXECUTORCH_REQUIRE_CUDA=1 turns a skip into a failure — same binary, same empty device list, exit 1 with 19 failures. The CI invocation now passes that variable, and the job it sits in already asks for a GPU runner and starts its container with --gpus all. What keeps these cases off most runs is not the runner but the lane gate on the whole ExecuTorch job, below.
  • The zero-answer path is covered, but not by inducing a real query failure. AnEmptyInputRunsWithThePoolEnabled reaches it through a legitimate empty input on a dynamic profile and reads the pool's capacity at three points, pinning the first of them to the one-byte minimum exactly, which kills a zero handed no buffer at all, a zero sized from the engine's profile-wide figure, and a minimum raised to anything the next call's requirement would still bound. AnUndersizedScratchInstallIsSeenAsRefused then drives the same TensorRT call execute() makes over a live kUSER_MANAGED context: a correct install is accepted, a one-byte install for an engine expecting more is refused, and the empty-batch context still accepts one byte. A genuine failed updateDeviceMemorySizeForShapes() cannot be induced from inside the process — the size execute() installs is the pool's capacity, which is never below what the query just returned — so what is pinned end to end is the check that catches its consequence rather than the cause itself. That execute() still goes through the checked install is pinned separately by ExecuteInstallsPooledScratchThroughTheCheckedHelper, which leaves a counting IErrorRecorder on the delegate's own context and asserts it is replaced and restored exactly once per pooled run; replacing the checked call with a bare setDeviceMemoryV2 takes both counts to zero and no other case notices. What no test pins is the refusal reaching a caller as Error::InvalidState, because the refusal cannot be reached. The neighbouring mistakes are covered too: an engine kept in the pool when it needs no scratch under any shape is killed by AnEngineNeedingNoActivationScratchRunsWithThePoolEnabled, and a missing init capture by EachEngineRecordsWhetherItNeedsActivationScratch.
  • A failed cudaEventSynchronize on a replaced buffer fails the call, and that path is not tested. It leaks the buffer rather than freeing it under a possibly-live enqueue, and returns Error::InvalidProgram instead of enqueueing against a context on a device already in a faulted state. Neither a failed event wait nor a failed cudaFree can be induced from inside the suite without corrupting the device for every case after it.
  • cudaDeviceReset() is documented, not tested. Reproducing it would destroy the primary context for every test that ran after it in the same process, so the hazard is argued in the installed header and the README rather than exercised. The stream-capture refusal beside it is tested, twice: APooledEngineRefusesToRunWhileItsStreamIsCapturing under Relaxed, and APooledEngineRefusesACaptureBeforeAnythingCanInvalidateIt under the default Global, which is the one that can see where in execute() the refusal comes. Two more cover the path the refusal points a caller at: AnUnpooledEngineCapturesOnAFirstCallThatStagesNothing and AnUnpooledEngineInvalidatesACaptureOnceAnEnqueueIsInFlight, both under Global. What none of the four covers is Relaxed with the option off, where the wait on a previous enqueue is permitted and runs outside the graph.
  • The ExecuTorch runtime CI job has been skipped on every run at this head, gated on a channel that concurrency kept cancelling, and the gate ignores skipped channels — so every number here was local. tests/cpp/executorch carries 4 cc_test targets and 40 cases at the merge base with main; this PR takes it to 6 targets and 79 cases, the two new targets being test_shared_scratch_pool (20) and test_shared_scratch_backend (19). All 6 targets and all 79 cases have been compiled and run under bazel on a real A100 with --nocache_test_results and with TORCHTRT_EXECUTORCH_REQUIRE_CUDA=1 set, so a skip would have been a failure; nothing skipped and all pass. The lane itself still needs one clean run.
  • The stream-handle hazards are argued, not reproduced end to end. The destroyed-stream 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.
  • Not measured: the ~210-engine target model (the saving is linear by construction and the growth policy is exercised), and weight streaming combined with the pool end to end.

Type of change

  • New feature (non-breaking change which adds functionality)
  • This change requires a documentation update

Checklist:

  • My code follows the style guidelines of this project (You can use the linters)
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas and hacks
  • I have made corresponding changes to the documentation
  • I have added tests to verify my fix or my feature
  • New and existing unit tests pass locally with my changes
  • I have added the relevant labels to my PR in so that relevant reviewers are notified

@meta-cla meta-cla Bot added the cla signed label Aug 26, 2026
@github-actions github-actions Bot added component: tests Issues re: Tests component: api [C++] Issues re: C++ API labels Aug 26, 2026

@shoumikhin shoumikhin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, enqueueV3 does 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 enqueueV3 returns 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.
It is actually possible for the engine to require 0 activation scratch so an extra check for that case is also needed.

// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

// 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

// 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, with your first two suggestions. The header could not be made private since TensorRTBackend.h needs it for set_option.

EXPECT_EQ(out, 1024u);
}

TEST(SharedScratchPool, FirstAllocationFailureReturnsNullAndStoresNothing) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

],
)

cc_test(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

::executorch::runtime::DelegateHandle* handle,
::executorch::runtime::Span<::executorch::runtime::EValue*> args) const override;

// Applies the runtime backend options a caller passes to

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

@shoumikhin shoumikhin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pool design reads well. Three things.

Two pooled engines running at once on one device give wrong numbers, silently. The device lock is released when get_or_grow_shared_scratch returns, so setDeviceMemoryV2 and enqueueV3 run without it. The new enqueue is only recorded on the event afterwards. A second thread claiming in that gap gets the same buffer. That thread does wait on the event, but the event carries an earlier enqueue, not the one now in flight. So the wait does not order the two against each other, and both engines write the same scratch.

I tried this with two real engines sharing one buffer. On every trial one engine's output was wrong, with no CUDA error and no TensorRT error. The written caveat points at growing the pool and freeing the buffer. This needs neither, so it is the normal state once the pool has settled.

Holding the device lock from the claim through the enqueue and the event record fixed it every time. It looks safe here: engine->mu is already held across enqueueV3 today, and core/runtime/execute_engine.cpp already puts a mutex around the enqueue and says the other context calls belong in the same scope.

The option cannot be turned on from Python or from a .pte. It only arrives through the C++ set_option. weight_streaming_budget handles this in the same file with a load-time runtime spec plus a compile-spec fallback.

The release lambda frees the old buffer even when the cudaEventSynchronize before it failed, which is the one case the wait exists to prevent.

One note: the ExecuTorch CI jobs are skipped on this commit because the matrix job was cancelled, so the two new test files have not run yet.

Happy to share the harness or the measurements.

// sets it.
Error get_or_grow_shared_scratch(int device_id, size_t need, cudaStream_t stream, void*& out_ptr, size_t& out_size) {
SharedScratchDevice& dev = scratch_pool.get(device_id);
std::lock_guard<std::mutex> lk(dev.mu);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This lock ends when the function returns, so setDeviceMemoryV2 and enqueueV3 run without it, and the enqueue is only recorded on the event after that. Nothing else serializes two pooled engines on one device, since engine->mu is per handle.

So a second thread can claim inside that gap and get the same buffer back, because the reuse branch returns the existing buffer untouched whenever the capacity fits. It does call cudaStreamWaitEvent, but the event carries an earlier enqueue rather than the one now in flight, so the wait does not order the two against each other. Both engines then write the same scratch.

I tried this with two real engines sharing one buffer. On every trial one engine's output was wrong, with no CUDA error and no TensorRT error. Nothing grows and nothing is freed, so this is the ordinary state once the pool has settled on its largest size. The caveat in the comment above, and in the README, points at growth freeing a live buffer, which is the case that did not corrupt in my testing: cudaFree is implicitly synchronizing, and I measured it blocking for the whole remaining kernel. So the documented hazard is the survivable one and this one is not mentioned.

In a harness following the same call order, holding the claim through the enqueue and the event record fixed it every time. That means execute() owning the device lock across setDeviceMemoryV2, enqueueV3 and the mark, not a local change here. It looks safe: engine->mu is already held across enqueueV3 in execute() today, and core/runtime/execute_engine.cpp already puts a mutex around the enqueue and says the other context calls belong in the same scope. Worth checking the cost for a weight streaming engine, where the header says enqueueV3 becomes synchronous.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Checked the weight-streaming cost you flagged: at budget 0, enqueueV3 took 0.439 ms against a 75.7 ms inference, and a second context's enqueue issued mid-stream returned in 0.267 ms. Didn't reproduce.

// pool rather than allocating its own.
//
// execute() must read EngineHandle::shared_scratch, never this.
std::atomic<bool> scratch_enabled{false};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This global is the only way in, and the only writer is the C++ set_option, so a program loaded from Python has no way to turn the pool on. A hand written compile spec for the key would not help either, since init() only looks for the weight streaming key.

weight_streaming_budget in this same file takes a load time runtime spec first and falls back to a compile spec baked in at export, and the comment there explains that the compile spec exists for loaders that cannot pass backend options yet. The new option has neither channel.

Not a blocker, since the C++ path works and the feature is off by default. But the users who hit the memory problem this solves are often the ones loading a .pte.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred. There's no Python binding for set_option so for this to be done in a follow-up, the only route would be through compile spec during export.

},
[](void* old, cudaEvent_t wait_for) {
if (wait_for != nullptr) {
cudaEventSynchronize(wait_for);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The result is discarded and cudaFree(old) runs on the next line either way. If the wait fails, the free is the exact thing the wait exists to prevent, so an enqueue may still be reading that buffer. Returning early instead would leak that one replaced buffer, which is a bounded cost.

Also worth knowing that no test reaches this path: every pooled engine in the backend test asks for the same size, so the reuse branch is always taken and the pool never grows. One extra engine with a larger shape would cover both the free and the wait.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The larger engine covers the allocation and the free, but not the wait — cudaFree synchronizes device-wide anyway, so deleting the wait still leaves the test green. The comment says so rather than claiming it.

if (wait_for != nullptr) {
cudaEventSynchronize(wait_for);
}
cudaFree(old);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swapping the device sync for an event wait does not remove the device-wide wait, because cudaFree performs one itself. On an H100 with the handoff event already complete and unrelated work queued on another stream, the event wait returned in under a millisecond and this cudaFree took about 1.5 seconds, matching the queued work. It also runs with the device lock held, so a claim on that device waits behind unrelated work too.

Either move the free outside the lock and keep a retire list, or say in the commit message and the README that a growth still stalls on everything queued on the device.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

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 A100, 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.
…zero-size query, cover the pooled path

Four changes to the shared activation-scratch pool the parent commit added.

A zero from `updateDeviceMemorySizeForShapes()` is ambiguous: TensorRT answers a
failed query and an engine that genuinely needs no activation scratch the same
way. The parent treated it as "no scratch needed" and carried on, but
`setDeviceMemoryV2(nullptr, 0)` is itself rejected and returns nothing to test,
so the context kept whatever buffer it last held -- which a pool growth may
already have freed. Forcing that path against a freed buffer reproduces an
illegal memory access: the enqueue is submitted, and the fault surfaces at the
stream synchronize that follows it. Each engine now records what
`ICudaEngine::getDeviceMemorySizeV2()` reports at its own init, and a zero fails
the `execute()` only when that recorded requirement is non-zero. An engine that
needs no scratch is given no buffer, so it has nothing to claim and nothing for
the next claimant to order against.

The pool's state and its lock are now per device. The parent held one
process-wide mutex across `cudaMalloc` and across a `cudaDeviceSynchronize` in
the release path, so a growth on one device blocked a claim on another, and the
sync waited on everything queued rather than on the scratch users. A registry
lock now finds a device's entry and is held for the lookup alone, never across a
CUDA call; the device's own lock covers the claim, the allocation and the wait
before a free; and that wait is on the handoff event rather than the device.
Entries are never erased and `std::unordered_map` keeps references valid across
rehashing, which is what lets the registry lock be dropped before the entry is
used.

The helper's unit tests drive fakes and reach none of the delegate wiring, so
the single-threaded pooled path had no automated coverage at all: the execution
context could be reverted to `kSTATIC`, or the
`updateDeviceMemorySizeForShapes`/`setDeviceMemoryV2` pair deleted, with every
test still green. `tests/cpp/executorch/test_shared_scratch_backend.cpp` links
the delegate and covers `set_option`'s three behaviours, the per-engine capture
of the setting and of the engine's own scratch requirement, the pooled
`execute()` path, the four-contexts-one-allocation claim, the event handoff
across two caller streams, and an engine that needs no activation scratch at
all. It builds its own TensorRT engine rather than loading a `.pte`. It needs a
CUDA device: without one it skips every test and exits zero, and the workflow's
`--test_output=errors` keeps the skip reason out of the log, so a green run on a
device-less host says nothing about the pool.

Finally, four of the parent's statements no longer hold. Its description of what
`updateDeviceMemorySizeForShapes()` returns was wrong: it does not report the
requirement for the shapes just bound. Whether an engine does that or reports
its profile maximum is fixed when the engine is built -- the builder's
`PreviewFeature::kRUNTIME_ACTIVATION_RESIZE_10_10` produces the former, and
without it either can happen depending on how TensorRT planned the engine -- so
the pool can settle well above the live data and a runtime cannot tighten it.
The reclaimed memory is likewise the sum of the N per-engine requirements less
the largest of them, not `(N-1)` times a uniform per-engine figure. "One buffer
serves every context on a device" is true only of contexts created while the
option is on; one created while it was off keeps its own scratch. And
`set_option` is untested there because no target in `tests/cpp/executorch/`
links the backend; `test_shared_scratch_backend.cpp` is such a target and covers
all three of its behaviours.
@Conarnar
Conarnar force-pushed the perf/executorch-shared-scratch-pool branch from 75ed049 to c6795bb Compare September 1, 2026 22:10
@Conarnar
Conarnar requested a review from shoumikhin September 1, 2026 22:48

@shoumikhin shoumikhin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some notes inline.

buffer, so it can stall for far longer than the event wait that precedes it. The
backend keeps that stall out from under the per-device lock, so it does not hold
up another engine on the device, but it does fall after the growing call's own
enqueue, so that one `execute()` waits for its own engine work too. A growth

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The line that says a growth happens only on an engine's first run does not hold for a dynamic shape engine, and the paragraph three lines below it says so itself. The per call query answers for the shapes just bound. I built an engine with a real dynamic profile and a user managed context, no preview feature, and the same engine asked for 33554432, 67108864, 100663296 and 134217728 bytes as the batch went from 1 to 4. That is three growths from one engine, so loading the largest engine first does not bound the allocations.

This matters beyond the text. The comment above the leak on a failed pre free wait says the cost is bounded because growth is rare, and the readme tells a caller the device wide free stall is avoidable by load order. Neither is true here. Could you either scope both claims to fixed shape engines, or say plainly that a dynamic shape engine can grow on any call?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, though I was not able to replicate your findings. Growth depends on whether an engine's reported requirement varies with the bound shapes, so I scoped the README by that rather than by fixed versus dynamic shape.

Comment thread cpp/BUILD Outdated
filegroup(
name = "executorch_api_headers",
srcs = [
"include/torch_tensorrt/executorch/SharedScratchPool.h",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason given for keeping this header public does not hold in the code as it stands. TensorRTBackend.h does not include it. It names the option key in three comments and nowhere else, and it compiles without it. The readme example passes the string literal, and the new backend test spells the key out on purpose.

So what ships to users, for one string constant, is a mutex, a per device registry, and three functions whose only lock rule is a sentence saying to call them with the device lock held. There is no assert and no annotation, and the lock and the state are separate public members so the type cannot enforce it. Fourteen of the sixteen cases in the pool unit test call those helpers with no lock at all, which is the first thing a new reader will copy.

Moving the key constant into TensorRTBackend.h next to set_option, and taking the pool header out of the installed set, closes this without adding anything.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, though SharedScratchPool.h was moved to cpp/src/torch_tensorrt/executorch/ as libtorchtrt.tar.gz ships TensorRTBackend.cpp as buildable source, which requires the header.

":test_executorch_binding_names",
":test_executorch_blob_header",
":test_executorch_weight_streaming_budget",
":test_shared_scratch_backend",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two new tests only run inside the ExecuTorch runtime job, and that job is skipped in every run at this head. It is gated on the standard channel, and that channel was cancelled by concurrency. The required gate only fails on a real failure, so skipped channels do not block anything.

That means every test number in the description is a local number, and the first time either file gets compiled in CI would be the push to main. Could you run the full lane on this branch once before merging?

Worth pairing with the device less skip you already documented. Right now a run that skips all twelve cases and a run that never happened both look green.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran //tests/cpp/executorch:executorch_backend_tests under bazel on a real device, at the pushed head and again with this stack applied. 6/6 both times, including the two new files.

@github-actions github-actions Bot added the component: api [Python] Issues re: Python API label Sep 3, 2026
@Conarnar

Conarnar commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Pushed two more commits.

The zero-scratch guard rejected a valid input. With the pool on, a dynamic engine given an
empty input (batch 0 inside a [0..128] profile, all dimensions specified) has
updateDeviceMemorySizeForShapes() legitimately answer 0 while the engine's own requirement is
non-zero. That is the shape the guard treated as a failed query, so execute() returned
Error::InvalidState and the engine never ran, on an input the backend otherwise supports (it
allocates one-byte placeholders for empty tensors). Reproduced on TensorRT 11.1, 10.13.3 and
11.2.1.2, with and without kRUNTIME_ACTIVATION_RESIZE_10_10.

A zero has three causes: a query that failed, a call whose bound shapes need no scratch, and an
engine that needs none under any shape. Only the last can be told apart, and it is told apart once
at init: such an engine is left out of the pool entirely, so it takes no per-device lock and does
not serialize against the engines that do. The other two arrive at execute() indistinguishable,
and both are handed whatever the pool already holds, at its current size, which grows nothing;
any live buffer is large enough for a call that needs none.

Neither can be handed nothing at all. enqueueV3 refuses a kUSER_MANAGED context with no device
memory installed as soon as the engine reports needing some under any shape, however little the
bound shapes need: Parameter check failed, condition: noDeviceMemory. So against a pool holding
nothing the call allocates a one-byte minimum and the first call with a real requirement sizes the
pool. Standing the engine's own getDeviceMemorySizeV2() in for the zero satisfies that check too,
but that figure covers the whole profile: on the dynamic engine the tests build it is 4 MiB against
the 128 KiB the next call needs, and since the pool never shrinks a single empty batch would pin it
at 32x for the process lifetime, on the path the pool exists to keep small.

Capturing a CUDA graph from a pooled execute()'s stream is now refused with
Error::NotSupported. The handoff between one enqueue and the next is a wait on an event recorded
outside the capture, and cudaStreamWaitEvent on such an event fails with
cudaErrorStreamCaptureIsolation and invalidates the capture under every capture mode,
cudaStreamCaptureModeRelaxed included. That wait, not the allocation, is what produced the 901
and the null graph from cudaStreamEndCapture when the guard was removed and measured; the pool
was already large enough that no growth ran. A growth is mode-dependent by comparison: its
cudaMalloc, cudaEventSynchronize and cudaFree each invalidate the capture under Global and
ThreadLocal, while Relaxed permits all three and runs them uncaptured, leaving a replay pointed at
a buffer the pool may since have freed. Measured directly on CUDA 13.0, one process per cell,
rather than read off the documentation. The check is one cudaStreamIsCapturing ahead of the
device lock; loading the engine with the option off still captures cleanly.

cudaDeviceReset() is documented rather than guarded, in the installed header and the README.
The pool holds its buffer and its handoff event for the process lifetime and a reset destroys the
primary context under both. Catching it would mean revalidating both on every call, on the pool's
hot path, and the check costs about what it protects. The header's execute() contract also now
states that a growth turns an otherwise asynchronous call into a device-wide cudaFree wait.

The handoff event passes cudaEventBlockingSync. Created with cudaEventDisableTiming alone,
cudaEventSynchronize busy-waits, measured at 99.5% of a core on an H100 versus 0.1% with the
flag, and that wait runs while the per-device lock is held, so one core spins and every other
engine on the device queues behind it. The engine-completion event in the same file already passed
the flag. The README example also handles set_option's Error, which is not ET_NODISCARD and
so compiled while dropping it, immediately above a paragraph telling the caller to check exactly
that return.

Two test defects, both of which weakened the suite rather than the backend. Nothing reset the
process-wide pool between tests, so the suite passed in declaration order and in no other: running
the empty-input case before ASecondPooledEnqueueWaitsForTheFirstOnAnotherStream turned the
latter's first run into a growth, whose cudaFree waits device-wide and so waited on the blocking
host function that test parks on its own stream, from the one thread that could release it. A
deadlock ending at the 60 s watchdog. --gtest_repeat=2 on the growth case failed on its own
second iteration, blaming the backend for a fixture artifact. A fixture now resets the pool in
setup and teardown through test-only hooks declared in SharedScratchPool.h, which ships under
src/ and is not installed, so no production semantics and no installed API changed. Separately,
device_bytes_in_use() returned zero on a failed cudaMemGetInfo; both memory comparisons
subtract two of its readings, and a zero for the first of a pair made the second look like the
whole cost of what was measured between them. It now reports the failure and the call sites assert
on it.

tests/cpp/executorch is 6 targets and 70 cases, run on a real device: 69 at the pushed head and
70 with the last commit, the extra being a unit test for the reset hook. Two tests that existed
only to pin a now-unused engine-wide byte count were replaced by one pinning the predicate the
pooled path branches on.

Three fixes to the shared per-device activation-scratch pool: the device lock
now spans the enqueue, the growth's `cudaFree` moves out from under it, and the
growth path gets its first test.

Left open: the option cannot be turned on from Python or from a `.pte`. Closing
it means giving the option a load-time runtime spec and a compile-spec fallback
the way `weight_streaming_budget` has, which nothing here builds.

**Two pooled engines running at once on one device gave wrong output, silently.**
`get_or_grow_shared_scratch` dropped the device lock when it returned, so
`setDeviceMemoryV2`, `enqueueV3` and the record of that enqueue on the marker's
event all ran unlocked. A second thread claiming inside that window was handed
the same buffer and told to wait on the enqueue *before* the one now in flight,
so nothing ordered the two and both wrote the same scratch. Reproduced on two
real engines: one output wrong on every trial, with no CUDA error and no
TensorRT error. It needs no growth and no free, so it is the ordinary state once
the pool has settled -- unlike the growth hazard the comments and the README did
warn about.

`execute()` now holds the claim from `get_or_grow_shared_scratch` through
`setDeviceMemoryV2`, `enqueueV3` and `mark_shared_scratch_in_flight`, as a
`SharedScratchClaim` whose destructor covers the early returns in between.
`enqueueV3` is already called under the per-handle `EngineHandle::mu` here, and
`core/runtime/execute_engine.cpp` brackets its own enqueue with
`compiled_engine->mu` and states that the other `IExecutionContext` calls belong
in that scope, so this is a narrower instance of a pattern the runtime already
relies on. It nests inside `EngineHandle::mu` and is never taken the other way
round.

Overlapping `execute()` calls on two pooled handles on one device are therefore
now safe -- serialized at submission rather than concurrent. The README and the
installed `TensorRTBackend.h` told the caller to stagger them; both now say the
backend does it, and that the pool costs the parallelism between them.

Holding the lock across `enqueueV3` is cheap here, including for the case the
TensorRT header warns about ("If the Engine is streaming weights, enqueueV3 will
become synchronous"). Measured on TensorRT 11.2.1.2 and an A100, on a 12-layer
engine with 768 MiB of streamable weights, `enqueueV3` stays a submission:

| engine | `enqueueV3` | enqueue + drain |
|---|---|---|
| no weight streaming | 0.079 ms | 0.57 ms |
| weight streaming, budget = streamable size (dormant) | 0.079 ms | 0.57 ms |
| weight streaming, budget = half | 0.292 ms | 42.4 ms |
| weight streaming, budget = 0 | 0.439 ms | 75.7 ms |

At budget 0 the enqueue is 0.6% of the inference, and a second context's
`enqueueV3` issued while that 75 ms streamed inference was still in flight
returned in 0.267 ms. So the lock is held for a submission, not for an
inference. The warning still stands in the header, so a different TensorRT
version or platform could differ; the pool remains opt-in.

`TwoThreadsRunningPooledEnginesOnOneDeviceKeepTheirOwnOutputs` covers the fix: two
pooled engines on one device, 60 runs each from two threads on two non-blocking
streams, released into their submission together and compared byte-for-byte against
what each engine produces with private scratch. Against a build that restores the
pre-fix lock scope it reports 118 to 120 of the 120 runs wrong; against this one, 0.
It costs 1.8 s. Without the two threads lined up on each submission the host copies
around each run serialize them and the same mutant loses only 2 runs of 120, so the
rendezvous is what makes the test discriminate.

**The growth's `cudaFree` no longer runs under the device lock, and no longer
runs at all after a failed wait.** `cudaFree` performs a device-wide
synchronization, so swapping the old `cudaDeviceSynchronize` for an event wait
did not remove the device-wide wait: measured on an H100 with unrelated work
queued on another stream, the event wait returned in under a millisecond and the
`cudaFree` took about 1.5 s, matching the queued work -- with the device lock
held, so an unrelated claim on that device waited behind it too. Reproduced on
an A100 with 1.5 s of unrelated work on a second stream and the handoff event
already complete: event wait 0.004 ms, `cudaFree` 1490 ms, over two trials.

`shared_scratch_get_or_grow` now reports the displaced buffer through a
`RetiredScratch` out-parameter instead of freeing it through a callback. The
host wait on the marker's event stays under the lock, because the caller records
its own enqueue on that same event before it unlocks and a wait deferred past
that point would block on it. The free moves to `SharedScratchClaim::release()`,
after the unlock, and reports and clears a CUDA error of its own rather than
discarding one: `cudaFree` synchronizes, so its return is often where an earlier
asynchronous fault on the device first surfaces. Two consequences of moving the
free, both documented in the README: the stall no longer blocks another engine on
the device, and it now falls after the growing call's own enqueue, so that one
`execute()` waits for its own engine work.

A retire list was considered and rejected: deferring frees would make peak
device memory the sum of every size the pool ever grew to rather than the
maximum, which is the saving the feature exists for.

If the `cudaEventSynchronize` before the free fails, the buffer is now leaked
with an error logged and the sticky CUDA error cleared rather than freed, since
that wait is the only thing keeping the free off a buffer an enqueue may still
be reading. The cost is bounded because growth is: it happens only when an
engine larger than every engine before it runs for the first time. On a
212-engine model the pool allocated once, at 7,353.1 MiB, and never grew,
because the largest engine ran first. On a 62-engine one it grew twice before
settling, 131.0 -> 142.0 -> 554.0 MiB. The six-engine synthetic fixture, built
with deliberately unequal engines, grows four times: 44 -> 76 -> 140 -> 268 MiB.

**The growth path had no test.** Every pooled engine in
`test_shared_scratch_backend` asked the pool for the same number of bytes, so the
reuse branch always won. `ALargerEngineGrowsThePoolAndFreesTheBufferItReplaces`
runs a four-times-larger engine after a smaller one (33,554,432 -> 134,217,728
bytes) and brackets the growth's device-memory cost from both sides: the lower
bound fails if no growth happened, the upper bound fails if the buffer that was
replaced was not freed. It also re-runs the smaller engine afterwards, since the
growth freed the address that engine's context was last given.

The test was checked against three mutations of the production code, each of
which kills it:

- never free the displaced buffer: the growth costs 134,217,728 bytes against a
  117,440,512-byte upper bound.
- never grow (always take the reuse branch): `enqueueV3` refuses the undersized
  buffer and `execute()` returns `InvalidState`.
- install the pool buffer once per context instead of on every call: the smaller
  engine's next run hits CUDA error 700 on the freed address.

Verified on TensorRT 11.2.1.2, CUDA 13, one A100. `test_shared_scratch_pool`
16/16, `test_shared_scratch_backend` 12/12, and 12/12 skipped with no CUDA
device visible. The default-off path is unchanged: 9/9 runs (3 programs x 3
repetitions) byte-identical in stdout and canonicalized stderr against a binary
built from `main`, with the negative control discriminating. The pool's own A/B
is unmoved: 1656 -> 316 MB, 792 -> 316 MB and 1188 -> 372 MB, same outputs.
Two corrections to the shared per-device activation-scratch pool: its header
leaves the installed set, and the claim that a growth happens only on an
engine's first run is scoped to the engines it holds for.

Not addressed here: the ExecuTorch runtime CI job has been skipped on every run
at this head, which needs a full CI lane.

**`SharedScratchPool.h` was in the installed header set for no reason.**
`TensorRTBackend.h` does not include it and does not use anything it declares: it
named `kSharedActivationScratchKey` in three comments and nowhere else, and the
README example and the backend test both spell the key out as a string literal. So
the installed set carried a mutex, a per-device registry and three functions whose
only locking rule is a sentence saying to call them with the device lock held --
no assert, no annotation, and the lock and the state are separate public members,
so the type cannot enforce it -- to deliver one string constant.

The constant moves next to `TensorRTBackend::set_option`, the one thing that reads
it, and the header moves from `cpp/include/torch_tensorrt/executorch/` to
`cpp/src/torch_tensorrt/executorch/`, beside the source that includes it.

**The header has to stay in the package; the move itself is convention.** Dropping
it from `executorch_api_headers` alone would have dropped it from the release too.
`libtorchtrt.tar.gz` ships `TensorRTBackend.cpp` as buildable source -- the
"Standalone Backend Archive" section of the README tells a caller to build
`libexecutorch_trt_backend.a` from it -- and that source includes the pool header;
reproducing the released layout without it fails with
`fatal error: torch_tensorrt/executorch/SharedScratchPool.h: No such file or
directory`. `executorch_api_headers` is also part of the source set that feeds the
`rules_foreign_cc` CMake build in `py/torch-tensorrt-executorch-runtime/native`.
Listing the header in `executorch_backend_source_files` covers both.

That listing would have been enough on its own, wherever the file sat:
`pkg_files` writes each file to its `prefix` plus the file's basename
(`BUILD.bazel:134`, `:211`), so where a file lands in the tarball follows
filegroup membership, not its path in the repository -- the header could have
stayed under `cpp/include/` and still shipped under `src/`. It is moved for what
`cpp/include/` means here: every header under it belongs to an installed set,
the four ExecuTorch ones by name in `executorch_api_headers` and the rest by the
`api_headers` glob over `include/**/*.h`. A private header left there would be
the first exception, and would read as public to the next person who found it.
`cpp/src/` held no headers before this, so the placement sets a convention
rather than following one. The two CMake builds that compile
`TensorRTBackend.cpp` gain the directory the header now sits under as an include
root; `#include "torch_tensorrt/executorch/SharedScratchPool.h"` is unchanged in
all three build systems.

**"A growth happens only on an engine's first run" is false for a dynamic-shape
engine.** `execute()` calls `updateDeviceMemorySizeForShapes()` once the input
shapes are bound, on every call, and an engine that answers with what those shapes
need answers differently as they change. Measured on TensorRT 11.2.1.2, one engine
over a `[1..4, 512, 512]` profile with a `kUSER_MANAGED` context:

| built with | batch 1 | batch 2 | batch 3 | batch 4 |
|---|---|---|---|---|
| `kRUNTIME_ACTIVATION_RESIZE_10_10` | 2 MiB | 4 MiB | 6 MiB | 8 MiB |
| no preview feature | 8 MiB | 8 MiB | 8 MiB | 8 MiB |

The first row is four growths out of one engine, so no ordering of the engines
bounds the number of allocations; the second reports the profile maximum every
time and grows the pool once. Which one an engine is, is fixed when it is built,
which the README paragraph three lines below the claim already said.

The statements that rested on the claim are corrected:

- the README's growth-frequency guidance, and the advice that putting the largest
  engine first reduces the pool to a single allocation -- now scoped to the
  engines it holds for, and to run order rather than load order, since the pool
  allocates nothing until an engine runs. The measurement above stays in this
  message rather than going into the README: it pins a TensorRT version and the
  rule it supports does not;
- the comment above the leak on a failed pre-free wait, which called the leak
  bounded because growth is rare. It is one buffer per growth whose wait fails,
  and the number of growths is not bounded by the engine count;
- three descriptions of the pool as sized to "the largest engine's need", each of
  which assumes a per-engine constant: the README sentence introducing the option,
  the comment over the process-wide `scratch_pool`, and the comment over the
  per-call `setDeviceMemoryV2`, which put a move of the buffer down to "a larger
  engine".

Verified: `//tests/cpp/executorch:test_shared_scratch_pool` 16/16, and the 12
cases of `test_shared_scratch_backend.cpp` 12/12 on an A100 with TensorRT
11.2.1.2, none skipped. The standalone CMake archive builds both from this tree
and from a reproduction of the released package layout.
…per-shape query answers zero

`updateDeviceMemorySizeForShapes()` returns 0 for a valid empty input. A dynamic
engine over a `[0..128]` batch profile, bound to batch 0, answers 0 with
`allInputDimensionsSpecified()` true while `getDeviceMemorySizeV2()` is not zero,
so the guard in `execute()` read that pair as a failed query and returned
`Error::InvalidState`. With `use_shared_activation_scratch` on, such a call never
reached the engine. Measured on TensorRT 11.2.1.2, the two-softmax network over
`[0..128, 64, 64]` used by the test reports 4194304 bytes engine-wide and 0,
32768, 131072 and 4194304 bytes for batches 0, 1, 4 and 128.

A zero has three causes and nothing at that point tells them apart: a failed
query, an engine that needs no scratch under any shape, and a call whose bound
shapes need none. The engine's own requirement covers all three, so the guard is
replaced by a substitution rather than loosened. Where `engine_scratch_bytes` is
zero no shape needs scratch and no buffer is installed, which is what the
scratch-free path already did. Where it is not, it is an upper bound over every
shape the engine accepts: a conservatively over-sized but valid buffer for a call
whose own requirement could not be read, which is better than the stale, possibly
freed pointer the guard existed to prevent. A `Debug` log records that a zero was
seen and what was substituted for it. The comments that described the old
discrimination -- on `engine_scratch_bytes` in the public header and on
`TheEngineLevelRequirementSeparatesTheTwoFixtureEngines` -- are rewritten to
match.

`AnEmptyInputRunsWithThePoolEnabled` covers the empty input, on a fourth fixture
engine built over a dynamic batch whose profile minimum is empty. Three
assertions pin the test to that cause rather than the other two: the per-shape
query answers zero at batch 0, the engine's own requirement is not zero, and the
same engine does report a requirement at batch 4. Run against the previous guard
the test fails with the guard's own message, and the other twelve in the target
pass.

Three smaller fixes in the same area:

- The pool's handoff event is created with `cudaEventBlockingSync` as well as
  `cudaEventDisableTiming`, matching the engine-completion event in `init()`. The
  one host wait on it runs with the per-device lock held, so a spin burned a core
  for the whole of a wait that was already serializing the device.
- The `get_or_grow_shared_scratch` precondition no longer lists `cudaFree` among
  the calls it makes on the current device; that free moved to
  `SharedScratchClaim::release()` earlier in this branch.
- The README's `set_option` example stores and handles the `Error` it returns
  instead of dropping it, and the four em dashes this branch added, on three
  lines, become a comma, a pair of parentheses and a full stop.
…le profile

`updateDeviceMemorySizeForShapes()` answers zero for a call whose bound shapes
need no activation scratch, and the previous commit stood the engine's own
`getDeviceMemorySizeV2()` in for that zero. That figure covers every shape in the
profile. Measured on the test's dynamic `[0..128, 64, 64]` engine: batch 0
answers 0 and installed 4194304 bytes, while the next call, at batch 4, needs
131072. The pool never shrinks, so one empty batch pinned it at 32x the next
call's requirement for the process lifetime, on the path the pool exists to keep
small.

A zero now asks for whatever the pool already holds, at its current size, which
grows nothing: any live buffer is large enough for a call that needs none.

Handing such a call no buffer at all does not work, which is worth recording
because it is the obvious reading of "it needs nothing". `enqueueV3` refuses a
`kUSER_MANAGED` context with no device memory installed as soon as the engine
reports needing some under any shape, whatever the shapes actually bound need:
`Parameter check failed, condition: noDeviceMemory. The engine requires 4194304
device memory.` So against an empty pool the call allocates
`kMinPooledScratchBytes`, and the first call with a real requirement grows it;
the same engine's batch-4 call then takes the pool to 131072 rather than 4 MiB.

`EngineHandle::engine_scratch_bytes` becomes `engine_needs_scratch`, a bool. The
size had no remaining reader, but the predicate has one: an engine that needs no
scratch under any shape is left out of the pool entirely, so it takes no
per-device lock and does not serialize against the engines that do.

Two hazards the pool did not address:

- Stream capture is now refused with `Error::NotSupported`. The handoff waits on
  an event recorded outside the capture, which `cudaStreamWaitEvent` fails with
  `cudaErrorStreamCaptureIsolation` and which invalidates the capture under every
  capture mode; that wait, and not the allocation, is what makes
  `cudaStreamEndCapture` hand back 901 and a null graph far from the cause. A
  growth's `cudaMalloc`, its `cudaEventSynchronize` and its `cudaFree` invalidate a
  capture too, but only outside `cudaStreamCaptureModeRelaxed`, which permits all
  three and then runs them uncaptured. The check is one `cudaStreamIsCapturing`
  ahead of the device lock.
- `cudaDeviceReset()` is documented rather than guarded, in the installed header
  and the README. The pool holds its buffer and its handoff event for the process
  lifetime and a reset destroys both; catching that would mean revalidating both
  on every call, which costs as much as the work it protects.

The installed `execute()` contract also now says that a growth turns an otherwise
asynchronous call into a device-wide `cudaFree` wait.

The README, the installed header and the test comments are brought into agreement
on the shape of the ambiguity: a zero from the per-shape query has three causes,
the engine-level one is settled at init and never reaches `execute()`, and the
other two are indistinguishable where the zero is read.

Test changes:

- The fixture resets the process-wide pool in setup and teardown, through
  test-only hooks declared in `SharedScratchPool.h`, which ships under `src/` and
  is not installed. Nothing reset the pool before, and most of these tests depend
  on what it holds when they start, so the suite passed in declaration order and
  in no other. Running `AnEmptyInputRunsWithThePoolEnabled` before
  `ASecondPooledEnqueueWaitsForTheFirstOnAnotherStream` turned the latter's first
  run into a growth, whose `cudaFree` waits device-wide and so waited on the
  blocking host function that test parks on its own stream, from the one thread
  that could release it: a deadlock ending at the 60 s watchdog. `--gtest_repeat=2`
  on the growth case failed on its own second iteration. Both now pass.
- `AnEmptyInputRunsWithThePoolEnabled` asserts what the pool holds rather than
  only that the call succeeds, at three points: after an empty call against an
  empty pool, after a call that needs scratch, and after a second empty call.
  Against the previous behaviour it fails on the first and third. The first is
  pinned to `kMinPooledScratchBytes` exactly rather than bounded below the next
  call's requirement, so a minimum raised to anything under 128 KiB fails it too.
- `ResetHandsBackEverySlotAndLeavesItEmpty` covers `reset_for_testing` over the
  pool's fakes, which nothing did: that each slot's buffer and event both reach
  the disposer, that a slot left without an event is handled, that the reference
  `get` handed out survives, and that the next claim allocates rather than
  reusing the capacity of a buffer the reset has already released.
- `AnEngineNeedingNoActivationScratchRunsWithThePoolEnabled` also checks the pool
  still holds nothing afterwards, which is what pins such an engine being left out
  of the pool rather than merely surviving it.
- `device_bytes_in_use()` reports a failed `cudaMemGetInfo` instead of returning
  zero. Both memory comparisons subtract two of its readings, and a zero for the
  first of a pair made the second look like the whole cost of what was measured
  between them.
- `APooledEngineRefusesToRunWhileItsStreamIsCapturing` covers the capture guard,
  and checks the refusal did not itself spoil the capture.
- `EachEngineRecordsItsOwnActivationScratchRequirement` and
  `TheEngineLevelRequirementSeparatesTheTwoFixtureEngines` are replaced by
  `EachEngineRecordsWhetherItNeedsActivationScratch`, which pins the predicate the
  pooled path now branches on.
A zero from `updateDeviceMemorySizeForShapes()` reaches `execute()` for two
reasons and the pooled path treated both as "any buffer will do". Only one of
them is safe with a buffer that small.

Where the bound shapes genuinely need nothing, the engine expects nothing and
the pool's minimum is accepted. Where the query *failed*, the engine still
expects what it always did: `setDeviceMemoryV2` refuses the undersized buffer,
returns `void`, and leaves the context pointed at whatever it was last given --
which a pool growth may already have freed. Measured on TensorRT 11.2.1.2 with
this branch's own two-softmax net (expected 8388608): installing 1 byte,
`enqueueV3` returns true, `cudaStreamSynchronize` reports no error, the output
matches byte for byte, and the engine writes the stale buffer. Free it, let the
next `cudaMalloc` take the address, and 4090 of 4096 bytes of an unrelated
allocation are overwritten with no error anywhere; when the block goes back to
the driver instead, `cudaErrorIllegalAddress`.

The refusal is observable, through an `IErrorRecorder` attached to the context
for the duration of that one call. Measured on the same TensorRT: a correct
install records nothing, an undersized one records `kINVALID_ARGUMENT` naming
both sizes, and the genuinely-empty case -- expected size zero, one byte
installed -- records nothing, so it keeps working. `install_pooled_scratch`
makes the install and reads that back; a refusal ends the call with
`Error::InvalidState` and nothing reaches `enqueueV3`. The engine requirement
does not have to come back as a size, so the pool is still never pinned at the
profile maximum.

Also here:

- The pool is a leaked singleton rather than a namespace-scope object. Static
  destruction destroyed the registry mutex, every device mutex and the map nodes
  a live reference points into while a thread could still be between the lookup
  and its unlock: 7 of 18 runs of a driver over the real header segfaulted at
  exit, against 0 of 18 for the same code leaked.

- The capture guard runs before `execute()` makes any call a capture cannot
  take, rather than after the wait on a previous enqueue and the `cudaMalloc`
  that grows a host-input staging buffer. Under `cudaStreamCaptureModeGlobal`
  those two fail first, so the refusal used to arrive with the caller's capture
  already invalidated -- reproduced through the delegate: `cudaEventSynchronize`
  returns `cudaErrorStreamCaptureUnsupported`, `execute()` answers
  `InvalidProgram`, and `cudaStreamEndCapture` hands back 901 and a null graph.
  The staging copy is not one of them: a pageable `cudaMemcpyAsync` is captured
  cleanly under all three modes, measured on CUDA 13.0 at 4 KiB and 64 MiB, and
  the device query and the device switch are captured cleanly too.

- The guard still sees only the selected stream, and the header and README now
  say so instead of promising more. Widening it is not available:
  `cudaStreamIsCapturing(cudaStreamLegacy)` reports a capture on another stream
  only when that stream is a blocking one, and then reports it under `Relaxed`
  and under another thread's `ThreadLocal` too, where the pool's calls are
  permitted and refusing would be wrong. CUDA has no process-wide query.

- `reset_for_testing` empties every slot under the locks and disposes of what it
  took with neither held. The production disposer runs `cudaSetDevice`,
  `cudaFree` and `cudaEventDestroy`, and a device-wide free blocks on everything
  queued on that device -- a parked host function included, which the suite
  parks deliberately -- so under the registry lock one device's teardown held up
  every other device's claims.

- A failed `cudaEventSynchronize` on the buffer a growth replaces now fails the
  call. It is a device synchronization, so what it reports is usually an
  asynchronous fault already raised on that device, and continuing enqueued
  against a context on a device in that state.

- The four comments claiming a `cudaGetLastError` stops an error resurfacing
  under the name of the next call said the opposite of what happens: a sticky
  error survives the clear and the next `cudaMalloc` still returns it. Only
  non-sticky errors recover that way, and the comments say that now. The free
  path's message no longer blames the pool for a fault the free merely surfaced.

- `need = dev.capacity > 0 ? dev.capacity : kMinPooledScratchBytes` collapses to
  `kMinPooledScratchBytes`: the reuse branch already answers with the existing
  capacity for any request it covers, so the two are identical over every
  reachable pool state (6 of 6 measured). It read like the profile-wide sizing
  policy the previous commit removed.

- `SharedScratchMarker`'s comment said the event is never destroyed; the reset
  hook destroys it. It says what is true.

Tests, 70 -> 74 cases over the same 6 targets:

- `AnUndersizedScratchInstallIsSeenAsRefused` drives the same TensorRT call
  `execute()` makes over a live `kUSER_MANAGED` context: a correct install is
  accepted, a one-byte install for an engine expecting more is refused, and the
  empty-batch context accepts the one byte. A failed per-shape query cannot be
  induced from inside the process, so what is pinned is the check that catches
  its consequence.
- `APooledEngineRefusesACaptureBeforeAnythingCanInvalidateIt` captures in the
  default `Global` mode and requires the refused run to leave the capture
  intact, which the existing relaxed-mode case cannot see.
- `ResetLeavesEveryEntryWhereItWas` looks up 512 entries either side of a reset.
  The old single-address check passes against an erasing reset, because the
  allocator hands the freed node straight back; over 512 entries it does not.
- `ResetDisposesWithNoLockHeld` observes, from another thread and while the
  disposer runs, that the slot's device lock is free and that a lookup for a
  device the reset never touched completes.
- Without a CUDA device the backend suite prints how many of its cases it
  skipped, and `TORCHTRT_EXECUTORCH_REQUIRE_CUDA=1` turns the skip into a
  failure. Getting the CI job onto a GPU runner is a separate matter.
@Conarnar
Conarnar force-pushed the perf/executorch-shared-scratch-pool branch from 1889ddc to f09abdd Compare September 7, 2026 01:27
…nship the test hooks

Five changes to the shared activation-scratch pool.

**The capture advice was wrong.** The README's closing sentence and the
refusal's log text both told a caller to load with the option off in order to
capture. The guard is inside the `if (pooled_scratch)` branch, so with the
option off nothing refuses -- and `execute()` still makes calls a capture
cannot take. Measured on CUDA 13.0, one process per cell, on a non-blocking
stream: `cudaEventSynchronize`, `cudaMalloc` and `cudaFree` are prohibited
under `Global` and `ThreadLocal` and permitted under `Relaxed`, while
`cudaStreamSynchronize` on the capturing stream is prohibited under **every**
mode, `Relaxed` included. So with the option off a call captures cleanly when
it is submitted on the capturing stream through a `CallerStreamGuard`, binds
only non-empty device-resident tensors, aliases no output, and follows no call
that left an enqueue in flight -- and since such a call leaves its own enqueue
in flight, under `Global` and `ThreadLocal` a handle is good for one captured
call rather than for capture in general. The README, the installed header and
the refusal message now say that, and two new tests pin both outcomes.

Moving the guard out of the pooled branch was the alternative. It is declined:
it would refuse exactly the calls above, for contexts that never touch the
pool. Mutating the guard to be unconditional fails both new tests, which is
that behaviour change made visible.

**`cudaFree`'s device-wide wait is documented, not fixed.** Re-measured: with a
host function parked on a stream neither the pool nor the growing engine had
used, the growth's `cudaMalloc` and its wait on the retired buffer each
returned at once and the `cudaFree` returned after 3000 ms. Nothing later on
the path already pays that wait, so deferring the free only moves it, and
`cudaFreeAsync` pairs only with `cudaMallocAsync`, so a stream-ordered free
would move every pool allocation onto a different allocator. The installed
contract already called this "an unbounded wait on work this call did not
submit"; it now says that unbounded is literal -- if some queued stream is
waiting on work only this thread submits after `execute()` returns, the call
does not come back. The backend's own tests deadlocked on exactly that once.

**The CI check was armed by nothing.** `TORCHTRT_EXECUTORCH_REQUIRE_CUDA` turns
a device-less skip into a failure, and no workflow set it. With
`CUDA_VISIBLE_DEVICES=''` the backend target reported 19 of 19 skipped and
exited 0; with the variable set it exits 1 with 19 failures. The CI invocation
now passes it.

**The pool's test hooks no longer ship.** `shared_scratch_capacity_for_testing`
and `reset_shared_scratch_pool_for_testing` were defined in
`TensorRTBackend.cpp` and so exported from the archive the README tells a
consumer to build, one of them freeing the live pool with no wait for work in
flight. They move to `SharedScratchPoolTestHooks.{h,cpp}`, compiled by a
`testonly` Bazel target and by neither released target, so a release build has
no definition and no symbol -- checked with `nm`/`readelf` on the release
object. Reaching the pool from a separate translation unit is why
`scratch_pool()` is now `inline` in `SharedScratchPool.h`; that was cheaper
than a macro guard, which would have needed a build-system flag or a second
copy of the backend target.

**Three of this branch's earlier fixes had no discriminating test.** Each now
has one, and each was proven by mutating the fix away:

- `ExecuteInstallsPooledScratchThroughTheCheckedHelper`. Forcing the refusal
  through `execute()` is not constructible -- the size installed is the pool's
  capacity, which is never below what the query just returned. What is
  observable is that `install_pooled_scratch` scopes an `IErrorRecorder` over
  the install: a recorder the test leaves on the delegate's context sees itself
  replaced and restored exactly once per pooled run. Replacing the checked call
  with a bare `setDeviceMemoryV2` takes both counts to zero.
- `TheProcessPoolOutlivesStaticDestructionUnderALiveClaimant`, in the CPU-only
  pool target. Forks children that leave a thread inside the pooled path and
  then `exit()`. Putting the registry back on a function-local static object
  killed 24 of 24 children on a signal; the leaked singleton kills 0 of 24.
- `AGrowthFreesTheBufferItReplacesWithTheDeviceLockDropped`. Parks work on an
  unrelated stream so the growth's free stalls, then takes the device's pool
  lock while the growth is still inside it, reading the grown capacity under
  that same `try_lock` so a lock that is free only because the growth has not
  started cannot satisfy it. Moving the unlock below the `cudaFree` fails it.

6 targets, 74 -> 79 cases, all passing on an A100 with `--nocache_test_results`
and `TORCHTRT_EXECUTORCH_REQUIRE_CUDA=1` set, so nothing skipped.
…scratch, drop the lock across a growth's wait

Every figure below is from local runs on one A100 host; the CI job that would
run these tests has not run them at this head.

**The capture advice was wrong in a way three rounds of correcting it missed, so
it is withdrawn rather than corrected again.** Measured through the delegate: with
the option off, a first call over device-resident tensors on the capturing stream
captures, and the graph instantiates, launches and reproduces the bytes the same
engine produces outside a capture. The next ordinary `execute()` on that handle
then fails `Error::InvalidProgram`, logging `cudaEventSynchronize failed: invalid
argument`, because the completion event `execute()` records for its asynchronous
return was recorded inside the graph. No call shape avoids it: the alternative to
recording that event is the `cudaStreamSynchronize` that ends a staging call, and
a capturing stream cannot be synchronized under any mode. So the README, the
installed header and the refusal message now say that this delegate does not
support CUDA graph capture, with the option on or off, instead of pointing a
caller at the option-off path.

The shipped test hid this by destroying the captured graph without launching it,
which also left the poisoned handle's failure to surface as an unasserted log line
from `~EngineHandle`. It now launches the graph, checks the engine's output
against a reference run, and asserts the poisoning as the measurement the
documentation rests on. Making the option-off path work means reworking the
completion event, which the asynchronous-return contract rests on, and refusing
capture on every path would refuse calls that do capture today for contexts that
never touch the pool; both are recorded in the README as declined.

**A pooled call now installs its own requirement, not the pool's capacity.** They
differ after any growth -- measured, 3 of 139 pooled installs were oversized, one
of them telling an engine needing 33,554,432 bytes that it owned 134,217,728, of
which the sampled tail still held 4078 of 4096 bytes of the previous engine's
activations. The larger figure hands a context write access to another engine's
scratch, keeps an overrun past its own requirement inside the region TensorRT
thinks it owns, and blunts the install check this branch added: after a growth the
capacity can cover what a failed per-shape query concealed. The pool never clears
the buffer between users, which the README now says.

**A growth's host wait moves out from under the device lock, beside the free.**
The lock was held across a wait for the previous inference to complete, so another
pooled engine on the device blocked 2,119 ms -- serialized at completion rather
than at submission, which is what the installed contract promises. The wait now
runs in `SharedScratchClaim::release()`, after the unlock and before the
device-wide `cudaFree` it guards. Deferring it can pick up enqueues submitted
after the unlock, and that costs nothing, because the free that follows waits for
every stream on the device anyway; it stays sufficient for the retired buffer
because every claimant orders its stream after the marker event before enqueueing,
so the latest recording completes only once the earlier ones have. A failed wait
still leaks the buffer rather than freeing it under a live enqueue, and still
fails the call, now after draining the enqueue this call submitted.

**A refused option span no longer applies the entries before the bad one.**
`set_option` stored each value as it read it, so a valid
`use_shared_activation_scratch=true` followed by a wrong-typed duplicate returned
`Error::InvalidArgument` and left the pool on for every engine loaded afterwards.
The span is now validated in full before anything is stored; where it names the
key more than once the last one wins, as before.

**The `cudaGetLastError` in the capture refusal moves inside the failed-query
branch.** On the ordinary refusal it was clearing an error the caller was already
carrying -- measured with a real `cudaErrorMemoryAllocation` left pending, which
the refusal swallowed -- and that error is not sticky, so nothing surfaced it
later either.

Also: two comments that described every `kUSER_MANAGED` context as drawing on the
pool, which the exclusion of scratch-free engines makes false; the declaration of
`install_pooled_scratch` moved out of the pool header, which is meant to build
against the CUDA headers alone, into the header of the target that defines it; the
destructor's remaining `clear sticky error` comment, which the rest of the branch
disproves; the README's `set_option` example, which dropped one of the two errors
it demonstrates and did not compile as printed; `shared_scratch_get_or_grow`
clearing its retirement output on every path, so a caller reusing one is not
handed a buffer that was already disposed of; and the NVIDIA copyright header the
new CPU-side pool test was missing.

`mark_shared_scratch_in_flight` loses its two error branches, which were dead from
its only caller: the claim is holding the device's lock, so its device pointer
cannot be null, and `get_or_grow_shared_scratch` has already failed the call with
`Error::Internal` if the marker had no event, which only the test-only reset clears
again and that needs the lock the claim holds. Instrumenting both branches over a
whole suite run reached neither. The contract that makes them unnecessary is now
written above the function instead, and the accessor whose only caller was one of
them goes with them. What can still fail there is the `cudaEventRecord`.

The suite's device-less skip banner no longer prints when
`TORCHTRT_EXECUTORCH_REQUIRE_CUDA` is set. The counter it reads is incremented
before the arming turns the missing device into a failure, so the banner appeared
underneath those failures, called the run a passing target and advised setting the
variable that had just produced them. With the variable unset it is unchanged, and
that is the only run on which a case skips, which the file's coverage note now says
rather than promising the count either way.

Three descriptions the changes above left behind: the handoff event's blocking-sync
comment, which still had its one host wait running under the device lock; the
comment at the capture refusal's call site, which had the host wait and the staging
`cudaMalloc` invalidating a capture unconditionally, without the
`cudaStreamCaptureModeRelaxed` carve-out the same file states thirty lines earlier;
and the second capture case, whose comment and assertion messages still described
the option off as buying one capture per handle.

Five test gaps in the pool's coverage are closed. The reset cases now cover a
slot holding an event and no buffer, the state a claim whose allocation failed
leaves behind. The reset rendezvous has a deadline, so a reset that disposes of
nothing fails in under a second instead of hanging until the target times out.
The growth case takes a control reading over a window as long as its measurement
and skips rather than blaming the pool when another process moved device-wide
memory in it; that is a sample of a different window and not a guarantee --
measured under a process cycling 256 MiB allocations, 8 runs gave 1 skip and 1
failure of the bound -- so the bound's own message now names unrelated device
activity too. A new case drives a pooled call whose allocation fails, by taking
the device's memory first, and pins that it leaves the device's pool lock free
and the pool usable -- the only one of the pooled early returns that can be
reached from inside the process. And the reuse case asserts on an output
variable the call under test is the only writer of.

6 targets, 79 -> 85 cases, all passing on a real GPU with `--nocache_test_results`
and `TORCHTRT_EXECUTORCH_REQUIRE_CUDA=1`. Every behavioural fix was proven by
mutating it away: storing before validating the span, the growth's wait back under
the lock, the error clear back outside its branch, the retirement output left
uncleared, a reset that skips buffer-less slots, a reset that disposes of nothing,
a reuse that does not write its output size, and a manually locked device mutex
whose only unlock is the explicit release -- each fails the case named for it and,
where the target could be run whole, only that case.
…th's free on its stream, unship the install helper

Every figure below is from local runs on one A100 host with CUDA 13.0 and driver
13.0; the CI job that would run these tests has not run them at this head.

**A pooled call that fails after staging a host-resident input no longer returns
with that copy still running.** The staging copy is a `cudaMemcpyAsync` reading
the caller's own memory, and the caller owns that memory again the moment
`execute()` returns. Measured, from pinned host memory: the copy returns in 0.0 ms
without having read anything, and 100% of the bytes the device receives are what
the caller wrote after the return -- while the same copy from pageable memory
blocks for 1005.8 ms and takes the bytes it was called with, so the hazard is
specific to pinned sources. The success path already synchronizes whenever
anything was staged; a scope guard declared where the staging flag is now covers
every error return between the first copy and that point -- the two this branch
added in the pooled scratch block, and, all predating it, the copy's own failure,
`setTensorAddress`, `inferShapes`, the whole output-binding loop, `enqueueV3`
itself and the two returns after the enqueue -- so no instance of this class is
left anywhere in `execute()`.
It adds no synchronize to a path that did not already make one, because its
condition is the staging flag and the success path's own test includes that flag.
`AFailedPooledCallDrainsTheHostInputCopyItQueued` drives the one reachable
failure -- the pool's allocation, with the device full -- behind a parked stream
and checks what the device ends up holding; without the drain it holds the
sentinel the caller wrote after the return.

**A growth's disposal of the buffer it replaces is now stream-ordered, so the
growing call does not stall.** The branch documented `cudaFree`'s device-wide wait
as unavoidable because `cudaFreeAsync` "pairs only with cudaMallocAsync". That is
false as measured: on a 256 MiB `cudaMalloc` buffer with a host function parked on
an unrelated stream, `cudaFreeAsync` returned `cudaSuccess` in 0.2 ms without
waiting for it, deferred the free until the stream reached it, returned the bytes
to the device, and `compute-sanitizer --tool memcheck` reported no errors; the
same `cudaFree` did not return until the parked work did, at 902.6 ms.
`SharedScratchClaim::release()` now queues the free on the stream the claim
enqueued on. Ordering rather than a wait is what makes that safe: every claimant
makes its stream wait on the marker event before it enqueues and records on it
afterwards, so a free queued on this stream is behind every enqueue that ever used
the retired buffer. Where a device has no stream-ordered allocator,
`cudaFreeAsync` reports `cudaErrorNotSupported` and the old host wait and
`cudaFree` stay as the fallback, which the installed contract and the README now
describe as the platform-dependent case rather than as what always happens. The
two growth cases are rewritten around the new behaviour; forcing the fallback
fails exactly those two, each after 60 s of parked work, and leaves the other 22
backend cases passing.

**The capture documentation overstated what a capture costs the handle.** It said
every later call on the handle fails. Measured through the delegate: the next call
fails `Error::InvalidProgram` on the wait for the completion event, that call
clears the in-flight flag before returning, and the call after it records the
event again outside the capture and produces the bytes the same engine produces
uncaptured. The installed header, the README and the refusal message now say the
next call fails, and say what is not recovered -- a replay of the captured graph
enqueues on the same context and no `execute()` waits for it. The case is renamed
`AnUnpooledEngineCaptureReplaysAndCostsTheHandleItsNextCall` and asserts the
recovery.

**Two detected failures no longer become hangs.** The pool's test-only reset took
every device lock unconditionally, so the leaked-lock defect that
`AFailedPooledAllocationLeavesTheDeviceLockFree` exists to catch would hang the
fixture that resets before and after every case -- a target timeout, with the
message naming the leak never printed. The reset now waits a bounded 5 s per run
and reports the slots it could not take; the fixture fails on that report. And in
the fork-based static-destruction case, a child that throws on its way to the
state under test unwound back into gtest and ran on through the rest of the binary
-- one run printed 25 teardown banners, one per child -- while the parent's
`waitpid` had no deadline, so a wedged child parked it until the target timed out.
The child body is now wrapped in a catch-all that `_exit`s with a distinct status,
and the parent reaps with a 30 s deadline, killing and reporting an overrun.
Mutating each failure back in fails the case that hunts it, with one teardown
banner rather than 25.

**`install_pooled_scratch` is out of the installed header set.** It is described
in its own comment as an implementation detail of `execute()`, and the header it
was declared in ships in the release tarball, so the next release would freeze it
as public API. The declaration moves to `PooledScratchInstall.h` beside the
sources, which ships with them and not in the include tree, following what this
branch already did for the pool header. Built `//:include_executorch`, the header
half of the release tarball, and read the tar: none of its five headers mentions
it. The symbol itself is still a global `T` in the backend object, because the
backend's own test links against it to cover a refusal that cannot be induced
through `execute()`; nothing short of dropping that test removes it, and the
declaration is what decides whether the next release has to keep the entry point.

**The installed `execute()` contract now names the pooled path's error returns.**
Beyond the `Error::NotSupported` it already documented for a capturing stream, a
pooled call can fail four ways an unpooled one cannot, and the contract described
none of them: `Error::Internal` where the handoff event cannot be created,
`Error::MemoryAllocationFailed` where the buffer cannot be allocated or grown,
`Error::InvalidState` from the wait that orders this call behind the previous
enqueue, from an install TensorRT refuses, or from the record of this enqueue, and
`Error::InvalidProgram` from the fallback disposal's host wait, which leaks the
retired buffer rather than freeing it under a live enqueue. The contract now lists
them, says each logs at `Error` first, and names the only two reached after the
enqueue is submitted -- both of which synchronize the stream before returning, so
neither leaves engine work in flight.

Also: the zero-to-minimum substitution is made once at the call site instead of
twice, so the size the pool guarantees and the size the context is told it owns
cannot drift apart -- `setDeviceMemoryV2` refuses an undersized install and says
nothing about an oversized one, so nothing downstream would catch it;
`shared_scratch_get_or_grow` drops the size out-parameter no production caller
read, and the tests read `dev.capacity`, which they already did elsewhere;
`get_or_grow_shared_scratch` and `mark_shared_scratch_in_flight` become
`claim_shared_scratch` and `record_shared_scratch_enqueue`, so the wrappers are no
longer their wrappees' words in a different order; `scratch_from_pool` is gone,
being `pooled_scratch` on every path that reaches its read; the growth case's
direction check joins the interference skip below it rather than failing on the
unrelated device activity that skip exists to absorb; the two-thread rendezvous
gets a 30 s deadline, so a pooled call that blocks reports here instead of
spinning until the target times out; and the file's coverage note no longer claims
that `TORCHTRT_EXECUTORCH_REQUIRE_CUDA` turns every skip into a failure -- it
covers the missing device, and three cases skip for reasons it does not reach.

Three descriptions the stream-ordered free left behind, each of which had the old
synchronous disposal as the normal path: the capture refusal's own doc comment,
which still had a growth's `cudaEventSynchronize` and `cudaFree` invalidating a
capture outside `Relaxed` and permitted under it, where the header and the README
at this head both say the `cudaFreeAsync` is refused under all three modes and
drops the growth onto the fallback; the handoff event's blocking-sync rationale,
whose "the one host wait on this event" is now a wait only the fallback path
makes; and the growth test's bounds comment, which explained which of a host wait
and a device-wide free the bounds cover when neither is on the path any more.

6 targets, 85 -> 87 cases, all passing on a real GPU with `--nocache_test_results`
and `TORCHTRT_EXECUTORCH_REQUIRE_CUDA=1`, no skips. Every behavioural change was
proven by mutating it away.
@Conarnar
Conarnar force-pushed the perf/executorch-shared-scratch-pool branch from 37d9145 to 45ea07c Compare September 8, 2026 05:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla signed component: api [C++] Issues re: C++ API component: api [Python] Issues re: Python API component: tests Issues re: Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants