Stream sync behaving like a device sync on first use of device API fns printf, cudaMalloc etc

I have observed an unexpected CUDA behaviour which we can’t find documented, namely that certain CUDA on device API calls (printf, cudaMalloc etc) seem to cause a non-blocking stream synchronisation on an unrelated stream to behave like device synchronisation. This only happens the first time they are used. We noticed the problem when there is a persistent kernel running and the stream sync never exits. The workaround is to ensure that you exercise the problem APIs at least once and sync before the persistent kernel runs.

Specifically, I replicated it like this:

1 Launching WarmUpKernel to use cudaMalloc once (needed to avoid a hang below)
2 Launching Dummy persistent kernel
3 Launching DummyShortKernel in non-blobking stream B (also uses cudaMalloc)
4 cudaStreamSynchronize on stream B  <- Hangs if no step 1

I replicated this in a standalone visual studio project (relocatable device code on)

#include <cuda_runtime_api.h>
#include <stdio.h>

// Persistent kernel - infinite loop, never returns.
__global__ void DummyPk()
{
    while (1) {   __nanosleep(1000);   }
}

// This kernel uess the device APIs
__global__ void DummyShortKernel()
{
    void* pTemp;
    cudaMalloc(&pTemp, sizeof(int));
    cudaFree(pTemp);
}

// this kernel exercises the deice APIs once before they are used for real. 
__global__ void WarmUpKernel()
{
    void* pTemp;
    cudaMalloc(&pTemp, sizeof(int));
    cudaFree(pTemp);
}

int main()
{
    cudaStream_t StreamA = 0;
    cudaStream_t StreamB = 0;
    const bool bRunWarmup = false;

    CUDA_CHECK(cudaStreamCreateWithFlags(&StreamA, cudaStreamNonBlocking));
    CUDA_CHECK(cudaStreamCreateWithFlags(&StreamB, cudaStreamNonBlocking));

    // Touch the function attributes to force JIT compilation now
    cudaFuncAttributes attr;
    CUDA_CHECK(cudaFuncGetAttributes(&attr, &DummyPk));
    CUDA_CHECK(cudaFuncGetAttributes(&attr, &DummyShortKernel));
    CUDA_CHECK(cudaFuncGetAttributes(&attr, &WarmUpKernel));

    // launch warmup kernel to exercise APIs first time
    if (bRunWarmup)
    {
        printf("Launching WarmUpKernel...\n");
        WarmUpKernel << <1, 1, 0, StreamA >> > ();
        CUDA_CHECK(cudaGetLastError());
        CUDA_CHECK(cudaStreamSynchronize(StreamA));
    }

    // Launch persistent kernel which never exits.
    printf("Launching DummyPk (never exits)...\n");
    DummyPk << <1, 1, 0, StreamA >> > ();
    CUDA_CHECK(cudaGetLastError());

    printf("Launching DummyShortKernel on StreamB...\n");
    DummyShortKernel << <1, 1, 0, StreamB >> > ();
    CUDA_CHECK(cudaGetLastError());

    // Without the warmup Kernel call this stream sync hangs! Why?
    printf("Calling cudaStreamSynchronize(StreamB)\n");
    CUDA_CHECK(cudaStreamSynchronize(StreamB));  // Coda hangs here iff bRunWarmup is false
    printf("cudaStreamSynchronize(StreamB) returned.\n");

    printf("Done.\n");
    return 0;
}

With const bool bRunWarmup = false;

Launching DummyPk (never exits)...
Launching DummyShortKernel on StreamB...
Calling cudaStreamSynchronize(StreamB)

and there the program hangs.

With const bool bRunWarmup = true;

Launching WarmUpKernel...
CDP warm-up
Launching DummyPk (never exits)...
Launching DummyShortKernel on StreamB...
Calling cudaStreamSynchronize(StreamB)
In Short kernel
cudaStreamSynchronize(StreamB) returned.
Done.

It seems to also apply to these fns: printf(), cudaMemsetAsync, cudaMemcpyAsync, cudaStreamCreateWithFlags, cudaStreamDestroy.

Can anyone explain this behaviour or find where it is documented?

It might be the synchronization associated with lazy loading.

Thanks for the speedy reply. I should have said we’re using cuda 12.9 and the environment variable CUDA_MODULE_LOADING=EAGER is set. As we’re working on a real time application we need to avoid lazy loading.

The code runs fine for me in both cases with driver 580.95.05 with cuda 12.8 and cuda 13.0 . (gpu arch sm_86)

Thanks for trying. We are also on sm_86 (rtx3080 or 3050). If possible could you give me your environment vars (i.e. set (win) or export (linux) output) and nvcc command so I can see if there are any relevant differences. It seems unlikely that just 12.9 has the issue if 12.8 and 13.0 are OK but I’ll have to wait until we go to 13.0 to test that.

I ran on cc8.9 (L4 GPU) on Linux with CUDA 13.0 and was able to reproduce the hang. I also ran on cc7.5 on godbolt with CUDA 13.0 and CUDA 13.1 and was able to reproduce the hang (although it requires a little deduction on godbolt - godbolt apparently times out waiting for your code after ~60 seconds). My cc8.9 case was using driver 580.65.06. If time permits I will try switching that machine to driver 580.95.05.

Hi Robert, Is there any progress or timeline for investigating this issue?

I haven’t made any further progress. My suggestion would be to file a bug. I’m not really convinced that it is a bug. I have investigated issues like this in the past with persistent kernels along with other “simultaneous” activity, and eventually went to the dev teams because I couldn’t understand the behavior. In at least one case, the response that I got back was that it was expected behavior and the only documentation support was the note that any CUDA API call can have variable latency.

I’m not saying this is that, or anything really, except that it might be a bug or it might not. But by filing a bug, the dev team will usually look at it.

Thanks for filing 6150942. We can replciate the behavior and we are investigating this. We will bring back conclusion when available.

The printf device syscall writes in the to printf FIFO which is sized using cuCtxSetLimit with CU_LIMIT_PRINTF_FIFO_SIZE.

The malloc/free syscalls alloc/free out of the device heap which is sized using cuCtxSetLimit with CU_LIMIT_MALLC_HEAP_SIZE.

Thread local stack memory is referenced by {logical smid, warpid, threadid} into the stack allocation which is sized CU_LIMIT_STACK_SIZE.

CUDA driver lazy allocates most of these buffers. Allocation an reallocation can require synchronization. If you use these syscalls I highly recommend you size and immediately call dummy kernels using these featuers to trigger the allocation of the buffers. Any resizing of these via cuCtxSetLimit can require a context level synchronization.

Nsight Visual Studio Edition CUDA trace and NVIDIA Visual Profiler showed when these were resized. Nsight Systems and Nsight Compute currently do not show and warn on the impact of resizing these resources.

CUDA programming model can support “persistent” kernels but it is not designed to support unending persistency as there are CUDA context level resources that require a synchronization to resize.

Thanks for the info. Our design does have long term persistence (persistent kernels and self relaunching graphs) to meet hard real time latency requirements. We’re not calling cuCtxSetLimit explicitly.

The “warmup kernel” approach to force initial resizing before we start the persistent kernels seems like a valid workaround for printf and cuda alloc/free.

Will resizing occur for any reason other than calling cuCtxSetLimit explicitly and the initial use of printf/malloc/free? For instance can the stack get resized? More generally are there other unexpected causes of synchronization that we should be aware of since we have persistence?

I am not aware of any other reasons the printf and heap buffers would be resized. I would recommend filing a bug and getting an answer directly from the CUDA Driver team.

The driver will reallocated a larger stack if a grid launch is determined to need more stack space. The driver does not reduce the size of the allocation. Several CUDA minor versions ago stack reduction was removed. A developer can reduce using cuCtxSetLimit.

Thanks for the clarification. I have raised bug 6150942 (see above).

So say we’re on the A100 with 108 SMs and max 2048 threads/SM (64 warps). Is it enough to launch a dummy kernel with108 blocks/grid and 2048 threads/block to ensure the max stack allocation occurs at startup? Or might the scheduling mean actual stack usage is still not the maximum possible?

1024 threads/block is the maximum.

2048 is threads/SM (multiprocessor), if more than one block (of the same or different kernels) is resident at the same time.

the understanding I had is that stack size and in general the kernel local memory allocation at launch was not conditioned on the size of the grid launched. I believe any sort of launch would be sufficient to establish the stack size and corresponding allocation.

stack and local memory (or printf buffer) don’t appear to be issues with the particular code provided (heap is involved, of course). It may still be that a sync/interlock is the reason for the issue, and it may be that that sync/interlock is occasioned by some sort of local memory allocation at launch. I personally don’t know that, however, so I think the results from the bug, if any, may be the best guide for underlying behavior.

Local memory is based upon the physical maximums of the device (sm_count x max_threads_per_sm x stack_size_per_thread). You are likely safe to launch 1 thread but I would recommend if have a stack requirement. If you launch a dummy kernel it may not force the initial allocation.

Hi dear developers ,

Thanks to the triage efforts from our CUDA driver engineer , here is the conclusion.

What happened -
In addition to explicit cuCtxSetLimit/cudaDeviceSetLimit, CUDA may lazily allocate or resize per-context resources when a kernel first needs them.
The main case here is per-thread local memory / stack. cuCtxSetLimit and cudaDeviceSetLimit can be used to size it explicitly, but an ordinary kernel launch can also trigger growth if that kernel requires more local memory or stack than the context currently has reserved. Applying that new configuration has to be ordered with other in-flight work in the context, so with persistent or long-running kernels this can look like an unexpected context-level synchronization.

Device-side printf, malloc/free, and CUDA Dynamic Parallelism/device-runtime paths are also initialized lazily on first launch of a kernel that uses them.

Work around -
The workaround described is the right approach:

  • Set required limits before starting persistent work.
  • Run representative warmup kernels that exercise the same device-side features and maximum local-memory/stack requirements as the steady-state kernels.
  • Synchronize, then start the persistent kernels.

An arbitrary dummy kernel may not be sufficient. The warmup needs to reach the same resource high-water marks and touch the same device-runtime paths as the real workload. Otherwise the first real launch can still trigger allocation or resizing after the persistent kernels are already running. Note that this is why the warmup kernel in your case needs to be ‘same’ to that in streamB kernel to unlock the hang.

To size the warmup, you can inspect kernel resource usage with nvcc -Xptxas -v / --ptxas-options=-v, cuobjdump -res-usage, or cudaFuncGetAttributes/cuFuncGetAttribute localSizeBytes. Subsequent launches will reuse the initialized resources unless a later launch requires a larger allocation or the application changes the relevant limits.

Why CUDA_MODULE_LOADING=EAGER doesn’t make difference -
That is expected, and it points to an important distinction. There are two independent mechanisms, and that setting only affects one of them:

  1. Lazy module/kernel loading - controlled by CUDA_MODULE_LOADING=LAZY/EAGER. This governs when CUDA loads module/kernel code. EAGER avoids deferred-code-loading delays, but it does not force per-context runtime resources to be allocated or sized up front.
  2. First-use runtime resource bring-up and launch-time resource sizing - independent of CUDA_MODULE_LOADING. This is what affected your repro.

When these resources are actually created:

  • Per-thread local memory / stack: a default is reserved at context creation, but it can grow at kernel launch whenever a launched kernel needs more than the current reservation. This depends on the specific kernel’s footprint, so module-loading mode does not pre-size it.
  • Device printf and device malloc/free: their backing buffers are created on the first launch of a kernel that uses the feature, regardless of module-loading mode.
  • Dynamic parallelism / device-side launch: some device-runtime setup is tied to loading a module that contains device-launch code, so EAGER may move that part earlier. However, EAGER still does not pre-size launch-dependent resources such as local memory / stack.

Why it is designed this way: these resources are optional and can be large. Most kernels do not use device printf, device malloc/free, or device-side launch, and local-memory reservations can be substantial. Allocating all of this for every context up front would waste memory for the common case. Several of these resources are also tunable via cudaDeviceSetLimit/cuCtxSetLimit and are effectively fixed once created, so deferring creation lets CUDA honor an application-set limit before the resource is locked in at a size.

Best,
Yuki

Thank you for the detailed response whcih is very useful for us.