Unit IV — Programming Models & Tools

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

#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 forSplits loop iterations across the teamData parallel
omp sectionsRuns several different code blocks in parallelTask parallel
omp singleBlock runs on exactly one thread (any one)One-off work
omp masterBlock runs on the master thread onlyOne-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
staticEqual, fixed chunks decided up front. No coordination cost.Every iteration costs the same
dynamicThreads grab the next chunk when they finish. Self-balancing.Uneven / unpredictable work
guidedLike 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
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
grab when free
assignment decided at run time — roughly one iteration each, repeatedly24 ms
+ overhead
guided
big 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 — garbage10 — completely untouched
firstprivate(y)its own copy, initialised to 2020 — also untouched
lastprivate(z)its own copy300 — 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) sharedthe same variable as everyone elsewhatever 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 barriersfor the slowest thread, 4 times480 ms
With nowait on the first threeonly 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 s1.0×100%
40.044 s3.4×86%
80.026 s5.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 reduction over hand-rolled critical
  • Compile with -O2; measure with omp_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

Homework

Next session: MPI — Message Passing (Lab 2: Parallel Sort)

OpenMP stops at one machine. MPI is how thousands of machines cooperate.