Unit IV · Sessions 10–12

Programming Models & Tools

Complete revision notes. Everything in this unit, organised by topic — definitions, diagrams, formulas, comparison tables and worked examples. Revise from this alone and you have the whole unit.

Contents

Quick recall — every table and formula in one place · Self-check questions

Part A — OpenMP in Depth

A1. The model and the boilerplate

OpenMP is an API for shared-memory parallelism in C, C++ and Fortran, consisting of compiler directives, a runtime library and environment variables. It uses the fork-join model: the master thread forks a team at a parallel region, and the threads join at an implicit barrier at the end.

#include <omp.h>

#pragma omp parallel              // fork a team; all threads run this block
{
    int id = omp_get_thread_num();      // 0 .. n-1
    int n  = omp_get_num_threads();     // team size
}                                 // implicit barrier — threads join here

omp_set_num_threads(4);           // request a team size
double t = omp_get_wtime();       // wall-clock timer for measurement

compile:  gcc -fopenmp -O2 prog.c -o prog
control:  export OMP_NUM_THREADS=8

A key property: compile without -fopenmp and the pragmas are ignored, leaving a valid serial program. This is why OpenMP is described as incremental parallelisation — you annotate existing code rather than rewriting it.

A2. Work-sharing constructs

A parallel region alone makes every thread execute the same code. Work-sharing constructs divide the work between them.

ConstructWhat it doesFlavour
omp forSplits loop iterations across the teamData parallel
omp sectionsRuns several different code blocks in parallel, one per threadTask parallel
omp singleBlock runs on exactly one thread (any one); others wait at the implicit barrierOne-off work
omp masterBlock runs on the master thread only; no implicit barrierOne-off work
omp taskCreates an explicit task for the runtime to schedule; suits recursion and irregular workTask parallel
// The combined shortcut you will use most: parallel + for in one line
#pragma omp parallel for
for (int i = 0; i < N; i++)
    a[i] = b[i] + c[i];        // each thread gets a slice of the iterations

// Task parallelism with sections
#pragma omp parallel sections
{
    #pragma omp section
    load_file();
    #pragma omp section
    compress();
}

A3. Scheduling — who gets which iterations

schedule(kind [, chunk]) decides how loop iterations map onto threads. This is Unit III's load balancing, made concrete.

KindHow it assigns workUse when
staticEqual, fixed chunks decided up front by arithmetic. No runtime coordination.Every iteration costs about the same
static, 1Round-robin, one iteration at a time — still computed, not negotiatedCost varies predictably (e.g. rises with i)
dynamicThreads take the next chunk when they finish. Self-balancing.Uneven, unpredictable work
guidedLike dynamic, but chunks start large and shrinkUneven work, but you want fewer scheduling decisions
auto / runtimeLeft to the compiler, or set by OMP_SCHEDULEExperimentation

Worked example — one loop, four schedules. 12 iterations, 4 threads; iteration i costs (i+1) ms, so 1, 2, 3 … 12 ms, total 78 ms.

ClauseT0T1T2T3Finish
static — contiguous blocks0,1,2 → 6 ms3,4,5 → 156,7,8 → 249,10,11 → 3333 ms — 2.4×
static, 1 — round-robin0,4,8 → 15 ms1,5,9 → 182,6,10 → 213,7,11 → 2424 ms — 3.3×
dynamic, 1Assignment decided at run time — roughly one iteration each, repeatedly24 ms + overhead
guidedLarge chunks early, shrinking towards the end — fewer scheduling decisions than dynamic~24 ms, less overhead

Stare at row 2. schedule(static, 1) matched dynamic scheduling's result with zero runtime overhead, because the assignment is still computed by arithmetic rather than negotiated. It works here because the cost varies predictably — dealing iterations out like playing cards gives every thread a fair mix of cheap and expensive ones. Reach for dynamic only when the cost is genuinely unpredictable.

A4. Data scoping — the number one bug source

ClauseMeaning
shared(x)One copy, all threads see it. Fine for read-only data or disjoint writes.
private(x)Each thread gets its own uninitialised copy.
firstprivate(x)Private, but initialised from the value held before the region.
lastprivate(x)Private, but the logically last iteration's value is copied back out.
reduction(op:x)Private copy per thread, initialised to the operator's identity, combined at the end.
default(none)Forces you to declare the scope of every variable explicitly.
SHARED BY ACCIDENT — the classic bug

int i, j, temp;                       int i, j, temp;
#pragma omp parallel for              #pragma omp parallel for private(j, temp)
for (i = 0; i < N; i++)               for (i = 0; i < N; i++)
  for (j = 0; j < N; j++) {             for (j = 0; j < N; j++) {
    temp = a[i][j] * 2;                   temp = a[i][j] * 2;
    b[i][j] = temp;                       b[i][j] = temp;
  }                                     }

  i is private (it's the loop           j and temp are private now.
  counter). j and temp are SHARED —     Correct, and deterministic.
  threads overwrite each other.
  Garbage output, different each run.

Hygiene rule: always write default(none). It is extra typing, but the compiler then refuses to build until you have declared the scope of every variable — catching scoping bugs at compile time rather than as a silent, luck-dependent wrong answer at run time.

A5. Synchronisation, and the barrier you did not write

ConstructWhat it doesCost
criticalOnly one thread at a time may enter the block. General purpose.Relatively slow — a lock
atomicA single memory update (e.g. x += v) performed safely by a hardware instructionFaster than critical, still contended
barrierEvery thread waits until all have arrived, then all continueEveryone waits for the slowest
reduction(+:sum)Private partial per thread, combined safely at the endFast and correct — prefer this
nowaitRemoves the implicit barrier at the end of a work-sharing constructA speed win — and a correctness hazard

Worked example — the barrier you did not write. Every omp for, sections and single ends with an implicit barrier you never typed.

#pragma omp parallel
{
    #pragma omp for
    for (i=0;i<N;i++)  a[i] = f(i);      // <-- invisible barrier here
    #pragma omp for
    for (i=0;i<N;i++)  b[i] = g(i);      // <-- and here
}

Each loop averages 100 ms per thread, but the slowest thread takes 120 ms.
Four such loops:

  With the implicit barriers  -> every thread waits for the slowest,
                                 four times            = 480 ms
  With nowait on the first 3  -> waits only once, at
                                 the end of the region = ~420 ms

And here is how nowait destroys your program. Add it when the second loop does depend on the first:

#pragma omp for nowait
for (i=0;i<N;i++)  a[i] = f(i);
#pragma omp for
for (i=0;i<N;i++)  b[i] = a[i] * 2;   // may read a[i] before it was written

A fast thread races ahead into the second loop and reads elements of a that no thread has written yet. Use nowait only when the next block genuinely does not depend on this one finishing.

A6. Pitfalls and good habits

Common bugGood habit
A loop variable left shared → corruptionAlways start with default(none)
Updating a shared total without reduction → data racePrefer reduction over a hand-rolled critical
schedule(dynamic) with a tiny chunk → overhead swamps the workMatch the schedule to the work: even → static, uneven → dynamic
A matrix too large for the stack → crashUse static or malloc for big arrays
Parallelising a short inner loop → slower than serialParallelise the outermost long-running loop; compile with -O2 and measure with omp_get_wtime

Part B — MPI: Message Passing

B1. The MPI mental model

MPI (Message Passing Interface) is a standard library for distributed-memory parallel programming. It runs processes, not threads: mpirun -np 4 launches four full copies of your program, each with its own private memory. They cannot see each other's variables, ever. The only way to share data is to send a message.

TermMeaning
ProcessOne running copy of your program, with its own memory
RankA process's unique id: 0, 1, … size−1 — its "jersey number"
SizeHow many processes there are in total
CommunicatorMPI_COMM_WORLD — the group containing all processes

MPI programs are SPMD (Unit I): one source file, run by every process, each behaving differently based on its rank. if (rank == 0) { ... } is how one process plays "master".

B2. The four calls every MPI program has

#include <mpi.h>
int main(int argc, char **argv) {
    MPI_Init(&argc, &argv);                    // 1. start MPI (first call)
    int rank, size;
    MPI_Comm_rank(MPI_COMM_WORLD, &rank);      // 2. which process am I?
    MPI_Comm_size(MPI_COMM_WORLD, &size);      // 3. how many of us?
    printf("Hello from rank %d of %d\n", rank, size);
    MPI_Finalize();                            // 4. shut MPI down (last call)
    return 0;
}

compile:  mpicc hello.c -o hello
run:      mpirun -np 4 ./hello

Output (order varies between runs!):
    Hello from rank 2 of 4
    Hello from rank 0 of 4
    Hello from rank 3 of 4
    Hello from rank 1 of 4

The print order changes between runs because the processes genuinely run at once. That non-determinism is your first sign of real parallelism — the same lesson as OpenMP thread ordering in Unit I.

B3. Point-to-point communication

MPI_Send(&value, count, MPI_INT, dest,   tag, MPI_COMM_WORLD);
MPI_Recv(&value, count, MPI_INT, source, tag, MPI_COMM_WORLD, &status);
//        buffer  how-many  type   who    label  group
BlockingNon-blocking
CallsMPI_Send / MPI_RecvMPI_Isend / MPI_Irecv, then MPI_Wait
ReturnsWhen it is safe to reuse the bufferImmediately
AdvantageSimple to reason aboutLets you overlap communication with computation, and sidesteps deadlock
RiskDeadlock (B4)Must not touch the buffer before MPI_Wait returns

B4. The deadlock everyone writes once

int partner = 1 - rank;                    // 0 <-> 1
MPI_Send(sendbuf, n, MPI_DOUBLE, partner, 0, MPI_COMM_WORLD);
MPI_Recv(recvbuf, n, MPI_DOUBLE, partner, 0, MPI_COMM_WORLD, &status);
nRank 0Rank 1Result
1Send copies into a small internal buffer and returns immediatelysameWorks perfectly
1,000,000Send blocks — too big to buffer, waits for a matching RecvSend blocks — same reasonDEADLOCK — forever

Neither rank reaches its MPI_Recv, because each is stuck inside MPI_Send waiting for the other to reach a MPI_Recv it will never reach. Both processes sit at 100% CPU forever with no error message.

The dangerous part: the code is correct for small messages. It passes every test you write on your laptop with four elements, and hangs the first time it runs on real data. The threshold at which MPI_Send stops buffering is not defined in the standard — it varies by implementation, interconnect and message size.

Three fixes, worst to best:

1. ORDER THEM BY RANK — somebody sends before everybody receives
   if (rank % 2 == 0) { MPI_Send(...); MPI_Recv(...); }
   else               { MPI_Recv(...); MPI_Send(...); }

2. USE MPI_Sendrecv — one call, the library handles the ordering
   MPI_Sendrecv(sendbuf, n, MPI_DOUBLE, partner, 0,
                recvbuf, n, MPI_DOUBLE, partner, 0,
                MPI_COMM_WORLD, &status);

3. USE NON-BLOCKING — post both, then wait; also overlaps comms with work
   MPI_Irecv(recvbuf, n, MPI_DOUBLE, partner, 0, comm, &req[0]);
   MPI_Isend(sendbuf, n, MPI_DOUBLE, partner, 0, comm, &req[1]);
   /* do useful computation here */
   MPI_Waitall(2, req, MPI_STATUSES_IGNORE);

B5. Collectives — the workhorses

You could build everything from Send and Recv, but you should not. Collectives move data among all processes at once, and the library implements them far more efficiently — typically in log(P) steps rather than P. Every process in the communicator must call the collective.

CollectiveWhat it does
MPI_BcastRoot sends one value to every process
MPI_ScatterRoot splits an array — one chunk to each process
MPI_GatherInverse of Scatter: collect chunks back onto the root
MPI_ReduceCombine values (sum, max, …) onto the root
MPI_AllreduceLike Reduce, but every process receives the result
MPI_BarrierAll processes wait until every process has arrived
   BCAST                SCATTER              GATHER / REDUCE
   root: [X]            root: [A B C D]      ranks: [a][b][c][d]
      |  |  |  |              |  |  |  |          \  |  |  /
   [X][X][X][X]          [A][B][C][D]              [a+b+c+d]
   all get the same     each gets a chunk       combined onto root

   PARALLEL SORT (Lab 2) uses exactly this shape:
     Scatter chunks -> each rank sorts locally -> Gather -> final merge

Scatter is fan-out and Reduce is fan-in. Together they are the map-reduce pattern — the same shape as the large-scale data jobs in Unit V.

B6. OpenMP vs MPI

OpenMPMPI
Unit of workThreadsProcesses
MemorySharedPrivate per process
ScaleOne machine, tens of coresThousands of machines
Data sharingAutomatic — same RAMExplicit messages
Programming effortAdd a pragma to existing codeRestructure the program around messages
Failure modeRace conditions, false sharingDeadlock, load imbalance across ranks
Fault toleranceProcess dies, everything diesAlso fragile, but ranks are isolated

Hybrid MPI + OpenMP is the real supercomputer recipe: MPI between nodes, OpenMP within each node, and CUDA on the GPU inside each node. Messages across the cluster, shared memory inside each box.

Part C — CUDA and the GPU Stack

C1. Host and device

CUDA is C with a few extensions that let the CPU hand work to the GPU. Two worlds, connected by a bus:

A KERNEL is a function marked __global__ that runs on the GPU.

__global__ void saxpy(int n, float a, float *x, float *y) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) y[i] = a * x[i] + y[i];      // bounds check is essential
}

saxpy<<< blocks, threadsPerBlock >>>(n, 2.0f, d_x, d_y);
//     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^  the launch configuration
//     "run this kernel on (blocks x threadsPerBlock) threads"

Function qualifiers:  __global__  called from host, runs on device
                      __device__  called from device, runs on device
                      __host__    ordinary CPU function (the default)

C2. Thread → block → grid

GRID  (all the threads a kernel launches)
 +-----------------------------------------------+
 |  BLOCK 0            BLOCK 1            BLOCK 2 |
 | [t0 t1 t2 t3]      [t0 t1 t2 t3]      [t0 ...] |   threadIdx.x = 0..3
 +-----------------------------------------------+
   blockIdx.x=0        blockIdx.x=1       blockIdx.x=2    blockDim.x = 4

 GLOBAL INDEX OF A THREAD — memorise this line:

   i = blockIdx.x * blockDim.x + threadIdx.x

   e.g. block 2, thread 1, blockDim 4  ->  2 * 4 + 1 = 9

 LAUNCH CONFIGURATION for n elements:
   int threads = 256;
   int blocks  = (n + threads - 1) / threads;   // round UP
   kernel<<<blocks, threads>>>(n, ...);
   // rounding up means the last block has spare threads —
   // hence the  if (i < n)  bounds check inside the kernel
Built-inMeaning
threadIdxThis thread's position inside its block
blockIdxWhich block this thread belongs to
blockDimHow many threads per block
gridDimHow many blocks in the grid

Threads within a block are further grouped by the hardware into warps of 32 (Unit II), which is why block sizes are conventionally multiples of 32 — 128 or 256 are typical. Threads in the same block can cooperate via shared memory and __syncthreads(); threads in different blocks cannot.

C3. The five-step pattern

The GPU cannot read your ordinary C arrays. Host memory and device memory are separate, and moving data across the PCIe bus is the real cost of GPU programming — so you do it as little as possible.

EVERY CUDA PROGRAM FOLLOWS THESE FIVE STEPS

1. cudaMalloc(&d_x, bytes);                    // allocate on the DEVICE
2. cudaMemcpy(d_x, h_x, bytes, H2D);           // copy inputs  HOST -> DEVICE
3. kernel<<<blocks, threads>>>(d_x, ...);      // launch
4. cudaMemcpy(h_y, d_y, bytes, D2H);           // copy result  DEVICE -> HOST
5. cudaFree(d_x);                              // free device memory

H2D = cudaMemcpyHostToDevice,  D2H = cudaMemcpyDeviceToHost

Kernel launches are ASYNCHRONOUS — control returns to the host
immediately. cudaDeviceSynchronize() waits for the GPU to finish.

Learn these five steps once and every kernel is just step 3 getting cleverer. Steps 2 and 4 are also why a GPU can lose to a CPU on small jobs (Unit II) — the transfer may cost more than the computation saves.

C4. GPU memory spaces

MemoryShared bySpeedUse it for
RegistersOne threadFastestA thread's own local variables
SharedAll threads in a block~100× faster than globalThreads in a block cooperating — e.g. reduction
GlobalEvery thread and the hostSlowest, but hugeThe big input/output arrays (cudaMalloc)
Constant / textureEvery thread (read-only)CachedRead-only parameters and lookup data

Getting speed out of a GPU is mostly about which memory you use: closer means faster and smaller. The standard trick is to load data once from slow global memory into fast shared memory, do all the back-and-forth work there, and write back once.

C5. Coalescing — worth 30×

A warp's 32 threads issue their memory request at the same instant. If those 32 addresses are consecutive, the hardware satisfies them in one transaction. If they are scattered, it needs 32.

COALESCED                          STRIDED
c[i] = a[i] * 2;                   c[i] = a[i*32] * 2;
warp reads a[0..31]                warp reads a[0], a[32], a[64] ... a[992]
= 128 consecutive bytes            = 32 separate transactions
= ONE transaction                  = 3% of each one actually used
100% of it used

   ~2,800 GB/s                        ~90 GB/s        -> 31x slower

Same instruction count. Same number of elements. 31x the runtime.

The twist that catches every CPU programmer. Summing the rows of a row-major matrix:

// thread per ROW — feels natural,      // thread per COLUMN — feels wrong,
// SLOW on a GPU                        // FAST on a GPU
for (j=0;j<N;j++)                       for (j=0;j<N;j++)
    sum += a[tid*N + j];                    sum += a[j*N + tid];

At any instant thread 0 reads a[0],     At any instant threads 0-31 read
thread 1 reads a[N], thread 2 reads     a[0..31] — consecutive.
a[2N] — scattered by N.                 ONE transaction.

On a CPU (Unit II) you wanted one thread walking along a row. On a GPU you want the opposite — because the thing that must be contiguous is not what one thread reads over time, but what the 32 threads of a warp read at the same instant.

C6. Parallel reduction on a GPU

Summing an array on a GPU is the canonical non-trivial kernel, because it needs threads to cooperate rather than work independently. The pattern is a tree — the same tree whose work and span you computed in Unit III.

TREE REDUCTION INSIDE ONE BLOCK (8 threads shown)

  data   [ 3  1  7  0  4  1  6  3 ]
                                        stride = 4
  step1  [ 7  2 13  3  |  .  .  .  ]    t0..t3 add data[i+4]
                                        stride = 2
  step2  [20  5  |  .  .  .  .  .  ]    t0,t1 add data[i+2]
                                        stride = 1
  step3  [25  |  .  .  .  .  .  .  ]    t0 adds data[i+1]

  log2(8) = 3 steps instead of 7 sequential adds.
  __syncthreads() between every step — or threads read stale values.

__global__ void reduce(float *in, float *out) {
    __shared__ float s[256];
    int tid = threadIdx.x;
    int i   = blockIdx.x * blockDim.x + threadIdx.x;
    s[tid] = in[i];                       // global -> shared, once
    __syncthreads();
    for (int stride = blockDim.x/2; stride > 0; stride /= 2) {
        if (tid < stride) s[tid] += s[tid + stride];
        __syncthreads();                  // ESSENTIAL
    }
    if (tid == 0) out[blockIdx.x] = s[0]; // one partial per block
}

Each block produces one partial sum; a second pass (or an atomic add) combines the partials. Note the structure: accumulate locally, combine once — the identical principle as an OpenMP reduction (Unit I), the fix for false sharing (Unit II) and the combiner in MapReduce (Unit V).

Quick recall

Syntax you must be able to write from memory

OPENMP
  #pragma omp parallel for reduction(+:sum) schedule(dynamic) private(j)
  omp_get_thread_num()  omp_get_num_threads()  omp_get_wtime()
  gcc -fopenmp -O2 prog.c

MPI
  MPI_Init / MPI_Comm_rank / MPI_Comm_size / MPI_Finalize
  MPI_Send(buf, count, type, dest, tag, comm)
  MPI_Recv(buf, count, type, source, tag, comm, &status)
  Bcast · Scatter · Gather · Reduce · Allreduce · Barrier
  mpicc prog.c -o prog   &&   mpirun -np 4 ./prog

CUDA
  __global__ void kernel(...)          kernel<<<blocks, threads>>>(...)
  i = blockIdx.x * blockDim.x + threadIdx.x        <-- the one to memorise
  blocks = (n + threads - 1) / threads             <-- round up
  cudaMalloc · cudaMemcpy(H2D) · launch · cudaMemcpy(D2H) · cudaFree
  __shared__   __syncthreads()   warp = 32 threads

The three models side by side

OpenMPMPICUDA
Memory modelSharedDistributedSeparate device memory
UnitThreadProcess (rank)GPU thread in a warp
CommunicationShared variablesExplicit messagesHost–device copies + shared memory
ScaleOne nodeThousands of nodesOne GPU, thousands of threads
Signature bugData race / false sharingDeadlockUncoalesced access, missing __syncthreads()

Self-check

Answer each out loud before opening it.

1. Name the OpenMP work-sharing constructs and say which are data- and which task-parallel.
omp for splits loop iterations — data parallel. omp sections runs different blocks in parallel — task parallel. omp task creates explicit tasks for irregular or recursive work — task parallel. omp single and omp master run a block on one thread — one-off work. Without a work-sharing construct, every thread in a parallel region executes the whole block.
2. Compare static, dynamic and guided scheduling. When does static, 1 beat dynamic?
static assigns fixed equal chunks by arithmetic — zero overhead, but idle threads if work is lumpy. dynamic lets threads take the next chunk when free — self-balancing, but pays coordination on every grab. guided is dynamic with chunks that start large and shrink, reducing that overhead. static, 1 deals iterations round-robin; when cost varies predictably (e.g. rises with i) it gives every thread a fair mix and matches dynamic's balance with zero runtime overhead — 24 ms versus 33 ms for contiguous static in the twelve-iteration example.
3. Explain shared, private, firstprivate and lastprivate, and why default(none) is good practice.
shared — one copy seen by all threads. private — each thread gets its own uninitialised copy. firstprivate — private, initialised from the value before the region. lastprivate — private, with the last iteration's value copied back out. default(none) forces you to declare every variable's scope, so the compiler refuses to build until you have thought about each one — turning silent, luck-dependent wrong answers into compile errors.
4. A nested loop parallelised with #pragma omp parallel for gives garbage. Diagnose it.
The outer loop counter i is private automatically, but the inner counter j and any temporaries remain shared, so threads overwrite each other's values. Fix with private(j, temp), or declare them inside the loop body. This is the single most common OpenMP bug.
5. What is the implicit barrier, when does nowait help, and when does it break correctness?
Every omp for, sections and single ends with a barrier you never typed, so every thread waits for the slowest. Four loops averaging 100 ms with a 120 ms straggler cost 480 ms; with nowait on the first three, ~420 ms. It breaks correctness when the next loop reads what the previous loop wrote — a fast thread races ahead and reads values not yet written. Use it only for genuinely independent loops.
6. Describe the MPI mental model. What are rank, size and communicator?
MPI runs processes, not threads. mpirun -np 4 launches four full copies of the program, each with private memory that no other process can read. A rank is a process's unique id (0 … size−1); size is the total number of processes; a communicator (MPI_COMM_WORLD) is the group they belong to. Programs are SPMD: one source file, behaviour differentiated by rank.
7. Write the four calls every MPI program must contain.
MPI_Init(&argc, &argv) first; MPI_Comm_rank(MPI_COMM_WORLD, &rank); MPI_Comm_size(MPI_COMM_WORLD, &size); MPI_Finalize() last. Compile with mpicc, run with mpirun -np N. Output order varies between runs because the processes genuinely run simultaneously.
8. Two ranks each call Send then Recv to swap data. Why does it work for n = 1 and hang for n = 1,000,000?
For a small message MPI copies it into an internal buffer and MPI_Send returns immediately, so both ranks reach their MPI_Recv. A large message cannot be buffered, so MPI_Send blocks until a matching MPI_Recv is posted — and both ranks are stuck inside Send, waiting for a Recv neither will ever reach. Deadlock, at 100% CPU, with no error message. The buffering threshold is implementation-defined, which is why the bug passes all your small tests. Fixes: order sends and receives by rank parity; use MPI_Sendrecv; or use non-blocking MPI_Isend/MPI_Irecv with MPI_Waitall.
9. Name six MPI collectives and say why they beat hand-written Send/Recv.
Bcast (root to all), Scatter (split an array across ranks), Gather (collect back to root), Reduce (combine onto root), Allreduce (combine, result to all), Barrier (all wait). The library implements them in roughly log(P) steps using tree algorithms, rather than the P sequential messages a naive hand-written version would use. Scatter is fan-out and Reduce is fan-in — together the map-reduce shape.
10. Compare OpenMP and MPI across memory, scale and effort.
OpenMP uses threads in shared memory on one machine; data sharing is automatic and you parallelise by adding a pragma; it scales to tens of cores and its signature bug is a data race. MPI uses processes with private memory across thousands of machines; all sharing is explicit messaging and the program must be restructured around it; its signature bug is deadlock. Real supercomputers use both — MPI between nodes, OpenMP within a node, CUDA on the GPU.
11. Give the global thread index formula and compute it for block 2, thread 1, blockDim 4.
i = blockIdx.x * blockDim.x + threadIdx.x = 2 × 4 + 1 = 9. Launch configuration for n elements: blocks = (n + threads - 1) / threads, rounding up — which is exactly why the kernel needs an if (i < n) bounds check, since the last block has spare threads.
12. List the five steps of every CUDA program.
1. cudaMalloc on the device. 2. cudaMemcpy host → device. 3. Launch the kernel with <<<blocks, threads>>>. 4. cudaMemcpy device → host. 5. cudaFree. Steps 2 and 4 cross the PCIe bus and are the real cost of GPU programming — and the reason a GPU can lose to a CPU on small jobs.
13. Name the GPU memory spaces and explain the shared-memory trick.
Registers (per thread, fastest), shared memory (per block, ~100× faster than global), global memory (all threads and the host, slowest but huge), plus read-only constant and texture memory. The standard optimisation is to load data once from global memory into shared memory, do all the repeated work there, and write back once — exactly what a block-level reduction does.
14. What is memory coalescing, and why is "one thread per row" fast on a CPU but slow on a GPU?
The 32 threads of a warp issue their memory requests simultaneously; if the addresses are consecutive the hardware serves them in one transaction (~2,800 GB/s), and if scattered it needs 32 (~90 GB/s) — about 31× slower. On a CPU you want one thread walking along a row, because what matters is what a single thread reads over time. On a GPU you want thread t to handle column t, because what matters is what the 32 threads read at the same instant. Same data layout, opposite access pattern.
15. Describe a GPU tree reduction and say why __syncthreads() is essential.
Load the block's data from global into shared memory, then halve the stride repeatedly: at each step the threads below the stride add the element one stride away, so 8 elements reduce in log₂8 = 3 steps instead of 7 sequential additions. __syncthreads() is required between steps because otherwise a fast thread reads a partial sum that another thread has not yet written. Each block emits one partial; a second pass combines them. Note the recurring principle — accumulate locally, combine once.
All unit notes ← Unit III Unit V →