Week 9 · OpenMP I · Companion to lecture 09b

The Trapezoid,
Three Ways, in Threads

Week 4's problem again: x² on [0, 5]. The old course code solves it three times, and each version deletes something. Two of them also bring a bug back.

Concept map

Monday gave you the words. Today is one file that uses all of them. Click a node to jump to it.

todaylater
Three versions, one answer to 10⁻¹³. Test with a tolerance, test at an awkward t, and keep the work outside the critical.

Same problem. What changed?

f(x) = x², [0, 5], n = 1.024·10⁸ today (the Cardinal job uses Week 4's 1.024·10⁹). Exact answer 125/3 = 41.666…

Week 4 · MPIToday · OpenMP
Who am IMPI_Comm_rank, MPI_Comm_sizeomp_get_thread_num, omp_get_num_threads
Getting a, b, nrank 0 sets them; four MPI_Bcastsalready there: they are shared
My stripmyA = a + rank·(n/p)·hthe same line, with the thread id
The mergeMPI_Reduce(SUM) to rank 0critical (1, 2), then reduction (3)
The split bugn/p kept a fraction; Trap took a longn/t in int, and myA stored in an int
Who hid itp = 1, 2, 4, 8 divide 1.024·10⁹the old test used [0, 16] with n = 16
In Week 4, thirdMethod broadcast a, b, h and n because rank 3 had no other way to see rank 0's variables. What does the OpenMP version do instead?

Everything that moved data is gone. firstprivate would also work, but it is unnecessary: the values are only read. What did not go away is the arithmetic of the split — and neither did its bug.

TrapRule1: split by hand, critical inside

As written in 2018. main opens #pragma omp parallel and makes one call. Every thread runs the whole function.

void parallelTrap(double a, double b, int n, double& result){
    double h, x, myRetValue;
    int myId       = omp_get_thread_num();
    int numThreads = omp_get_num_threads();
    int myN, myA, myB;

    h   = (b - a) / n;
    myN = n / numThreads;
    myA = a + myId * myN * h;
    myB = myA + myN * h;

    myRetValue = (f(myA) + f(myB)) / 2.0;
    for (int i = 1; i < myN; i++) {
        x = myA + i * h;
        myRetValue += f(x);
    }
    myRetValue = myRetValue * h;

    #pragma omp critical
    result += myRetValue;
}

Walk it

  • myId and numThreads replace rank and size.
  • Locals declared inside a function called from the region: each thread has its own. Monday's "declared inside is private".
  • result is main's variable, passed by reference: shared.
  • critical: t additions, one at a time. t, not n — cheap.
  • Look at the highlighted line. Do not say anything yet.
The old course ran this on [0, 16] with n = 16 and got the serial answer. Now [0, 5], n = 1.024·10⁸. Right answer 41.67. At t = 2, what prints?

myA and myB are x-coordinates stored in an int. At t = 2, thread 1 should start at 2.5. It starts at 2.

[0, 5], n = 1.024e8                      exact 41.667
t=1   41.666666666671155   CHECK ok
t=2   32.916666507978192   CHECK WRONG
t=3   32.407406631586333   CHECK WRONG
t=4   29.479166538494464   CHECK WRONG
t=5   41.666666666666224   CHECK ok

The old input: [0, 16], n = 16          serial 1368
t=1, 2, 4, 8   1368        CHECK ok
t=3, 5         1127.5      CHECK WRONG

Two bugs, both hidden by the test the old code used. On [0, 16] with n = 16, h = 1: every coordinate was already whole. And t = 3 with n = 16 gives myN = 5, 3 × 5 = 15 — strip [15, 16] is never added. That is Week 4's remainder bug, in integer form. Right at t = 5 on [0, 5] because 5/5 = 1: whole-number strip edges again.

The bug, explored

The same arithmetic TrapRule1 does, computed here. Move t and watch where each thread thinks its strip starts.


    

Edges that truncate are shown in red. The number is computed from the same formula the code uses — a closed form of the loop, so it runs instantly at n = 10⁸. It reproduces the slide's output to the printed digits.

The lesson that crosses both modules: a test case where everything divides evenly is not a test. p = 1, 2, 4, 8 hid Week 4's bug; n = 16 on [0, 16] hid this one. Test at t = 3 and t = 7.

TrapRule2: the caller merges

The worker now returns its strip. It no longer touches result, so it can be tested alone. Main owns the merge.

// parallelTrap() now RETURNS its strip (same int split)
#pragma omp parallel num_threads(numThreads)
{
    //Incorrect First option
    // #pragma omp critical
    // result += parallelTrap(a, b, n);

    double localResult;
    localResult += parallelTrap(a, b, n);

    #pragma omp critical
    result += localResult;
}

Also note num_threads(numThreads) with numThreads = 4 hard-coded in main: Monday's backup bug. The port drops it so a sweep over t can work.

Two questions. 1. What is in localResult before the +=? 2. The commented-out "Incorrect First option" puts the critical around the call. Is its answer right or wrong?

Question 1 is Monday's private-vs-firstprivate trap in a new place: declared, never set, then added to.

$ make all          (g++ 13 -O2)
warning: 'localResult' is used uninitialized
t=1..7   same digits as the TrapRule1 port    (it happened to be 0)

$ clang++ -O2       (same source, [0,16], n=16)
t=1   trap2   nan
t=2   trap2   nan
t=4   trap2   nan

trap2_CriticalCall   t=1..7   CHECK ok

Under g++ it was 0 for years and nobody noticed. Under clang, nan at every t. The compiler owes you nothing, and it warned. Fix: double local = parallelTrap(…); — declare and set in one line. Question 2 is right, and that is the whole point of the next screen.

What the critical holds

A critical section is correct for any block of code. The question is only what you put inside it.

A model, not a timing: each strip is n/t trapezoids of work; the critical is drawn as a queue. Measured on Cardinal, Oct 19: trap2_CriticalCall ran 831 ms at t = 1 and 829 ms at t = 48 — speedup 1.00 at every t — while trap1 went from 834 ms to 17.8 ms.

Model: T(t) = t × (n/t)·t_f = n·t_f. The serial time, plus the cost of starting the threads. Rule: keep the work outside the critical; put only the update inside.

The fix is Week 4's fix

fourthMethod with thread ids. Counts in long, coordinates in double, the first n % t threads take one extra.

// myStrip(): used by trap1, trap2, trap2_Reduction, trap2_CriticalCall
int  tid = omp_get_thread_num();
int  t   = omp_get_num_threads();

double h = (b - a) / n;

long base  = n / t;
long extra = n % t;

long myN   = base;
long first = tid * base + extra;

if (tid < extra) {                 // the first n % t threads take one more
    myN   = base + 1;
    first = tid * base + tid;
}

double myA = a + first * h;
double myB = a + (first + myN) * h;
// … the serial trapezoid over [myA, myB] with myN steps, times h …
// trap2: TrapRule2's shape, fixed
#pragma omp parallel
{
    double local = myStrip(a, b, n);  // declared AND set

    #pragma omp critical
    result += local;                  // t updates, not n
}
// trap2_Reduction: the merge as a clause
#pragma omp parallel reduction(+:result)
result += myStrip(a, b, n);
// reduction works on a bare region too:
// a private result per thread, added at the join

Explore the fixed split

Every trapezoid owned by exactly one thread; every edge a true double. CHECK ok at t = 1, 2, 3, 4, 7 on the test machine, and at 1 … 48 on Cardinal.

TrapRule3: the serial loop, plus one line

Put this next to TrapRule1. Everything the programmer computed by hand is now the runtime's job.

double h        = (b - a) / n;
double retValue = (f(a) + f(b)) / 2.0;

#pragma omp parallel for reduction(+:retValue)
for (long i = 1; i <= n - 1; i++) {
    retValue += f(a + i * h);
}
retValue = h * retValue;

// The 2019 file still calls this serialTrap().
// The name is the only thing left over.

Gone

  • myId, numThreads
  • myN, myA, myB — and both bugs
  • the critical

Who decided the split?

The schedule: contiguous blocks, the remainder spread exactly as on Monday's for_Owners. In MPI you wrote the Scatter. Here one pragma made the same decision, correctly, and did not show you.

The price of hiding it: when the split matters (uneven work per iteration) you have to know it is there. That is schedule(dynamic), Wednesday Oct 28.

Right to 10⁻¹³, and different in the last digits

Every correct version, t = 1, 2, 3, 4, 7. All CHECK ok against 125/3.

Same t, two correct versions (trap1 and trap3): do they print the same 17 digits? Same version, same t, run ten times: same digits?
           trap3                 trap1 (manual split)
t=1   41.666666666671155    41.666666666671155
t=2   41.666666666671055    41.666666666671048
t=3   41.666666666667766    41.666666666668483
t=4   41.666666666669542    41.666666666669542
t=7   41.666666666668064    41.666666666668419
exact 41.666666666666667

trap3, ten runs at t=4:  ...542 ×8   ...549 ×2
trap3, ten runs at t=7:  ...064 ×9   ...071 ×1

Each thread rounds its own partial sum, and (a + b) + c ≠ a + (b + c) in floating point. The t partials are combined in arrival order — so the same t can give a different last digit on the next run. trap1's strips start at different i than trap3's blocks, so the sums are grouped differently too. == is the wrong test. R05 checks within 1e-9 relative of the exact answer.

See it in your browser

This runs the actual sum here, in double precision: n = 10⁶ terms of f(a + i·h) split into t contiguous blocks, each block summed serially, then the t partials combined in the order shown. Same numbers, different grouping.


  

Run it on Cardinal

Same branch as Monday: lecture09. If you built it then, skip to the experiments. About 20 minutes.

  1. Clone once, check out the lecture branch

    ssh <your-username>@cardinal.osc.edu
    git clone -o class git@gitlab.com:cs4540-spring-2019/cs-4170-5170-fall-2026/openmp.git openmp
    cd openmp
    git fetch class
    git checkout -b lecture09 class/fa2026-openmp/lecture09
  2. Build, then get cores of your own

    One warning is expected ('h' may be used uninitialized — Monday's bug). 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/OMP <method> [n [a b]]. The trapezoid methods: trap_Serial trap2_CriticalCall trap1 trap2 trap2_Reduction trap3. Default n = 1.024·10⁸.

Experiments

A · Every correct version, at an awkward t

for m in trap_Serial trap1 trap2 trap2_Reduction trap3; do
    OMP_NUM_THREADS=3 srun -c 8 ./Default/OMP $m | grep -E '^trap|CHECK'
done
What you should see

Five CHECK … ok lines, and five numbers that agree to about 10⁻¹² and differ in the last three or four digits. Then run trap3 three more times at t = 3 and compare the final digits between runs.

B · What the critical holds: read ELAPSED

for t in 1 8; do
    OMP_NUM_THREADS=$t srun -c 8 ./Default/OMP trap2_CriticalCall | grep -E 'CHECK|ELAPSED'
    OMP_NUM_THREADS=$t srun -c 8 ./Default/OMP trap1              | grep -E 'CHECK|ELAPSED'
done
What you should see

All four ok. At t = 1 the two times are about the same. At t = 8, trap1 is roughly 8× faster than at t = 1 and trap2_CriticalCall is not faster at all. Same answer; the difference is what the critical holds.

C · Bring the bug back

The branch keeps only the fixed versions. Reintroduce TrapRule1's bug yourself: in src/main.cpp, inside myStrip, change double myA and double myB to int. Rebuild. Predict the number at t = 2 before you run it (the explorer on screen 4 will tell you if you were right).

cd Default && make OSC && cd ..
OMP_NUM_THREADS=2 srun -c 8 ./Default/OMP trap1
OMP_NUM_THREADS=5 srun -c 8 ./Default/OMP trap1
OMP_NUM_THREADS=3 srun -c 8 ./Default/OMP trap1 16 0 16
OMP_NUM_THREADS=4 srun -c 8 ./Default/OMP trap1 16 0 16
git checkout src/main.cpp && cd Default && make OSC && cd ..    # put it back
What you should see

t = 2: WRONG, about 32.9. t = 5: ok — 5/5 = 1, whole-number edges again. On the old input, t = 4 passes and t = 3 fails — which is exactly why it shipped in 2018. Then exit to release the allocation.

Before Friday Oct 30 (R05 lab): pull lecture09, run trap3 at t = 1 and 3, and look at the digits. R05 is today's trap3, timed — strong and weak scaling, t = 1 … 48 on one socket, with a tolerance check, never ==.

Check yourself

Six questions, instant feedback, nothing recorded.

1. TrapRule1 stores myA in an int. On [0, 5] with t = 2, where does thread 1's strip actually start, and where should it?
myN is a count and int is fine for it; myA and myB are x-coordinates and truncate. Every thread is misplaced; the result is 21% low.
2. Why did the old test input ([0, 16], n = 16) never show the int-coordinate bug?
A test where everything divides evenly is not a test. The remainder bug was still there (t = 3 → 1127.5), but t = 1, 2, 4, 8 hid that one too.
3. #pragma omp critical around result += parallelTrap(a, b, n);. Right or wrong, and how fast?
T(t) = t × (n/t)·t_f = n·t_f. Cardinal: 831 ms at t = 1, 829 ms at t = 48. Keep the work outside; put only the update inside.
4. double localResult; localResult += parallelTrap(…); compiles with a warning and prints the right answer under g++. Is it correct?
Monday's private-vs-firstprivate trap in a new place. The compiler warned. Fix: declare and set in one line.
5. trap3 at t = 4 printed …542 eight times and …549 twice. Which statement is true?
Floating-point addition is not associative; the combine order is not fixed by the spec. Compare against a reference with a relative tolerance — never ==.
6. In trap3, who decided which iterations thread 2 runs?
Decomposition still happens; OpenMP hides it. When the split matters, you have to know it is there — schedule(dynamic) is next week.
← → keys also work