Parallel Programming Models
Session 03 • 2311CSC501J — Parallel Processing • + your first OpenMP program
What You'll Learn
- Three ways to split work: data, task, pipeline
- The models landscape: OpenMP, MPI, CUDA
- OpenMP & the fork-join model
- Write your first parallel program — and meet the race condition
"Add one line above a for-loop, and the compiler runs it across all your cores. That's where we start."
— The promise of OpenMP
From Theory to Typing
Session 01
Why the world went parallel. The free lunch ended.
Session 02
How machines are classified. Flynn's taxonomy; shared vs distributed memory.
Session 03 — today
How YOU write parallel code. Your first real program.
Today parallelism stops being a lecture and becomes something you type. We start with OpenMP — the gentlest possible entry: ordinary C with a few #pragma hints.
This is the last session of Unit I.
Three Ways to Split Work
Data
Same operation, lots of data. Split the array; each core does its slice.
100 students grade one page each of the same exam. → OpenMP, GPUs.
Task
Different operations, at once. Thread A downloads, B compresses, C uploads.
One chops, one fries, one plates.
Pipeline
An assembly line of stages. Items flow through stages that run concurrently.
Factory line: weld, paint, seats — all busy on different cars.
A factory line, once full, ships a finished car at the rate of its slowest stage — not the sum of all stages. Your CPU pipelines instructions the same way. Most real systems mix all three. Today's OpenMP work is data parallelism.
Example: One Job, Split Three Ways
The job: encode a 1-hour video. Serially it takes 60 minutes, made of four stages — decode (10 min), denoise (8 min), encode video (40 min), encode audio (2 min).
| Split | How you cut it | Max useful workers | Time | Speedup |
|---|---|---|---|---|
| Data | Cut the video into 8 segments of 7.5 min; every core encodes one; concatenate | 8, 80, 800… | ~8.5 min | 7× |
| Task | One thread per stage: decode / denoise / video / audio | 4. That's it. | ~40 min | 1.5× |
| Pipeline | Same 4 stages, but frames flow through them continuously | 4. That's it. | ~40 min | 1.5× — but see below |
Read the "max useful workers" column. That's the whole lesson.
- Data parallelism scales with your hardware. Buy 64 cores, use 64.
- Task and pipeline parallelism are capped by the problem's structure. There are four stages, so there are four workers. A 64-core machine buys you nothing.
This is why data parallelism dominates real high-performance code — and why today's OpenMP work is data parallelism.
Two honest catches on the data split: the segments must be independent (video compression reuses earlier frames, so you cut only at keyframes), and you inherit a small sequential concatenate step at the end. That's the 8.5 rather than 7.5, and it's Amdahl already showing up on day three.
Real encoders (ffmpeg, YouTube's pipeline) use all three at once. These aren't rival choices; they're layers.
Worked Example: Why a Pipeline Feels Faster
A 3-stage line: wash (2 min) → dry (3 min) → fold (1 min). One load takes 6 minutes. You have 4 loads.
min: 0 2 4 6 8 10 12 14 16
|----|----|----|----|----|----|----|----|
L1 [W ][ D ][F]
L2 [W ][ D ][F]
L3 [W ][ D ][F] <-- W waits for the dryer
L4 [W ][ D ][F]
serial: 4 loads x 6 min = 24 min
pipeline: 6 + (3 loads x 3 min) = 15 min <-- 3 = the SLOWEST stage
| Loads | Serial | Pipelined | Speedup |
|---|---|---|---|
| 1 | 6 min | 6 min | 1.0× — no gain at all |
| 4 | 24 min | 15 min | 1.6× |
| 20 | 120 min | 63 min | 1.9× |
| 1000 | 6000 min | 3003 min | 2.0× — the ceiling |
Two rules fall straight out of the table:
- Throughput = 1 / slowest stage. Not the sum. The dryer is the whole system's speed limit.
- Latency never improves. Every single load still takes 6 minutes end to end. (Session 01's toll booth, again.)
So the fix for a slow pipeline is never "add another stage" — it's buy a second dryer. Speed up the bottleneck, or nothing changes.
Your CPU does exactly this to every instruction — fetch, decode, execute, write-back, overlapping across ~15 stages. It's why a 3 GHz chip retires roughly one instruction per cycle even though a single instruction takes many cycles to travel through.
The Programming-Models Landscape
Session 02's memory model decides your tool — because it decides how workers communicate.
| Memory model | Tool | What the code looks like |
|---|---|---|
| Shared memory | Threads / OpenMP | Add #pragma hints; all threads share the same variables |
| Distributed memory | MPI | Explicit send / receive messages between processes |
| GPU (SIMD) | CUDA | A "kernel" that runs on every data element at once |
| Hybrid | MPI + OpenMP + CUDA | Messages between nodes, threads within a node, kernels on the GPU |
This course teaches all three — OpenMP, MPI, CUDA — in Unit IV. Today we get our hands on the friendliest one: OpenMP.
What OpenMP Actually Is
- Not a new language. Not a big library. Just compiler directives — special comments of the form
#pragma omp ...— added to normal C/C++. - The compiler reads them and generates all the thread code for you: creating, scheduling, cleaning up.
- If the compiler doesn't understand OpenMP, the pragmas are ignored — your code still runs correctly, just serially. A built-in safety net.
Turn it on with one flag
gcc -fopenmp myprogram.c -o myprogram
Set the team size without recompiling, straight from the shell:
OMP_NUM_THREADS=4 ./myprogram # a team of 4
OMP_NUM_THREADS=1 ./myprogram # serial (team of 1)
The Fork-Join Model
- Program starts as one thread — the master.
- At
#pragma omp parallelit forks into a team; all threads run the region. - At the closing brace they join back into one, and the program continues serially.
FORK-JOIN MODEL
#pragma omp parallel
|
v
____ fork ____
/ | \
master ●========● thread 1 \
(serial) \ thread 2 > all run AT ONCE
\ thread 3 / (the parallel region)
\____ join ___/
|
v
master ●========● (serial again)
You never wrote a line of thread-creation code. One pragma — the compiler handled fork, scheduling, and join. See it move: examples/04-fork-join.html.
Worked Example: Fork-Join Is Not Free
Forking a team and joining it back costs roughly 5 microseconds. Sounds like nothing. Let's find out when it isn't.
Same loop, 8 cores, each iteration does 10 ns of work. Only N changes:
| N | Serial | Parallel = 5µs fork + work/8 | Verdict |
|---|---|---|---|
| 100 | 1 µs | 5 + 0.1 = 5.1 µs | 5× SLOWER |
| 5,000 | 50 µs | 5 + 6.3 = 11.3 µs | 4.4× faster |
| 1,000,000 | 10 ms | 0.005 + 1.25 = 1.26 ms | 7.9× faster |
| 100,000,000 | 1.0 s | 0.000005 + 0.125 = 0.125 s | 8.0× — overhead vanishes |
Row one is the one to remember. You added #pragma omp parallel for and your program got five times slower. The pragma is correct; the loop is just too small to pay for the team.
The classic disaster — parallelising the inner loop:
for (i = 0; i < 1000000; i++) // outer
#pragma omp parallel for // WRONG: forks a team 1,000,000 times
for (j = 0; j < 8; j++) // 8 iterations of nothing
c[i][j] = a[i][j] + b[i][j];
One million fork-joins × 5 µs = 5 seconds of pure overhead for a job that took 80 ms serially. Move the pragma to the outer loop: one fork, a million iterations of real work.
Rule of thumb: parallelise the outermost loop that has enough independent work — and if a loop's total work is under ~50 µs, leave it serial. Always measure; never assume the pragma made things faster.
Today's OpenMP Toolkit
| Directive / function | What it does |
|---|---|
#pragma omp parallel | Fork a team; the block runs once per thread |
#pragma omp parallel for | Fork a team and split the loop's iterations across it |
omp_get_thread_num() | This thread's id (0, 1, 2, …) |
omp_get_num_threads() | How many threads are in the team |
reduction(+:sum) | Private partial sum per thread, then a safe combine |
OMP_NUM_THREADS | Env var: set team size without recompiling |
That's the whole toolkit for today. Six things — and you can already write real parallel code.
Your First OpenMP Program
#include <stdio.h>
#include <omp.h>
int main(void) {
#pragma omp parallel
{
int id = omp_get_thread_num();
int total = omp_get_num_threads();
printf("Hello from thread %d of %d\n", id, total);
}
return 0;
}
Without the pragma: prints once.
With it: the block runs once per thread.
gcc -fopenmp hello.c -o hello
OMP_NUM_THREADS=4 ./hello
No compiler? OnlineGDB · godbolt.org (add -fopenmp) · Colab.
The First Lesson: Order Is Not Yours
A likely run prints:
Hello from thread 2 of 4
Hello from thread 0 of 4
Hello from thread 3 of 4
Hello from thread 1 of 4
The ids are out of order — 2, 0, 3, 1 — and run it again, the order changes. This is not a bug. The threads run genuinely at once; the OS decides who reaches printf first.
Parallel output is non-deterministic. "What order do things happen in?" often has the answer: you don't get to know. That single fact is the source of most parallel bugs — including the next one.
Worked Example: Who Runs Which Iteration?
12 iterations, a team of 4. What does parallel for actually do?
#pragma omp parallel for
for (int i = 0; i < 12; i++)
printf("i=%2d ran on thread %d\n", i, omp_get_thread_num());
The split (default: contiguous chunks)
| Thread | Gets iterations |
|---|---|
| 0 | 0, 1, 2 |
| 1 | 3, 4, 5 |
| 2 | 6, 7, 8 |
| 3 | 9, 10, 11 |
Each i runs exactly once, on exactly one thread.
A likely printed order
i= 6 ran on thread 2
i= 0 ran on thread 0
i= 9 ran on thread 3
i= 3 ran on thread 1
i= 7 ran on thread 2
i= 1 ran on thread 0
i=10 ran on thread 3
... and so on
Run it again — different order. Same 12 lines.
Separate the two things carefully — students mix them up constantly:
- The partition is predictable. Thread 1 gets iterations 3–5, every run.
- The timing is not. Who reaches
printffirst is anyone's guess.
And look what OpenMP quietly did for you: it computed a start and end per thread from that thread's id. That is exactly the SPMD rank arithmetic from Session 02 — you just didn't have to type it. One pragma, same idea.
Since each i lands on one thread, writing to c[i] is perfectly safe — no two threads ever touch the same slot. The moment they do share a slot, everything changes. Next slide.
Parallel Sum — the WRONG Way
long sum = 0;
#pragma omp parallel for
for (int i = 0; i < N; i++) {
sum += a[i]; // RACE: many threads write 'sum' at once
}
Looks fine. It's broken — the answer is too small, and different every run. Why? sum += a[i] is really three steps:
load sum from memory
compute sum + a[i]
store it back to sum
Two threads both read 100, one writes 105, the other writes 103 — clobbering the 105. An update is lost. That's a data race — and it does not exist in serial code.
Worked Example: Watch the Race Eat Your Sum
a[] = {10, 20, 30, 40}, two threads. Thread 0 takes 10 and 20; thread 1 takes 30 and 40. The correct answer is 100.
| Time | Thread 0 | Thread 1 | sum in memory |
|---|---|---|---|
| t1 | read sum → 0 | 0 | |
| t2 | 0 + 10 = 10 | 0 | |
| t3 | write 10 | 10 | |
| t4 | read sum → 10 | 10 | |
| t5 | read sum → 10 ◀ same value | 10 | |
| t6 | 10 + 20 = 30 ✓ | 10 | |
| t7 | 10 + 30 = 40 ✓ | 10 | |
| t8 | write 30 | 30 | |
| t9 | write 40 ◀ clobbers the 30 | 40 | |
| t10 | read sum → 40 | 40 | |
| t11 | 40 + 40 = 80 | 40 | |
| t12 | write 80 | 80 |
Final answer: 80. Should be 100. The 20 vanished — thread 0's write at t8 was overwritten at t9 before anyone read it.
Every arithmetic step above is correct. Nobody computed anything wrong. The bug is that sum += a[i] is three separate operations, and another thread can slip in between them.
Recognise the shape? This is the ATM slide from Session 01, byte for byte. Read, modify, write — interleaved. Same bug, one at a bank, one in your for-loop.
And the cruelty: with a 4-element array it will probably give you 100 a thousand times in a row. Run it with a million elements on 8 threads and you get a different wrong answer every time.
Parallel Sum — the RIGHT Way
long sum = 0;
#pragma omp parallel for reduction(+:sum)
for (int i = 0; i < N; i++) {
sum += a[i]; // each thread gets a PRIVATE partial sum
}
reduction(+:sum): every thread gets its own private sum, adds up its own slice with no interference, and OpenMP safely combines them at the end. Correct answer, same every run, still fast.
Heavier alternatives (know they exist)
#pragma omp critical/atomic— let one thread update at a time. Correct, but they serialize the line and kill your speedup.- For a sum, reduction is the right tool: correct and parallel.
Session 01 warned: parallelism trades the speed problem for coordination problems. The race condition is that trade, live.
Worked Example: What reduction Actually Does
a[] = {10, 20, 30, 40, 50, 60, 70, 80}, 4 threads, reduction(+:sum). The true total is 360.
| Thread | Its slice | Its own private sum |
Touches anyone else's memory? |
|---|---|---|---|
| 0 | 10, 20 | 30 | No |
| 1 | 30, 40 | 70 | No |
| 2 | 50, 60 | 110 | No |
| 3 | 70, 80 | 150 | No |
| Combine | 30 + 70 + 110 + 150 = 360 ✓ correct, every single run | OpenMP does this safely | |
The trick: don't guard the shared variable — get rid of it. During the loop there is no shared sum. Four threads, four private variables, zero contention, full speed. Only at the very end, once, are the four partials combined.
Compare the alternatives on 100 million elements, 8 threads:
| Approach | Correct? | Roughly |
|---|---|---|
| Serial | Yes | 100 ms |
Naked sum += a[i] | No — wrong answer | — |
#pragma omp critical around the add | Yes | ~8,000 ms — 80× SLOWER than serial |
#pragma omp atomic | Yes | ~900 ms — still slower than serial |
reduction(+:sum) | Yes | ~14 ms |
critical and atomic are correct and catastrophic. They force 100 million threads-worth of turn-taking through one line. Correct and slow is not a win.
One subtlety worth knowing: on float/double data the combine order can vary between runs, and floating-point addition isn't perfectly associative — so you may see the last decimal digit wobble. Not a bug, and not something to fear; just don't compare parallel float results with ==.
Quick Check: Will This Loop Parallelise?
The one question that decides it: can two different iterations touch the same memory, where at least one is writing?
for (i=0; i<n; i++)
c[i] = a[i] * b[i];
SAFE. Iteration i writes only c[i]. No overlap. Add the pragma and go.
for (i=1; i<n; i++)
a[i] = a[i-1] + 1;
UNSAFE. Iteration i reads what i-1 wrote — a loop-carried dependency. No pragma fixes this; the algorithm itself is sequential.
for (i=0; i<n; i++)
if (a[i] > max)
max = a[i];
UNSAFE — every iteration may write max. But the algorithm is fine. Fix: reduction(max:max).
for (i=0; i<n; i++)
total += a[i]*a[i];
UNSAFE as written — the race you just traced. Fix: reduction(+:total).
for (i=0; i<n; i++)
printf("%d\n", a[i]);
It depends — and this is the interesting one. No memory is corrupted, so it won't crash or give a wrong value. But the lines come out in a scrambled order. If the output is a log, fine. If it's a CSV someone will parse, you have just silently broken your program. "Safe" means safe for your definition of correct — not just free of races.
Example in Code: Shared By Accident
The race you just saw was obvious — sum was clearly shared. Here is the version that catches people out.
Broken — and it looks fine
double temp;
#pragma omp parallel for
for (i = 0; i < n; i++) {
temp = a[i] * a[i];
b[i] = temp + 1;
}
temp was declared outside the region, so all threads share the one variable. Thread 2 can overwrite temp between thread 5's two lines.
Fixed — two ways
// 1. tell OpenMP explicitly
#pragma omp parallel for private(temp)
// 2. better — declare it inside
#pragma omp parallel for
for (i = 0; i < n; i++) {
double temp = a[i] * a[i];
b[i] = temp + 1;
}
Option 2 needs no OpenMP knowledge at all — a variable declared inside the loop body is private automatically.
The default scoping rule, memorise it: in an OpenMP region, everything declared outside is shared; the loop counter i is the one exception (automatically private); anything declared inside is private.
Why this one is nastier than the sum race: it doesn't produce an obviously-wrong total. It produces an array where a few scattered elements are wrong, different ones each run. On a small test array with 4 threads it will often look perfect.
This is the single most common OpenMP bug there is. Before you add any pragma, ask of every variable in the loop: should each thread have its own?
Your Turn: Three Bugs, Five Minutes
In pairs. This compiles cleanly, runs without crashing, and is wrong in three separate ways. Find them.
double avg = 0.0, diff;
int count = 0;
#pragma omp parallel for
for (int i = 1; i < n; i++) {
diff = a[i] - a[i-1];
avg += diff;
a[i] = diff;
if (diff > 0) count++;
}
avg = avg / (n-1);
Bug 1 — shared temp
diff is declared outside → shared by every thread. Fix: declare it inside the loop.
Bug 2 — two races
avg += and count++ are both unguarded shared writes. Fix: reduction(+:avg,count).
Bug 3 — the fatal one
It reads a[i-1] and writes a[i]. Iteration i+1 may read a slot iteration i already overwrote. No pragma can fix this.
Bug 3 is the lesson of the whole session. Bugs 1 and 2 are syntax — you learn a clause and move on. Bug 3 is structure: the loop overwrites the data the next iteration needs. You fix it by writing into a separate output array — that is, by changing the algorithm.
Parallelism is not something you sprinkle on at the end. It is a property of how you structured the problem — which is exactly where Unit III begins.
Recap, Unit I Wrap-Up & What's Next
Key Takeaways
- Work splits three ways: data, task, pipeline.
- Memory model → tool: shared → OpenMP, distributed → MPI, GPU → CUDA (all three in Unit IV).
- OpenMP =
#pragmadirectives on normal C, compiled with-fopenmp; the fork-join model. - Parallel output is non-deterministic; a data race is fixed here by
reduction(+:sum).
Unit I in one breath
01 why we went parallel → 02 how machines are classified (Flynn, memory models) → 03 how you program them (OpenMP, and the race condition). Why → how organized → how programmed.
Next: Unit II — Parallel Architecture
The hardware underneath — how all those cores and memories actually talk (interconnection networks).