Skip to content

Commit fdc2196

Browse files
authored
Merge branch 'master' into fix/cylindrical-angular-flux
2 parents 654b5cb + 22c2aae commit fdc2196

394 files changed

Lines changed: 12695 additions & 6372 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/rules/common-pitfalls.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,35 @@ covered in `docs/documentation/contributing.md`.
3636
- `@:ACC_SETUP_VFs(...)`/`@:ACC_SETUP_SFs(...)` GPU pointer setup compiles only under
3737
Cray. Around MPI: `GPU_UPDATE(host=...)` before send, `GPU_UPDATE(device=...)` after
3838
receive.
39+
- An array whose bound is a device global (`dimension(num_fluids)`, `dimension(num_species)`) may be
40+
passed to a device routine **from a parallel-loop body, but not from inside another
41+
`GPU_ROUTINE(parallelism='[seq]')`**. CCE OpenACC rejects the second form with
42+
`ftn-7066 ... Global in accelerator routine without declare -- num_fluids`, and reports it at
43+
whatever line it gave up on: remove one trigger and the message *walks forward* to the next call,
44+
so the reported line is not the cause. Only the plain lanes fail - under `--case-optimization`
45+
those bounds are `parameter`s, so a green Case Opt lane beside a failing plain one is the
46+
signature. Every accepted call site in the tree already obeys this (`m_cbc`, `m_ibm`,
47+
`m_bubbles_EL`, `s_compute_cell_state`): form such a call in the loop body and pass scalars
48+
deeper. Neither `cray_inline` nor a `num_fluids_max` bound nor dropping optional dummies helps -
49+
all three were measured.
50+
- nvfortran 23.11/24.1 segfault (`fort2 TERMINATED by signal 11`) on a caller that passes a
51+
`parameter` array from `m_thermochem` (e.g. `molecular_weights`) into a declare-target routine.
52+
Read such arrays directly in the kernel, or pass a plain local computed from them.
53+
- The `USING_AMD` fypp guards (86 sites, `#:set` in `src/common/include/shared_parallel_macros.fpp`) are
54+
load-bearing, not a stale workaround - do not "modernize" them away. They swap a device-global array
55+
bound for a literal: `dimension(3)` for `num_dims`/`num_fluids` when case optimization is off (64
56+
sites), and `dimension(20)` for `sys_size` in `m_compute_cbc` (21 sites, with a matching
57+
`@:PROHIBIT` in `m_start_up` capping `sys_size <= 20` under AMD+CBC). Setting `USING_AMD = False`
58+
and rebuilding amdflang `--gpu mp` without case optimization compiles CLEAN - 728 s, zero
59+
diagnostics - and then NaNs at step 50 in CBC, riemann `wave_speeds=2`, IBM, surface tension,
60+
QBMM/viscous and MHD HLLD, while both Lagrange bubble cases *complete* with out-of-tolerance
61+
answers. Measured 2026-08-29 on MI210. A compile-only check returns green, so any future attempt to
62+
drop these must run the tests, not just build.
63+
- The same "call it from the loop body" rule covers `m_thermochem`: calling `get_species_*` from
64+
inside a `GPU_ROUTINE` rather than from the kernel gave CCE OpenMP a runtime
65+
`Memory access fault by GPU node-N ... Reason: Unknown` on the first step (exit 134), while every
66+
other backend ran. Evaluate them at the call site and pass the arrays in. Note this one only shows
67+
at runtime, and only on a case that reaches the path - the build is clean.
3968

4069
## Parameters
4170

@@ -105,6 +134,27 @@ covered in `docs/documentation/contributing.md`.
105134
- Tests are generated programmatically in `toolchain/mfc/test/cases.py` (parameter
106135
modifications on `BASE_CFG` via the `CaseGeneratorStack` push/pop pattern); test UUID =
107136
CRC32 of the trace string; `./mfc.sh test -l` lists all.
137+
- `--only` matches whole trace *elements*, not substrings, and `_filter_only`
138+
(`toolchain/mfc/test/test.py`) **ANDs labels while ORing UUIDs**. So `--only bubbles` matches
139+
nothing (the element is `Bubbles`), and `--only low_Mach=1 low_Mach=2` asks for cases carrying
140+
both and also matches nothing. It then exits **143**, which reads like an external kill rather
141+
than an empty filter. Pass UUIDs whenever you want the union of several groups.
142+
- Sibling `define_case_d` calls off the same stack level are never *combined*. Two switches that
143+
only matter together (`avg_state=1` needs `wave_speeds=2` to be read at all) therefore get zero
144+
effective coverage unless something pushes one and defines the other beneath it. Check
145+
reachability before trusting that a flag is tested.
146+
- `--no-build` silently runs whatever binary is on disk for a configuration it did not build.
147+
Chemistry has its own config (`gpu-mp-chem-*`) that a plain `./mfc.sh build` never produces, so
148+
a `--no-build` run reports failures from stale binaries and hides real compile breaks. Run
149+
chemistry-touching sets without it.
150+
- Pick the newest binary by the *binary's* mtime (`ls -t build/install/*/bin/simulation`), not the
151+
install directory's - a stale config's directory can be newer than a fresh build's.
152+
- The pre-commit hook lives in the main repo's `.git/hooks/` and git exports `GIT_DIR` there
153+
during a commit, so from a worktree the toolchain lint enumerates the *other* checkout and
154+
fails. Reproduce with `GIT_DIR=<main>/.git ./mfc.sh precheck`. Run precheck by hand and commit
155+
with `--no-verify`.
156+
- `/tmp` is node-local: scratch does not survive a compute-node change, and its absence is
157+
silence, not an error. Keep patches and resource baselines on a shared filesystem.
108158
- Golden files are tolerance-compared. Regenerate only the affected tests
109159
(`./mfc.sh test --generate --only <tests>`) — an unexplained golden-file diff is a bug
110160
report, not noise to be regenerated away.

.github/scripts/check_coverage_map_health.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
"""Fail loudly if the committed coverage map is stale or under-covers. Used by coverage-health.yml."""
22
import datetime
3-
import subprocess
43
import sys
54
from pathlib import Path
65

76
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "toolchain"))
8-
from mfc.test.coverage import COVERAGE_MAP_PATH, load_map, map_health # noqa: E402
7+
from mfc.test.coverage import COVERAGE_MAP_PATH, load_map, map_health, run_git # noqa: E402
98
from mfc.test.cases import list_cases # noqa: E402 (returns the current test list)
109

1110
MAX_AGE_DAYS = 10
@@ -40,7 +39,7 @@ def verified_sha(cwd=None):
4039
caller must read that as undeterminable and fall back to the wall-clock age rule, not
4140
as a failure -- an absent ref is not evidence of a broken refresh.
4241
"""
43-
rev = subprocess.run(["git", "rev-parse", "--verify", "--quiet", f"{VERIFIED_REF}^{{commit}}"], capture_output=True, text=True, check=False, cwd=cwd)
42+
rev = run_git(["rev-parse", "--verify", "--quiet", f"{VERIFIED_REF}^{{commit}}"], cwd)
4443
return rev.stdout.strip() or None
4544

4645

@@ -53,10 +52,10 @@ def verified_after_last_change(git_sha, cwd=None):
5352
"""
5453
if not git_sha:
5554
return None
56-
last = subprocess.run(["git", "log", "-1", "--format=%H", "--", *COVERAGE_RELEVANT_PATHS], capture_output=True, text=True, check=False, cwd=cwd)
55+
last = run_git(["log", "-1", "--format=%H", "--", *COVERAGE_RELEVANT_PATHS], cwd)
5756
if last.returncode != 0 or not last.stdout.strip():
5857
return None # shallow clone or no such commit -> fall back to the age rule
59-
ancestor = subprocess.run(["git", "merge-base", "--is-ancestor", last.stdout.strip(), git_sha], capture_output=True, check=False, cwd=cwd)
58+
ancestor = run_git(["merge-base", "--is-ancestor", last.stdout.strip(), git_sha], cwd)
6059
return {0: True, 1: False}.get(ancestor.returncode) # anything else -> None (unknown sha, shallow history)
6160

6261

.github/scripts/monitor_slurm_job.sh

Lines changed: 123 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ output_file="$2"
3535
echo "Submitted batch job $job_id"
3636
echo "Monitoring output file: $output_file"
3737

38+
# Put the one thing a reader needs on the run's summary page. Without this,
39+
# learning why a job failed means opening a log of tens of thousands of lines --
40+
# and an infrastructure fault looks exactly like a test failure until you do.
41+
# Silent when not running under Actions.
42+
ci_summary() {
43+
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || return 0
44+
printf '%b\n' "$1" >> "$GITHUB_STEP_SUMMARY"
45+
}
46+
3847
# Robustly check SLURM job state using squeue with sacct fallback.
3948
# Returns the state string (PENDING, RUNNING, COMPLETED, FAILED, etc.)
4049
# or "UNKNOWN" if both commands fail.
@@ -70,9 +79,13 @@ get_job_state() {
7079
echo "UNKNOWN"
7180
}
7281

73-
# Check if a state is terminal (job is done, for better or worse)
74-
# PREEMPTED is intentionally excluded: with --requeue the job restarts under
75-
# the same job ID and we must keep monitoring rather than exiting early.
82+
# Check if a state is terminal (job is done, for better or worse).
83+
# PREEMPTED is handled separately (below): Phoenix preempts 'embers' jobs with
84+
# PreemptMode=CANCEL, not REQUEUE (verified via `scontrol show config`), so a
85+
# preempted job is killed outright and never restarts under the same ID.
86+
# --requeue is a no-op for it. It is surfaced via PREEMPT_EXIT so the submit
87+
# wrapper can resubmit a fresh job instead of failing the CI step.
88+
PREEMPT_EXIT=76
7689
is_terminal_state() {
7790
case "$1" in
7891
COMPLETED|FAILED|CANCELLED|CANCELLED+|TIMEOUT|OUT_OF_MEMORY|NODE_FAIL|BOOT_FAIL|DEADLINE|REVOKED)
@@ -82,18 +95,68 @@ is_terminal_state() {
8295
esac
8396
}
8497

98+
# Optionally bound how long a job may sit un-started in the queue. On the
99+
# preemptible Phoenix 'embers' QOS a job routinely stays PENDING for hours and
100+
# needs most of the job-level `timeout-minutes` (480m) window to backfill onto a
101+
# free node; that job timeout is the real backstop. Default to 0 (wait
102+
# indefinitely, up to the job timeout) so ordinary queue pressure does not turn
103+
# otherwise-healthy jobs into red CI. Set SLURM_MAX_QUEUE_SECONDS>0 to opt into
104+
# an earlier queue-starvation cutoff where the scheduler is not preemptible.
105+
: "${SLURM_MAX_QUEUE_SECONDS:=0}" # 0 = wait indefinitely (job timeout is the backstop)
106+
# Reject a non-integer override rather than silently skipping the budget.
107+
if ! [[ "$SLURM_MAX_QUEUE_SECONDS" =~ ^[0-9]+$ ]]; then
108+
echo "ERROR: SLURM_MAX_QUEUE_SECONDS must be a non-negative integer (seconds), got '$SLURM_MAX_QUEUE_SECONDS'" >&2
109+
exit 1
110+
fi
111+
# How long to wait between status polls and between output-stabilization
112+
# checks. Overridable so tests can exercise this script without sleeping
113+
# through it; CI leaves it at the default.
114+
: "${MFC_MONITOR_POLL_SECONDS:=5}"
115+
116+
queue_start=$(date +%s)
117+
118+
abort_queue_starvation() {
119+
local waited="$1"
120+
echo "##[error]SLURM job $job_id did not start within ${waited}s (SLURM_MAX_QUEUE_SECONDS=$SLURM_MAX_QUEUE_SECONDS)."
121+
echo "QUEUE STARVATION: the cluster scheduler could not start this job in time."
122+
echo "This is an infrastructure / queue-availability problem, NOT a code or test failure."
123+
echo "Cancelling the queued job so it does not keep holding a CI runner slot."
124+
scancel "$job_id" 2>/dev/null || true
125+
exit 75 # EX_TEMPFAIL — distinguishes queue starvation from a real test failure
126+
}
127+
85128
# Wait for file to appear, using robust state checking.
86-
# Never give up due to transient squeue/sacct failures — the CI job timeout
87-
# is the ultimate backstop.
129+
# Never give up due to transient squeue/sacct failures — the queue-wait budget
130+
# above (or the CI job timeout) is the ultimate backstop.
88131
echo "Waiting for job to start..."
89132
unknown_count=0
90133
while [ ! -f "$output_file" ]; do
91134
state=$(get_job_state "$job_id")
92135

136+
# A started job (RUNNING/COMPLETING) whose output file is merely NFS-delayed
137+
# is exempt, so work in progress is never killed here.
138+
if [ "$SLURM_MAX_QUEUE_SECONDS" -gt 0 ]; then
139+
case "$state" in
140+
RUNNING|COMPLETING) ;;
141+
*)
142+
waited=$(( $(date +%s) - queue_start ))
143+
if [ "$waited" -ge "$SLURM_MAX_QUEUE_SECONDS" ]; then
144+
abort_queue_starvation "$waited"
145+
fi
146+
;;
147+
esac
148+
fi
149+
93150
case "$state" in
94-
PENDING|CONFIGURING|PREEMPTED)
151+
PREEMPTED)
152+
# Preempted before producing output (embers, PreemptMode=CANCEL): the job
153+
# is dead and will not requeue. Signal the caller to resubmit a fresh job.
154+
echo "[$(date +%H:%M:%S)] Job $job_id PREEMPTED before start/output — signaling resubmit."
155+
exit "$PREEMPT_EXIT"
156+
;;
157+
PENDING|CONFIGURING)
95158
unknown_count=0
96-
sleep 5
159+
sleep "$MFC_MONITOR_POLL_SECONDS"
97160
;;
98161
RUNNING|COMPLETING)
99162
unknown_count=0
@@ -106,7 +169,7 @@ while [ ! -f "$output_file" ]; do
106169
if [ $((unknown_count % 12)) -eq 1 ]; then
107170
echo "Warning: Could not query job $job_id state (SLURM may be temporarily unavailable)..."
108171
fi
109-
sleep 5
172+
sleep "$MFC_MONITOR_POLL_SECONDS"
110173
;;
111174
*)
112175
# Terminal state — job finished without creating output
@@ -115,7 +178,7 @@ while [ ! -f "$output_file" ]; do
115178
exit 1
116179
fi
117180
# Unrecognized state, keep waiting
118-
sleep 5
181+
sleep "$MFC_MONITOR_POLL_SECONDS"
119182
;;
120183
esac
121184
done
@@ -138,6 +201,12 @@ last_heartbeat=$(date +%s)
138201
while true; do
139202
state=$(get_job_state "$job_id")
140203

204+
if [ "$state" = "PREEMPTED" ]; then
205+
# Preempted mid-run (embers, PreemptMode=CANCEL): dead, will not requeue.
206+
echo "[$(date +%H:%M:%S)] Job $job_id PREEMPTED mid-run — signaling resubmit."
207+
exit "$PREEMPT_EXIT"
208+
fi
209+
141210
if is_terminal_state "$state"; then
142211
echo "[$(date +%H:%M:%S)] Job $job_id reached terminal state: $state"
143212
break
@@ -150,11 +219,17 @@ while true; do
150219
last_heartbeat=$current_time
151220
fi
152221

153-
sleep 5
222+
sleep "$MFC_MONITOR_POLL_SECONDS"
154223
done
155224

156-
# Give tail a moment to flush the final lines, then stop streaming.
225+
# Give tail a moment to flush the final lines, then stop streaming. Whether it
226+
# was still alive decides how much needs reprinting below: if it streamed the
227+
# whole job, printing the file again just doubles every log.
157228
sleep 2
229+
streamed_ok=0
230+
if kill -0 "${tail_pid}" 2>/dev/null; then
231+
streamed_ok=1
232+
fi
158233
kill "${tail_pid}" 2>/dev/null || true
159234
tail_pid=""
160235

@@ -174,13 +249,24 @@ if [ -f "$output_file" ]; then
174249
if [ $same_count -ge 2 ]; then
175250
break
176251
fi
177-
sleep 5
252+
sleep "$MFC_MONITOR_POLL_SECONDS"
178253
done
179254
fi
180255

256+
# Reprint only what streaming may have missed. `tail -f` above already emitted
257+
# the whole file as it was written, so cat'ing it again duplicated every job's
258+
# output -- measured at 3 copies of each line on a GPU job, and 65,000 lines of
259+
# offload diagnostics repeated for a single fault. The reprint exists solely as
260+
# a safety net for a tail that died mid-job, so it is bounded when tail survived
261+
# and complete only when it did not.
181262
echo ""
182-
echo "=== Final output ==="
183-
cat "$output_file"
263+
if [ "${streamed_ok:-0}" -eq 1 ]; then
264+
echo "=== Final output (tail; the full log streamed above) ==="
265+
tail -n "${MFC_MONITOR_FINAL_LINES:-40}" "$output_file"
266+
else
267+
echo "=== Final output (streaming stopped early; reprinting in full) ==="
268+
cat "$output_file"
269+
fi
184270

185271
# Check exit status with sacct fallback
186272
exit_code=""
@@ -207,9 +293,32 @@ if [ -z "$exit_code" ]; then
207293
exit 1
208294
fi
209295

296+
# The preflight's node-fault verdict comes back as the job's own exit code.
297+
# Relay it verbatim: flattening it to 1 would leave the submit wrapper unable to
298+
# tell "this node is unusable" (exclude it and try again) from "the tests
299+
# failed" (report it).
300+
faulted_node=$(grep -oE 'MFC_FAULT_NODE=[^ ]+' "$output_file" 2>/dev/null | tail -n1 | cut -d= -f2 || true)
301+
302+
case "$exit_code" in
303+
77:*)
304+
echo "Job $job_id failed preflight: the node is unusable — signaling caller to exclude it and resubmit."
305+
ci_summary "### :warning: Infrastructure fault — not a code or test failure\n\nNode \`${faulted_node:-unknown}\` could not run MFC (job \`$job_id\`). It is excluded and the job resubmitted elsewhere.\n"
306+
monitor_success=1
307+
exit 77
308+
;;
309+
esac
310+
210311
# Check if job succeeded
211312
if [ "$exit_code" != "0:0" ]; then
212313
echo "ERROR: Job $job_id failed with exit code $exit_code"
314+
# A GPU memory fault explains itself in a block the test harness prints; lift
315+
# it onto the summary page so the faulting kernel and source line are visible
316+
# without opening the log at all.
317+
if grep -q 'GPU fault summary' "$output_file" 2>/dev/null; then
318+
ci_summary "### GPU memory fault\n\n\`\`\`\n$(grep -A6 'GPU fault summary' "$output_file" | head -8 | sed 's/`/'"'"'/g')\n\`\`\`\n"
319+
else
320+
ci_summary "### Job \`$job_id\` failed (exit $exit_code)\n\n\`\`\`\n$(tail -n 15 "$output_file" | sed 's/`/'"'"'/g')\n\`\`\`\n"
321+
fi
213322
exit 1
214323
fi
215324

.github/scripts/node-exclude.sh

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/bin/bash
2+
# Bookkeeping for the sbatch --exclude list used when a node fails preflight.
3+
#
4+
# The in-allocation preflight prints "MFC_FAULT_NODE=<name>" into the job's
5+
# output file when it finds the node unusable (dead GPU, SIGILL from a binary
6+
# built for another microarchitecture). The submit wrapper reads that back, adds
7+
# the node to --exclude and resubmits, so the retry lands elsewhere.
8+
#
9+
# Bad nodes are concentrated rather than scattered -- over 2026-08-18..31 one
10+
# Phoenix node accounted for 25 of 29 ECC failures -- which is why excluding the
11+
# offender is worth doing and why it was previously a hand-edited constant.
12+
#
13+
# Usage:
14+
# node-exclude.sh node-from <output-file> print the faulted node, if any
15+
# node-exclude.sh merge <csv> <node> print <csv> with <node> added once
16+
17+
set -uo pipefail
18+
19+
usage() {
20+
echo "Usage: $0 {node-from <output-file>|merge <csv> <node>}" >&2
21+
}
22+
23+
case "${1:-}" in
24+
node-from)
25+
file="${2:-}"
26+
[ -f "$file" ] || exit 0
27+
# Last marker wins: one output path is reused across resubmits, so an
28+
# earlier attempt's marker can still be sitting above the current one.
29+
sed -n 's/.*MFC_FAULT_NODE=\([A-Za-z0-9._-]\{1,\}\).*/\1/p' "$file" | tail -n1
30+
;;
31+
32+
merge)
33+
csv="${2-}"
34+
node="${3-}"
35+
if [ -z "$node" ]; then
36+
printf '%s\n' "$csv"
37+
exit 0
38+
fi
39+
if [ -z "$csv" ]; then
40+
printf '%s\n' "$node"
41+
exit 0
42+
fi
43+
# Wrapping both sides in commas compares whole fields, so a shorter name
44+
# that happens to be a prefix of the new one is not mistaken for a match.
45+
case ",$csv," in
46+
*",$node,"*) printf '%s\n' "$csv" ;;
47+
*) printf '%s,%s\n' "$csv" "$node" ;;
48+
esac
49+
;;
50+
51+
*)
52+
usage
53+
exit 2
54+
;;
55+
esac

.github/scripts/prebuild-case-optimization.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ if [ -n "$shard" ] && [ "$shard_count" -gt 1 ]; then
9090
fi
9191
fi
9292

93+
# Deliberately no node probe here. This pre-build is submitted as a *cpu*
94+
# allocation (see test.yml: it is --dry-run, so it only builds), while the
95+
# binaries it produces are GPU builds. syscheck built with --gpu therefore
96+
# asserts omp_get_num_devices() > 0 and exits non-zero on a node that has no
97+
# GPU by design -- which a probe would report as a bad node. It did: three
98+
# healthy Phoenix nodes were condemned and two excluded before the wrapper gave
99+
# up. The GPU allocation that actually runs these cases is probed instead, in
100+
# run_case_optimization.sh.
101+
93102
idx=0
94103
for case in "${benchmarks[@]}"; do
95104
idx=$((idx + 1))

0 commit comments

Comments
 (0)