Gates → GPUs · Notes
Stanford CS149 · Lecture 7 companion

GPU architecture and CUDA programming

A graphics processor must color a huge number of image locations every frame. That work helped shape hardware with many arithmetic units and many threads waiting for data at once. This lecture follows the path from graphics shaders to CUDA kernels, then looks inside an NVIDIA V100 to connect the code's threads and blocks to the machine that runs them.

You'll be able to
Explain how graphics work shaped high-throughput GPUs. Read a CUDA kernel and its grid, blocks, thread indices, host launch, and memory choices. Show how shared memory and __syncthreads() reduce repeated source-level loads in the convolution example, while identifying their costs. Describe a V100 SM, warps, and SIMT execution. Trace how resource limits affect block placement and why a cross-block wait can deadlock.
Background
Lecture 2 (SIMD, multithreading, the V100 SM) and Lecture 3 (SPMD, ISPC gangs and tasks). Unit 5 of these notes (Understand GPUs) covers occupancy, coalescing and tiling in more depth.
Source
Follows the structure and examples of Stanford CS149 Parallel Computing, Fall 2023, Lecture 7, "GPU Architecture & CUDA Programming" (Kayvon Fatahalian and Kunle Olukotun). Lectures 4–6 aren't covered here yet. The explanations, diagrams and explorers are original study material, not a copy of the slides.
Separate the work described by CUDA from the schedule chosen by the GPU. A kernel describes threads grouped into blocks. Threads in one block can cooperate; an ordinary grid must remain correct even if its blocks run in different orders. On the V100, hardware schedules blocks onto streaming multiprocessors and executes threads in groups called warps. That mapping explains both the speed of well-organized code and the limits on synchronization.
Part 1 · From graphics to compute

How a graphics chip became a parallel computer

Why rendering produced this kind of hardware, and how people first bent it to other uses.

Section 1

What GPUs were originally designed to do: 3D rendering

Imagine drawing a textured triangle on screen. The renderer starts with three vertices (points in 3D), a camera, lights, and a texture that supplies surface colors. It performs two broad kinds of work:

  • Transform the triangle's vertices to determine where its shape lies on the 2D screen.
  • For each covered image sample, calculate a candidate color. A covered sample from one triangle is called a fragment; several triangles may produce fragments for the same pixel.

A fragment shader is a small program that computes one such candidate color. This simplified GLSL example reads a texture at coordinates supplied for the fragment, then darkens the color according to the surface's direction relative to a light:

#version 330 core
uniform sampler2D myTexture;  // texture available to every invocation
uniform vec3 lightDir;      // one light direction for this draw
in vec3 norm;                // interpolated normal for this fragment
in vec2 uv;                  // interpolated texture coordinates
out vec4 fragColor;

void main() {
  vec3 kd = texture(myTexture, uv).rgb;
  float brightness = max(dot(normalize(lightDir), normalize(norm)), 0.0);
  fragColor = vec4(kd * brightness, 1.0);  // red, green, blue, alpha
}

The uniform inputs have one value for this draw; norm and uv can differ across fragments. The example is map-like: many invocations perform the same instructions on different inputs. A fragment shader is not required to be a pure function, and one pixel can have several candidate fragments. Depth testing and other graphics operations decide which results contribute to the final image. The abundance of similar, independent candidate-color calculations is what made wide parallel hardware useful.

Many similar fragments create throughput demand. A GPU uses many cores, groups work into SIMD-like execution within each core, and keeps many threads available while others wait for data such as texture samples. High-end designs also provide wide memory systems. The drawing is a schematic of this organization, not a count of parts in a particular GPU.
Section 2

From a graphics hack to a compute interface

The shader pattern also describes non-graphics work. If an array has 512 × 512 independent elements, a function could run once for each element just as a fragment shader runs for each image location. Early researchers used this data-parallel match even before GPUs offered a direct compute interface.

The hack

On the graphics interfaces of that period, launching the computation meant posing it as a drawing problem:

  1. Put input values in textures and create a 512 × 512 output image to hold the result.
  2. Draw two triangles covering that output image, so the graphics pipeline generates a fragment for every location.
  3. Write the array operation as a fragment shader. Its invocation for location (i, j) reads the corresponding inputs and writes the corresponding result.
A map over a 512 × 512 collection, disguised as drawing. The fragment shader becomes the function being mapped; the output image becomes the result array. This "GPGPU" (general-purpose computing on GPUs) approach produced early work on fluid simulation, ray tracing and sparse matrix solvers.

Stanford's Brook language (2004) let programmers write the array view instead. A stream held many values, and a kernel described the operation on one value. In the example below, element k of output_stream becomes scale_amount × input_stream[k]; Brook translated that work into graphics commands and shaders for the available GPUs.

kernel void scale(float amount, float a<>, out float b<>) {
  b = amount * a;
}
float input_stream<1000>;    // stream declarations
float output_stream<1000>;
scale(scale_amount, input_stream, output_stream);  // map the kernel over the streams

Compute mode

The detour through triangles was useful, but it forced a numerical program to speak in graphics terms. NVIDIA's Tesla generation introduced a compute interface in 2007: a program could allocate GPU buffers, copy input data, and launch a function directly over a chosen collection of work items.

From “draw an image” to “launch a kernel.” The left shows the old graphics route: shaders and geometry pass through a rendering pipeline. The right shows the Tesla-era compute route: prepare GPU memory and launch a kernel, a function run by many GPU threads. The diagram shows an explicit-copy workflow; CUDA also offers other memory-management options.

CUDA is NVIDIA's programming platform for this compute route. Its C++ extensions let the host CPU name the kernel, choose how many GPU threads to launch, and decide what data they can access. The programmer no longer needs to draw triangles, but does need to understand how thousands of logical threads are grouped and scheduled. That is the next part of the lecture.

Part 2 · CUDA abstractions

What a CUDA program means

Threads, blocks and grids; host and device; storage scopes and synchronization.

Section 3

A hierarchy of concurrent threads

Adding two matrices is a good first kernel: output C[j][i] needs only A[j][i] and B[j][i], so each output element can have its own worker. CUDA makes those workers explicit. A kernel launch creates a grid of thread blocks, each containing many CUDA threads. A block and a thread can each have one-, two-, or three-dimensional IDs; two dimensions fit the rows and columns here.

A CUDA thread is a logical worker, not an operating-system thread. Like a CPU thread, it has its own local values and path through the code. The GPU groups many of these lightweight workers for execution; it does not create a separate OS stack and scheduler entry for each one. Part 3 shows how.

The host CPU chooses a 4 × 3 block, so each block covers twelve output cells. For a 12 × 6 matrix, it needs 3 blocks across and 2 down. The arrays below are flattened in row-major order: element (i, j) is at offset j * Nx + i.

// host code (runs on the CPU)
const int Nx = 12, Ny = 6;
dim3 threadsPerBlock(4, 3);
dim3 numBlocks((Nx + threadsPerBlock.x - 1) / threadsPerBlock.x,
               (Ny + threadsPerBlock.y - 1) / threadsPerBlock.y);
// devA, devB, devC are device allocations; launch 6 blocks, 72 threads
matrixAdd<<<numBlocks, threadsPerBlock>>>(devA, devB, devC, Nx, Ny);
// device code (runs on the GPU)
__global__ void matrixAdd(const float* A, const float* B,
                          float* C, int Nx, int Ny) {
  int i = blockIdx.x * blockDim.x + threadIdx.x;
  int j = blockIdx.y * blockDim.y + threadIdx.y;
  if (i < Nx && j < Ny) {
    int k = j * Nx + i;
    C[k] = A[k] + B[k];
  }
}

For example, thread (1, 2) in block (2, 1) computes i = 2 × 4 + 1 = 9 and j = 1 × 3 + 2 = 5, so it writes C[5][9]. blockIdx names the block, threadIdx names the thread inside it, and blockDim is the block's size. __global__ marks code the host can launch on the device. The launch queues GPU work and normally returns to the CPU before the threads finish; the host must wait or use an ordered copy before consuming their results.

Matrix:
Click any thread.

Blocks tile the matrix. Each outline is one 4 × 3 block; each cell is one thread's potential output. Switching to 11 × 5 still requires ⌈11/4⌉ × ⌈5/3⌉ = 3 × 2 blocks, so 72 threads are launched but only 55 have valid elements. Hatched cells fail the bounds check and do nothing. Choosing launch dimensions explicitly is a CUDA responsibility, unlike the earlier image-size mapping.

The programmer also marks where functions can run. __device__ marks a GPU helper callable by device code; an ordinary C++ function runs on the host unless annotated for device use. Next we need to put the matrices somewhere the kernel can read them.

Section 4

The CUDA memory model

In the explicit-memory version used here, host arrays and device arrays are separate allocations. The CPU allocates GPU storage with cudaMalloc and copies input bytes into it with cudaMemcpy. A pointer returned for ordinary device memory is passed to a kernel; the CPU does not dereference it as if it were a host array:

float* A = new float[N];              // host memory
for (int i = 0; i < N; i++) A[i] = (float)i;
float* deviceA;
cudaMalloc(&deviceA, sizeof(float) * N);    // device memory
cudaMemcpy(deviceA, A, sizeof(float) * N, cudaMemcpyHostToDevice);
// deviceA[i] = ... here would be invalid: deviceA is not a pointer
// into the host's address space

For this workflow, cudaMemcpy plays a role similar to sending a message: bytes move explicitly between host and device storage. CUDA also supports managed memory, where the runtime can migrate data rather than requiring these explicit copies. Inside the GPU, it helps to distinguish three scopes of storage:

Who can access each value? A thread's private variables belong to that thread; the compiler may keep them in registers or spill them to local memory. Threads in one block can use its on-chip shared memory together. All blocks can address device global memory, with synchronization or atomics needed when they coordinate updates. Grouping threads that reuse data into one block lets them share one staged copy.
Section 5

Example: 1D convolution, and why shared memory helps

Consider a short sliding-window calculation, often called a stencil: output[i] = (input[i] + input[i+1] + input[i+2]) / 3. Output 0 needs inputs 0, 1, and 2; output 1 needs 1, 2, and 3. The two threads therefore ask for some of the same values. First, give each thread one output and let it read all three values from device global memory:

#define THREADS_PER_BLK 128
__global__ void convolve(int N, float* input, float* output) {
  int index = blockIdx.x * blockDim.x + threadIdx.x;  // thread-local
  float result = 0.0f;                                 // thread-local
  for (int i = 0; i < 3; i++)
    result += input[index + i];
  output[index] = result / 3.f;
}
// host: N = 1024 * 1024 outputs (divisible by 128);
// device input has N + 2 elements, device output has N
convolve<<<N/THREADS_PER_BLK, THREADS_PER_BLK>>>(N, devInput, devOutput);

For a block producing outputs 0 through 127, the union of required inputs is only 0 through 129: 130 values. Yet version 1 writes three load operations per thread, or 384 across the block. Version 2 first has the block cooperatively copy that 130-value support region into shared memory. Each thread loads one input, and threads 0 and 1 load the two extra values at the right edge. Then all threads read their three inputs from the shared copy:

__global__ void convolve(int N, float* input, float* output) {
  __shared__ float support[THREADS_PER_BLK+2];     // one copy per block
  int index = blockIdx.x * blockDim.x + threadIdx.x;
  support[threadIdx.x] = input[index];                 // each thread loads one
  if (threadIdx.x < 2)                                  // two threads load the extra 2
    support[THREADS_PER_BLK + threadIdx.x] = input[index + THREADS_PER_BLK];
  __syncthreads();                                    // barrier: all loads done
  float result = 0.0f;
  for (int i = 0; i < 3; i++)
    result += support[threadIdx.x + i];
  output[index] = result / 3.f;
}
Version:

Count source-level loads, then ask what memory actually transfers. Version 1 performs 3 × 128 = 384 per-thread global load operations per block. Version 2 performs 130, then 384 shared-memory reads. The drawing shows only the first 16 threads. These counts are not DRAM transactions: adjacent loads can coalesce, caches can serve repeated values, and shared staging adds a barrier and uses 520 bytes per block. For this three-value stencil, version 2 is not automatically faster.

The __syncthreads() call is essential to version 2's correctness. Thread 1 loads support[1], which thread 0 needs for output 0; without coordination, thread 0 might read that slot before thread 1 writes it. A barrier makes every thread in the block wait until all have finished the loading phase, then makes those writes visible to the block. The barrier sits outside the if, so all 128 threads reach it. In this example N is divisible by 128; a general kernel with a partial final block would need carefully guarded loads without letting some threads skip the barrier. Unit 5, Lesson 23 revisits the pattern for tiled matrix multiplication.

CUDA's synchronization constructs

  • __syncthreads(): participating threads in one block wait for one another, and their prior memory writes become visible at the barrier. It does not synchronize separate blocks.
  • Atomic operations, such as atomicAdd, perform one indivisible update to an address in supported shared or global memory.
  • Host/device synchronization: a kernel launch normally returns to the CPU before completion. The host can wait with cudaDeviceSynchronize() or cudaStreamSynchronize(); an ordered blocking device-to-host copy can also wait for the result it copies.
CUDA abstractionWhat it provides
ExecutionBulk launch of many threads, in a two-level hierarchy: threads grouped into thread blocks
Storage scopeIn this explicit-copy model: host and device allocations; on the device, private thread values, block-shared memory, and device-global memory
SynchronizationBarriers within a block; atomics for selected shared/global updates; streams and host-side waits for ordered completion

Shared memory has a cost beyond the barrier: every resident block reserves space for its own 130-float support array. The next part shows how that resource requirement affects how many blocks a GPU can run at once.

Part 3 · Implementation

How a GPU actually runs CUDA

Compilation, the thread-block scheduler, the V100 SM, warps, and what the scheduling rules allow and forbid.

Section 6

A million threads? What the implementation must decide

The convolution launch names 1,048,576 CUDA threads in 8,192 blocks. That does not mean a million operating-system threads or a million full stacks must exist at once. It describes a million logical work items. Only blocks currently resident on the GPU consume per-thread register slots and per-block shared-memory space; the rest wait for a turn.

This separation lets the same launch run on GPUs with different numbers of streaming multiprocessors (SMs). The code specifies what each block does, not which SM receives it. A large CUDA grid resembles a data-parallel forall more than a loop calling pthread_create().

What the compiler records

The compiled kernel contains instructions and resource information such as registers used per thread and its static shared-memory allocation. In version 2, support contributes 130 floats, or 520 bytes, of shared memory per resident block. The launch, not the binary, supplies 128 threads per block and the number of blocks. Scheduling must fit both the launch shape and the kernel's resource use.

Pending blocks are assigned as resources allow. A host launch queues a grid. GPU scheduling logic places whole blocks on SMs with enough registers, thread capacity, and shared memory. A correct ordinary kernel must tolerate blocks running in any order, in parallel or one after another; it cannot wait for a particular other block to be scheduled.

Think of blocks as jobs waiting for a limited set of workers. An SM takes more blocks only while their combined resource needs fit. As blocks finish, their slots can be reused. CPU task runtimes, including those used with ISPC, can also maintain a worker pool, but CUDA's workers are GPU SMs rather than host threads. Next we look at the hardware inside one of them.

Section 7

Inside the NVIDIA V100: sub-cores, warps and SIMT

The V100 has 80 streaming multiprocessors (SMs). Each SM is divided into four processing partitions, called sub-cores in this diagram. A partition has a scheduler that selects work from resident warps, arithmetic and memory units, and a share of the SM's registers:

One of the V100 SM's four partitions. It has 16 FP32 units capable of floating-point multiply-add, 16 INT32 units for integer arithmetic, 8 FP64 units, tensor cores for matrix operations, and load/store units. Its 64 KB register-file share holds private register values for resident threads. In the diagram's simple throughput model, applying one operation to all 32 threads takes two clocks on the 16-wide FP32 path or four on the 8-wide FP64 path; those are lane-throughput counts, not general instruction latencies.

Warps

CUDA groups consecutive threads of a block into warps of 32: threads 0–31 form one warp, 32–63 the next, so a 256-thread block has eight warps. Each V100 partition can keep up to 16 warps resident, subject to the block's register and shared-memory needs. When one warp waits for data, the scheduler can issue ready work from another without saving and restoring an OS thread's stack.

SIMT means single instruction, multiple threads. Threads retain separate values and logical control flow, while a warp can execute a common instruction for several active threads together. If a branch sends threads down different paths, the hardware may execute each path with only its participating threads active; the unused lanes reduce throughput. This is divergence. Volta can schedule threads within a warp more independently than a simple lockstep drawing suggests, but divergent work still has a cost.

A warp resembles an ISPC gang, but the mapping differs. ISPC compiles a logical gang into CPU vector operations. CUDA presents scalar-looking code for each thread, and NVIDIA hardware groups neighboring threads into warps for execution. Warp size and documented warp-level operations such as shuffle and vote are exposed to CUDA programmers, so a warp is more than an invisible implementation detail. Correct code should still rely on documented synchronization rules rather than assuming all lanes always advance together.
Illustrative issue versus execution time. The three warp instructions are independent. In this simplified schedule, a 32-thread FP32 or INT32 operation occupies a 16-wide path for two clocks, but the next instruction can start a clock later if it uses a different available unit. Thus an INT32 add can overlap an FP32 multiply. Dependencies or occupied units would change this timing.

The whole SM and the whole chip

Four partitions make one V100 SM. Each has a warp scheduler and 64 KB of registers, for 256 KB per SM. The diagram distributes warps round-robin across partitions and shows their shared 128 KB L1/shared-memory storage. Sixty-four warps is an architectural maximum; a particular kernel may fit fewer because its blocks reserve registers, shared memory, or other limited resources.
V100 quantityValueWhere it comes from
Clock used below1.245 GHzThe lecture's base-clock example, not maximum boost
SMs80Cores per chip
fp32 mul-add ALUs5,12080 SMs × 4 sub-cores × 16
FP32 FMA rate at that clock≈ 12.7 TFLOP/s5,120 × 2 FLOPs per FMA × 1.245 GHz, an idealized peak
Maximum resident warps5,12080 SMs × at most 64 warps each
Maximum resident CUDA threads163,8405,120 warps × 32 threads
Memory16 GB HBM at 900 GB/s; 6 MB L2As given in the lecture

These resident counts are upper bounds, not the number a kernel necessarily achieves. The convolution block's registers and shared array also take space. A million-thread launch is still possible: blocks beyond the current resident capacity remain pending until earlier blocks finish. We can now trace that process on a smaller GPU where every allocation is visible.

Section 8

Running a kernel: the block scheduler, step by step

Return to the convolution block: 128 threads and a 520-byte shared array must be placed together on one SM. To make placement visible, the lecture uses a fictitious two-core GPU. Each core has room for 384 resident threads (12 warps) and 1,536 bytes of shared memory. The real launch has thousands of blocks, but this explorer follows only the first few.

Kernel:

Every resident block consumes several resources. In this particular toy schedule, NEXT labels the next block to assign and the scheduler alternates between cores with room. The version-2 block needs 128 thread slots and 520 shared-memory bytes. Three blocks fit the thread limit exactly (3 × 128 = 384) but exceed the shared limit (3 × 520 = 1,560 > 1,536), so only two fit per core. A completed block releases both resources. CUDA does not promise the NEXT order or this alternating placement.

Try version 1: with no shared array, three 128-thread blocks fit on a core. The 256-thread preset needs 1,032 shared bytes, so only one of those larger blocks fits. The limiting resource changes with the kernel. In every case, the source program names blocks rather than SMs; the GPU places pending blocks as space opens. This is why a single launch can adapt to machines of different sizes.

Section 9

What the scheduling rules allow, and what they forbid

Why must a whole block be resident at once?

Suppose a block has 256 threads but an SM could hold only 128 of them. Running the first half to completion before admitting the second would break a block-wide barrier: the first half could wait at __syncthreads() for threads that have not started. CUDA therefore places a whole block on one SM with resources for all its threads before it runs. The warps then take turns issuing instructions; “resident together” does not mean every thread performs an instruction in the same clock.

Blocks: any order, but not necessarily independent

In an ordinary kernel without an explicit grid-wide synchronization mechanism, blocks may run in any order or at the same time, so one block cannot require another particular block to finish first. That does not forbid every shared update. For example, if a histogram's global counters start at zero and each input is counted once with atomicAdd(&counts[A[i]], 1), the final integer counts do not depend on block order. A different pattern is dangerous:

// thread block 0
// do stuff here
atomicAdd(&myFlag, 1);
// thread block 1
while (atomicAdd(&myFlag, 0) == 0)
  { }            // wait for block 0
// do stuff here
GPU has room for:
Scheduler starts:

One permitted schedule deadlocks. In the illustrated schedule, block 1 waits for a flag set by block 0. If one block fits and block 1 starts first, it occupies the sole slot while spinning; block 0 cannot start and set the flag. Other pictured schedules happen to finish, but correctness requires all permitted schedules to work. The flag is assumed initialized to zero and used only as a completion signal; publishing other data would also require appropriate memory ordering.

Persistent threads: taking scheduling into your own hands

An advanced persistent-kernel style launches a limited number of long-lived blocks. The GPU scheduler still places those blocks initially; each resident block then loops, claiming new chunks of work from a counter. This moves chunk assignment into software. The V100's 80 SMs × (2,048 maximum threads per SM ÷ 128 threads per block) = 1,280 blocks is only a thread-capacity ceiling: registers, shared memory, and other block limits can lower the number that fits. Persistent kernels must size and coordinate their blocks carefully; residency is not the same as every thread executing simultaneously.

Section 10

Summary: CUDA's semantics and its implementation

What CUDA promisesHow NVIDIA GPUs implement itISPC analogy
Thread blocksMay execute in any order, in parallel or in series; ordinary kernels must tolerate that choiceScheduling logic assigns pending blocks to SMs as resources become freeLike independently scheduled ISPC tasks
Threads in a blockCan cooperate through shared memory and block barriers; the whole block must fit on one SMIts warps reside on one SM and take turns using execution unitsLike cooperating program instances, with a different hardware mapping
WarpWarp size and documented warp-level operations are exposed; ordinary code need not prescribe warp schedulingGroups of 32 threads use SIMT execution; divergence can waste lane capacityResembles a gang, but hardware forms the group
MemoryExplicit-copy code uses host and device allocations; threads have private, block-shared, and global scopesRegisters, shared/L1 storage, and HBM behind an L2 cacheCPU ISPC code commonly accesses host memory directly

CUDA combines ideas from several models. A grid describes many similar work items. Threads inside a block are SPMD workers that can share data and synchronize. Host-to-device copies in the explicit-memory example resemble message passing. These descriptions apply at different scopes; none alone describes the whole program.

The GPU still offers a graphics pipeline, but a CUDA kernel launch does not pass through its graphics-specific fixed-function stages. It runs on the programmable SMs and uses memory and caches shared with other GPU workloads. The SM also has tensor cores for selected matrix operations; NVIDIA rates the V100 at up to 125 Tensor TFLOP/s for supported mixed-precision work. Unit 6 returns to matrix units. The next time you read a GPU kernel, start with its thread and block mapping, then ask which resources and synchronization rules make that mapping safe and fast.

Review

Terms to know, and questions to test yourself

Shader
A programmable graphics stage. A fragment shader computes candidate results for covered image samples; several fragments may contribute to one pixel.
GPGPU
General-purpose computation on GPUs, originally by disguising it as rendering.
Compute interface
A way to launch GPU kernels directly on data without posing the work as drawing.
Kernel
A __global__ function the host launches over a grid of threads.
Grid, block, thread
The launch's hierarchy: a grid of thread blocks, each containing threads, with 1–3D IDs.
Host, device
The CPU side and GPU side of a CUDA program. Explicit-memory code allocates and copies data between them; managed memory is another option.
Shared memory
Fast on-chip memory shared by the threads of one block.
__syncthreads()
A barrier across all threads of a block.
SM, sub-core
A V100 streaming multiprocessor, divided into four processing partitions with separate warp schedulers and execution units.
Warp, SIMT
A warp groups 32 neighboring CUDA threads; SIMT execution can apply a common instruction to its active threads.
Block scheduler
GPU hardware that assigns thread blocks to SMs as resources allow.
Persistent kernel
A limited set of long-lived blocks that repeatedly claim new chunks of work after hardware schedules them.
Test yourselfThe lecture's questions, plus a few more.
  1. matrixAdd launches <<<(3,2), (4,3)>>>. How many threads, and which (i, j) does thread (1, 2) of block (2, 1) compute?
    Show answer

    6 blocks × 12 threads = 72. i = 2 × 4 + 1 = 9, j = 1 × 3 + 2 = 5: element C[5][9].

  2. For an 11 × 5 matrix and 4 × 3-thread blocks, how many threads must the host launch, and why does the kernel need a bounds check?
    Show answer

    Ceiling division gives 3 × 2 blocks of 12 threads, or 72 threads. Only 55 correspond to matrix elements. The other 17 must skip array accesses.

  3. Does the CPU wait for matrixAdd<<<...>>> to finish before continuing? How can it safely use the result?
    Show answer

    A kernel launch normally returns before completion. The CPU can wait with cudaDeviceSynchronize() or synchronize the relevant stream, then copy and read the result. An ordered blocking device-to-host copy can also wait for preceding work in its stream.

  4. In convolution version 2, how many per-thread global load operations does one block perform, versus version 1? Does that prove a proportional reduction in DRAM traffic?
    Show answer

    130 (128 + 2 support elements) versus 3 × 128 = 384. No: coalescing and caches can combine or serve loads, while shared staging adds its own reads, a barrier, and resource use.

  5. Why does the v2 kernel fit only two blocks per core on the fictitious GPU, even though the core has contexts for three?
    Show answer

    Shared memory: 3 × 520 = 1,560 bytes > 1,536 (1.5 KB). The threads would fit (3 × 128 = 384), but a block can't start without its shared allocation.

  6. In the lecture's lane-throughput model, how many clocks does a 32-thread warp's FP64 operation occupy an eight-wide FP64 path?
    Show answer

    32 lane operations ÷ 8 units = 4 clocks. This is a throughput count for the simplified diagram, not the latency of every FP64 instruction.

  7. Why can't a GPU run a 256-thread block as two halves, one after the other?
    Show answer

    Threads in one block may meet at __syncthreads(). The first half could wait for a second half that cannot start until the first half leaves. CUDA places the whole block on one SM with enough resources for all its threads.

  8. Is the histogram-with-atomics kernel correct? Is the flag-spinning kernel?
    Show answer

    With zeroed counters and one atomic increment per input, the histogram's integer counts are order-independent. The flag program can deadlock if block 1 occupies the only slot before block 0 starts, so it is not correct for every permitted schedule.

Based on Stanford CS149 (Fall 2023), Lecture 7, "GPU Architecture & CUDA Programming", by Kayvon Fatahalian and Kunle Olukotun. The V100 and programming examples draw on the lecture; the CUDA snippets are adapted for this guide. The explanations, diagrams and interactive models are original study material. For CUDA's exact semantics see NVIDIA's CUDA C++ Programming Guide. For the course's slides and videos, see the CS149 course site.