all_lessons / gpu_kernels / 31 · cuda common operations lesson 31 / 33

Common operations, declarations, call flows

A from-first-principles tour of the CUDA mechanical model: how the host queues work, how the device executes it, and the exact vocabulary an interviewer expects you to use when you talk about it.

Linearized thinking

Carry this invariant through every line of CUDA you read or write. For each statement, answer three questions:

  1. Which thread owns which output element? (the index math)
  2. Which bytes does that thread touch? (which memory space, what stride, is the access coalesced)
  3. What must be synchronized before the next step can safely read the result? (a barrier, a stream order, an event, a device sync)

A CUDA program is correct exactly when every line has a defensible answer to all three. If you cannot name the owner, the bytes, and the synchronization, you have a bug waiting to happen.

1. The split-execution model

CUDA is two computers cooperating. The host is the CPU and its DRAM. The device is the GPU and its on-board memory (HBM/GDDR). They have separate physical address spaces: a raw pointer the CPU dereferences names host DRAM; a raw pointer the GPU dereferences names device memory. A host pointer dereferenced on the device (or vice versa) is undefined behavior, not a copy.

The relationship is producer/consumer through a queue. The host does not run GPU code; it enqueues work onto a stream, and the device's hardware scheduler consumes that queue asynchronously. This is the single most important fact in the whole API:

ASYNCHRONOUS BY DEFAULT

A kernel launch kernel<<<...>>>(...) returns to the CPU before the kernel has finished — usually before it has even started. The launch only places a work item on the stream. The CPU is then free to keep going. This is a feature (it lets you overlap CPU work, copies, and compute), but it is also the source of nearly every beginner bug: timing, reading results too early, and "errors" that surface on a later, unrelated API call.

Because execution is asynchronous and the address spaces are separate, synchronization is always explicit. There is no automatic "wait for the GPU and copy the answer back." You issue a copy, and you issue a wait. Nothing is implicit except the ordering within a single stream (covered below).

2. A minimal complete program: SAXPY

SAXPY is the "hello world" of dense compute: out = a*x + y for vectors x and y and a scalar a. It is embarrassingly parallel — every output element is independent — so it shows the full host/device call flow with no algorithmic distraction.

First the error-checking macro. Every runtime call returns a cudaError_t; ignoring it is the most common cause of silent corruption. Wrap them.

#include <cstdio>
#include <cstdlib>
#include <cuda_runtime.h>

// Wrap every runtime call. __FILE__/__LINE__ pinpoint the failing site.
#define CHECK_CUDA(call)                                                  \
    do {                                                                  \
        cudaError_t err_ = (call);                                        \
        if (err_ != cudaSuccess) {                                        \
            fprintf(stderr, "CUDA error %s at %s:%d: %s\n",               \
                    cudaGetErrorName(err_), __FILE__, __LINE__,           \
                    cudaGetErrorString(err_));                            \
            exit(EXIT_FAILURE);                                           \
        }                                                                 \
    } while (0)

The kernel. Note the three linearization answers are visible right here: the owner is i, the bytes touched are x[i], y[i], out[i] (contiguous, one element per thread), and nothing needs synchronizing because threads never touch each other's elements.

__global__ void saxpy(int n, float a,
                      const float* __restrict__ x,
                      const float* __restrict__ y,
                      float* __restrict__ out)
{
    int i = blockIdx.x * blockDim.x + threadIdx.x;   // this thread's global index
    if (i < n)                                       // boundary check: grid may overshoot n
        out[i] = a * x[i] + y[i];
}

The host driver. Read it as: allocate on both sides, push inputs to the device, launch, check, wait, pull the result back, free.

int main(void)
{
    const int    n     = 1 << 20;          // ~1M elements
    const size_t bytes = (size_t)n * sizeof(float);
    const float  a     = 2.0f;

    // 1. Host allocation + init.
    float* h_x   = (float*)malloc(bytes);
    float* h_y   = (float*)malloc(bytes);
    float* h_out = (float*)malloc(bytes);
    for (int i = 0; i < n; ++i) { h_x[i] = 1.0f; h_y[i] = 2.0f; }

    // 2. Device allocation.
    float *d_x, *d_y, *d_out;
    CHECK_CUDA(cudaMalloc(&d_x,   bytes));
    CHECK_CUDA(cudaMalloc(&d_y,   bytes));
    CHECK_CUDA(cudaMalloc(&d_out, bytes));

    // 3. Host -> Device copies (inputs).
    CHECK_CUDA(cudaMemcpy(d_x, h_x, bytes, cudaMemcpyHostToDevice));
    CHECK_CUDA(cudaMemcpy(d_y, h_y, bytes, cudaMemcpyHostToDevice));

    // 4. Launch. Cover n with ceil(n / blockSize) blocks of 256 threads.
    int block = 256;
    int grid  = (n + block - 1) / block;
    saxpy<<<grid, block>>>(n, a, d_x, d_y, d_out);

    // 5. Catch launch-config errors immediately (synchronous).
    CHECK_CUDA(cudaGetLastError());
    // 6. Wait for the kernel and surface any runtime (async) error.
    CHECK_CUDA(cudaDeviceSynchronize());

    // 7. Device -> Host copy (result). cudaMemcpy also blocks until done.
    CHECK_CUDA(cudaMemcpy(h_out, d_out, bytes, cudaMemcpyDeviceToHost));

    // 8. Verify + clean up.
    printf("out[0] = %f (expect 4.0)\n", h_out[0]);
    cudaFree(d_x); cudaFree(d_y); cudaFree(d_out);
    free(h_x); free(h_y); free(h_out);
    return 0;
}
WHY TWO ERROR CHECKS AFTER A LAUNCH

A launch can fail in two distinct ways. Configuration errors (too many threads per block, kernel not found, invalid shared-memory request) are detected synchronously and reported by cudaGetLastError() right after the launch. Runtime errors inside the kernel (illegal address, misaligned access) happen later on the device and only surface on the next blocking call — here cudaDeviceSynchronize(). You need both checks to know whether the launch was accepted and whether it ran cleanly.

3. The call flow, drawn out

HOST (CPU + DRAM) DEVICE (GPU + HBM) ----------------- ------------------ malloc h_x,h_y,h_out ........ owns the input/output arrays in DRAM cudaMalloc(&d_x ...) -- reserve --> [ d_x | d_y | d_out ] (uninitialized HBM) cudaMemcpy H2D ===== bytes =====> d_x, d_y now hold inputs (PCIe / NVLink transfer) saxpy<<<grid,block>>> -- enqueue --> launch returns IMMEDIATELY (CPU keeps running) SM scheduler runs blocks -> warps each thread: out[i] = a*x[i]+y[i] cudaDeviceSynchronize -- block --> ... CPU parked until kernel done ... (CPU resumes here) <-- signal -- kernel complete, d_out filled cudaMemcpy D2H <==== bytes ====== h_out now holds the result cudaFree(d_*) -- release --> HBM reclaimed free(h_*) ........ DRAM reclaimed

4. The execution hierarchy

When you launch a kernel you spawn a grid of threads, organized into a strict three-level hierarchy. Knowing the level each builtin lives at is core interview vocabulary.

The built-in variables, all of type dim3 (with .x/.y/.z fields):

BuiltinMeaningScope
threadIdxthis thread's index within its block0 .. blockDim-1
blockIdxthis block's index within the grid0 .. gridDim-1
blockDimnumber of threads per blockset at launch
gridDimnumber of blocks in the gridset at launch
WHY 32 MATTERS

The 32-thread warp is a hardware constant, not a tuning knob. Consequences you must be able to recite: (1) Block sizes should be multiples of 32 or you waste lanes on the final partial warp. (2) Warp divergence — if threads in one warp take different branches of an if, the hardware serializes the branches (executes the taken path with the other lanes masked off), so divergent control flow within a warp costs throughput. (3) Memory coalescing is evaluated per warp: if the 32 lanes access 32 contiguous, aligned words, the hardware merges them into the minimum number of memory transactions. (4) Warp-shuffle primitives exchange data among exactly these 32 lanes for free.

5. The launch configuration

The triple-angle-bracket syntax takes up to four parameters:

kernel<<< grid, block, sharedBytes, stream >>>(args...);
ParamTypeMeaning
griddim3 or intnumber of blocks in the grid (its dimensions)
blockdim3 or intnumber of threads per block (its dimensions)
sharedBytessize_tbytes of dynamic shared memory per block (the size of the extern __shared__ array). Default 0.
streamcudaStream_tthe stream to enqueue on. Default 0 (the default/null stream).

An int is promoted to a 1D dim3, so saxpy<<<grid, block>>> means dim3(grid,1,1) blocks of dim3(block,1,1) threads. For a 2D launch you build the dim3 explicitly: dim3 b(16,16); dim3 g((W+15)/16,(H+15)/16);

6. Function and variable declarations

CUDA extends C++ with execution-space qualifiers (where a function may be called from and where it runs) and memory-space qualifiers (where a variable lives).

QualifierMeaningInterview trap
__global__a kernel: callable from host, runs on device. Must return void; results come back through pointer arguments.Cannot return a value, and the launch is async — don't expect a result on the next line without a sync.
__device__a function callable only from device code (kernels or other device functions).Not callable from the host. Forgetting the qualifier on a helper a kernel calls is a compile error.
__host__ordinary CPU function. This is the implicit default if nothing is specified.A plain function is host-only and invisible to device code unless also marked __device__.
__host__ __device__compiled twice, once for each space; callable from both.The body must use only constructs valid on both sides; you cannot call a host-only library (e.g. std::vector operations) inside it.
__constant__read-only device global, cached in the constant cache; set from host with cudaMemcpyToSymbol.Fast only when all threads in a warp read the same address (broadcast); divergent addresses serialize. Limited to 64 KB total.
__shared__per-block scratchpad in on-chip SRAM; one copy shared by all threads of the block.Lifetime is the block, scope is the block — it is not shared across blocks, and writes need __syncthreads() before other threads read them.
__restrict__a promise that this pointer does not alias any other restricted pointer, enabling the compiler to cache and reorder loads.It is a promise; if the buffers actually overlap you get silent wrong answers, not a diagnostic.
dim3a 3-component unsigned struct used for grid/block sizes; unspecified components default to 1.dim3 b(256) is 256×1×1; people forget the .y/.z default and miscompute 2D launches.

7. Indexing patterns

1D global index with boundary check

The grid usually covers more elements than exist (because block size rarely divides n), so the boundary check is not optional — without it the tail threads write out of bounds.

int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) out[i] = /* ... */;

2D row-major: make the column the fastest-varying axis

For a row-major matrix A[row][col] stored as A[row*width + col], you want consecutive threads (consecutive threadIdx.x, i.e. one warp) to hit consecutive col values so their global addresses are contiguous and coalesce. Therefore map threadIdx.x to the column:

int col = blockIdx.x * blockDim.x + threadIdx.x;   // fastest-varying -> contiguous
int row = blockIdx.y * blockDim.y + threadIdx.y;
if (row < height && col < width)
    out[row * width + col] = in[row * width + col];
COALESCING TRAP

If you instead write out[col * height + row] (mapping threadIdx.x to the row), the 32 lanes of a warp now stride by height elements apart in memory. Each access becomes its own transaction and you can lose ~10x of bandwidth. The fastest-varying thread dimension must align with the fastest-varying (innermost, unit-stride) memory dimension.

Grid-stride loop

Instead of sizing the grid to n, launch a fixed, hardware-sized grid and have each thread stride through the data by the total thread count:

__global__ void saxpy_gs(int n, float a, const float* x, const float* y, float* out)
{
    int stride = blockDim.x * gridDim.x;          // total threads launched
    for (int i = blockIdx.x * blockDim.x + threadIdx.x; i < n; i += stride)
        out[i] = a * x[i] + y[i];
}

Why it exists: it decouples the launch size from the problem size. You pick a grid that just saturates the GPU (e.g. a few blocks per SM) once, and the same binary handles n = 1000 or n = 10^9 without relaunching or overflowing gridDim limits. It keeps every SM busy, makes the kernel trivially correct for any n, and improves data reuse since each thread processes multiple elements. It is the idiomatic default for elementwise kernels.

8. Memory spaces

The performance of a CUDA kernel is dominated by which memory space each access hits. Memorize this hierarchy — it is the most-asked table in interviews.

SpaceScopeLifetimeRelative speedTypical use
Registersone threadthreadfastest (~1 cycle)scalars, loop counters, accumulators
Sharedone blockblockvery fast on-chip SRAM (~10s of cycles)tiling, intra-block communication, reductions
Constantgrid (read-only)applicationfast when broadcast (cached)coefficients, params read uniformly by a warp
Localone threadthreadslow (it lives in global memory!)register spills, per-thread arrays indexed dynamically
Global / HBMwhole deviceapplication (until freed)slowest (~100s of cycles), highest capacitythe main input/output arrays
"LOCAL" IS NOT FAST

Despite the name, local memory is not on-chip. It is a per-thread region carved out of global memory, used when a thread has more live values than registers (a "spill") or indexes a local array with a runtime variable. Seeing high local-memory traffic in a profile means you are accidentally hitting HBM for what you thought were registers.

9. Host/device memory operations

CallWhat it does
cudaMalloc(&p, bytes)allocate bytes in device global memory; p is a device pointer.
cudaMemcpy(dst, src, bytes, kind)copy between spaces, blocking on the host. kind is cudaMemcpyHostToDevice, ...DeviceToHost, ...DeviceToDevice, or ...Default (inferred from pointer attributes).
cudaMemcpyAsync(dst, src, bytes, kind, stream)same copy, enqueued on stream, returns immediately. Truly asynchronous w.r.t. the host only if the host buffer is pinned.
cudaFree(p)release a cudaMalloc allocation.
cudaMallocHost(&p, bytes)allocate pinned (page-locked) host memory; free with cudaFreeHost.
WHY PINNED MEMORY ENABLES OVERLAP

Ordinary host memory is pageable — the OS may move or swap it. The DMA engine that drives a copy cannot safely transfer from a buffer that might move, so a copy from pageable memory is staged through an internal pinned bounce buffer, which forces it to be effectively synchronous. Pinned memory is page-locked, so the DMA engine can read it directly. Only then can cudaMemcpyAsync overlap a transfer with kernel execution or with another copy on a different stream. Pinned memory is the prerequisite for any real copy/compute overlap.

10. Streams

A stream is an ordered queue of device work. The ordering rule is the whole point:

Putting independent copy/compute/copy chains on multiple streams lets the GPU overlap a transfer on one stream with a kernel on another:

cudaStream_t s;
CHECK_CUDA(cudaStreamCreate(&s));

float* h_pinned;
CHECK_CUDA(cudaMallocHost(&h_pinned, bytes));    // pinned -> async is real
// ... fill h_pinned ...

CHECK_CUDA(cudaMemcpyAsync(d_x, h_pinned, bytes, cudaMemcpyHostToDevice, s));
saxpy<<<grid, block, 0, s>>>(n, a, d_x, d_y, d_out);   // 4th launch arg = stream
CHECK_CUDA(cudaGetLastError());
CHECK_CUDA(cudaMemcpyAsync(h_pinned, d_out, bytes, cudaMemcpyDeviceToHost, s));

CHECK_CUDA(cudaStreamSynchronize(s));            // block host until s is drained
CHECK_CUDA(cudaStreamDestroy(s));
CHECK_CUDA(cudaFreeHost(h_pinned));

The three operations above run in order because they share stream s: the copy-in finishes before the kernel starts, the kernel finishes before the copy-out starts. Split them across two streams and the kernel could read d_x before the copy-in completed — a classic race.

11. Events

An event is a marker you record into a stream. It serves two jobs: expressing cross-stream dependencies, and timing GPU work accurately.

cudaEvent_t start, stop;
CHECK_CUDA(cudaEventCreate(&start));
CHECK_CUDA(cudaEventCreate(&stop));

CHECK_CUDA(cudaEventRecord(start, s));               // mark "now" on stream s
saxpy<<<grid, block, 0, s>>>(n, a, d_x, d_y, d_out);
CHECK_CUDA(cudaEventRecord(stop, s));

CHECK_CUDA(cudaEventSynchronize(stop));              // wait until 'stop' is reached
float ms = 0.0f;
CHECK_CUDA(cudaEventElapsedTime(&ms, start, stop));  // GPU time between the two marks
printf("kernel took %.3f ms\n", ms);

To make stream B wait for work in stream A, record an event in A and call cudaStreamWaitEvent(B, evt, 0). This is the correct, fine-grained alternative to a full cudaDeviceSynchronize(), which would stall everything.

TIME WITH EVENTS, NOT THE CPU CLOCK

Wrapping a launch in a CPU timer measures the enqueue time, not the kernel runtime — the launch returns immediately. Events are recorded into the stream alongside the work, so the interval between two events is genuine on-device time. (See the next section for why the CPU-timer approach is wrong.)

12. Shared memory and __syncthreads()

A neighbor/stencil computation is the canonical reason to use shared memory: each output reads several adjacent inputs, so cooperatively staging a tile into shared memory lets the block read each global element once instead of repeatedly. Here each thread averages itself with its two neighbors, using dynamic shared memory plus a halo of one element on each side.

// Launch with sharedBytes = (blockDim.x + 2) * sizeof(float)
__global__ void stencil3(const float* __restrict__ in, float* __restrict__ out, int n)
{
    extern __shared__ float tile[];          // size set by the 3rd launch param
    int g = blockIdx.x * blockDim.x + threadIdx.x;
    int t = threadIdx.x + 1;                 // +1 leaves room for the left halo

    // Each thread loads its own element into the interior of the tile.
    tile[t] = (g < n) ? in[g] : 0.0f;

    // The two edge threads also load the halo cells.
    if (threadIdx.x == 0) {
        tile[0]                = (g > 0)     ? in[g - 1] : 0.0f;
        int r = g + blockDim.x;
        tile[blockDim.x + 1]   = (r < n)     ? in[r]     : 0.0f;
    }

    __syncthreads();                         // ALL loads must finish before any read

    if (g < n)
        out[g] = (tile[t - 1] + tile[t] + tile[t + 1]) / 3.0f;
}

__syncthreads() is a block-wide barrier: no thread proceeds past it until every thread in the block has reached it. It is what makes "thread A writes tile[...], thread B reads it" safe. Without the barrier, B could read tile before A's store landed.

DIVERGENT BARRIER = DEADLOCK

Every non-exited thread in the block must reach the same __syncthreads(). Putting it inside divergent control flow hangs the kernel forever, because the threads that took the other branch never arrive:

if (threadIdx.x < 16) {
    __syncthreads();    // WRONG: threads 16..blockDim never reach it -> deadlock
}

The barrier must be at uniform program scope across the block. Compute the condition, sync unconditionally, then branch on the result.

13. Warp-level primitives (intro)

Threads in a warp can exchange register values directly, with no shared memory and no barrier, via shuffle and vote intrinsics. The leading argument is an active mask — a 32-bit bitset naming which lanes participate (use 0xffffffff when the whole warp is active). Every lane in the mask must execute the same intrinsic.

// Sum the 32 values held by one warp; result lands in lane 0.
__device__ float warp_sum(float v) {
    for (int d = 16; d > 0; d >>= 1)
        v += __shfl_down_sync(0xffffffff, v, d);
    return v;
}

These are register-to-register, so they are dramatically faster than the shared-memory equivalent. Lesson 02 builds full reductions and scans on top of them.

14. Atomics

When many threads must update the same address — e.g. building a histogram — an ordinary ++ is a read-modify-write race. atomicAdd makes the update indivisible:

__global__ void histogram(const unsigned char* data, int n, unsigned int* bins)
{
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n)
        atomicAdd(&bins[data[i]], 1u);    // indivisible RMW on bins[...]
}
PRIVATIZE, THEN MERGE

Atomics on the same address serialize: contention turns parallel threads into a queue. The standard instinct is to privatize — give each block its own histogram in shared memory, let threads atomicAdd into that low-contention local copy, __syncthreads(), then have the block do one atomicAdd per bin into the global histogram. You trade many high-contention global atomics for few, and the shared-memory atomics are far cheaper.

15. Error handling done right

CUDA errors come in two flavors, and they surface at different times. Getting this wrong is the most common interview red flag.

ASYNC ERRORS ARE STICKY

Once a kernel triggers an error such as an illegal address, the error becomes sticky — it corrupts the CUDA context and is returned by every subsequent call until the process exits. So an error reported by a cudaMemcpy two calls later may actually originate from an earlier kernel. This is why you check cudaGetLastError() right after the launch and sync-and-check before trusting results: it pins the failure to the right site instead of letting it masquerade as a later API call's problem.

WHY CPU-SIDE TIMING AROUND A LAUNCH IS WRONG
auto t0 = now();
kernel<<<g, b>>>(...);     // returns immediately, kernel still queued
auto t1 = now();          // measures ENQUEUE latency, not kernel runtime!

Because the launch is asynchronous, the CPU clock stops the instant the work is queued, not when it completes. You either measure almost nothing, or — if you add a cudaDeviceSynchronize() before t1 — you fold in launch overhead and any unrelated queued work. Use CUDA events (Section 11) recorded into the stream for accurate device timing.

THE SHAPE OF A CORRECT LAUNCH

Allocate → copy inputs H2D → launch → cudaGetLastError()cudaDeviceSynchronize() (or rely on the next blocking call) → copy results D2H → free. Every step has a defensible answer to "owner / bytes / synchronization."

Interview checklist: the 5-step linearization

When asked to read or write a kernel, walk these in order — out loud. It is the linearized thinking lens turned into a procedure.

  1. Owner. Compute the global index (blockIdx*blockDim + threadIdx) and state which output element this thread owns. Add the boundary check.
  2. Bytes. Name the memory space of every access (register / shared / global / constant) and check that consecutive lanes hit consecutive addresses (coalescing); fix the index mapping if not.
  3. Synchronization. For each cross-thread data dependency, place the right barrier: __syncthreads() within a block, stream order or events across streams, cudaDeviceSynchronize() before the host reads results.
  4. Launch config. Choose block size (multiple of 32), grid to cover n (or a fixed grid-stride grid), and the dynamic shared-memory bytes; pick the stream.
  5. Errors & timing. cudaGetLastError() after the launch for config errors, a sync for sticky runtime errors, and CUDA events — never the CPU clock — for timing.