Keep the CUDA memory pool warm between delegates - #22312
Open
shoumikhin wants to merge 1 commit into
Open
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22312
Note: Links to docs will display an error until the docs builds have been completed. ✅ You can merge normally! (1 Unrelated Failure)As of commit 4fef258 with merge base c27baa8 ( BROKEN TRUNK - The following job failed but were present on the merge base:👉 Rebase onto the `viable/strict` branch to avoid these failures
This comment was automatically generated by Dr. CI and updates every 15 minutes. |
|
shoumikhin
marked this pull request as draft
August 29, 2026 19:36
shoumikhin
force-pushed
the
fix/cuda-mempool-release-threshold
branch
from
August 29, 2026 21:41
ba2b029 to
ea11a4d
Compare
shoumikhin
marked this pull request as ready for review
August 29, 2026 21:41
shoumikhin
force-pushed
the
fix/cuda-mempool-release-threshold
branch
from
August 30, 2026 18:02
ea11a4d to
aab59d3
Compare
The CUDA delegate allocates through the stream ordered allocator, whose pool hands physical memory back to the driver whenever a synchronization observes a pending free. With the default release threshold of zero that happens repeatedly during one inference, so nearly every allocation has to map memory again. Raising the threshold lets the pool keep the memory it already has. The threshold caps what the pool may keep rather than granting extra headroom, so any fixed value silently stops helping once a model's footprint passes it: a 64 MiB setting against a 256 MiB working set still costs about 259 microseconds per allocation, and at 1 GiB it is no better than no threshold at all. The maximum makes the win independent of model size, and it is what PyTorch's own async allocator sets. Because the memory is then held rather than returned at each synchronize, the backend gives it back explicitly when the last delegate handle is destroyed. The pool being configured is the device default pool, which is shared with every other user of the async allocator in this process. So this reads the current threshold, only ever raises it, remembers the previous value per device, and puts it back on release, rather than overwriting a setting another user chose. Two details that decide whether any of it works. The release trims every device this code raised, taken from its own record, not whichever device the destroying thread happens to be current on, which on a multi GPU host is often not the device the model ran on. And it synchronizes before trimming, because the pool can only release a free the driver has already observed and nothing else on the teardown path waits. A failure to configure the pool only costs speed, so it is logged and execution continues, but the CUDA error is cleared rather than left latched for the next kernel launch check to report against an unrelated kernel. Test plan: Three tests in backends/cuda/runtime/test/test_cuda_allocator.cpp. Each one allocates, frees, synchronizes, and asserts the pool retained the block before releasing it, so none of them can pass when the change is absent. The live allocation test also checks that reserved memory falls but stays above the block still in use, rather than asserting a driver guarantee that holds either way. Measured, per allocation, allocating and freeing with a synchronize between: Orin Nano 3790.79 us before, 2.34 us after Thor 363.00 us before, 1.51 us after H100 40.30 us before, 1.01 us after A100 18.60 us before, 1.03 us after A model split into 25 delegates went from about 714 to about 518 microseconds median on an H100. Peak reserved memory is unchanged on a model whose footprint fits in one pool block. The pool calls have no equivalent in the HIP compatibility header, so both the allocator's pool code and the three tests are compiled out on ROCm, and the change is a no-op there. Not measured: Windows, and whether a second copy of this translation unit in one process keeps its own pool record.
shoumikhin
force-pushed
the
fix/cuda-mempool-release-threshold
branch
from
August 30, 2026 19:27
aab59d3 to
4fef258
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The CUDA delegate allocates through the stream ordered allocator, whose pool
hands physical memory back to the driver whenever a synchronization observes a
pending free. With the default release threshold of zero that happens repeatedly
during one inference, so nearly every allocation has to map memory again.
Raising the threshold lets the pool keep the memory it already has. The threshold
caps what the pool may keep rather than granting extra headroom, so any fixed
value silently stops helping once a model's footprint passes it: a 64 MiB setting
against a 256 MiB working set still costs about 259 microseconds per allocation,
and at 1 GiB it is no better than no threshold at all. The maximum makes the win
independent of model size, and it is what PyTorch's own async allocator sets.
Because the memory is then held rather than returned at each synchronize, the
backend gives it back explicitly when the last delegate handle is destroyed.
The pool being configured is the device default pool, which is shared with every
other user of the async allocator in this process. So this reads the current
threshold, only ever raises it, remembers the previous value per device, and puts
it back on release, rather than overwriting a setting another user chose.
Two details that decide whether any of it works. The release trims every device
this code raised, taken from its own record, not whichever device the destroying
thread happens to be current on, which on a multi GPU host is often not the
device the model ran on. And it synchronizes before trimming, because the pool
can only release a free the driver has already observed and nothing else on the
teardown path waits.
A failure to configure the pool only costs speed, so it is logged and execution
continues, but the CUDA error is cleared rather than left latched for the next
kernel launch check to report against an unrelated kernel.
Test plan:
Three tests in backends/cuda/runtime/test/test_cuda_allocator.cpp. Each one
allocates, frees, synchronizes, and asserts the pool retained the block before
releasing it, so none of them can pass when the change is absent. The live
allocation test also checks that reserved memory falls but stays above the block
still in use, rather than asserting a driver guarantee that holds either way.
Measured, per allocation, allocating and freeing with a synchronize between:
Orin Nano 3790.79 us before, 2.34 us after
Thor 363.00 us before, 1.51 us after
H100 40.30 us before, 1.01 us after
A100 18.60 us before, 1.03 us after
A model split into 25 delegates went from about 714 to about 518 microseconds
median on an H100. Peak reserved memory is unchanged on a model whose footprint
fits in one pool block.
The pool calls have no equivalent in the HIP compatibility header, so both the
allocator's pool code and the three tests are compiled out on ROCm, and the
change is a no-op there.
Not measured: Windows, and whether a second copy of this translation unit in one
process keeps its own pool record.