CS 4170 / 5170 · Reflection 06 · posted Monday, Nov 9 · due Sunday, Nov 15

A Dependence Removed, and the Honest Baseline

Six steps on one program. Before each run you write down a number; then you run one command, paste what came back, and say one thing about it. The last step ends with a decision you will keep for the rest of the course.

Concept map

One loop, three versions, two builds. Every box is a step you will run.

A speedup is a ratio. You can make it bigger by making the code faster, or by making the serial time you divide by slower. By the end of step 6 you will have done both to the same program, and you will decide which number to report.

How the six steps work

Each step has the same four parts. Predict: a banner asks for a number or a choice before you run anything. Run: one command, copied from the page. Paste: the lines it printed go into the numbered block of README.md §2 that each step names. Say one thing: a single sentence for §3. Reference numbers from one Cardinal run sit beside each result so you can tell a surprise from a typo.

Branch fa2026-openmp/lab06-solution. The code is complete. There is nothing to write in src/main.cpp; there is a lot to read in it. README.md on the branch is the one document in the repository: setup, what goes where, troubleshooting, and the write-up you fill in. This page is the step-by-step directions.

Step 1 · Set up and build it twice

Every command on this page is meant to be copied and pasted exactly as written. The only thing you ever type yourself is your names, once, in 1c.

-O2 is the build you have timed all semester: good generic x86-64 code. -O3 -march=native adds two things: more aggressive optimization, and permission to use every instruction this CPU has. "This CPU" is why it is compiled on a compute node, through srun, not on the login node.

Every block from step 2 on starts with cd ~/openmp and module load gcc/12.3.0. Both are harmless to repeat, so a block works the same whether you just logged in or have been here an hour.

  1. 1a · Make your repository on GitLab (in the browser)

    New project → Create blank project. Name: CS4170_FA2026_R06_LASTNAME with your last name. Visibility: Private. Uncheck "Initialize repository with a README". Create it, then Settings → Members → add greenr@bgsu.edu as Maintainer.

  2. 1b · Log in to Cardinal

    VS Code Remote-SSH to cardinal.osc.edu and open a terminal, or from your own terminal (put your OSC username in place of OSC_USERNAME):

    ssh OSC_USERNAME@cardinal.osc.edu
  3. 1c · Your names, once

    Paste these two lines, then fix them before you press Enter: your GitLab username, and your last name exactly as it is in the project name from 1a.

    GL_USER=your-gitlab-username
    LAST=YOURLASTNAME

    Check them. This should print your project's address; if it doesn't, run 1c again.

    echo git@gitlab.com:$GL_USER/CS4170_FA2026_R06_$LAST.git
  4. 1d · The lab branch, pushed to your repository

    Your openmp clone is the one from R05. If cd ~/openmp says No such file or directory, run the "No clone yet?" block first.

    cd ~/openmp
    git fetch class
    git checkout -b lab06 class/fa2026-openmp/lab06-solution
    git remote add r06 git@gitlab.com:$GL_USER/CS4170_FA2026_R06_$LAST.git
    git push -u r06 lab06:main
    git config push.default upstream
    No clone yet?
    git clone -o class git@gitlab.com:cs4540-spring-2019/cs-4170-5170-fall-2026/openmp.git ~/openmp

    Then run 1d. If git asks for a password or says Permission denied (publickey), Cardinal has no GitLab key yet: the Permission denied (publickey) row of the troubleshooting table in README.md on the branch (view it on GitLab), or the MPI setup you did in September.

    What you should see

    Switched to a new branch 'lab06', then a push ending branch 'lab06' set up to track 'r06/main'. Check with:

    git status
    ls

    On branch lab06, and README.md Default ex6 harness jobScript.slurm predictions src among the files.

  5. 1e · Cores of your own

    One allocation for steps 1 to 5: 48 cores for 30 minutes. Wait for Granted job allocation before you paste anything else. If you run out of time, run this line again; nothing is lost.

    salloc -N 1 -n 1 -c 48 -t 00:30:00 -A PCS0313
    salloc or srun fails? (for example Protocol authentication error)

    Skip the allocation. From the login node, paste this once; every srun on this page then queues its own short job with the right account and cores, and the commands work unchanged. Paste it again after any new login.

    srun() { command srun -A PCS0313 -N 1 -n 1 -c 48 -t 00:10:00 "$@"; }

    Each command now waits in the queue before it runs, usually seconds, sometimes a few minutes.

  6. 1f · Both builds, on the compute node

    cd ~/openmp
    module load gcc/12.3.0
    srun -c 48 make -C Default OSC O3
    What you should see

    Two built ... with: lines, one ending g++ -O2 and one ending g++ -O3 -march=native. Then:

    ls -l Default/OMP Default/OMP_O3

    Two executables. No such file for either one means the build failed: scroll up to the first error:.

    Paste → nothing. Step 1 has no block in the README; the job in step 6 rebuilds both binaries anyway.
If something goes wrong in step 1
You seeDo this
fatal: a branch named 'lab06' already existsYou did 1d before. git checkout lab06, then carry on with the lines after the checkout
error: remote r06 already existsFine if it points at your project: git remote -v. If it doesn't: git remote set-url r06 git@gitlab.com:$GL_USER/CS4170_FA2026_R06_$LAST.git
! [rejected] lab06 -> main (fetch first)The project was made with a README. Replace it with the lab: git push -f -u r06 lab06:main, then the git config line
ERROR: Repository not foundGL_USER or LAST doesn't match the project. Rerun 1c, then the git remote set-url line above
make: g++: No such file or GLIBCXX errorsThe module isn't loaded: module load gcc/12.3.0, then 1f again

Step 2 · The original loop

A leaky running total: each element is most of the previous total plus a new value. Five lines, and one of them is the whole problem.

// KEEP = 0.99999999: each step keeps almost all of the total so far
double s = 0.0;

for (long i = 0; i < n; i++) {
    s = KEEP * s + x[i];
    y[i] = s;
}

The highlighted line reads s, which the previous iteration wrote. Iteration i cannot start its multiply until iteration i − 1 has finished its add. That is a loop-carried dependence: a value carried from one iteration to the next. Its distance is how many iterations back the value comes from.

Before you run it: which earlier iteration does iteration i wait on, and what kind of dependence is it?

s is written in iteration i − 1 and read in iteration i: a read after a write, one iteration apart. Flow dependence, distance 1. The loop is one chain of n dependent multiply-adds, and a chain runs one link at a time whatever the core count.

Run it

Still inside your allocation from 1e. tee shows the output and keeps a copy in ex6/steps/, so nothing depends on your scrollback.

cd ~/openmp
module load gcc/12.3.0
mkdir -p ex6/steps
OMP_NUM_THREADS=1 srun -c 48 Default/OMP leaky_serial 1e8 | tee ex6/steps/step2.txt

The two lines for block 2, and nothing else:

grep -E '^(CHECK|ELAPSED)' ex6/steps/step2.txt
What you should see
HOST <a compute node>
THREADS 1
N 100000000  KEEP 0.99999999
CHECK leaky_serial ok  max rel err 0 (tolerance 1e-09)
ELAPSED …

The CHECK line is against the same loop, so the error is exactly zero. The time is a chain of 10⁸ dependent multiply-adds; keep it in mind for step 5.

Paste → README.md §2, block 2: the CHECK line and the ELAPSED line (two lines).

Step 3 · The wrong fix

The obvious move: put parallel for on the loop and hope. The program has this version on purpose. It is called leaky_racy.

y[0] = x[0];

#pragma omp parallel for schedule(static)
for (long i = 1; i < n; i++) {
    y[i] = KEEP * y[i - 1] + x[i];     // y[i-1] may not be written yet
}

With 48 threads and schedule(static), thread 1 starts at i ≈ n/48 and its first iteration reads y[n/48 − 1], the last element of thread 0's chunk. Thread 0 writes that element last. So thread 1's read is right only if thread 1 starts a whole chunk's worth of time late. Before any run, y is zeroed, so an early read sees 0, and the error rides forward through the rest of the chunk because KEEP is almost 1. A read that lands partway, after thread 0 has written some of its chunk but not the last element, sees whatever is there at that instant.

Before you run it: 100 runs at 48 threads, n = 10⁸. How many come out right?

At n = 10⁸ a chunk is about 2 million elements of work; no thread starts that late, so every thread reads the zero. Cardinal, 100 runs at 48 threads: …. Wrong, and wrong the same way every time. Now n = 10⁴, where a chunk is about 200 elements and the fork's skew between threads is comparable to a chunk's work: …. Still wrong every run, but 99 different wrong answers: each thread read whatever its neighbour had written at that instant. Same code, two sizes, and the only thing that changed between the 100 runs at 10⁴ was timing. (Option d was the closest: it describes 10⁸, not 10⁴.)

Run it, at two sizes

cd ~/openmp
module load gcc/12.3.0
mkdir -p ex6/steps
OMP_NUM_THREADS=48 srun -c 48 Default/OMP leaky_racy 1e8 --repeat 100 | tee ex6/steps/step3_1e8.txt
OMP_NUM_THREADS=48 srun -c 48 Default/OMP leaky_racy 1e4 --repeat 100 | tee ex6/steps/step3_1e4.txt

The four lines for block 3, in order (10⁸ first):

grep -hE '^(CORRECT|IDENTICAL)' ex6/steps/step3_1e8.txt ex6/steps/step3_1e4.txt
What you should see
run 1 of 100:  CHECK leaky_racy WRONG  97916665 of 100000000 elements … first at i = 2083335 (block 1 of 48) …
CHECK leaky_racy WRONG  0 of 100 runs correct
CORRECT 0/100
IDENTICAL 100/100  (bitwise, against run 1)      ← n = 10⁸: the same wrong answer every run
…
CORRECT 0/100
IDENTICAL 1/100  (bitwise, against run 1)        ← n = 10⁴: a different wrong answer almost every run

The first failure says where it went wrong: block 1 of 48, the first element thread 1 touched. The IDENTICAL line is the one to read: a correct program at a fixed thread count gives the same bits every run, and so does a program that is wrong for a reason that never varies. Bits that change from run to run mean the answer depends on when each thread ran. One sentence for §3: a run that came out right would not be evidence the code is right, because what came out was the timing.

Paste → README.md §2, block 3: the CORRECT k/100 and IDENTICAL j/100 lines from the 10⁸ run, then the same two lines from the 10⁴ run (four lines, in that order).
A race is a bug whose answer depends on the schedule. Here it is wrong on every schedule, and at 10⁴ it is wrong differently on each one. "I ran it and it was right" is a statement about one schedule.

Step 4 · The right fix

Remove the dependence instead of ignoring it: a two-pass blocked scan. Each thread scans its own block from zero, one thread works out what each block should have started from, and each thread adds that back in.

Why it works, in one line of algebra

Call s the running total a block gets when it starts from 0, and c the true total just before the block (its carry-in). Because each step keeps KEEP of the total, the carry-in fades by one factor of KEEP per element:

y[lo + j]  =  s_(lo + j)  +  KEEP^(j + 1) * c          // j = 0, 1, 2, ... inside the block

So the block can be scanned without c, and c added in afterwards. The carry-in of the next block is this block's true last value, which is one more line of the same algebra. There are only 48 carries, so one thread computes all of them in a loop of 48 steps.

The three parts, animated

// pass 1: scan my block from zero
double s = 0.0;
for (long i = lo; i < hi; i++) {
    s = KEEP * s + x[i];
    y[i] = s;
}
blockLast[tid] = s;

// the carries: every thread must finish pass 1 first
#pragma omp barrier

#pragma omp single
{
    for (int k = 1; k < p; k++) {
        long len = blockLo(n, k, p) - blockLo(n, k - 1, p);
        carryIn[k] = std::pow(KEEP, (double) len) * carryIn[k - 1] + blockLast[k - 1];
    }
}   // implicit barrier: every carryIn[] is set

// pass 2: add my decayed carry back in -- a running factor, not pow per element
double c = carryIn[tid];
for (long i = lo; i < hi; i++) {
    c *= KEEP;
    y[i] += c;
}
Before you run it: two constructs sit between pass 1 and pass 2. Which one ends in a barrier that every thread waits at, and which does not?

The explicit barrier makes every blockLast[] visible before one thread reads them. The single ends in an implicit barrier, so nobody starts pass 2 before every carryIn[] is written. master has no barrier after it; single nowait drops the one it has. Either of those is the step-3 bug in a new coat, and the 20-run gate below is how you would see it.

Run it

cd ~/openmp
module load gcc/12.3.0
mkdir -p ex6/steps
OMP_NUM_THREADS=48 srun -c 48 Default/OMP leaky 1e8 --repeat 20 | tee ex6/steps/step4.txt

The two lines for block 4:

grep -E '^(CORRECT|IDENTICAL)' ex6/steps/step4.txt
What you should see
THREADS 48
N 100000000  KEEP 0.99999999
CHECK leaky ok  20 of 20 runs correct
CORRECT 20/20
IDENTICAL 20/20  (bitwise, against run 1)
ELAPSED …

IDENTICAL is the stricter one: a correct scan at a fixed thread count produces the same bits every run, and different bits would mean something depends on timing. Twenty passes do not prove there is no race; reading the two constructs does.

Paste → README.md §2, block 4: the CORRECT 20/20 line and the IDENTICAL 20/20 line (two lines).

Step 5 · What the fix costs

The scan gives the same answer as the loop, in parallel. It is not free. On one thread it does strictly more work than the loop it replaced, and that extra work is paid at every thread count, divided among the threads.

Count the bytes one element moves

VersionReads of 8 bytesWrites of 8 bytesBytes per elementDependent multiply-adds
Original loopx[i]y[i]161
Scan, pass 1x[i]y[i]161
Scan, pass 2y[i]y[i]161 (the running factor)
Scan, both passes322

The 48 carries are a loop of 48 steps and a pow each: nothing against 10⁸ elements.

Before you run it: the scan on one thread, divided by the original loop, both -O2. From the table, the ratio is about…

Two passes, each the same shape as the loop: about 2× is the planning number from the bytes. Cardinal comes in under it, because pass 2's chain is a bare multiply with no add waiting on it, so its links are shorter than the loop's. Min of 5 at -O2: scan on 1 thread …, original loop …, ratio …. Whatever your measured ratio is, it is the fix's price, and it does not go away with more threads: it is divided among them.

Commit your three predictions now, before the run: the ratio above, the -O3 gain in step 6, and the speedup at 48. The file is predictions/ex6.yaml.

Open the file and fill in every field: in VS Code, open predictions/ex6.yaml from the explorer; in a plain terminal, use the line below (type your answers, then Ctrl+O, Enter, Ctrl+X to save and quit).

cd ~/openmp
nano predictions/ex6.yaml

Commit and push it. The last line prints the commit hash for README §1.

cd ~/openmp
git add predictions/ex6.yaml
git commit -m "R06 prediction, before step 5 and the job"
git push
git log -1 --format=%h

Run it: the first look

cd ~/openmp
module load gcc/12.3.0
mkdir -p ex6/steps
OMP_NUM_THREADS=1 srun -c 48 Default/OMP leaky 1e8 | tee ex6/steps/step5_scan.txt
OMP_NUM_THREADS=1 srun -c 48 Default/OMP leaky_serial 1e8 | tee ex6/steps/step5_serial.txt

The two ELAPSED lines for block 5, scan first. The ratio (first ÷ second) is yours to work out and write on a third line.

grep -h '^ELAPSED' ex6/steps/step5_scan.txt ex6/steps/step5_serial.txt

Done with the allocation. Give the cores back; step 6 is submitted from the login node. (Used the srun() line from 1e instead of salloc? Skip this: there is no allocation, and exit would log you out.)

exit
What you should see

Two ELAPSED lines; divide the first by the second. Cardinal, one run each: …. This is one run each, so it is a first look. The job in step 6 measures both five times, and the Record table uses its min-of-5, which is what you compare your prediction against.

Paste → README.md §2, block 5: the scan's ELAPSED line, then leaky_serial's, then their ratio (first ÷ second) on a third line. Your three predictions go in predictions/ex6.yaml, committed before this run.

Step 6 · The honest baseline

Speedup = serial time ÷ parallel time. Step 1 built two serial times for the same loop. This step measures both, and the scan at 1 to 48 threads, and makes you choose.

Four sweeps, one job, under ten minutes: the original loop at -O2 and at -O3 -march=native (one thread, five trials each); the scan on one thread at -O2 (the fix's cost, for the Record); and the scan at 1, 2, 4, 8, 16, 32 and 48 threads at -O3. The job also checks the scan at every thread count in both builds and runs the 20-run gate, into check.txt.

Before you run it: the original loop at -O2 divided by the same loop at -O3 -march=native. One chain of dependent multiply-adds. The optimized build is…

Vector instructions cannot help a chain where each element needs the previous result; but -march=native lets the compiler fuse the multiply and the add into one FMA, and the chain's link gets shorter. Cardinal, min of 5: -O2 …, -O3 …, gain …. The serial loop you have been dividing by all semester was slower than it had to be.

Run the job

From the login node (after the exit in step 5), in the repository root. Commit your prediction first; it was step 5.

cd ~/openmp
sbatch jobScript.slurm

It prints Submitted batch job and a number. Check on it; rerun this line every minute or two until your job is no longer listed (it runs under ten minutes once it starts, and you also get an email when it ends):

squeue -u $USER

When it is gone from the list, check that everything came back:

cd ~/openmp
ls ex6/results
cat ex6/results/check.txt

Then print block 6 exactly as the README lays it out, each table under its file name:

cd ~/openmp
for f in serial_O2 serial_O3 scan1_O2 scan_O3; do
    echo "ex6/results/$f.csv"
    python3 harness/reduce.py ex6/results/$f.csv
    echo
done
The job finished but files are missing?

The job's own log says why. The newest one is:

cd ~/openmp
tail -n 30 $(ls -t slurm-*.out | head -1)

error: Default/OMP_O3 not found means the build in the job failed (scroll up in the same log for the first error:). DUE TO TIME LIMIT means the job ran out of its ten minutes: run sbatch jobScript.slurm again. Only one copy of the job at a time: two in the same folder both run make clean and break each other.

What you should see

ls lists check.txt, the four CSVs and provenance.json. Fourteen CHECK leaky ok lines and CORRECT 20/20 / IDENTICAL 20/20 in check.txt. Four tables from reduce.py, each with a T (ms) column. The S column in the last table is against the scan on one thread, not against the original loop: do not copy it into the Record.

Paste → README.md §2, block 6: the whole table reduce.py prints for each of the four files, under that file's name, in the order above. Then fill the three-row table from the T (ms) columns (min of 5) and the extra line under it: the 48-thread scan against the -O2 serial.

Two speedups from one run

Type your four min-of-5 times (ms). The Record's three rows, and the one number this step is about, come out below. The reference column is one Cardinal run.

Same 48-thread run, two speedups. The code did not change between the two rows at the bottom. The denominator did.

The decision, in writing, at the end of §3: from now on every speedup you report is measured against the best serial time you have, the -O3 -march=native build of the original loop. Assignment 2 is measured this way.

What you hand in

The URL of your CS4170_FA2026_R06_LASTNAME project, in Canvas, by Sunday, November 15, 11:59 PM. Three sections in README.md and a time line.

SectionWhat goes in itWhere it comes from
1 · Predictionpredictions/ex6.yaml pasted unchanged, plus its commit hashCommitted in step 5, before the run and before the job
2 · RecordBlocks 2–5: the lines steps 2, 3, 4 and 5 name. Block 6: the four reduce.py tables. Then the three-row table and the one extra speedup against the -O2 serialYour terminal and ex6/results/, pasted as each step says. No prose
3 · JudgmentAt most six sentences, every claim carrying a number from §2, ending in the baseline decisionThe prompt is on the Canvas page
TimeOne honest lineUngraded
git add -A
git commit -m "R06 record and judgment"
git push

There is no Diff Journal for R06: you started from the solution. Your LLM transcripts, labelled with the contract block you were under, go in the repository as usual. The IDENTICAL line from step 4 and the k/100 lines from step 3 are the two things a reader will look at first.

Check yourself

Six questions. No numbers to look up; each one is about a mechanism you just ran.

1. In s = KEEP * s + x[i], the dependence is…
s is written in one iteration and read in the next. Read after write, one iteration apart: flow, distance 1.
2. leaky_racy, 100 runs at 48 threads: at n = 10⁸ it prints IDENTICAL 100/100; at n = 10⁴, IDENTICAL 1/100. Both are CORRECT 0/100. What does the difference say?
IDENTICAL compares bits across runs at a fixed thread count. A deterministic program, right or wrong, gives 100/100. Bits that change from run to run mean the answer depends on timing, which is the definition of a race, and 10⁴ is where the chunk is short enough for the timing to show.
3. Replace the explicit barrier before the carries with nothing. What happens?
single picks one thread to run the block; it does not wait for the others to arrive. The barrier at its end is after the block, too late for the reads inside it.
4. Pass 2 uses c *= KEEP each step instead of std::pow(KEEP, j + 1). Why?
The running factor is the same number pow would give, built one multiply at a time. The carries loop uses pow because it runs 48 times, not 10⁸.
5. -O3 -march=native made the original loop faster, though it is one dependent chain. What changed?
A chain cannot be vectorized or reordered. It can be made of shorter links. FMA is one of them, and -march=native is what allows it.
6. Your scan at 48 threads: 30× against the -O2 serial, 20× against the -O3 serial. Which do you report?
The parallel code did the same work in both rows. Reporting 30× claims credit for a slow denominator. From R06 on, the baseline is the optimized serial build.
← → keys also work