Unit I — Introduction to Parallel Processing

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
DataCut the video into 8 segments of 7.5 min; every core encodes one; concatenate8, 80, 800…~8.5 min
TaskOne thread per stage: decode / denoise / video / audio4. That's it.~40 min1.5×
PipelineSame 4 stages, but frames flow through them continuously4. That's it.~40 min1.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
16 min6 min1.0× — no gain at all
424 min15 min1.6×
20120 min63 min1.9×
10006000 min3003 min2.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 memoryThreads / OpenMPAdd #pragma hints; all threads share the same variables
Distributed memoryMPIExplicit send / receive messages between processes
GPU (SIMD)CUDAA "kernel" that runs on every data element at once
HybridMPI + OpenMP + CUDAMessages 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

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

  1. Program starts as one thread — the master.
  2. At #pragma omp parallel it forks into a team; all threads run the region.
  3. 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
1001 µs5 + 0.1 = 5.1 µs5× SLOWER
5,00050 µs5 + 6.3 = 11.3 µs4.4× faster
1,000,00010 ms0.005 + 1.25 = 1.26 ms7.9× faster
100,000,0001.0 s0.000005 + 0.125 = 0.125 s8.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 parallelFork a team; the block runs once per thread
#pragma omp parallel forFork 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_THREADSEnv 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
00, 1, 2
13, 4, 5
26, 7, 8
39, 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 printf first 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:

1. Read

load sum from memory

2. Add

compute sum + a[i]

3. Write

store it back to sum

Two threads both read 100, one writes 105, the other writes 103clobbering 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
t1read sum → 00
t20 + 10 = 100
t3write 1010
t4read sum → 1010
t5read sum → 10  ◀ same value10
t610 + 20 = 30 ✓10
t710 + 30 = 40 ✓10
t8write 3030
t9write 40  ◀ clobbers the 3040
t10read sum → 4040
t1140 + 40 = 8040
t12write 8080

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)

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?
010, 2030No
130, 4070No
250, 60110No
370, 80150No
Combine30 + 70 + 110 + 150 = 360  ✓ correct, every single runOpenMP 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
SerialYes100 ms
Naked sum += a[i]No — wrong answer
#pragma omp critical around the addYes~8,000 ms — 80× SLOWER than serial
#pragma omp atomicYes~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

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).