Stream-ordered allocation and memory pools
Give each iteration of day 54's pipeline a fresh scratch buffer, and its
throughput falls. The kernels and sizes have not changed. The only new calls
are cudaMalloc(&d_tmp, bytes) at the top of the loop and cudaFree(d_tmp)
at the bottom.
Those calls stop the overlap from day 51 and the pipeline from day 54. A timer shows the cost but not its cause. This page compares three versions of the loop and uses an Nsight Systems timeline to find the call responsible.
What cudaFree waits for
cudaMalloc and cudaFree are not stream
operations. They take no stream argument and act on the whole device. A block
being freed may still be in use by queued kernels, so the allocator must wait
until they can no longer write to it.
NVIDIA's walkthrough of this API says: "the first
cudaFree call has to wait for kernelA to finish, so it synchronizes the
device before freeing the memory"
(https://developer.nvidia.com/blog/using-cuda-stream-ordered-memory-allocator-part-1/
, checked 2026-09-01).
Put that inside a loop and each iteration queues two kernels, then waits for the device to finish. This has the same effect as a device synchronise. The queue never holds more than one iteration, so the program cannot overlap iterations.
cudaMallocAsync and cudaFreeAsync change the ordering rules rather than
the cost of the bytes. Each takes a stream, so the host queues the allocation
and free like kernel launches, then continues.
The memory comes from a
memory pool owned by the device, and
"Each call to cudaFreeAsync returns memory to the pool, which is then
available for re-use on subsequent cudaMallocAsync requests" (same post,
checked 2026-09-01). The second iteration's allocation is a bookkeeping
entry against a block the pool already holds, not a request to the OS. The
host does not wait for the device.
Diagram: one iteration, three allocation strategies. Three horizontal bands, each a CUDA API row above a GPU kernel row on one time axis, showing three loop iterations. Band 1, malloc per iteration: each iteration is a
cudaMallocbar, two short launch bars, then a widecudaFreebar spanning the kernels below, with a gap on the kernel row before the next iteration starts. Caption "100 allocations, 100 device drains." Band 2, hoisted: the API row is a burst of narrow launch bars that ends early; the kernel row is solid. Caption "1 allocation, 0 drains." Band 3, cudaMallocAsync: same solid kernel row as band 2, and the API row adds narrow alloc and free bars but still finishes ahead of the device. Caption "100 allocations, 0 drains." Alt text: "Freeing with cudaFree drains the device every iteration and leaves gaps on the kernel row. A hoisted buffer and a hundred stream-ordered allocations both keep the kernel row solid."
Host malloc does not track GPU work
Host malloc and free are
calls into a user-space allocator that mostly touches a free list; nothing
about them cares what your other threads are doing with unrelated data, and
putting them in a loop is rarely what kills a C program. So
cudaMalloc in a loop looks like a style problem, not a performance
problem.
On the GPU, the free depends on the work queue because the caller returns
before the kernels run. Only the driver knows when the block is idle, so
cudaFree waits for all queued work.
cudaFreeAsync resolves it by making the free part of the queue itself:
the block is reclaimed when the stream reaches the free, after the kernels
that use it and before whatever is queued next. This keeps the same safety
without stopping the host.
Day 51 gave you the rule that order lives in streams rather than in source code; this page extends that rule to the lifetime of memory.
Three loops, one accumulator
Full program in
code/day55-mallocasync/mempool.cu.
Three properties matter.
Every loop does identical work and must produce identical results. One
hundred iterations, each writing 2 * in[i] into a 16 MiB scratch buffer
and accumulating it. The three accumulators are checked against the host
reference, so "the fast version skipped work" is a failure, not a footnote.
The events bracket the whole loop, on the
stream. Device time the GPU spends idle while the host stands inside
cudaMalloc or cudaFree is inside the measurement, because that idle
time is the cost being tested. A warm-up before any timing covers both kernels
and both allocators, so the first cudaMallocAsync does not pay for
creating the pool's first block inside a measured phase.
Every phase is an NVTX range, named the way day 41
named them, so nsys stats --report nvtx_sum prints the comparison as a
table without the GUI.
Here is the loop everyone writes first, and the one this page exists to replace:
// The loop everyone writes first. cudaMalloc and cudaFree are not stream
// operations: cudaFree cannot hand back memory the queued kernels still
// use, so it waits for the device to drain, once per iteration.
static void loopMallocPerIter(const float* d_in, float* d_scratch, float* d_acc,
cudaStream_t stream, int blocks) {
(void)d_scratch; // this variant allocates its own
nvtxRangePushA("malloc-per-iter");
for (int iter = 0; iter < kIters; ++iter) {
float* d_tmp = nullptr;
CUDA_CHECK(cudaMalloc(&d_tmp, kScratchBytes));
writeScaled<<<blocks, kThreadsPerBlock, 0, stream>>>(d_in, d_tmp,
kElems);
accumulate<<<blocks, kThreadsPerBlock, 0, stream>>>(d_tmp, d_acc,
kElems);
CUDA_CHECK(cudaFree(d_tmp));
}
CUDA_CHECK(cudaGetLastError());
nvtxRangePop();
}
The stream-ordered version differs by exactly two calls, which is the point: this is a mechanical replacement, not a rewrite. The hoisted variant between them, one buffer allocated before the loop, is in the repo and is the baseline both others are judged against.
// Same shape as the first loop, but allocation and free are operations in
// stream order against the device's default pool. The host queues them and
// moves on; the free returns the block to the pool, and the next iteration's
// cudaMallocAsync reuses it without touching the OS.
static void loopMallocAsync(const float* d_in, float* d_scratch, float* d_acc,
cudaStream_t stream, int blocks) {
(void)d_scratch; // this variant allocates its own
nvtxRangePushA("malloc-async");
for (int iter = 0; iter < kIters; ++iter) {
float* d_tmp = nullptr;
CUDA_CHECK(cudaMallocAsync(&d_tmp, kScratchBytes, stream));
writeScaled<<<blocks, kThreadsPerBlock, 0, stream>>>(d_in, d_tmp,
kElems);
accumulate<<<blocks, kThreadsPerBlock, 0, stream>>>(d_tmp, d_acc,
kElems);
CUDA_CHECK(cudaFreeAsync(d_tmp, stream));
}
CUDA_CHECK(cudaGetLastError());
nvtxRangePop();
}
The pool has one knob this lesson touches: the release threshold. CUDA 12.6 documents it as the "Amount of reserved memory in bytes to hold onto before trying to release memory back to the OS" with a default of zero (https://docs.nvidia.com/cuda/archive/12.6.2/cuda-runtime-api/group__CUDART__MEMORY__POOLS.html , checked 2026-09-01). Default zero means every synchronisation empties the pool, and the next allocation after a sync goes back to the OS as if the pool were not there.
// The release threshold is the amount of memory the pool may keep across a
// synchronisation. At the default of zero, any stream or event synchronise
// hands everything back to the OS; at UINT64_MAX the pool keeps what it has.
static void raiseReleaseThreshold(cudaMemPool_t pool) {
uint64_t threshold = UINT64_MAX;
CUDA_CHECK(cudaMemPoolSetAttribute(pool, cudaMemPoolAttrReleaseThreshold,
&threshold));
}
The program proves the knob works by reading
cudaMemPoolAttrReservedMemCurrent twice: after a synchronise at the
default threshold, and after a synchronise with the threshold raised.
Note. Inside a loop with no synchronisation, the default threshold costs nothing, because releasing happens at sync points and the loop has none. The threshold matters to a program that syncs between batches, which is every real program, and that is why the demo reads the pool after a sync rather than inside the loop.
Results
Re-verified 2026-09-02 on the same Tesla T4 with driver 580.173.02 and CUDA 13.0 (V13.0.88). All correctness and pool-reservation gates reproduced. The loop ratios moved only from 2.56x to 2.52x and from 1.01x to 1.01x.
Both transcripts and the fresh CUDA 13 report are listed in front matter.
The published CUDA 12.6 report the exercise needs is at
content/profiles/stream-ordered-allocation/: day55-mempool.nsys-rep
with its nvtx_sum and cuda_api_sum CSV exports beside it.
| Loop | Total (ms) | us per iteration | vs hoisted |
|---|---|---|---|
| malloc-per-iter | 85.588 | 855.9 | 2.52x |
| hoisted | 33.977 | 339.8 | 1.00x |
| malloc-async | 34.248 | 342.5 | 1.01x |
| Release threshold | Pool reserved after sync (bytes) |
|---|---|
| 0 (default) | 0 |
| UINT64_MAX | 33,554,432 |
Predictions
malloc-per-itercosts at least 3 timeshoisted, and the excess sits incudaFree, notcudaMalloc: incuda_api_sum,cudaFree's total time exceedscudaMalloc's, because the free carries the drain and the malloc only carries the OS bookkeeping.cuda_api_sumaggregates the whole capture, and here that grades the loop: 100 of the capture's 104 calls to each of the two functions come from themalloc-per-iterloop, the other four from setup, teardown and one warm-up. If the two totals come out even, the mechanism section above is half wrong and gets rewritten.malloc-asyncstays within 1.25 timeshoisted. A hundred pool reuses should cost queue entries, not memory traffic. Over 1.5 and the pool is doing per-iteration work I have not accounted for.- The pool holds nothing at the default threshold and at least 16,779,660 bytes after the threshold is raised, both read after a stream synchronise. That number is one scratch buffer; the pool may legally hold more.
- The timeline shapes differ more than the totals. Inside
malloc-per-iterthe kernel row shows a gap every iteration under acudaFreeAPI bar; insidemalloc-asyncthe API row finishes ahead of the kernel row, the day 51 signature of a host running free of its device.
What the run said
- Both parts failed.
The loop cost is smaller than predicted: 2.52x hoisted, under
the 3x bar. In the published CUDA 12.6
cuda_api_sum, the excess sits incudaMalloc(104 calls, 81.143 ms total) rather thancudaFree(104 calls, 49.310 ms). On this driver, the allocation call after the free accounts for most of the wait, so the prediction assigned the cost to the wrong call. - Held.
malloc-asynclanded at 1.01x hoisted: a hundred pool reuses cost queue entries, not memory traffic. - Held. The pool reports 0 bytes at the default threshold and 33,554,432 bytes after raising it, both read after a stream synchronise; that is two scratch buffers' worth, which the prediction's "may legally hold more" allowed.
- Left to the report. The two timeline shapes (a gap under an API
bar per iteration against an API row running ahead of the kernel row)
are the shipped capture's to show; open
day55-mempool.nsys-repand compare themalloc-per-iterandmalloc-asyncranges.
The ratio matters more than the measured milliseconds. The drain cost varies
by GPU, but cudaFree inside a loop must wait for queued uses of the block.
Stream-ordered freeing avoids that wait.
Run it yourself
Use a CUDA GPU that reports cudaDevAttrMemoryPoolsSupported as 1.
nvcc -std=c++17 -O3 -arch=sm_75 -lineinfo -o mempool mempool.cu
nsys profile -t cuda,nvtx -o day55-mempool ./mempool
The program checks whether cudaMallocAsync is supported and reports an
unsupported device instead of failing. If you cannot run the profiler, read
/setup/learn-cuda-without-a-gpu and do
the exercise from the shipped report.
Exercise
Write the three loops yourself, or take your own pipeline from day 54 and
give each iteration a per-iteration scratch buffer, then convert it to
cudaMallocAsync. Measure all three with events, profile the malloc and
async versions with -t cuda,nvtx, and name the API call that cost the
difference.
Time: 30 to 45 minutes. Submit: the three per-iteration times,
the malloc-per-iter over malloc-async ratio, and one sentence naming
the call the time came from.
Check: a ratio, since absolute times differ card to card.
malloc-per-iter over hoisted should come out well above
1, and malloc-async over hoisted close to 1. If your async loop is no
faster than your malloc loop, either the pool is releasing between
iterations because something inside your loop synchronises, or the
allocations are not on the stream the kernels are on; the
cuda_api_sum table says which, and the shipped report at
content/profiles/stream-ordered-allocation/ gives the same table if you
cannot run nsys.
Hint 1
The cost you are removing is not the bytes; the same bytes get allocated a hundred times in both loops. Which call in the slow loop cannot return until the device has finished everything, and what is the device doing while the host waits in it?
Hint 2
Sort cuda_api_sum by total time. Compare the total for cudaFree
against the total for cudaMalloc, then against the sum of everything
else in the loop. One of those three comparisons is the whole lesson.
Solution
The call is cudaFree. It cannot release a block the queued kernels
might still touch, so it waits for the device to drain, once per
iteration, and the drain costs more than the allocation bookkeeping ever
did. cudaFreeAsync queues the free into the stream instead, after the
kernels that use the block, so the pool reclaims it at exactly the right
moment with nobody waiting; the next cudaMallocAsync reuses the block
without going near the OS.
Queue a resource's lifetime operations in the stream that uses it. A host call that waits for the device serialises the pipeline. A stream-ordered operation keeps that wait in the queue.
Pitfalls
Your async loop is no faster because something inside it synchronises.
A cudaStreamSynchronize, a blocking event query
or a synchronous cudaMemcpy inside the loop drains the queue, and at the
default release threshold every drain also empties the pool back to the
OS. Hoist the sync out of the loop, or raise the threshold and keep the
sync.
You freed on a different stream than the one using the memory.
cudaFreeAsync orders the free against the stream you pass it, not
against every stream. Freeing on stream B while stream A's kernels still
read the block is a use-after-free that no compiler warns about. Free on
the stream that used the memory, or connect the two with an event first,
the day 52 pattern.
You timed the async loop with a host clock and no sync.
cudaMallocAsync, the launches and cudaFreeAsync all return
immediately, so a wall clock around the loop measures submission, not
execution. Synchronise the stream before reading the clock. The program
times with events on the stream and synchronises on the stop event, which
is the same discipline day 9 taught with one kernel.
You expected the first allocation to get faster. The pool starts
empty, and its first block still comes from the OS; what gets cheap is
reuse. A program that allocates once at startup gains nothing from
cudaMallocAsync, which is why day 5 taught
cudaMalloc and this page has no reason to unteach it there.
You read the pool holding memory as a leak. After the threshold is
raised, nvidia-smi shows the process holding the pool's reserved bytes
even though every allocation was freed. That memory is the pool doing its
job. cudaMemPoolAttrReservedMemCurrent against
cudaMemPoolAttrUsedMemCurrent tells you how much is cache versus how
much your code still holds.
Go deeper
- CUDA C++ Programming Guide, "Stream Ordered Memory Allocator": https://docs.nvidia.com/cuda/archive/12.6.2/cuda-c-programming-guide/index.html#stream-ordered-memory-allocator (checked 2026-09-01)
- CUDA Runtime API, "Stream Ordered Memory Allocator" module, for
cudaMallocAsync,cudaFreeAsyncand everycudaMemPoolAttr: https://docs.nvidia.com/cuda/archive/12.6.2/cuda-runtime-api/group__CUDART__MEMORY__POOLS.html (checked 2026-09-01) - "Using the NVIDIA CUDA Stream-Ordered Memory Allocator, Part 1", the post this page quotes twice: https://developer.nvidia.com/blog/using-cuda-stream-ordered-memory-allocator-part-1/ (checked 2026-09-01)
cuda-samples,Samples/2_Concepts_and_Techniques/streamOrderedAllocation
Next
Day 56 captures a whole launch sequence as a CUDA
graph and replays it for less than the
launch overhead day
48 measured, and stream-ordered allocation is
what makes memory legal inside one: a graph can own allocation nodes, but
never a cudaMalloc. Day 60's frame pipeline then uses today's pool under
a real frames-per-second target, where one accidental drain is a dropped
frame you can see.