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.
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.
-
1a · Make your repository on GitLab (in the browser)
New project → Create blank project. Name:
CS4170_FA2026_R06_LASTNAMEwith your last name. Visibility: Private. Uncheck "Initialize repository with a README". Create it, then Settings → Members → addgreenr@bgsu.eduas Maintainer. -
1b · Log in to Cardinal
VS Code Remote-SSH to
cardinal.osc.eduand open a terminal, or from your own terminal (put your OSC username in place ofOSC_USERNAME):ssh OSC_USERNAME@cardinal.osc.edu
-
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
-
1d · The lab branch, pushed to your repository
Your
openmpclone is the one from R05. Ifcd ~/openmpsays 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.mdon 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 endingbranch 'lab06' set up to track 'r06/main'. Check with:git status ls
On branch lab06, andREADME.md Default ex6 harness jobScript.slurm predictions srcamong the files. -
1e · Cores of your own
One allocation for steps 1 to 5: 48 cores for 30 minutes. Wait for
Granted job allocationbefore 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
sallocorsrunfails? (for example Protocol authentication error)Skip the allocation. From the login node, paste this once; every
srunon 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.
-
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 endingg++ -O2and one endingg++ -O3 -march=native. Then:ls -l Default/OMP Default/OMP_O3
Two executables.
No such filefor either one means the build failed: scroll up to the firsterror:.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 see | Do this |
|---|---|
fatal: a branch named 'lab06' already exists | You did 1d before. git checkout lab06, then carry on with the lines after the checkout |
error: remote r06 already exists | Fine 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 found | GL_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 errors | The 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.
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.
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.
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.
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).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; }
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.
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
| Version | Reads of 8 bytes | Writes of 8 bytes | Bytes per element | Dependent multiply-adds |
|---|---|---|---|---|
| Original loop | x[i] | y[i] | 16 | 1 |
| Scan, pass 1 | x[i] | y[i] | 16 | 1 |
| Scan, pass 2 | y[i] | y[i] | 16 | 1 (the running factor) |
| Scan, both passes | 32 | 2 |
The 48 carries are a loop of 48 steps and a pow each: nothing against 10⁸ elements.
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.
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.
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.
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
doneThe 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.
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.
-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.
| Section | What goes in it | Where it comes from |
|---|---|---|
| 1 · Prediction | predictions/ex6.yaml pasted unchanged, plus its commit hash | Committed in step 5, before the run and before the job |
| 2 · Record | Blocks 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 serial | Your terminal and ex6/results/, pasted as each step says. No prose |
| 3 · Judgment | At most six sentences, every claim carrying a number from §2, ending in the baseline decision | The prompt is on the Canvas page |
| Time | One honest line | Ungraded |
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.
s = KEEP * s + x[i], the dependence is…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?barrier before the carries with nothing. What happens?c *= KEEP each step instead of std::pow(KEEP, j + 1). Why?-O3 -march=native made the original loop faster, though it is one dependent chain. What changed?