OpenMP in Depth
Session 10 • 2311CSC501J — Parallel Processing • Lab 1: Matrix Multiplication
What You'll Learn
- Work-sharing:
for,sections,single - Scheduling: static / dynamic / guided
- Data scoping — the #1 OpenMP bug source
- Lab 1: parallel matrix multiplication
"OpenMP is the gentlest way into parallel programming: you write ordinary loops, add one #pragma, and the compiler wakes up every core."
— the whole session in one line
Recap: Fork-Join & Parallel Regions
- Your program starts on one master thread, running serially.
#pragma omp parallelforks a team of threads — the block that follows runs on all of them at once.- At the end of the block they join back to one thread. You never created or destroyed a thread by hand.
#include <omp.h>
#include <stdio.h>
int main(void) {
printf("Before: one master thread\n"); // serial
#pragma omp parallel // FORK a team
{
int id = omp_get_thread_num(); // 0, 1, 2, ...
printf("Hello from thread %d\n", id); // runs on every thread
} // JOIN back to one
printf("After: serial again\n"); // serial
return 0;
}
Set the team size at run time with OMP_NUM_THREADS=8 ./program — no recompile. Compile everything today with gcc -fopenmp.
Work-Sharing: Splitting the Job
A parallel region alone makes every thread do the same work. Work-sharing constructs divide the work up:
| Construct | What it does | Flavour |
|---|---|---|
omp for | Splits loop iterations across the team | Data parallel |
omp sections | Runs several different code blocks in parallel | Task parallel |
omp single | Block runs on exactly one thread (any one) | One-off work |
omp master | Block runs on the master thread only | One-off work |
// The combined shortcut you'll 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
for is the same work over many data. sections is different jobs at once — one cook chops, one boils rice, one fries.
#pragma omp parallel
{
#pragma omp sections // the team splits up the sections
{
#pragma omp section
{ load_data(); } // one thread runs this
#pragma omp section
{ render_report(); } // another thread runs this
#pragma omp section
{ send_email(); } // another thread runs this
} // implicit barrier: wait for all three
}
Each section runs on exactly one thread. More sections than threads? Some threads run two, back-to-back.
There's an implicit barrier at the end. Add nowait to skip it if you don't need to wait.
Scheduling: Who Gets Which Iterations?
schedule(kind [,chunk]) decides how loop iterations map to threads. This ties directly to load balancing (Session 09).
| Kind | How it assigns work | Use when |
|---|---|---|
static | Equal, fixed chunks decided up front. No coordination cost. | Every iteration costs the same |
dynamic | Threads grab the next chunk when they finish. Self-balancing. | Uneven / unpredictable work |
guided | Like dynamic, but chunks start big and shrink. | Uneven, want less overhead |
Trade-off: static is cheapest but leaves threads idle when work is lumpy. dynamic keeps everyone busy but pays a coordination cost each time a thread grabs work. Even work → static; uneven work → dynamic/guided.
Data Scoping — The #1 Bug Source
When threads share a variable they shouldn't, you get a silent, luck-dependent wrong answer. Scoping clauses say who sees what:
| Clause | Meaning |
|---|---|
shared(x) | One copy, all threads see it. Fine for read-only or disjoint writes. |
private(x) | Each thread gets its own uninitialised copy. |
firstprivate(x) | Private, but initialised from the value before the region. |
lastprivate(x) | Private, but the last iteration's value is copied back out. |
default(none) | Forces you to declare the scope of every variable. |
Hygiene tip: always write default(none). It's extra typing, but the compiler then refuses to build until you've thought about every variable — catching scoping bugs before they run.
Synchronization: Taking Turns Safely
critical
Only one thread at a time may enter the block. General but relatively slow.
atomic
A single memory update (like x += v) done safely, using a fast hardware instruction.
barrier
Every thread waits here until all have arrived, then all continue together.
reduction(+:sum)
Each thread keeps a private partial; OpenMP combines them safely at the end. Fast and correct.
long sum = 0;
#pragma omp parallel for reduction(+:sum) // the right way to combine
for (int i = 0; i < N; i++) sum += a[i]; // no race, correct every run
nowait removes the implicit barrier at the end of a work-sharing loop — a speed win when the next block doesn't depend on this one finishing.
Lab 1: Matrix Multiplication
Multiplying two matrices, C = A × B, is the textbook "embarrassingly parallel" problem: every output cell is an independent dot product. No two cells depend on each other — so they can all be computed at once.
C[i][j] = A[i][0]*B[0][j] + A[i][1]*B[1][j] + ... + A[i][n-1]*B[n-1][j]
A (rows) B (cols) C (independent cells)
┌───────────┐ ┌───┬───┬───┐ ┌───┬───┬───┐
row i│ ● ● ● ● ● │ × │ │ │ │ │ │ │ = │ · │ ✦ │ · │ ← each ✦ is
└───────────┘ │ │ │ │ │ │ │ ├───┼───┼───┤ its own job,
│ ▼ │ ▼ │ ▼ │ │ · │ · │ · │ computed by
└───┴───┴───┘ └───┴───┴───┘ any thread
The plan: parallelise the outer (row) loop, so each thread owns a band of rows and fills them independently. One #pragma, and the work spreads across every core.
Lab 1: The Serial Version
The classic triple loop, running on one thread. This is our baseline — we time it, then race the parallel version against it.
void multiply_serial(const double *A, const double *B, double *C) {
for (int i = 0; i < N; i++) { // each row
for (int j = 0; j < N; j++) { // each column
double sum = 0.0; // dot product accumulator
for (int k = 0; k < N; k++) { // walk the row x column
sum += A[i*N + k] * B[k*N + j];
}
C[i*N + j] = sum; // one output cell
}
}
}
double t0 = omp_get_wtime(); // high-resolution timer
multiply_serial(A, B, C);
double serial_time = omp_get_wtime() - t0; // seconds
omp_get_wtime() returns wall-clock seconds and ships with OpenMP — the right tool for measuring parallel speedup.
Lab 1: The Parallel Version — One Line
The maths is identical. We add one pragma on the outer loop — and get the scoping right.
void multiply_parallel(const double *A, const double *B, double *C) {
#pragma omp parallel for schedule(static) \
default(none) shared(A, B, C)
for (int i = 0; i < N; i++) { // i is private (the omp for index)
for (int j = 0; j < N; j++) { // j declared here -> private
double sum = 0.0; // sum is local -> private
for (int k = 0; k < N; k++) { // k declared here -> private
sum += A[i*N + k] * B[k*N + j];
}
C[i*N + j] = sum; // threads write disjoint rows: no race
}
}
}
Why no race? Each thread writes its own rows of C. No two threads touch the same cell.
The trap: if j or k were shared, threads would corrupt each other's inner loop. Declaring them inside makes them private.
Lab 1: Results & Speedup
Same 512×512 multiply, just changing OMP_NUM_THREADS (measured on an 8-core laptop):
| Threads | Time | Speedup | Efficiency |
|---|---|---|---|
| 1 (serial) | 0.150 s | 1.0× | 100% |
| 4 | 0.044 s | 3.4× | 86% |
| 8 | 0.026 s | 5.6× | 70% |
Why not a perfect 8×? Memory bandwidth (all threads hammer the same RAM), cache misses (reading B by column is cache-unfriendly), fork/join overhead, and turbo/thermal limits. This is Amdahl's Law and coordination cost (Session 08) made concrete — efficiency drops as you add cores.
Pitfalls & Good Habits
Common bugs
- A loop variable left shared → corruption
- Updating a shared total without
reduction→ a data race schedule(dynamic)with a tiny chunk → overhead swamps the work- Forgetting a matrix is too big for the stack (use
static/malloc)
Good habits
- Always start with
default(none) - Prefer
reductionover hand-rolledcritical - Compile with
-O2; measure withomp_get_wtime - Match the schedule to the work: even→static, uneven→dynamic
The CTO framing: OpenMP is the "just make this loop use all the cores" tool. Scoping bugs are the same shared-mutable-state bugs that bite every backend engineer — two workers writing the same thing without coordinating.
Recap & What's Next
Key Takeaways
- Work-sharing:
forsplits a loop,sectionssplits different jobs. - Scheduling maps iterations to threads — match it to how even the work is.
- Data scoping is the #1 bug source;
default(none)saves you. - Synchronization (
reduction,atomic,critical) makes shared updates safe. - Lab 1: one pragma turned a serial matrix multiply into a 3–5× speedup.
Homework
- Run
03-matrix-multiply.cat 1, 2, 4, 8 threads and tabulate speedup. - Change
schedule(static)todynamic— does it get faster or slower? Why? - Bump
Nto 1024 and watch how the speedup changes.
Next session: MPI — Message Passing (Lab 2: Parallel Sort)
OpenMP stops at one machine. MPI is how thousands of machines cooperate.