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
A · OpenMP
B · MPI
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.
| Construct | What it does | Flavour |
|---|---|---|
omp for | Splits loop iterations across the team | Data parallel |
omp sections | Runs several different code blocks in parallel, one per thread | Task parallel |
omp single | Block runs on exactly one thread (any one); others wait at the implicit barrier | One-off work |
omp master | Block runs on the master thread only; no implicit barrier | One-off work |
omp task | Creates an explicit task for the runtime to schedule; suits recursion and irregular work | Task 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.
| Kind | How it assigns work | Use when |
|---|---|---|
static | Equal, fixed chunks decided up front by arithmetic. No runtime coordination. | Every iteration costs about the same |
static, 1 | Round-robin, one iteration at a time — still computed, not negotiated | Cost varies predictably (e.g. rises with i) |
dynamic | Threads take the next chunk when they finish. Self-balancing. | Uneven, unpredictable work |
guided | Like dynamic, but chunks start large and shrink | Uneven work, but you want fewer scheduling decisions |
auto / runtime | Left to the compiler, or set by OMP_SCHEDULE | Experimentation |
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.
| Clause | T0 | T1 | T2 | T3 | Finish |
|---|---|---|---|---|---|
static — contiguous blocks | 0,1,2 → 6 ms | 3,4,5 → 15 | 6,7,8 → 24 | 9,10,11 → 33 | 33 ms — 2.4× |
static, 1 — round-robin | 0,4,8 → 15 ms | 1,5,9 → 18 | 2,6,10 → 21 | 3,7,11 → 24 | 24 ms — 3.3× |
dynamic, 1 | Assignment decided at run time — roughly one iteration each, repeatedly | 24 ms + overhead | |||
guided | Large 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
| Clause | Meaning |
|---|---|
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
| Construct | What it does | Cost |
|---|---|---|
critical | Only one thread at a time may enter the block. General purpose. | Relatively slow — a lock |
atomic | A single memory update (e.g. x += v) performed safely by a hardware instruction | Faster than critical, still contended |
barrier | Every thread waits until all have arrived, then all continue | Everyone waits for the slowest |
reduction(+:sum) | Private partial per thread, combined safely at the end | Fast and correct — prefer this |
nowait | Removes the implicit barrier at the end of a work-sharing construct | A 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 bug | Good habit |
|---|---|
| A loop variable left shared → corruption | Always start with default(none) |
Updating a shared total without reduction → data race | Prefer reduction over a hand-rolled critical |
schedule(dynamic) with a tiny chunk → overhead swamps the work | Match the schedule to the work: even → static, uneven → dynamic |
| A matrix too large for the stack → crash | Use static or malloc for big arrays |
| Parallelising a short inner loop → slower than serial | Parallelise 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.
| Term | Meaning |
|---|---|
| Process | One running copy of your program, with its own memory |
| Rank | A process's unique id: 0, 1, … size−1 — its "jersey number" |
| Size | How many processes there are in total |
| Communicator | MPI_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
| Blocking | Non-blocking | |
|---|---|---|
| Calls | MPI_Send / MPI_Recv | MPI_Isend / MPI_Irecv, then MPI_Wait |
| Returns | When it is safe to reuse the buffer | Immediately |
| Advantage | Simple to reason about | Lets you overlap communication with computation, and sidesteps deadlock |
| Risk | Deadlock (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);
| n | Rank 0 | Rank 1 | Result |
|---|---|---|---|
| 1 | Send copies into a small internal buffer and returns immediately | same | Works perfectly |
| 1,000,000 | Send blocks — too big to buffer, waits for a matching Recv | Send blocks — same reason | DEADLOCK — 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.
| Collective | What it does |
|---|---|
MPI_Bcast | Root sends one value to every process |
MPI_Scatter | Root splits an array — one chunk to each process |
MPI_Gather | Inverse of Scatter: collect chunks back onto the root |
MPI_Reduce | Combine values (sum, max, …) onto the root |
MPI_Allreduce | Like Reduce, but every process receives the result |
MPI_Barrier | All 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
| OpenMP | MPI | |
|---|---|---|
| Unit of work | Threads | Processes |
| Memory | Shared | Private per process |
| Scale | One machine, tens of cores | Thousands of machines |
| Data sharing | Automatic — same RAM | Explicit messages |
| Programming effort | Add a pragma to existing code | Restructure the program around messages |
| Failure mode | Race conditions, false sharing | Deadlock, load imbalance across ranks |
| Fault tolerance | Process dies, everything dies | Also 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:
- Host — the CPU. Runs
main(), owns normal RAM, orchestrates: allocates GPU memory, copies data, launches kernels. - Device — the GPU. Runs kernels across thousands of threads at once, owns its own separate memory.
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-in | Meaning |
|---|---|
threadIdx | This thread's position inside its block |
blockIdx | Which block this thread belongs to |
blockDim | How many threads per block |
gridDim | How 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
| Memory | Shared by | Speed | Use it for |
|---|---|---|---|
| Registers | One thread | Fastest | A thread's own local variables |
| Shared | All threads in a block | ~100× faster than global | Threads in a block cooperating — e.g. reduction |
| Global | Every thread and the host | Slowest, but huge | The big input/output arrays (cudaMalloc) |
| Constant / texture | Every thread (read-only) | Cached | Read-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
| OpenMP | MPI | CUDA | |
|---|---|---|---|
| Memory model | Shared | Distributed | Separate device memory |
| Unit | Thread | Process (rank) | GPU thread in a warp |
| Communication | Shared variables | Explicit messages | Host–device copies + shared memory |
| Scale | One node | Thousands of nodes | One GPU, thousands of threads |
| Signature bug | Data race / false sharing | Deadlock | Uncoalesced 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.
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?
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?
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?
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.
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.
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.
14. What is memory coalescing, and why is "one thread per row" fast on a CPU but slow on a GPU?
15. Describe a GPU tree reduction and say why __syncthreads() is essential.
__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.