CS 4170 / 5170 · Week 11 · Monday, Nov 2

When the Parallelism Isn’t a Loop

Every pragma so far split a loop. Here: a few fixed pieces (sections), work you discover as you go (tasks), and how small a task can get before it costs more than it does.

Concept map

Three moves, in the lecture's order: a few fixed pieces, then work you discover as you go, then how small a piece of work can usefully be. Click a node to jump to it.

todaylater
Today in one line: when the work is not a loop, hand it out as sections or tasks — and give every task enough work to pay for itself.

Everything here runs from the openmp repository, branch lecture11: six small programs, one per idea — sections, tasks_bug, tasks_fixed, quicksort, fib, treesum. Each prints its serial answer next to its parallel one; the number after the program name is the knob. Every time on this page is from the course test machine (2 cores, shared with other jobs, g++ 13 -O2, min of 5 interleaved runs, Oct 30). Where Cardinal (48 cores, Oct 30) measured something different, the Cardinal section says so.

Wrote vs. made

A loop gave the runtime its pieces for free: the iterations. When the work is not a loop, someone has to make the pieces. Three nouns, kept apart for the rest of the page: a section is a piece you wrote, a task is a piece the program makes, a thread runs pieces.

SectionTaskThread
What it isA piece of work you wrote into the code, under #pragma omp section.A piece of work plus the data it needs, made while the program runs (#pragma omp task) and dropped in a pool.A worker. The team of t threads is fixed by parallel.
How manyAs many as you typed. Three sections is three pieces on 2 threads or 48.As many as the problem has. Quicksort makes two per partition: 40,148 at cutoff 10³.t: from OMP_NUM_THREADS.
Who runs itSome thread, once. A 4th thread with three sections has nothing to do.Any idle thread, once; it takes the next task from the pool.Runs section after section, or task after task.
CeilingSpeedup ≤ number of sections; the biggest section sets the time.Set by the cutoff and the serial first partitions, not by a count you typed.—

Thread ≠ task. A thread runs many tasks in sequence; a task runs on exactly one thread, exactly once. That is why the same task code runs at t = 2 and t = 48 without a change.

Quicksort on 10 million ints with cutoff 10³ makes about 40,000 pieces. Could you write it with sections?

Sections are fixed when you write the code: a section count is a compile-time fact. Quicksort does not know its pieces until it has partitioned, and it finds 40,148 of them; one section holding the recursion (b) would just run the whole sort on one thread. Tasks are the construct for pieces the program discovers.

Sections when you can count the pieces as you write the code. Tasks when the program finds them. First, the pieces you wrote.

Sections: a few fixed pieces

sections.cpp. Three different pieces of work, sizes 1 : 2 : 3. One thread alone takes 6 units. Each section runs once, on some thread, and nobody leaves the region until all three are done.

#pragma omp parallel sections
{
    #pragma omp section
    {
        result[0] = spin(1 * units, 1.0);
    }

    #pragma omp section
    {
        result[1] = spin(2 * units, 2.0);
    }

    #pragma omp section
    {
        result[2] = spin(3 * units, 3.0);
    }
}// Implicit barrier: the slowest thread decides.

spin(steps, seed) is a chain of dependent floating-point steps the compiler cannot delete or vectorize, so its time is exactly proportional to steps. One unit is the time for units steps. The listing leaves out the per-section timing lines; the file has them.

GCC (Cardinal's compiler) hands sections out in order, each to the next thread that is free. How long does the region take on 2 threads, in units?

Thread A takes section 0 (size 1), thread B takes section 1 (size 2). A finishes first and takes section 2 (size 3). A's total is 1 + 3 = 4 units. The best split, 3 | 1 + 2, would give 3 — but GCC does not look ahead. The order threads get sections in is left to the runtime by the OpenMP spec; clang happens to split 0, 1 | 2 and gets 3.

Same three sections on 4 threads. Region time, in units?

3 units from 3 threads on, forever. The biggest piece sets the time, so the best speedup here is 6 / 3 = 2, whatever the thread count. Sections cannot fix imbalance the way schedule(dynamic) did on Oct 28: there the pieces were small and many; here you wrote exactly three.

Hand out the sections yourself


    

Arithmetic in units, not a measurement. Each bar is one section on the thread that ran it.

The real run

$ OMP_NUM_THREADS=1 ./sections 3e7
section 0  size 1  thread 0  0.058 s
section 1  size 2  thread 0  0.118 s
section 2  size 3  thread 0  0.171 s
region 0.348 s
$ OMP_NUM_THREADS=2 ./sections 3e7
section 0  size 1  thread 0  0.056 s
section 1  size 2  thread 1  0.119 s
section 2  size 3  thread 0  0.168 s
region 0.224 s
results match serial: yes

Real output, one run each, test machine, 2 cores (the first run's last line cut). Min of 5: 0.227 s / 0.337 s = 0.67. The model: 4 / 6 = 0.67. Which thread gets which section changes between runs; on 2 threads one thread always ends up with sizes 1 and 3.

Sections is for a handful of different jobs known when you write the code. The imbalance is built in: you chose the pieces. Quicksort does not know its pieces until it has partitioned — that is what tasks are for.

One thread makes tasks; all of them run

The start of main() in quicksort.cpp. Every task program in this course starts this way. A task is a piece of work plus the data it needs, dropped in a shared pool; idle threads take work from the pool.

// The team: t threads.
#pragma omp parallel
{
    // ONE thread starts the recursion.
    #pragma omp single    {        parallelQuicksort(a, 0, n - 1, cutoff);
    }}
// Everyone else waits at single's barrier, and runs
// tasks while it waits. This barrier ends only when
// EVERY task, at every depth, is done.

Who does what

Makes tasks: the one thread inside single, and any task that spawns more.

Runs tasks: any idle thread, from the shared pool.

Waits: the other t − 1 threads go straight to the barrier at the end of single. A barrier is a task scheduling point: a thread waiting there takes tasks from the pool and runs them. The team is not idle; it is the workforce.

t = the number of threads in the team. single lets exactly one thread through — not necessarily thread 0.

Delete the single (keep its braces). What happens?

Every thread calls parallelQuicksort on the whole range. That is t copies of every task, all swapping elements of one array at the same time: a data race, not just waste. The places a task can start are task creation, taskwait, the end of a taskgroup, and every barrier — which is why the bug two sections from here happens.

Quicksort: two tasks per partition

parallelQuicksort from quicksort.cpp. After partition, every element of a[lo..p] is ≤ the pivot ≤ every element of a[p+1..hi], so the two halves can be sorted at the same time with no lock.

void parallelQuicksort(int* a, long lo, long hi, long cutoff) {

    if (lo >= hi) {
        return;
    }

    // A small piece is not worth a task.
    if (hi - lo + 1 < cutoff) {        serialQuicksort(a, lo, hi);        return;    }

    long p = partition(a, lo, hi);

    #pragma omp task
    {
        parallelQuicksort(a, lo, p, cutoff);
    }

    #pragma omp task
    {
        parallelQuicksort(a, p + 1, hi, cutoff);
    }

    // No taskwait: nobody here reads the sorted halves.
}

Read it

The cutoff c (highlighted): a piece with fewer than c elements is sorted serially, with no task.

The first partition touches all n elements, alone. The next level is two partitions of about n/2 on two threads, and so on.

The pointer a is copied into each task; the array it points to is shared. The two ranges do not overlap.

The program checks its result: the same as std::sort of the same input at 1, 2, 4 and 7 threads.

There is no taskwait. When main prints the time, how does it know every task is finished?

After spawning, a call has nothing left to do, so nothing in it needs to wait. The barrier on the previous page waits for every task of every generation. One more thing the code tells you: the first partition is n element-steps on one thread, the next n/2, and so on — roughly 2n steps on the longest chain against about n log₂n total. At n = 10⁷ (log₂n ≈ 23.3) that caps the speedup near 23.3 / 2 ≈ 11.6, however many threads.

Watch the tasks appear

This runs the same partition and the same cutoff test as quicksort.cpp, in your browser, on 48 random numbers. Each box is one call; a filled box partitioned and made two tasks, an outlined box was sorted serially. The count is the program's own rule: two tasks per call that partitions.


  

A task reads a shared variable when it runs

tasks_bug.cpp, part 1. 16 tasks, one per index. out has 17 slots, all −1; out[16] is a spare. square(k) does a little work, then returns k².

int g_i;                                // file scope: shared

#pragma omp parallel
{
    #pragma omp single
    {
        for (g_i = 0; g_i < N; g_i++) {            #pragma omp task
            {
                out[g_i] = square(g_i);
            }
        }
    }
}

N = 16. The file's comment: g_i lives at file scope, so it is shared, and a shared variable stays shared inside a task.

On 2 threads, what lands in out[0..15]?
$ OMP_NUM_THREADS=2 ./tasks_bug          # g++ 13
out[0..15] = . . . 9 . . . . . . . . . . . .
out[16] (spare) = 256
bug 1: 1 of 16 slots right

Every task holds the address of g_i, not its value, and reads it when it runs. By then the loop is usually over and g_i is 16, so the task writes 16² = 256 into the spare. In 20 runs on the test machine, 15 or 16 of the 16 slots were wrong every time; the occasional right slot is a task the other thread picked up while the loop was still at that index. That is what makes this bug look intermittent at scale.

Now 1 thread. Does that fix it?
$ OMP_NUM_THREADS=1 ./tasks_bug          # g++ 13
out[0..15] = . . . . . . . . . . . . . . . .
out[16] (spare) = 256
$ OMP_NUM_THREADS=1 ./tasks_bug_clang    # clang 18
out[0..15] = 0 1 4 9 16 25 36 49 64 81 ...
bug 1: 16 of 16 slots right

Same code, different answer. When a task runs is the runtime's choice, and code that depends on it is wrong on both. At 2 threads clang gets 0 of 16 right too. The fix is in tasks_fixed.cpp: #pragma omp task firstprivate(i) — a copy of i, taken when the task is made.

Step through one thread


    

A walk through the two runtimes' rules, not a recording. Cells are out[0..16]; the last one is the spare.

A global is not required for this bug. By the rules, a plain local int i declared before the parallel region is shared in the region and so shared in the task: the same bug. One compiler can happen to mask it, which is why the program uses a global — so g++, the Cardinal compiler, shows it every time.

What a task can see

The rules with no default clause. Inputs travel by copy; a result the parent reads has to be shared, and the parent has to wait for it.

The variableInside a task it isSo
Shared in the enclosing region: globals, statics, anything declared before parallelshared: the task holds its addressRead late: the out[] bug. Say firstprivate.
Declared inside the region, or a parameter (lo, hi, p)firstprivate: a copy made when the task is createdRight for inputs: quicksort needs no clauses at all
A result the parent readsfirstprivate: the task writes its own copyFix: shared(x) and taskwait
A C++ reference parameter, const std::vector<int>& treefirstprivate: copies the objectPass a pointer, or shared(tree)
// tasks_bug.cpp, part 2
#pragma omp single
{
    long x = 0;

    #pragma omp task
    {
        x = fibSerial(20);
    }

    #pragma omp taskwait

    result = x;
}
fib(20) = 6765. What does result hold after the region?
bug 2: x = 0   (fib(20) = 6765)

Every time, at 1 or 2 threads, with g++ or clang. The fix is two things: shared(x), so the task writes the parent's x, and the taskwait, so the parent does not read x before the task has written it. tasks_fixed.cpp prints fix 2: x = 6765.

The reference row, measured

treesum.cpp sums a tree of 2²⁰ − 1 ints (about 4 MB) twice: passing a pointer, and passing a const std::vector<int>&. With a depth cutoff of 6 each version makes 126 tasks. Each by-reference task copies the whole 4 MB tree.

Test machine, 2 cores, 1 thread, min of 5: ./treesum 20 6. Same sum both ways.

When in doubt write default(none) on the task. The compiler then makes you name every variable's scope, and every row of this table becomes a compile error until you choose.

When a task returns something: three waits

Quicksort never waits: its tasks write disjoint ranges and return nothing, so the barrier at the end of single is the only wait it needs. This is the one thing quicksort cannot show you, so the example changes to fibParallel from fib.cpp — the smallest program where a task hands a value back. Each call makes two tasks and needs both answers back, so x and y are shared and the call waits for its two children before it adds them.

long fibParallel(int n, int cutoff) {

    if (n < 2) {
        return n;
    }

    // A small call is not worth a task.
    if (n < cutoff) {
        return fibSerial(n);
    }

    long x;
    long y;

    // shared: the answer has to come back to this x and y.
    #pragma omp task shared(x)
    {
        x = fibParallel(n - 1, cutoff);
    }

    #pragma omp task shared(y)
    {
        y = fibParallel(n - 2, cutoff);
    }

    // Wait for my two children, then read what they wrote.
    #pragma omp taskwait
    return x + y;
}
WaitWaits for
taskwaitMy children only. Not a grandchild that a child spawned and left running.
taskgroup { }Every task made inside the braces, at any depth. Grandchildren too.
barrierEvery task in the team.
Wait for exactly what you are about to read, and no earlier. A taskwait right after the first spawn would make the two children run one after the other.
Task A spawns task B and returns without waiting for it. A's parent then runs taskwait and reads what B wrote. Is that safe?

In fib every child runs its own taskwait before returning, so by the time the parent's wait ends the whole subtree is done — that is why taskwait is enough there. Quicksort's style is the other one: a child spawns and returns. If a parent needed that whole subtree finished, it would need taskgroup. In parallelQuicksort nobody reads the halves, so neither is needed and the final barrier does the job.

The cutoff curve

./quicksort 1e7 c: sort n = 10⁷ ints, and sweep the cutoff c from 1 to 2 × 10⁷. A piece smaller than c is sorted serially, with no task.

At c = 1, about how many tasks does the sort make?

Every piece of 2 or more elements is partitioned, and every partition makes 2 tasks: the program counts 19,999,998, two per element. Each partition of a piece of size ≥ c makes 2 tasks, and there are roughly 2n/c such pieces, so about 4n/c tasks — but never more than one partition per element, so the count is capped near 2n: tasks ≈ min(4n/c, 2n). At c = 2 × 10⁷, bigger than n, the first test sends the whole array to serialQuicksort: zero tasks.

Before you move the slider: sketch time against c on a log axis, on 2 threads. Label each end with the thing that makes it high.

Measured: test machine, 2 cores, min of 5 per point, Oct 30. The task count is printed by the program (a separate serial walk of the same recursion). S = serial time ÷ 2-thread time, with serial = 1.032 s (c = 2 × 10⁷, 1 thread).

Left end: overhead

20 million tasks. On 1 thread they cost 1.272 − 1.032 = 0.24 s above the serial sort: about 12 ns per task. That is gentle because g++'s runtime stops queueing once a few dozen tasks per thread are waiting and runs each new task on the spot. More threads make the left end worse: Wednesday's fib runs go from 1.91 s on 1 thread to 14.84 s on 2.

Right end: no parallelism

c = 5 × 10⁶ leaves 2 to 4 pieces; c = 2 × 10⁷ leaves one. That is the serial recursion, 1.03 s. With 2 threads you need only a handful of pieces, so the floor starts early (c = 10) and ends late (c = 10⁵). The best, 0.532 s at c = 10³, is a speedup of 1.94 on 2 cores.

Which way does the knob turn?

"Cutoff" means opposite things in different codes. Both kinds put a floor under the size of a task; they turn in opposite directions.

Size cutoff — quicksort, fib

Make a task only if the piece has at least c elements (quicksort) or n is at least c (fib). c = 1: a task for nearly every call. c bigger than the problem: no tasks at all. Bigger c, fewer tasks.

quicksort 1e7: 4,012 tasks at c = 10⁴; 19,999,998 = 2n − 2 at c = 1.

Depth cutoff — treesum

Make a task only above depth d (the root is depth 0). d = 0: no tasks. d = 30: a task for every node. Bigger d, more tasks: 2 + 4 + … + 2ᵈ = 2ᵈ⁺¹ − 2.

treesum 20 6: 2 + 4 + 8 + 16 + 32 + 64 = 126 tasks.


    

Task counts are the program's counting rule on a tree of depth 20 (2²⁰ − 1 nodes), so nothing changes past d = 20. Times are shown only where they were measured: test machine, 2 cores, 1 thread, min of 5.

Someone tells you their program is fastest at cutoff 7. What must you ask before you predict what cutoff 1 does?

Name the knob's direction before you reason about an extreme. At one end of either knob the program is all overhead; at the other it is the serial recursion — and which end cutoff 1 is depends on the kind. Work out the rest yourself.

Run it on Cardinal

Branch lecture11: six programs, each built into Default/. About 20 minutes. Every number quoted above is from the 2-core test machine. Each experiment below also gives what Cardinal measured on Oct 30 (node c0318, gcc 12.3, 48 cores, min of 5); yours will land near those, and the shapes are what to compare.

  1. Fetch and check out the lecture branch

    If you have not cloned the OpenMP repository yet: git clone -o class git@gitlab.com:cs4540-spring-2019/cs-4170-5170-fall-2026/openmp.git openmp first.

    ssh <your-username>@cardinal.osc.edu
    cd openmp
    git fetch class
    git checkout -b lecture11 class/fa2026-openmp/lecture11
  2. Build all six, then get cores of your own

    Modules do not persist between logins. The login node is shared; anything you time goes in an allocation.

    module load gcc/12.3.0
    cd Default && make OSC && cd ..
    salloc -N 1 -n 1 -c 8 -t 00:15:00 -A PCS0313

    Every run: OMP_NUM_THREADS=t srun -c 8 ./Default/<program> [knob]. The thread count always comes from OMP_NUM_THREADS.

Experiments

A · Three sections on 1 to 4 threads

Predict the region time at each t, in units, before you run it.

for t in 1 2 3 4; do
    echo "== $t threads"
    OMP_NUM_THREADS=$t srun -c 8 ./Default/sections 3e7
done
What you should see

On 2 threads one thread runs sizes 1 and 3, so the region takes about 4/6 of the 1-thread time (test machine: 0.227 / 0.337 = 0.67). From 3 threads on it is 3/6, and adding threads changes nothing. results match serial: yes every time. Cardinal, sections 3e8: 4.69 s on 1 thread, 3.13 s on 2 (ratio 0.67), 2.35 s on 48 (ratio 0.50) — exactly 4/6 and 3/6.

B · The scoping bugs, then the fixes

for t in 1 2; do
    OMP_NUM_THREADS=$t srun -c 8 ./Default/tasks_bug
    OMP_NUM_THREADS=$t srun -c 8 ./Default/tasks_fixed
done
What you should see

Cardinal's compiler is g++, so tasks_bug is wrong at both thread counts: out[16] = 256, and 0 or 1 of 16 slots right; x = 0. tasks_fixed prints all 16 squares, out[16] = −1 and x = 6765. Cardinal got 0 of 16 right on 1 thread and 1 of 16 on 2; on 48 threads it got 14 of 16 right — with that many idle threads most tasks run before the loop moves on, and the bug nearly hides. Wrong code that passes is still wrong.

C · The cutoff curve on 8 threads

Sketch it first: where is each end, and is the floor wider or narrower than on 2 cores?

for c in 1 100 1000 100000 1e6 2e7; do
    echo "== cutoff $c"
    OMP_NUM_THREADS=8 srun -c 8 ./Default/quicksort 1e7 $c
done
What you should see

The task counts are arithmetic, so they match this page exactly: 19,999,998 at c = 1, 399,570 at c = 100, 40,148 at c = 10³, 376 at c = 10⁵, 28 at c = 10⁶, 0 at 2 × 10⁷. same as std::sort: yes every time. The times are yours: both ends should sit above a floor in the middle. With 8 threads to keep busy you need more pieces than on 2, so expect the right end to rise sooner.

D · A reference copies the object

OMP_NUM_THREADS=1 srun -c 8 ./Default/treesum 20 6
OMP_NUM_THREADS=1 srun -c 8 ./Default/treesum 20 30
What you should see

126 tasks at depth cutoff 6, 2,097,150 at 30. At 6, the by-reference sum is far slower than the by-pointer sum (test machine: 0.078 s against 0.001 s), because each of its 126 tasks copies a 4 MB tree (Cardinal, 1 thread: 0.159 s against 0.0014 s; 48 threads: 0.028 s against 0.0024 s). At 30 the program skips the by-reference run on purpose: every one of 2 million tasks would copy the tree.

E · Optional: the whole sweep at 48 threads

exit
sbatch jobScript.slurm
What you should see

About 10 minutes later, results/cardinal/quicksort_cutoff_min.txt holds min-of-5 times at 1 and 48 threads. The lecture's model for 48 threads assumed a serial sort of about 1 s and about 1 µs per task when 48 threads share one pool. Here is the model next to what Cardinal measured on Oct 30 (T₁ = 1.20 s):

cutoff ctasksmodel T (s)measured T (s)why
119,999,998≈ 0.50.807overhead
100399,570≈ 0.120.340still overhead: τ is bigger than assumed
10³40,148≈ 0.110.198not yet on the floor
10⁴ – 10⁵4,012 – 376≈ 0.110.120 – 0.126the floor: the first partitions are serial
10⁶28≈ 0.170.212fewer pieces than threads
5 × 10⁶4≈ 0.50.590one half sorted alone
2 × 10⁷0≈ 1.01.203the serial recursion

Best 0.120 s at c = 10⁴: speedup 9.97, against a predicted ≈ 9. The model got the best speedup and the whole right side right: the serial first partitions, not τ, cap quicksort at 48 threads. It got the left edge wrong: at c = 100 it said the floor had started, and Cardinal was still 3× above it, because τ with 48 threads on one pool is larger than 1 µs. So on 48 threads the floor is narrow — only c = 10⁴ to 10⁵ is within 10% of the best — where on 2 cores almost any middle cutoff worked.

Wednesday (Nov 4) was a code walk: fib.cpp timed live at five cutoffs, the cost of a task worked out by subtraction, and how to reason toward a cutoff. Its page: fib, What a Task Costs, and the Cutoff.

Check yourself

Six questions, instant feedback, nothing recorded.

1. Four sections of sizes 4, 1, 1, 2 (in that order), 2 threads, handed out in order to the next free thread. Region time, in units?
A takes section 0 (4). B takes section 1 (1), finishes first and takes section 2 (1), finishes at 2 and takes section 3 (2), finishing at 4. Both threads end at 4 units. Here the order happens to be kind; with sizes 1, 2, 3 it was not.
2. In the parallel/single idiom, which threads run the tasks?
One thread makes the tasks; the others wait at the barrier, and a barrier is a task scheduling point, so they take tasks from the pool and run them.
3. A loop index declared before the parallel region is used inside a task with no clauses. Inside the task it is…
Shared in the enclosing region stays shared in the task: the task holds its address. Fix: firstprivate(i).
4. A task computes a result into a local declared inside single. The parent then reads it after a taskwait and gets the initial value. The fix?
Declared inside the region means firstprivate in the task: the task wrote its own copy. shared(x) makes it write the parent's x; the taskwait makes the parent wait for it.
5. quicksort, n = 10⁷, cutoff c = 2 × 10⁷. How many tasks, and what is the time on 2 threads?
c is bigger than n, so the first test sends the whole array to serialQuicksort. Test machine: 1.035 s on 2 threads against 1.032 s serial.
6. treesum uses a depth cutoff d. Going from d = 6 to d = 7 does what to the task count?
2ᵈ⁺¹ − 2: 126 at d = 6, 254 at d = 7. A depth cutoff turns the other way from a size cutoff: bigger d, more tasks.
← → keys also work