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.
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. Who gets what?
| Clause | T0 gets | T1 | T2 | T3 | Finish |
|---|---|---|---|---|---|
staticcontiguous blocks | 0,1,2 6 ms | 3,4,5 15 | 6,7,8 24 | 9,10,11 33 | 33 ms 2.4× |
static, 1round-robin | 0,4,8 15 ms | 1,5,9 18 | 2,6,10 21 | 3,7,11 24 | 24 ms 3.3× |
dynamic, 1grab when free | assignment decided at run time — roughly one iteration each, repeatedly | 24 ms + overhead | |||
guidedbig chunks first | large chunks early, shrinking towards the end — fewer scheduling decisions than dynamic | ~24 ms less overhead | |||
The row worth staring at is row 2. schedule(static, 1) matched dynamic scheduling's result — and it has zero runtime overhead, because the assignment is still computed with arithmetic, not negotiation.
Why it works here: the cost varies predictably (it rises with i). Dealing the iterations out like playing cards gives every thread a fair mix of cheap and expensive ones. When imbalance is predictable, cyclic static is free load balancing.
Reach for dynamic when the cost is unpredictable — file sizes, Mandelbrot pixels, network calls. Then you're paying the overhead for information you genuinely cannot compute in advance.
One catch on static, 1: round-robin destroys spatial locality. Thread 0 touches elements 0, 4, 8… — and with a 64-byte cache line, threads 0–3 all end up writing the same line. That's Session 05's false sharing. Cyclic scheduling is excellent for balance and can be terrible for cache.
Which is why schedule(static, 8) or (dynamic, 64) — a chunk big enough to own whole cache lines, small enough to balance — usually beats both extremes. Measure it.
For reference: the best possible split of {1…12} into four groups is 20 ms. Naive static gave 33 — 65% worse. One word in a clause recovered almost all of it.
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.
Worked Example: Predict the Output
Cover the answers. Write down what you think this prints. Almost everyone gets at least one wrong the first time.
int x = 10, y = 20, z = 30, total = 0;
#pragma omp parallel for num_threads(4) \
private(x) firstprivate(y) lastprivate(z) reduction(+:total)
for (int i = 0; i < 4; i++) {
x = x + i; // what is x here?
y = y + i;
z = i * 100;
total = total + i;
}
printf("x=%d y=%d z=%d total=%d\n", x, y, z, total);
x=10 y=20 z=300 total=6
| Clause | Inside the loop, each thread has… | After the loop, the original is… |
|---|---|---|
private(x) | its own copy, uninitialised — garbage | 10 — completely untouched |
firstprivate(y) | its own copy, initialised to 20 | 20 — also untouched |
lastprivate(z) | its own copy | 300 — the value from the sequentially last iteration (i=3), whichever thread ran it |
reduction(+:total) | its own copy, initialised to 0 (the identity for +) | 6 — all partials combined: 0+1+2+3 |
(default) shared | the same variable as everyone else | whatever the threads left it as — race |
The trap that bites everyone: private does not copy the value in. Line 1 of the loop reads uninitialised memory. It will often look fine — the stack slot happens to contain 10 — and then break on a different compiler, a different thread count, or in production. If you need the old value, you want firstprivate.
Second trap: lastprivate is not "whichever thread finished last." It is defined as the value from the iteration that would have been last in a serial run — here i=3. Deterministic, and a genuinely useful escape hatch when you need one value out of a parallel loop.
Defensive habit that removes all of this: declare variables inside the loop body wherever you can, and add default(none) to the pragma. That forces the compiler to make you scope every single variable explicitly — it turns a silent wrong answer into a compile error.
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.
Worked Example: The Barrier You Didn't Write
Every omp for, sections and single ends with an implicit barrier you never typed. Sometimes that's exactly right. Sometimes it costs you 20%.
#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
}
Suppose each loop averages 100 ms per thread, but the slowest thread takes 120 ms. Four such loops:
| Version | Every thread waits… | Total |
|---|---|---|
| With the implicit barriers | for the slowest thread, 4 times | 480 ms |
With nowait on the first three | only once, at the very end | ~420 ms |
#pragma omp for nowait
for (i=0;i<N;i++) a[i] = f(i); // finish early, move straight on
#pragma omp for
for (i=0;i<N;i++) b[i] = g(i); // barrier at the end of the region anyway
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 loop 2 and reads elements of a that a slower thread hasn't written yet. Wrong answers, different every run, and it will pass your small test cases — because with few iterations nobody gets far enough ahead.
The rule: use nowait only when the next construct touches completely different data. The default (barrier everywhere) is the safe one, and that is exactly why OpenMP made it the default.
One more gotcha worth a mark in an exam: single has an implicit barrier; master does not. So master followed by code that uses what the master wrote is a race — you must add #pragma omp barrier yourself. Two constructs that look interchangeable and are not.
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.