From e0929faa9b4fee5809b5f86172a39896eb481d71 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sat, 28 Feb 2026 23:54:25 -0500 Subject: [PATCH 01/25] Add gcov-based test pruning with file-level coverage cache Build a coverage cache mapping each test to the source files it exercises, enabling --only-changes to skip tests unaffected by a PR's changes. Key components: - toolchain/mfc/test/coverage.py: cache build (3-phase: prepare, run, collect), cache load/staleness detection, git diff integration, test filtering - --build-coverage-cache CLI flag for one-time cache generation - --only-changes / --changes-branch CLI flags for coverage-based filtering - CI: rebuild-cache + commit-cache jobs auto-update cache when cases.py changes - Phoenix CI: use GNR nodes (192 cores) with 64-thread parallel test execution - 54 unit tests for coverage module Co-Authored-By: Claude Opus 4.6 --- .github/file-filter.yml | 3 + .github/workflows/phoenix/rebuild-cache.sh | 16 + .github/workflows/phoenix/submit.sh | 6 +- .github/workflows/phoenix/test.sh | 4 +- .github/workflows/test.yml | 104 ++- .gitignore | 3 + CMakeLists.txt | 1 + toolchain/mfc/cli/commands.py | 25 + toolchain/mfc/test/coverage.py | 647 ++++++++++++++++++ toolchain/mfc/test/test.py | 67 +- .../mfc/test/test_coverage_cache.json.gz | Bin 0 -> 11972 bytes toolchain/mfc/test/test_coverage_unit.py | 636 +++++++++++++++++ 12 files changed, 1502 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/phoenix/rebuild-cache.sh create mode 100644 toolchain/mfc/test/coverage.py create mode 100644 toolchain/mfc/test/test_coverage_cache.json.gz create mode 100644 toolchain/mfc/test/test_coverage_unit.py diff --git a/.github/file-filter.yml b/.github/file-filter.yml index a2910c89af..13063f12db 100644 --- a/.github/file-filter.yml +++ b/.github/file-filter.yml @@ -36,3 +36,6 @@ checkall: &checkall - *tests - *scripts - *yml + +cases_py: + - 'toolchain/mfc/test/cases.py' diff --git a/.github/workflows/phoenix/rebuild-cache.sh b/.github/workflows/phoenix/rebuild-cache.sh new file mode 100644 index 0000000000..f1f1044499 --- /dev/null +++ b/.github/workflows/phoenix/rebuild-cache.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Number of parallel jobs: use SLURM allocation or default to 24. +# Cap at 64 to avoid overwhelming MPI's ORTE daemons with concurrent launches. +NJOBS="${SLURM_CPUS_ON_NODE:-24}" +if [ "$NJOBS" -gt 64 ]; then NJOBS=64; fi + +# Build MFC with gcov coverage instrumentation (CPU-only, gfortran). +# -j 8 for compilation (memory-heavy, more cores doesn't help much). +./mfc.sh build --gcov -j 8 + +# Run all tests in parallel, collecting per-test coverage data. +# Each test gets an isolated GCOV_PREFIX directory so .gcda files +# don't collide. Coverage is collected per-test after all tests finish. +# --gcov is required so the internal build step preserves instrumentation. +./mfc.sh test --build-coverage-cache --gcov -j "$NJOBS" diff --git a/.github/workflows/phoenix/submit.sh b/.github/workflows/phoenix/submit.sh index 5b7162fef7..b52a107cca 100755 --- a/.github/workflows/phoenix/submit.sh +++ b/.github/workflows/phoenix/submit.sh @@ -24,9 +24,9 @@ case "$script_basename" in esac sbatch_cpu_opts="\ -#SBATCH -p cpu-small # partition -#SBATCH --ntasks-per-node=24 # Number of cores per node required -#SBATCH --mem-per-cpu=2G # Memory per core\ +#SBATCH -p cpu-gnr # partition (full Granite Rapids node) +#SBATCH --exclusive # exclusive access to all cores +#SBATCH -C graniterapids # constrain to GNR architecture\ " if [ "$job_type" = "bench" ]; then diff --git a/.github/workflows/phoenix/test.sh b/.github/workflows/phoenix/test.sh index 74c31c9fba..9daac0c7a8 100644 --- a/.github/workflows/phoenix/test.sh +++ b/.github/workflows/phoenix/test.sh @@ -51,7 +51,9 @@ while [ $attempt -le $max_attempts ]; do attempt=$((attempt + 1)) done -n_test_threads=8 +# Use up to 64 parallel test threads on CPU (GNR nodes have 192 cores). +# Cap at 64 to avoid overwhelming MPI's ORTE daemons with concurrent launches. +n_test_threads=$(( SLURM_CPUS_ON_NODE > 64 ? 64 : ${SLURM_CPUS_ON_NODE:-8} )) if [ "$job_device" = "gpu" ]; then gpu_count=$(nvidia-smi -L | wc -l) # number of GPUs on node diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5c46e91427..6894d5f981 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,8 +56,9 @@ jobs: file-changes: name: Detect File Changes runs-on: 'ubuntu-latest' - outputs: + outputs: checkall: ${{ steps.changes.outputs.checkall }} + cases_py: ${{ steps.changes.outputs.cases_py }} steps: - name: Clone uses: actions/checkout@v4 @@ -68,10 +69,47 @@ jobs: with: filters: ".github/file-filter.yml" + rebuild-cache: + name: Rebuild Coverage Cache + needs: [lint-gate, file-changes] + if: >- + github.event_name == 'pull_request' && + needs.file-changes.outputs.cases_py == 'true' && + github.repository == 'MFlowCode/MFC' && + github.event.pull_request.draft != true + timeout-minutes: 240 + runs-on: + group: phoenix + labels: gt + steps: + - name: Clone + uses: actions/checkout@v4 + with: + clean: false + + - name: Rebuild Cache via SLURM + run: bash .github/workflows/phoenix/submit.sh .github/workflows/phoenix/rebuild-cache.sh cpu none + + - name: Print Logs + if: always() + run: cat rebuild-cache-cpu-none.out + + - name: Upload Cache Artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-cache + path: toolchain/mfc/test/test_coverage_cache.json.gz + retention-days: 1 + github: name: Github - if: needs.file-changes.outputs.checkall == 'true' - needs: [lint-gate, file-changes] + needs: [lint-gate, file-changes, rebuild-cache] + if: >- + always() && + needs.lint-gate.result == 'success' && + needs.file-changes.result == 'success' && + (needs.rebuild-cache.result == 'success' || needs.rebuild-cache.result == 'skipped') && + needs.file-changes.outputs.checkall == 'true' strategy: matrix: os: ['ubuntu', 'macos'] @@ -98,6 +136,14 @@ jobs: - name: Clone uses: actions/checkout@v4 + - name: Download Coverage Cache + if: needs.rebuild-cache.result == 'success' + uses: actions/download-artifact@v4 + with: + name: coverage-cache + path: toolchain/mfc/test + continue-on-error: true + - name: Setup MacOS if: matrix.os == 'macos' run: | @@ -183,8 +229,15 @@ jobs: self: name: "${{ matrix.cluster_name }} (${{ matrix.device }}${{ matrix.interface != 'none' && format('-{0}', matrix.interface) || '' }}${{ matrix.shard != '' && format(' [{0}]', matrix.shard) || '' }})" - if: github.repository == 'MFlowCode/MFC' && needs.file-changes.outputs.checkall == 'true' && github.event.pull_request.draft != true - needs: [lint-gate, file-changes] + needs: [lint-gate, file-changes, rebuild-cache] + if: >- + always() && + needs.lint-gate.result == 'success' && + needs.file-changes.result == 'success' && + (needs.rebuild-cache.result == 'success' || needs.rebuild-cache.result == 'skipped') && + github.repository == 'MFlowCode/MFC' && + needs.file-changes.outputs.checkall == 'true' && + github.event.pull_request.draft != true continue-on-error: false timeout-minutes: 480 strategy: @@ -265,6 +318,14 @@ jobs: with: clean: false + - name: Download Coverage Cache + if: needs.rebuild-cache.result == 'success' + uses: actions/download-artifact@v4 + with: + name: coverage-cache + path: toolchain/mfc/test + continue-on-error: true + - name: Build if: matrix.cluster != 'phoenix' uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3 @@ -299,3 +360,36 @@ jobs: with: name: logs-${{ strategy.job-index }}-${{ steps.log.outputs.slug }} path: ${{ steps.log.outputs.slug }}.out + + commit-cache: + name: Commit Coverage Cache + needs: [rebuild-cache] + if: needs.rebuild-cache.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Clone + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + + - name: Download Coverage Cache + uses: actions/download-artifact@v4 + with: + name: coverage-cache + path: toolchain/mfc/test + + - name: Commit Updated Cache + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add toolchain/mfc/test/test_coverage_cache.json.gz + if git diff --cached --quiet; then + echo "Coverage cache unchanged." + else + git commit -m "Regenerate gcov coverage cache + + Automatically rebuilt because cases.py changed." + git push + fi diff --git a/.gitignore b/.gitignore index e80d14a6f9..943624a1f7 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ __pycache__ # Auto-generated version file toolchain/mfc/_version.py +# Raw coverage cache — legacy, not tracked (the .json.gz version IS committed) +toolchain/mfc/test/test_coverage_cache.json + # Auto-generated toolchain files (regenerate with: ./mfc.sh generate) toolchain/completions/mfc.bash toolchain/completions/_mfc diff --git a/CMakeLists.txt b/CMakeLists.txt index 9101d032fc..9cea0d7425 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -381,6 +381,7 @@ macro(HANDLE_SOURCES target useCommon) --no-folding --line-length=999 --line-numbering-mode=nocontlines + --line-marker-format=gfortran5 "${fpp}" "${f90}" DEPENDS "${fpp};${${target}_incs}" COMMENT "Preprocessing (Fypp) ${fpp_filename}" diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index d4b34df3d8..f8eb2d3593 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -458,6 +458,27 @@ type=str, default=None, ), + Argument( + name="build-coverage-cache", + help="Run all tests sequentially with gcov instrumentation to build the line-level coverage cache. Requires a prior --gcov build: ./mfc.sh build --gcov -j 8", + action=ArgAction.STORE_TRUE, + default=False, + dest="build_coverage_cache", + ), + Argument( + name="only-changes", + help="Only run tests whose covered lines overlap with lines changed since branching from master (uses line-level gcov coverage cache).", + action=ArgAction.STORE_TRUE, + default=False, + dest="only_changes", + ), + Argument( + name="changes-branch", + help="Branch to compare against for --only-changes (default: master).", + type=str, + default="master", + dest="changes_branch", + ), ], mutually_exclusive=[ MutuallyExclusiveGroup(arguments=[ @@ -488,6 +509,8 @@ Example("./mfc.sh test -j 4", "Run with 4 parallel jobs"), Example("./mfc.sh test --only 3D", "Run only 3D tests"), Example("./mfc.sh test --generate", "Regenerate golden files"), + Example("./mfc.sh test --only-changes -j 4", "Run tests affected by changed lines"), + Example("./mfc.sh build --gcov -j 8 && ./mfc.sh test --build-coverage-cache", "One-time: build line-coverage cache"), ], key_options=[ ("-j, --jobs N", "Number of parallel test jobs"), @@ -495,6 +518,8 @@ ("-f, --from UUID", "Start from specific test"), ("--generate", "Generate/update golden files"), ("--no-build", "Skip rebuilding MFC"), + ("--build-coverage-cache", "Build line-level gcov coverage cache (one-time)"), + ("--only-changes", "Run tests affected by changed lines (requires cache)"), ], ) diff --git a/toolchain/mfc/test/coverage.py b/toolchain/mfc/test/coverage.py new file mode 100644 index 0000000000..b6e65eae32 --- /dev/null +++ b/toolchain/mfc/test/coverage.py @@ -0,0 +1,647 @@ +""" +File-level gcov coverage-based test pruning for MFC. + +Build MFC once with gfortran --coverage, run all tests individually, record +which .fpp files each test executes, and cache that mapping. + +When files change on a PR, intersect the changed .fpp files against each test's +covered file set. Only tests that touch at least one changed file run. + +Workflow: + ./mfc.sh build --gcov -j 8 # one-time: build with coverage + ./mfc.sh test --build-coverage-cache # one-time: populate the cache + ./mfc.sh test --only-changes -j 8 # fast: run only affected tests +""" + +import io +import os +import re +import json +import gzip +import shutil +import hashlib +import tempfile +import subprocess +import datetime +from pathlib import Path +from typing import Optional +from concurrent.futures import ThreadPoolExecutor, as_completed + +from ..printer import cons +from .. import common +from ..common import MFCException +from ..build import PRE_PROCESS, SIMULATION, POST_PROCESS, SYSCHECK +from .case import input_bubbles_lagrange + + +COVERAGE_CACHE_PATH = Path(common.MFC_ROOT_DIR) / "toolchain/mfc/test/test_coverage_cache.json.gz" + +# Changes to these files trigger the full test suite. +# CPU coverage cannot tell us about GPU directive changes (macro files), and +# toolchain files define or change the set of tests themselves. +ALWAYS_RUN_ALL = frozenset([ + "src/common/include/parallel_macros.fpp", + "src/common/include/acc_macros.fpp", + "src/common/include/omp_macros.fpp", + "src/common/include/shared_parallel_macros.fpp", + "src/common/include/macros.fpp", + "toolchain/mfc/test/cases.py", + "toolchain/mfc/test/case.py", + "toolchain/mfc/params/definitions.py", + "toolchain/mfc/run/input.py", + "toolchain/mfc/case_validator.py", + "CMakeLists.txt", +]) + + +def _get_gcov_version(gcov_binary: str) -> str: + """Return the version string from gcov --version.""" + try: + result = subprocess.run( + [gcov_binary, "--version"], + capture_output=True, text=True, timeout=10, check=False + ) + for line in result.stdout.splitlines(): + if line.strip(): + return line.strip() + except Exception: + pass + return "unknown" + + +def find_gcov_binary(_root_dir: str = "") -> str: # pylint: disable=unused-argument + """ + Find a GNU gcov binary compatible with the system gfortran. + + On macOS with Homebrew GCC, the binary is gcov-{major} (e.g. gcov-15). + On Linux with system GCC, plain gcov is usually correct. + Apple LLVM's /usr/bin/gcov is incompatible with gfortran .gcda files. + """ + # Determine gfortran major version + major = None + try: + result = subprocess.run( + ["gfortran", "--version"], + capture_output=True, text=True, timeout=10, check=False + ) + m = re.search(r'(\d+)\.\d+\.\d+', result.stdout) + if m: + major = m.group(1) + except Exception: + pass + + # Try versioned binary first (Homebrew macOS), then plain gcov + candidates = [] + if major: + candidates.append(f"gcov-{major}") + candidates.append("gcov") + + for candidate in candidates: + path = shutil.which(candidate) + if path is None: + continue + try: + result = subprocess.run( + [path, "--version"], + capture_output=True, text=True, timeout=10, check=False + ) + version_out = result.stdout + if "Apple LLVM" in version_out or "Apple clang" in version_out: + continue # Apple's gcov cannot parse GCC-generated .gcda files + if "GCC" in version_out or "GNU" in version_out: + return path + except Exception: + continue + + raise MFCException( + "GNU gcov not found. gcov is required for the coverage cache.\n" + " On macOS (Homebrew): brew install gcc\n" + " On Linux (Debian/Ubuntu): apt install gcc\n" + " On Linux (RHEL/CentOS): yum install gcc\n" + "Apple's /usr/bin/gcov is incompatible with gfortran .gcda files." + ) + + +def find_gcno_files(root_dir: str) -> list: + """ + Walk build/ and return all .gcno files (excluding venv paths). + Raises if none found (indicates build was not done with --gcov). + """ + build_dir = Path(root_dir) / "build" + gcno_files = [ + p for p in build_dir.rglob("*.gcno") + if "venv" not in p.parts + ] + if not gcno_files: + raise MFCException( + "No .gcno files found. Build with --gcov instrumentation first:\n" + " ./mfc.sh build --gcov -j 8" + ) + return gcno_files + + +def zero_gcda_files(root_dir: str) -> None: + """ + Delete all .gcda files under build/ (excluding venv). + Called before each test run during cache building to isolate per-test coverage. + """ + build_dir = Path(root_dir) / "build" + for gcda in build_dir.rglob("*.gcda"): + if "venv" not in gcda.parts: + try: + gcda.unlink() + except OSError: + pass + + +def _parse_gcov_json_output(raw_bytes: bytes, root_dir: str) -> set: + """ + Parse gcov JSON output and return the set of .fpp file paths with coverage. + Handles both gzip-compressed (gcov 13+) and raw JSON (gcov 12) formats. + Only .fpp files with at least one executed line are included. + """ + try: + data = json.loads(gzip.decompress(raw_bytes)) + except (gzip.BadGzipFile, OSError): + try: + data = json.loads(raw_bytes) + except (json.JSONDecodeError, ValueError): + return set() + except Exception: + return set() + + result = set() + real_root = os.path.realpath(root_dir) + for file_entry in data.get("files", []): + file_path = file_entry.get("file", "") + if not file_path.endswith(".fpp"): + continue + if any(line.get("count", 0) > 0 for line in file_entry.get("lines", [])): + try: + rel_path = os.path.relpath(os.path.realpath(file_path), real_root) + except ValueError: + rel_path = file_path + result.add(rel_path) + + return result + + +def collect_coverage_for_test(gcno_files: list, root_dir: str, gcov_binary: str) -> set: + """ + Run gcov on all .gcno files and return the set of .fpp files with coverage. + + Expects .gcda files to be in their normal locations next to the .gcno files. + """ + merged = set() + + for gcno_file in gcno_files: + try: + cmd = [gcov_binary, "--json-format", "--stdout", str(gcno_file)] + proc = subprocess.run( + cmd, capture_output=True, cwd=root_dir, timeout=60, + check=False + ) + except subprocess.TimeoutExpired: + continue + except Exception: + continue + + if proc.returncode != 0 or not proc.stdout: + continue + + merged.update(_parse_gcov_json_output(proc.stdout, root_dir)) + + return merged + + +def _find_matching_gcno(root_dir: str) -> list: + """ + Find .gcno files that have a matching .gcda in the build tree. + + After installing a test's .gcda files, only .gcno files with a sibling + .gcda need gcov processing. This typically reduces 414 .gcno files + to ~50, giving an ~8x speedup. + """ + build_dir = Path(root_dir) / "build" + matching = [] + for gcda in build_dir.rglob("*.gcda"): + if "venv" in gcda.parts: + continue + gcno = gcda.with_suffix(".gcno") + if gcno.exists(): + matching.append(gcno) + return matching + + +def _gcda_path_to_fpp(gcda_rel: str) -> str: + """ + Map a .gcda relative path to the corresponding .fpp source path. + + Build tree layout: + CMakeFiles/.dir/fypp//.fpp.f90.gcda -> src//.fpp + + Returns empty string for non-.fpp files (plain .f90, modules/, ltrans). + """ + # Extract path after CMakeFiles/.dir/ + m = re.match(r'.*?CMakeFiles/[^/]+\.dir/(.*)', gcda_rel) + if not m: + return "" + inner = m.group(1) # e.g. fypp/simulation/m_rhs.fpp.f90.gcda + + # Only .fpp files: inner must contain ".fpp.f90.gcda" + if ".fpp.f90.gcda" not in inner: + return "" + + # fypp//.fpp.f90.gcda -> src//.fpp + path = inner.replace(".f90.gcda", "") # fypp//.fpp + if path.startswith("fypp/"): + path = "src/" + path[5:] # src//.fpp + return path + + +def collect_coverage_from_gcda(prefix_dir: str) -> set: + """ + Infer file-level coverage from .gcda file existence in a GCOV_PREFIX tree. + + This is much faster than running gcov (instant vs minutes) because we + only need to list files and map paths. A .gcda file is created by + gfortran's runtime for each compilation unit that executed at least one + function, so its existence implies the source file had coverage. + """ + result = set() + build_subdir = os.path.join(prefix_dir, "build") + if not os.path.isdir(build_subdir): + return result + for dirpath, _dirnames, filenames in os.walk(build_subdir): + for fname in filenames: + if not fname.endswith(".gcda"): + continue + full = os.path.join(dirpath, fname) + rel = os.path.relpath(full, prefix_dir) + fpp = _gcda_path_to_fpp(rel) + if fpp: + result.add(fpp) + return result + + +def _compute_gcov_prefix_strip(root_dir: str) -> str: + """ + Compute GCOV_PREFIX_STRIP so .gcda files preserve the build/ tree. + + GCOV_PREFIX_STRIP removes N leading path components from the compile-time + absolute .gcda path. We strip all components of the MFC root directory + so the prefix tree starts with ``build/staging/...``. + """ + real_root = os.path.realpath(root_dir) + return str(len(Path(real_root).parts) - 1) # -1 excludes root '/' + + +def _install_gcda_files(prefix_dir: str, root_dir: str) -> int: + """ + Copy .gcda files from a GCOV_PREFIX tree into the build directory. + + The prefix tree mirrors the build layout (e.g. ``/build/staging/…``). + Returns the number of files copied. + """ + build_subdir = os.path.join(prefix_dir, "build") + if not os.path.isdir(build_subdir): + return 0 + count = 0 + for dirpath, _dirnames, filenames in os.walk(build_subdir): + for fname in filenames: + if not fname.endswith(".gcda"): + continue + src = os.path.join(dirpath, fname) + rel = os.path.relpath(src, prefix_dir) + dst = os.path.join(root_dir, rel) + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copy2(src, dst) + count += 1 + return count + + +def _run_single_test_direct(test_info: dict, gcda_dir: str, strip: str) -> tuple: + """ + Run a single test by invoking Fortran executables directly. + + Bypasses ``./mfc.sh run`` entirely (no Python startup, no Mako template + rendering, no shell script generation). Input files and binary paths are + pre-computed by the caller. + + Returns (uuid, test_gcda_path). + """ + uuid = test_info["uuid"] + test_dir = test_info["dir"] + binaries = test_info["binaries"] # ordered list of (target_name, bin_path) + ppn = test_info["ppn"] + + test_gcda = os.path.join(gcda_dir, uuid) + os.makedirs(test_gcda, exist_ok=True) + + env = {**os.environ, "GCOV_PREFIX": test_gcda, "GCOV_PREFIX_STRIP": strip} + + # MPI-compiled binaries must be launched via an MPI launcher (even ppn=1). + # Use --bind-to none to avoid binding issues with concurrent launches. + if shutil.which("mpirun"): + mpi_cmd = ["mpirun", "--bind-to", "none", "-np", str(ppn)] + elif shutil.which("srun"): + mpi_cmd = ["srun", "--ntasks", str(ppn)] + else: + mpi_cmd = [] + + for _, bin_path in binaries: + if not os.path.isfile(bin_path): + continue + cmd = mpi_cmd + [bin_path] + try: + subprocess.run(cmd, check=False, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + env=env, cwd=test_dir, timeout=300) + except Exception: + pass + + return uuid, test_gcda + + +def _prepare_test(case, root_dir: str) -> dict: # pylint: disable=unused-argument + """ + Prepare a test for direct execution: create directory, generate .inp + files, and resolve binary paths. All Python/toolchain overhead happens + here (single-threaded) so the parallel phase is pure subprocess calls. + """ + try: + case.delete_output() + case.create_directory() + except Exception: + pass + + # Lagrange bubble tests need input files generated before running. + if case.params.get("bubbles_lagrange", 'F') == 'T': + try: + input_bubbles_lagrange(case) + except Exception: + pass + + test_dir = case.get_dirpath() + input_file = case.to_input_file() + + # Write .inp files directly (no subprocess, no Mako templates). + # Suppress console output from get_inp() to avoid 555×4 messages. + targets = [SYSCHECK, PRE_PROCESS, SIMULATION, POST_PROCESS] + binaries = [] + orig_file = cons.raw.file + cons.raw.file = io.StringIO() + try: + for target in targets: + inp_content = case.get_inp(target) + common.file_write(os.path.join(test_dir, f"{target.name}.inp"), + inp_content) + bin_path = target.get_install_binpath(input_file) + binaries.append((target.name, bin_path)) + finally: + cons.raw.file = orig_file + + return { + "uuid": case.get_uuid(), + "dir": test_dir, + "binaries": binaries, + "ppn": getattr(case, 'ppn', 1), + } + + +def build_coverage_cache( # pylint: disable=unused-argument,too-many-locals + root_dir: str, cases: list, extra_args: list = None, n_jobs: int = None, +) -> None: + """ + Build the file-level coverage cache by running tests in parallel. + + Phase 0 — Prepare all tests: generate .inp files and resolve binary paths. + This happens single-threaded so the parallel phase has zero Python overhead. + + Phase 1 — Run all tests concurrently. Each worker invokes Fortran binaries + directly (no ``./mfc.sh run``, no shell scripts). Each test's GCOV_PREFIX + points to an isolated directory so .gcda files don't collide. + + Phase 2 — For each test, copy its .gcda tree into the real build directory, + run gcov to collect which .fpp files had coverage, then remove the .gcda files. + + Requires a prior ``--gcov`` build: ``./mfc.sh build --gcov -j 8`` + """ + gcov_bin = find_gcov_binary(root_dir) + gcno_files = find_gcno_files(root_dir) + strip = _compute_gcov_prefix_strip(root_dir) + + if n_jobs is None: + n_jobs = max(os.cpu_count() or 1, 1) + cons.print(f"[bold]Building coverage cache for {len(cases)} tests " + f"({n_jobs} parallel)...[/bold]") + cons.print(f"[dim]Using gcov binary: {gcov_bin}[/dim]") + cons.print(f"[dim]Found {len(gcno_files)} .gcno files[/dim]") + cons.print(f"[dim]GCOV_PREFIX_STRIP={strip}[/dim]") + cons.print() + + # Phase 0: Prepare all tests (single-threaded, ~30s for 555 tests). + cons.print("[bold]Phase 0/2: Preparing tests...[/bold]") + test_infos = [] + for i, case in enumerate(cases): + test_infos.append(_prepare_test(case, root_dir)) + if (i + 1) % 100 == 0 or (i + 1) == len(cases): + cons.print(f" [{i+1:3d}/{len(cases):3d}] prepared") + cons.print() + + gcda_dir = tempfile.mkdtemp(prefix="mfc_gcov_") + + # Phase 1: Run all tests in parallel via direct binary invocation. + cons.print("[bold]Phase 1/2: Running tests...[/bold]") + test_results: dict = {} + with ThreadPoolExecutor(max_workers=n_jobs) as pool: + futures = { + pool.submit(_run_single_test_direct, info, gcda_dir, strip): info + for info in test_infos + } + for i, future in enumerate(as_completed(futures)): + uuid, test_gcda = future.result() + test_results[uuid] = test_gcda + if (i + 1) % 50 == 0 or (i + 1) == len(cases): + cons.print(f" [{i+1:3d}/{len(cases):3d}] tests completed") + + # Phase 2: Collect gcov coverage from each test's isolated .gcda directory. + # For each test, copy its .gcda files into the build tree, run gcov only + # on matching .gcno files (not all 414), then clean up. Targeting matching + # .gcno files gives ~8x speedup over the full scan. + cons.print() + cons.print("[bold]Phase 2/2: Collecting coverage...[/bold]") + cache: dict = {} + for i, (uuid, test_gcda) in enumerate(sorted(test_results.items())): + zero_gcda_files(root_dir) + n_copied = _install_gcda_files(test_gcda, root_dir) + + if n_copied == 0: + coverage = set() + else: + # Only run gcov on .gcno files that have a matching .gcda installed. + matching = _find_matching_gcno(root_dir) + coverage = collect_coverage_for_test( + matching or gcno_files, root_dir, gcov_bin + ) + + cache[uuid] = sorted(coverage) + if (i + 1) % 50 == 0 or (i + 1) == len(cases): + cons.print(f" [{i+1:3d}/{len(cases):3d}] tests processed") + + zero_gcda_files(root_dir) + + # Clean up temp directory. + shutil.rmtree(gcda_dir, ignore_errors=True) + + cases_py_path = Path(root_dir) / "toolchain/mfc/test/cases.py" + cases_hash = hashlib.sha256(cases_py_path.read_bytes()).hexdigest() + gcov_version = _get_gcov_version(gcov_bin) + + cache["_meta"] = { + "created": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "cases_hash": cases_hash, + "gcov_version": gcov_version, + } + + with gzip.open(COVERAGE_CACHE_PATH, "wt", encoding="utf-8") as f: + json.dump(cache, f, indent=2) + + cons.print() + cons.print(f"[bold green]Coverage cache written to {COVERAGE_CACHE_PATH}[/bold green]") + cons.print(f"[dim]Cache has {len(cases)} test entries.[/dim]") + + +def _normalize_cache(cache: dict) -> dict: + """Convert old line-level cache format to file-level if needed. + + Old format: {uuid: {file: [lines], ...}, ...} + New format: {uuid: [file, ...], ...} + """ + for key, value in cache.items(): + if key == "_meta": + continue + if isinstance(value, dict): + cache[key] = sorted(value.keys()) + return cache + + +def load_coverage_cache(root_dir: str) -> Optional[dict]: + """ + Load the coverage cache, returning None if missing or stale. + + Staleness is detected by comparing the SHA256 of cases.py at cache-build time + against the current cases.py. Auto-converts old line-level format if needed. + """ + if not COVERAGE_CACHE_PATH.exists(): + return None + + try: + with gzip.open(COVERAGE_CACHE_PATH, "rt", encoding="utf-8") as f: + cache = json.load(f) + except (OSError, gzip.BadGzipFile, json.JSONDecodeError, UnicodeDecodeError): + cons.print("[yellow]Warning: Coverage cache is unreadable or corrupt.[/yellow]") + return None + + cases_py = Path(root_dir) / "toolchain/mfc/test/cases.py" + current_hash = hashlib.sha256(cases_py.read_bytes()).hexdigest() + stored_hash = cache.get("_meta", {}).get("cases_hash", "") + + if current_hash != stored_hash: + cons.print("[yellow]Warning: Coverage cache is stale (cases.py changed).[/yellow]") + return None + + return _normalize_cache(cache) + + +def _parse_diff_files(diff_text: str) -> set: + """ + Parse ``git diff --name-only`` output and return the set of changed file paths. + """ + return {f for f in diff_text.strip().splitlines() if f} + + +def get_changed_files(root_dir: str, compare_branch: str = "master") -> Optional[set]: + """ + Return the set of files changed in this branch relative to the merge-base + with compare_branch, or None on git failure. + + Uses merge-base (not master tip) so that unrelated master advances don't + appear as "your changes." + """ + merge_base_result = subprocess.run( + ["git", "merge-base", compare_branch, "HEAD"], + capture_output=True, text=True, cwd=root_dir, timeout=30, check=False + ) + if merge_base_result.returncode != 0: + return None + merge_base = merge_base_result.stdout.strip() + if not merge_base: + return None + + diff_result = subprocess.run( + ["git", "diff", merge_base, "HEAD", "--name-only", "--no-color"], + capture_output=True, text=True, cwd=root_dir, timeout=30, check=False + ) + if diff_result.returncode != 0: + return None + + return _parse_diff_files(diff_result.stdout) + + +def should_run_all_tests(changed_files: set) -> bool: + """ + Return True if any changed file is in ALWAYS_RUN_ALL. + + GPU macro files and toolchain files cannot be correctly analyzed by CPU + coverage — changes to them must always trigger the full test suite. + """ + return bool(changed_files & ALWAYS_RUN_ALL) + + +def filter_tests_by_coverage( + cases: list, coverage_cache: dict, changed_files: set +) -> tuple: + """ + Filter test cases to only those whose covered files overlap with changed files. + + Returns (cases_to_run, skipped_cases). + + Conservative behavior: + - Test not in cache (newly added) -> include it + - No changed .fpp files -> skip all tests + - Test has incomplete coverage (no simulation files recorded but simulation + files changed) -> include it (cache build likely failed for this test) + """ + changed_fpp = {f for f in changed_files if f.endswith(".fpp")} + if not changed_fpp: + return [], list(cases) + + changed_sim = any(f.startswith("src/simulation/") for f in changed_fpp) + + to_run = [] + skipped = [] + + for case in cases: + uuid = case.get_uuid() + test_files = coverage_cache.get(uuid) + + if test_files is None: + # Test not in cache (e.g., newly added) -> conservative: include + to_run.append(case) + continue + + test_file_set = set(test_files) + + # If simulation files changed but this test has no simulation coverage, + # include it conservatively — the cache build likely failed for this test. + if changed_sim and not any(f.startswith("src/simulation/") for f in test_file_set): + to_run.append(case) + continue + + if test_file_set & changed_fpp: + to_run.append(case) + else: + skipped.append(case) + + return to_run, skipped diff --git a/toolchain/mfc/test/test.py b/toolchain/mfc/test/test.py index 049af9e560..02f0adbcad 100644 --- a/toolchain/mfc/test/test.py +++ b/toolchain/mfc/test/test.py @@ -76,7 +76,7 @@ def is_uuid(term): return cases, skipped_cases -# pylint: disable=too-many-branches, too-many-statements, trailing-whitespace +# pylint: disable=too-many-branches,too-many-locals,too-many-statements,trailing-whitespace def __filter(cases_) -> typing.List[TestCase]: cases = cases_[:] selected_cases = [] @@ -108,6 +108,53 @@ def __filter(cases_) -> typing.List[TestCase]: f"Specified: {ARG('only')}. Check that UUIDs/names are valid." ) + # --only-changes: filter based on file-level gcov coverage + if ARG("only_changes"): + from .coverage import ( # pylint: disable=import-outside-toplevel + load_coverage_cache, get_changed_files, + should_run_all_tests, filter_tests_by_coverage, + ) + + cache = load_coverage_cache(common.MFC_ROOT_DIR) + if cache is None: + cons.print("[yellow]Coverage cache missing or stale.[/yellow]") + cons.print("[yellow]Run: ./mfc.sh build --gcov -j 8 && ./mfc.sh test --build-coverage-cache[/yellow]") + cons.print("[yellow]Falling back to full test suite.[/yellow]") + else: + changed_files = get_changed_files(common.MFC_ROOT_DIR, ARG("changes_branch")) + + if changed_files is None: + cons.print("[yellow]git diff failed — falling back to full test suite.[/yellow]") + elif should_run_all_tests(changed_files): + cons.print() + cons.print("[bold cyan]Coverage Change Analysis[/bold cyan]") + cons.print("-" * 50) + cons.print("[yellow]Infrastructure or macro file changed — running full test suite.[/yellow]") + cons.print("-" * 50) + else: + changed_fpp = {f for f in changed_files if f.endswith(".fpp")} + if not changed_fpp: + cons.print() + cons.print("[bold cyan]Coverage Change Analysis[/bold cyan]") + cons.print("-" * 50) + cons.print("[green]No .fpp source changes detected — skipping all tests.[/green]") + cons.print("-" * 50) + cons.print() + skipped_cases += cases + cases = [] + else: + cons.print() + cons.print("[bold cyan]Coverage Change Analysis[/bold cyan]") + cons.print("-" * 50) + for fpp_file in sorted(changed_fpp): + cons.print(f" [green]*[/green] {fpp_file}") + + cases, new_skipped = filter_tests_by_coverage(cases, cache, changed_files) + skipped_cases += new_skipped + cons.print(f"\n[bold]Tests to run: {len(cases)} / {len(cases) + len(new_skipped)}[/bold]") + cons.print("-" * 50) + cons.print() + for case in cases[:]: if case.ppn > 1 and not ARG("mpi"): cases.remove(case) @@ -176,6 +223,24 @@ def test(): return + if ARG("build_coverage_cache"): + from .coverage import build_coverage_cache # pylint: disable=import-outside-toplevel + all_cases = [b.to_case() for b in cases] + + # Build all unique slugs (Chemistry, case-optimization, etc.) so every + # test has a compatible binary when run with --no-build. + codes = [PRE_PROCESS, SIMULATION, POST_PROCESS] + unique_builds = set() + for case, code in itertools.product(all_cases, codes): + slug = code.get_slug(case.to_input_file()) + if slug not in unique_builds: + build(code, case.to_input_file()) + unique_builds.add(slug) + + build_coverage_cache(common.MFC_ROOT_DIR, all_cases, + extra_args=ARG("--"), n_jobs=int(ARG("jobs"))) + return + cases, skipped_cases = __filter(cases) cases = [ _.to_case() for _ in cases ] total_test_count = len(cases) diff --git a/toolchain/mfc/test/test_coverage_cache.json.gz b/toolchain/mfc/test/test_coverage_cache.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..0d6ad05cbbc83618fdcdcb1ceb3922adec0a9e54 GIT binary patch literal 11972 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 str: + return self._uuid + + +# =========================================================================== +# Group 1: _parse_diff_files — git diff --name-only parsing +# =========================================================================== + +class TestParseDiffFiles(unittest.TestCase): + + def test_parse_single_file(self): + result = _parse_diff_files("src/simulation/m_rhs.fpp\n") + assert result == {"src/simulation/m_rhs.fpp"} + + def test_parse_multiple_files(self): + text = "src/simulation/m_rhs.fpp\nsrc/simulation/m_weno.fpp\nREADME.md\n" + result = _parse_diff_files(text) + assert result == { + "src/simulation/m_rhs.fpp", + "src/simulation/m_weno.fpp", + "README.md", + } + + def test_parse_empty(self): + assert _parse_diff_files("") == set() + assert _parse_diff_files("\n") == set() + + def test_parse_ignores_blank_lines(self): + text = "src/simulation/m_rhs.fpp\n\n\nsrc/simulation/m_weno.fpp\n" + result = _parse_diff_files(text) + assert result == {"src/simulation/m_rhs.fpp", "src/simulation/m_weno.fpp"} + + def test_parse_mixed_extensions(self): + text = "src/simulation/m_rhs.fpp\ntoolchain/mfc/test/cases.py\nCMakeLists.txt\n" + result = _parse_diff_files(text) + assert len(result) == 3 + assert "toolchain/mfc/test/cases.py" in result + assert "CMakeLists.txt" in result + + +# =========================================================================== +# Group 2: should_run_all_tests — ALWAYS_RUN_ALL detection +# =========================================================================== + +class TestShouldRunAllTests(unittest.TestCase): + + def test_parallel_macros_triggers_all(self): + assert should_run_all_tests( + {"src/common/include/parallel_macros.fpp"} + ) is True + + def test_acc_macros_triggers_all(self): + assert should_run_all_tests( + {"src/common/include/acc_macros.fpp"} + ) is True + + def test_omp_macros_triggers_all(self): + assert should_run_all_tests( + {"src/common/include/omp_macros.fpp"} + ) is True + + def test_shared_parallel_macros_triggers_all(self): + assert should_run_all_tests( + {"src/common/include/shared_parallel_macros.fpp"} + ) is True + + def test_macros_fpp_triggers_all(self): + assert should_run_all_tests( + {"src/common/include/macros.fpp"} + ) is True + + def test_cases_py_triggers_all(self): + assert should_run_all_tests( + {"toolchain/mfc/test/cases.py"} + ) is True + + def test_case_py_triggers_all(self): + assert should_run_all_tests( + {"toolchain/mfc/test/case.py"} + ) is True + + def test_definitions_py_triggers_all(self): + assert should_run_all_tests( + {"toolchain/mfc/params/definitions.py"} + ) is True + + def test_input_py_triggers_all(self): + assert should_run_all_tests( + {"toolchain/mfc/run/input.py"} + ) is True + + def test_case_validator_triggers_all(self): + assert should_run_all_tests( + {"toolchain/mfc/case_validator.py"} + ) is True + + def test_cmakelists_triggers_all(self): + assert should_run_all_tests( + {"CMakeLists.txt"} + ) is True + + def test_simulation_module_does_not_trigger_all(self): + assert should_run_all_tests( + {"src/simulation/m_rhs.fpp"} + ) is False + + def test_empty_set_does_not_trigger_all(self): + assert should_run_all_tests(set()) is False + + def test_mixed_one_trigger_fires_all(self): + assert should_run_all_tests({ + "src/simulation/m_rhs.fpp", + "src/common/include/macros.fpp", + }) is True + + +# =========================================================================== +# Group 3: filter_tests_by_coverage — core file-level selection logic +# =========================================================================== + +class TestFilterTestsByCoverage(unittest.TestCase): + + def test_file_overlap_includes_test(self): + cache = {"AAAA0001": ["src/simulation/m_rhs.fpp", "src/simulation/m_weno.fpp"]} + changed = {"src/simulation/m_rhs.fpp"} + cases = [FakeCase("AAAA0001")] + to_run, skipped = filter_tests_by_coverage(cases, cache, changed) + assert len(to_run) == 1 + assert len(skipped) == 0 + + def test_no_file_overlap_skips_test(self): + cache = {"AAAA0001": ["src/simulation/m_rhs.fpp"]} + changed = {"src/simulation/m_weno.fpp"} + cases = [FakeCase("AAAA0001")] + to_run, skipped = filter_tests_by_coverage(cases, cache, changed) + assert len(to_run) == 0 + assert len(skipped) == 1 + + def test_uuid_not_in_cache_is_conservative(self): + """Newly added test not in cache -> include it (conservative).""" + cache = {} + changed = {"src/simulation/m_rhs.fpp"} + to_run, _ = filter_tests_by_coverage([FakeCase("NEWTEST1")], cache, changed) + assert len(to_run) == 1 + + def test_no_fpp_changes_skips_all(self): + """Only non-.fpp files changed -> skip all tests.""" + cache = {"AAAA0001": ["src/simulation/m_rhs.fpp"]} + changed = {"toolchain/setup.py", "README.md"} + cases = [FakeCase("AAAA0001")] + to_run, skipped = filter_tests_by_coverage(cases, cache, changed) + assert len(to_run) == 0 + assert len(skipped) == 1 + + def test_empty_changed_files_skips_all(self): + cache = {"AAAA0001": ["src/simulation/m_rhs.fpp"]} + changed = set() + to_run, skipped = filter_tests_by_coverage([FakeCase("AAAA0001")], cache, changed) + assert len(to_run) == 0 + assert len(skipped) == 1 + + def test_multiple_tests_partial_selection(self): + """Only the test covering the changed file should run.""" + cache = { + "TEST_A": ["src/simulation/m_rhs.fpp", "src/simulation/m_weno.fpp"], + "TEST_B": ["src/simulation/m_bubbles.fpp"], + "TEST_C": ["src/simulation/m_rhs.fpp"], + } + changed = {"src/simulation/m_bubbles.fpp"} + cases = [FakeCase("TEST_A"), FakeCase("TEST_B"), FakeCase("TEST_C")] + to_run, skipped = filter_tests_by_coverage(cases, cache, changed) + uuids_run = {c.get_uuid() for c in to_run} + assert uuids_run == {"TEST_B"} + assert len(skipped) == 2 + + def test_multiple_changed_files_union(self): + """Changing multiple files includes any test that covers any of them.""" + cache = { + "TEST_A": ["src/simulation/m_rhs.fpp"], + "TEST_B": ["src/simulation/m_weno.fpp"], + "TEST_C": ["src/simulation/m_bubbles.fpp"], + } + changed = {"src/simulation/m_rhs.fpp", "src/simulation/m_weno.fpp"} + cases = [FakeCase("TEST_A"), FakeCase("TEST_B"), FakeCase("TEST_C")] + to_run, skipped = filter_tests_by_coverage(cases, cache, changed) + uuids_run = {c.get_uuid() for c in to_run} + assert uuids_run == {"TEST_A", "TEST_B"} + assert len(skipped) == 1 + + def test_test_covering_multiple_files_matched_via_second(self): + """Test matched because m_weno.fpp (its second covered file) was changed.""" + cache = {"AAAA0001": ["src/simulation/m_rhs.fpp", "src/simulation/m_weno.fpp"]} + changed = {"src/simulation/m_weno.fpp"} + to_run, _ = filter_tests_by_coverage([FakeCase("AAAA0001")], cache, changed) + assert len(to_run) == 1 + + def test_empty_cache_runs_all_conservatively(self): + """Empty coverage cache -> all tests included (conservative).""" + cache = {} + changed = {"src/simulation/m_rhs.fpp"} + cases = [FakeCase("T1"), FakeCase("T2"), FakeCase("T3")] + to_run, skipped = filter_tests_by_coverage(cases, cache, changed) + assert len(to_run) == 3 + assert len(skipped) == 0 + + def test_mixed_fpp_and_nonfpp_changes(self): + """Non-.fpp files in changed set are ignored for matching.""" + cache = {"TEST_A": ["src/simulation/m_rhs.fpp"]} + changed = {"src/simulation/m_rhs.fpp", "README.md", "toolchain/setup.py"} + to_run, _ = filter_tests_by_coverage([FakeCase("TEST_A")], cache, changed) + assert len(to_run) == 1 + + def test_incomplete_coverage_included_conservatively(self): + """Test with no simulation coverage but simulation file changed -> include.""" + cache = { + "GOOD_T": ["src/simulation/m_rhs.fpp", "src/pre_process/m_start_up.fpp"], + "BAD_T": ["src/pre_process/m_start_up.fpp", "src/common/m_helper.fpp"], + } + changed = {"src/simulation/m_rhs.fpp"} + cases = [FakeCase("GOOD_T"), FakeCase("BAD_T")] + to_run, skipped = filter_tests_by_coverage(cases, cache, changed) + uuids_run = {c.get_uuid() for c in to_run} + assert "GOOD_T" in uuids_run # direct file overlap + assert "BAD_T" in uuids_run # no sim coverage -> conservative include + assert len(skipped) == 0 + + def test_incomplete_coverage_not_triggered_by_preprocess(self): + """Test with no sim coverage is NOT auto-included for pre_process changes.""" + cache = { + "BAD_T": ["src/pre_process/m_start_up.fpp"], + } + changed = {"src/pre_process/m_data_output.fpp"} + to_run, skipped = filter_tests_by_coverage([FakeCase("BAD_T")], cache, changed) + assert len(to_run) == 0 # no sim change, no overlap -> skip + assert len(skipped) == 1 + + +# =========================================================================== +# Group 4: Corner cases from design discussion +# =========================================================================== + +class TestDesignCornerCases(unittest.TestCase): + + def test_gpu_ifdef_file_still_triggers_if_covered(self): + """ + GPU-specific code lives in the same .fpp file as CPU code. + At file level, changing any part of the file triggers tests that cover it. + """ + cache = {"MUSCL_T": ["src/simulation/m_muscl.fpp"]} + changed = {"src/simulation/m_muscl.fpp"} + to_run, _ = filter_tests_by_coverage([FakeCase("MUSCL_T")], cache, changed) + assert len(to_run) == 1 + + def test_macro_file_triggers_all_via_should_run_all(self): + """parallel_macros.fpp in changed files -> should_run_all_tests() is True.""" + assert should_run_all_tests({"src/common/include/parallel_macros.fpp"}) is True + + def test_new_fpp_file_no_coverage_skips(self): + """ + Brand new .fpp file has no coverage in cache. + All tests are skipped (no test covers the new file). + """ + cache = {"AAAA0001": ["src/simulation/m_rhs.fpp"]} + changed = {"src/simulation/m_brand_new.fpp"} + to_run, skipped = filter_tests_by_coverage([FakeCase("AAAA0001")], cache, changed) + assert len(to_run) == 0 + assert len(skipped) == 1 + + def test_non_fpp_always_run_all_detected(self): + """ + End-to-end: diff lists only cases.py (non-.fpp) -> + _parse_diff_files includes it -> should_run_all_tests fires. + """ + files = _parse_diff_files("toolchain/mfc/test/cases.py\n") + assert should_run_all_tests(files) is True + + def test_niche_feature_pruning(self): + """ + Niche features: most tests don't cover m_bubbles.fpp. + Changing it skips tests that don't touch it. + """ + cache = { + "BUBBLE1": ["src/simulation/m_bubbles.fpp", "src/simulation/m_rhs.fpp"], + "BUBBLE2": ["src/simulation/m_bubbles.fpp"], + "BASIC_1": ["src/simulation/m_rhs.fpp", "src/simulation/m_weno.fpp"], + "BASIC_2": ["src/simulation/m_rhs.fpp"], + "BASIC_3": ["src/simulation/m_weno.fpp"], + } + changed = {"src/simulation/m_bubbles.fpp"} + cases = [FakeCase(u) for u in ["BUBBLE1", "BUBBLE2", "BASIC_1", "BASIC_2", "BASIC_3"]] + to_run, skipped = filter_tests_by_coverage(cases, cache, changed) + uuids_run = {c.get_uuid() for c in to_run} + assert uuids_run == {"BUBBLE1", "BUBBLE2"} + assert len(skipped) == 3 + + +# =========================================================================== +# Group 5: _parse_gcov_json_output — gcov JSON parsing (file-level) +# =========================================================================== + +class TestParseGcovJsonOutput(unittest.TestCase): + + def _make_gcov_json(self, files_data: list) -> bytes: + """Build a fake gzip-compressed gcov JSON blob.""" + data = { + "format_version": "2", + "gcc_version": "15.2.0", + "files": files_data, + } + return gzip.compress(json.dumps(data).encode()) + + def test_returns_set_of_covered_fpp_files(self): + compressed = self._make_gcov_json([{ + "file": "/repo/src/simulation/m_rhs.fpp", + "lines": [ + {"line_number": 45, "count": 3}, + {"line_number": 46, "count": 0}, + {"line_number": 47, "count": 1}, + ], + }]) + result = _parse_gcov_json_output(compressed, "/repo") + assert result == {"src/simulation/m_rhs.fpp"} + + def test_ignores_file_with_zero_coverage(self): + compressed = self._make_gcov_json([{ + "file": "/repo/src/simulation/m_rhs.fpp", + "lines": [ + {"line_number": 10, "count": 0}, + {"line_number": 11, "count": 0}, + ], + }]) + result = _parse_gcov_json_output(compressed, "/repo") + assert result == set() + + def test_ignores_f90_files(self): + """Generated .f90 files must not appear in coverage output.""" + compressed = self._make_gcov_json([ + { + "file": "/repo/build/fypp/simulation/m_rhs.fpp.f90", + "lines": [{"line_number": 10, "count": 5}], + }, + { + "file": "/repo/src/simulation/m_rhs.fpp", + "lines": [{"line_number": 45, "count": 1}], + }, + ]) + result = _parse_gcov_json_output(compressed, "/repo") + assert result == {"src/simulation/m_rhs.fpp"} + + def test_handles_raw_json_gcov12(self): + """gcov 12 outputs raw JSON (not gzip). Must parse correctly.""" + data = { + "format_version": "1", + "gcc_version": "12.3.0", + "files": [{ + "file": "/repo/src/simulation/m_rhs.fpp", + "lines": [{"line_number": 45, "count": 3}], + }], + } + raw = json.dumps(data).encode() + result = _parse_gcov_json_output(raw, "/repo") + assert result == {"src/simulation/m_rhs.fpp"} + + def test_handles_invalid_data_gracefully(self): + result = _parse_gcov_json_output(b"not valid gzip or json", "/repo") + assert result == set() + + def test_handles_empty_files_list(self): + compressed = self._make_gcov_json([]) + result = _parse_gcov_json_output(compressed, "/repo") + assert result == set() + + def test_multiple_fpp_files(self): + compressed = self._make_gcov_json([ + { + "file": "/repo/src/simulation/m_rhs.fpp", + "lines": [{"line_number": 45, "count": 1}], + }, + { + "file": "/repo/src/simulation/m_weno.fpp", + "lines": [{"line_number": 200, "count": 2}], + }, + ]) + result = _parse_gcov_json_output(compressed, "/repo") + assert result == {"src/simulation/m_rhs.fpp", "src/simulation/m_weno.fpp"} + + +# =========================================================================== +# Group 6: _normalize_cache — old format conversion +# =========================================================================== + +class TestNormalizeCache(unittest.TestCase): + + def test_converts_old_line_level_format(self): + """Old format {uuid: {file: [lines]}} -> new format {uuid: [files]}.""" + old_cache = { + "TEST_A": { + "src/simulation/m_rhs.fpp": [45, 46, 47], + "src/simulation/m_weno.fpp": [100, 200], + }, + "TEST_B": { + "src/simulation/m_bubbles.fpp": [10], + }, + "_meta": {"cases_hash": "abc123"}, + } + result = _normalize_cache(old_cache) + assert isinstance(result["TEST_A"], list) + assert set(result["TEST_A"]) == {"src/simulation/m_rhs.fpp", "src/simulation/m_weno.fpp"} + assert result["TEST_B"] == ["src/simulation/m_bubbles.fpp"] + assert result["_meta"] == {"cases_hash": "abc123"} + + def test_new_format_unchanged(self): + """New format {uuid: [files]} passes through unchanged.""" + new_cache = { + "TEST_A": ["src/simulation/m_rhs.fpp", "src/simulation/m_weno.fpp"], + "_meta": {"cases_hash": "abc123"}, + } + result = _normalize_cache(new_cache) + assert result["TEST_A"] == ["src/simulation/m_rhs.fpp", "src/simulation/m_weno.fpp"] + + def test_empty_coverage_dict_becomes_empty_list(self): + """Test with 0 coverage (old format: empty dict) -> empty list.""" + old_cache = {"TEST_A": {}, "_meta": {"cases_hash": "abc"}} + result = _normalize_cache(old_cache) + assert result["TEST_A"] == [] + + +# =========================================================================== +# Group 7: Cache path format +# =========================================================================== + +class TestCachePath(unittest.TestCase): + + def test_cache_path_is_gzipped(self): + """Cache file must use .json.gz so it can be committed to the repo.""" + assert str(COVERAGE_CACHE_PATH).endswith(".json.gz") + + +# =========================================================================== +# Group 8: _gcda_path_to_fpp — .gcda path to .fpp source mapping +# =========================================================================== + +class TestGcdaPathToFpp(unittest.TestCase): + + def test_fypp_simulation_file(self): + path = "build/staging/abc123/CMakeFiles/simulation.dir/fypp/simulation/m_rhs.fpp.f90.gcda" + assert _gcda_path_to_fpp(path) == "src/simulation/m_rhs.fpp" + + def test_fypp_pre_process_file(self): + path = "build/staging/abc123/CMakeFiles/pre_process.dir/fypp/pre_process/m_grid.fpp.f90.gcda" + assert _gcda_path_to_fpp(path) == "src/pre_process/m_grid.fpp" + + def test_fypp_common_file_in_simulation(self): + """Common .fpp compiled into simulation: path says simulation, not common.""" + path = "build/staging/abc123/CMakeFiles/simulation.dir/fypp/simulation/m_helper.fpp.f90.gcda" + assert _gcda_path_to_fpp(path) == "src/simulation/m_helper.fpp" + + def test_plain_f90_returns_empty(self): + """Non-.fpp files (plain .f90) should be excluded.""" + path = "build/staging/abc123/CMakeFiles/simulation.dir/src/common/m_compile_specific.f90.gcda" + assert _gcda_path_to_fpp(path) == "" + + def test_module_file_returns_empty(self): + """Generated module files should be excluded.""" + path = "build/staging/abc123/CMakeFiles/simulation.dir/modules/simulation/m_thermochem.f90.gcda" + assert _gcda_path_to_fpp(path) == "" + + def test_ltrans_file_returns_empty(self): + """LTO artifacts should be excluded.""" + path = "build/staging/abc123/simulation.ltrans0.ltrans.gcda" + assert _gcda_path_to_fpp(path) == "" + + def test_syscheck_fpp(self): + path = "build/staging/abc123/CMakeFiles/syscheck.dir/fypp/syscheck/syscheck.fpp.f90.gcda" + assert _gcda_path_to_fpp(path) == "src/syscheck/syscheck.fpp" + + +if __name__ == "__main__": + unittest.main() From 6f6e7a4099d6b406d5433ccb83c3eeb69f41c108 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 00:17:48 -0500 Subject: [PATCH 02/25] Address review feedback: report target failures, gate Fypp flag, remove dead code - Report non-zero exit codes from post_process (and other targets) during cache build instead of silently swallowing them - Gate --line-marker-format=gfortran5 behind MFC_GCov so it only applies to gcov instrumented builds - Remove unused collect_coverage_from_gcda function Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 5 ++- toolchain/mfc/test/coverage.py | 60 ++++++++++++++-------------------- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9cea0d7425..473d833ab4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -138,6 +138,9 @@ if (CMAKE_Fortran_COMPILER_ID STREQUAL "GNU") $<$:-lgcov> $<$:--coverage> ) + + # Use gfortran5 line markers so gcov can map coverage to .fpp sources. + set(FYPP_GCOV_OPTS "--line-marker-format=gfortran5") endif() if (CMAKE_BUILD_TYPE STREQUAL "Debug") @@ -381,7 +384,7 @@ macro(HANDLE_SOURCES target useCommon) --no-folding --line-length=999 --line-numbering-mode=nocontlines - --line-marker-format=gfortran5 + ${FYPP_GCOV_OPTS} "${fpp}" "${f90}" DEPENDS "${fpp};${${target}_incs}" COMMENT "Preprocessing (Fypp) ${fpp_filename}" diff --git a/toolchain/mfc/test/coverage.py b/toolchain/mfc/test/coverage.py index b6e65eae32..be6936540c 100644 --- a/toolchain/mfc/test/coverage.py +++ b/toolchain/mfc/test/coverage.py @@ -259,31 +259,6 @@ def _gcda_path_to_fpp(gcda_rel: str) -> str: return path -def collect_coverage_from_gcda(prefix_dir: str) -> set: - """ - Infer file-level coverage from .gcda file existence in a GCOV_PREFIX tree. - - This is much faster than running gcov (instant vs minutes) because we - only need to list files and map paths. A .gcda file is created by - gfortran's runtime for each compilation unit that executed at least one - function, so its existence implies the source file had coverage. - """ - result = set() - build_subdir = os.path.join(prefix_dir, "build") - if not os.path.isdir(build_subdir): - return result - for dirpath, _dirnames, filenames in os.walk(build_subdir): - for fname in filenames: - if not fname.endswith(".gcda"): - continue - full = os.path.join(dirpath, fname) - rel = os.path.relpath(full, prefix_dir) - fpp = _gcda_path_to_fpp(rel) - if fpp: - result.add(fpp) - return result - - def _compute_gcov_prefix_strip(root_dir: str) -> str: """ Compute GCOV_PREFIX_STRIP so .gcda files preserve the build/ tree. @@ -320,7 +295,7 @@ def _install_gcda_files(prefix_dir: str, root_dir: str) -> int: return count -def _run_single_test_direct(test_info: dict, gcda_dir: str, strip: str) -> tuple: +def _run_single_test_direct(test_info: dict, gcda_dir: str, strip: str) -> tuple: # pylint: disable=too-many-locals """ Run a single test by invoking Fortran executables directly. @@ -349,18 +324,23 @@ def _run_single_test_direct(test_info: dict, gcda_dir: str, strip: str) -> tuple else: mpi_cmd = [] - for _, bin_path in binaries: + failures = [] + for target_name, bin_path in binaries: if not os.path.isfile(bin_path): continue cmd = mpi_cmd + [bin_path] try: - subprocess.run(cmd, check=False, text=True, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - env=env, cwd=test_dir, timeout=300) - except Exception: - pass + result = subprocess.run(cmd, check=False, text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + env=env, cwd=test_dir, timeout=300) + if result.returncode != 0: + failures.append((target_name, result.returncode)) + except subprocess.TimeoutExpired: + failures.append((target_name, "timeout")) + except Exception as exc: + failures.append((target_name, str(exc))) - return uuid, test_gcda + return uuid, test_gcda, failures def _prepare_test(case, root_dir: str) -> dict: # pylint: disable=unused-argument @@ -409,7 +389,7 @@ def _prepare_test(case, root_dir: str) -> dict: # pylint: disable=unused-argume } -def build_coverage_cache( # pylint: disable=unused-argument,too-many-locals +def build_coverage_cache( # pylint: disable=unused-argument,too-many-locals,too-many-statements root_dir: str, cases: list, extra_args: list = None, n_jobs: int = None, ) -> None: """ @@ -454,17 +434,27 @@ def build_coverage_cache( # pylint: disable=unused-argument,too-many-locals # Phase 1: Run all tests in parallel via direct binary invocation. cons.print("[bold]Phase 1/2: Running tests...[/bold]") test_results: dict = {} + all_failures: dict = {} with ThreadPoolExecutor(max_workers=n_jobs) as pool: futures = { pool.submit(_run_single_test_direct, info, gcda_dir, strip): info for info in test_infos } for i, future in enumerate(as_completed(futures)): - uuid, test_gcda = future.result() + uuid, test_gcda, failures = future.result() test_results[uuid] = test_gcda + if failures: + all_failures[uuid] = failures if (i + 1) % 50 == 0 or (i + 1) == len(cases): cons.print(f" [{i+1:3d}/{len(cases):3d}] tests completed") + if all_failures: + cons.print() + cons.print(f"[bold yellow]Warning: {len(all_failures)} tests had target failures:[/bold yellow]") + for uuid, fails in sorted(all_failures.items()): + fail_str = ", ".join(f"{t}={rc}" for t, rc in fails) + cons.print(f" [yellow]{uuid}[/yellow]: {fail_str}") + # Phase 2: Collect gcov coverage from each test's isolated .gcda directory. # For each test, copy its .gcda files into the build tree, run gcov only # on matching .gcno files (not all 414), then clean up. Targeting matching From 04321332a2f300bb6117b51cc39a7ef6ddbccf37 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 00:38:30 -0500 Subject: [PATCH 03/25] Disable LTO for gcov builds to fix post_process link failure GCC 12.3 with LTO on Granite Rapids generates AVX-512 FP16 instructions (vmovw) that the system assembler (binutils 2.35) cannot encode. Disabling LTO for gcov builds avoids this and is correct since gcov instrumentation at -O1 does not benefit from link-time optimization. Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 473d833ab4..59aff3d385 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -237,8 +237,11 @@ if (CMAKE_BUILD_TYPE STREQUAL "Release") endif() endif() - # Enable LTO/IPO if supported - if (CMAKE_Fortran_COMPILER_ID STREQUAL "NVHPC") + # Enable LTO/IPO if supported (skip for gcov — LTO interferes with coverage + # instrumentation and can trigger assembler errors on newer architectures). + if (MFC_GCov) + message(STATUS "LTO/IPO disabled for gcov build") + elseif (CMAKE_Fortran_COMPILER_ID STREQUAL "NVHPC") if (MFC_Unified) message(STATUS "LTO/IPO is not available with NVHPC using Unified Memory") elseif (CMAKE_Fortran_COMPILER_VERSION VERSION_GREATER "24.11" AND CMAKE_Fortran_COMPILER_VERSION VERSION_LESS "25.9") From 73cfc01cc994f04d2e1fb16a6790e08f63d15fcd Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 01:00:49 -0500 Subject: [PATCH 04/25] Fix gcov build on GNR: override -O3 and skip -march=native GCC 12.3 with -O3 -march=native on Granite Rapids emits AVX-512 FP16 instructions (vmovw) that binutils 2.35 cannot assemble. For gcov builds, force -O1 via CMAKE_Fortran_FLAGS_RELEASE and skip -march=native entirely. Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 59aff3d385..71656364a7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -131,14 +131,18 @@ if (CMAKE_Fortran_COMPILER_ID STREQUAL "GNU") add_compile_options( $<$:-fprofile-arcs> $<$:-ftest-coverage> - $<$:-O1> - ) + ) add_link_options( $<$:-lgcov> $<$:--coverage> ) + # Override Release -O3 with -O1 for gcov: coverage instrumentation is + # inaccurate at -O3, and aggressive codegen (e.g. AVX-512 FP16 on + # Granite Rapids) can emit instructions that older assemblers reject. + set(CMAKE_Fortran_FLAGS_RELEASE "-O1 -DNDEBUG" CACHE STRING "" FORCE) + # Use gfortran5 line markers so gcov can map coverage to .fpp sources. set(FYPP_GCOV_OPTS "--line-marker-format=gfortran5") endif() @@ -227,13 +231,17 @@ endif() if (CMAKE_BUILD_TYPE STREQUAL "Release") # Processor tuning: Check if we can target the host's native CPU's ISA. - CHECK_FORTRAN_COMPILER_FLAG("-march=native" SUPPORTS_MARCH_NATIVE) - if (SUPPORTS_MARCH_NATIVE) - add_compile_options($<$:-march=native>) - else() - CHECK_FORTRAN_COMPILER_FLAG("-mcpu=native" SUPPORTS_MCPU_NATIVE) - if (SUPPORTS_MCPU_NATIVE) - add_compile_options($<$:-mcpu=native>) + # Skip for gcov builds — -march=native on newer CPUs (e.g. Granite Rapids) + # can emit instructions the system assembler doesn't support. + if (NOT MFC_GCov) + CHECK_FORTRAN_COMPILER_FLAG("-march=native" SUPPORTS_MARCH_NATIVE) + if (SUPPORTS_MARCH_NATIVE) + add_compile_options($<$:-march=native>) + else() + CHECK_FORTRAN_COMPILER_FLAG("-mcpu=native" SUPPORTS_MCPU_NATIVE) + if (SUPPORTS_MCPU_NATIVE) + add_compile_options($<$:-mcpu=native>) + endif() endif() endif() From 531e7a103e9eb732551aea2c0c8ff77f6293fc2e Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 09:25:29 -0500 Subject: [PATCH 05/25] Address bot review: remove dead code, add set -e and repo guard - Remove _gcda_path_to_fpp (dead code, never called in production) - Remove 7 associated unit tests (TestGcdaPathToFpp) - Add set -e to rebuild-cache.sh for error propagation - Add github.repository guard to commit-cache job Co-Authored-By: Claude Opus 4.6 --- .github/workflows/phoenix/rebuild-cache.sh | 1 + .github/workflows/test.yml | 4 ++- toolchain/mfc/test/coverage.py | 26 -------------- toolchain/mfc/test/test_coverage_unit.py | 41 ---------------------- 4 files changed, 4 insertions(+), 68 deletions(-) diff --git a/.github/workflows/phoenix/rebuild-cache.sh b/.github/workflows/phoenix/rebuild-cache.sh index f1f1044499..71477d5376 100644 --- a/.github/workflows/phoenix/rebuild-cache.sh +++ b/.github/workflows/phoenix/rebuild-cache.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -e # Number of parallel jobs: use SLURM allocation or default to 24. # Cap at 64 to avoid overwhelming MPI's ORTE daemons with concurrent launches. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6894d5f981..36144055fc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -364,7 +364,9 @@ jobs: commit-cache: name: Commit Coverage Cache needs: [rebuild-cache] - if: needs.rebuild-cache.result == 'success' + if: >- + needs.rebuild-cache.result == 'success' && + github.repository == 'MFlowCode/MFC' runs-on: ubuntu-latest permissions: contents: write diff --git a/toolchain/mfc/test/coverage.py b/toolchain/mfc/test/coverage.py index be6936540c..31ce2b609e 100644 --- a/toolchain/mfc/test/coverage.py +++ b/toolchain/mfc/test/coverage.py @@ -233,32 +233,6 @@ def _find_matching_gcno(root_dir: str) -> list: return matching -def _gcda_path_to_fpp(gcda_rel: str) -> str: - """ - Map a .gcda relative path to the corresponding .fpp source path. - - Build tree layout: - CMakeFiles/.dir/fypp//.fpp.f90.gcda -> src//.fpp - - Returns empty string for non-.fpp files (plain .f90, modules/, ltrans). - """ - # Extract path after CMakeFiles/.dir/ - m = re.match(r'.*?CMakeFiles/[^/]+\.dir/(.*)', gcda_rel) - if not m: - return "" - inner = m.group(1) # e.g. fypp/simulation/m_rhs.fpp.f90.gcda - - # Only .fpp files: inner must contain ".fpp.f90.gcda" - if ".fpp.f90.gcda" not in inner: - return "" - - # fypp//.fpp.f90.gcda -> src//.fpp - path = inner.replace(".f90.gcda", "") # fypp//.fpp - if path.startswith("fypp/"): - path = "src/" + path[5:] # src//.fpp - return path - - def _compute_gcov_prefix_strip(root_dir: str) -> str: """ Compute GCOV_PREFIX_STRIP so .gcda files preserve the build/ tree. diff --git a/toolchain/mfc/test/test_coverage_unit.py b/toolchain/mfc/test/test_coverage_unit.py index e8f274d829..9c61d8703c 100644 --- a/toolchain/mfc/test/test_coverage_unit.py +++ b/toolchain/mfc/test/test_coverage_unit.py @@ -105,7 +105,6 @@ class _FakeMFCException(Exception): _parse_diff_files = _coverage_mod._parse_diff_files _parse_gcov_json_output = _coverage_mod._parse_gcov_json_output _normalize_cache = _coverage_mod._normalize_cache - _gcda_path_to_fpp = _coverage_mod._gcda_path_to_fpp should_run_all_tests = _coverage_mod.should_run_all_tests filter_tests_by_coverage = _coverage_mod.filter_tests_by_coverage ALWAYS_RUN_ALL = _coverage_mod.ALWAYS_RUN_ALL @@ -138,7 +137,6 @@ class _FakeMFCException(Exception): _parse_diff_files = _globals["_parse_diff_files"] _parse_gcov_json_output = _globals["_parse_gcov_json_output"] _normalize_cache = _globals["_normalize_cache"] - _gcda_path_to_fpp = _globals["_gcda_path_to_fpp"] should_run_all_tests = _globals["should_run_all_tests"] filter_tests_by_coverage = _globals["filter_tests_by_coverage"] ALWAYS_RUN_ALL = _globals["ALWAYS_RUN_ALL"] @@ -593,44 +591,5 @@ def test_cache_path_is_gzipped(self): assert str(COVERAGE_CACHE_PATH).endswith(".json.gz") -# =========================================================================== -# Group 8: _gcda_path_to_fpp — .gcda path to .fpp source mapping -# =========================================================================== - -class TestGcdaPathToFpp(unittest.TestCase): - - def test_fypp_simulation_file(self): - path = "build/staging/abc123/CMakeFiles/simulation.dir/fypp/simulation/m_rhs.fpp.f90.gcda" - assert _gcda_path_to_fpp(path) == "src/simulation/m_rhs.fpp" - - def test_fypp_pre_process_file(self): - path = "build/staging/abc123/CMakeFiles/pre_process.dir/fypp/pre_process/m_grid.fpp.f90.gcda" - assert _gcda_path_to_fpp(path) == "src/pre_process/m_grid.fpp" - - def test_fypp_common_file_in_simulation(self): - """Common .fpp compiled into simulation: path says simulation, not common.""" - path = "build/staging/abc123/CMakeFiles/simulation.dir/fypp/simulation/m_helper.fpp.f90.gcda" - assert _gcda_path_to_fpp(path) == "src/simulation/m_helper.fpp" - - def test_plain_f90_returns_empty(self): - """Non-.fpp files (plain .f90) should be excluded.""" - path = "build/staging/abc123/CMakeFiles/simulation.dir/src/common/m_compile_specific.f90.gcda" - assert _gcda_path_to_fpp(path) == "" - - def test_module_file_returns_empty(self): - """Generated module files should be excluded.""" - path = "build/staging/abc123/CMakeFiles/simulation.dir/modules/simulation/m_thermochem.f90.gcda" - assert _gcda_path_to_fpp(path) == "" - - def test_ltrans_file_returns_empty(self): - """LTO artifacts should be excluded.""" - path = "build/staging/abc123/simulation.ltrans0.ltrans.gcda" - assert _gcda_path_to_fpp(path) == "" - - def test_syscheck_fpp(self): - path = "build/staging/abc123/CMakeFiles/syscheck.dir/fypp/syscheck/syscheck.fpp.f90.gcda" - assert _gcda_path_to_fpp(path) == "src/syscheck/syscheck.fpp" - - if __name__ == "__main__": unittest.main() From e3e001259b274161163addf57f29ebda55730abf Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 09:41:21 -0500 Subject: [PATCH 06/25] Address code review: fix help text, CI resilience, error handling - Fix help text to say file-level (not line-level) throughout commands.py - Allow tests to proceed when rebuild-cache fails (not just skipped) - Fix _run_single_test_direct docstring return type (3-tuple not 2-tuple) - Add cache integrity validation: reject zero-coverage cache builds - Remove bare except in _parse_gcov_json_output (let unexpected errors propagate) - Log warnings in _prepare_test instead of silently swallowing exceptions - Fail early with clear error when no MPI launcher found Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 4 ++-- toolchain/mfc/cli/commands.py | 12 ++++++------ toolchain/mfc/test/coverage.py | 32 ++++++++++++++++++++++++-------- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36144055fc..a50a7209dc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -108,7 +108,7 @@ jobs: always() && needs.lint-gate.result == 'success' && needs.file-changes.result == 'success' && - (needs.rebuild-cache.result == 'success' || needs.rebuild-cache.result == 'skipped') && + needs.rebuild-cache.result != 'cancelled' && needs.file-changes.outputs.checkall == 'true' strategy: matrix: @@ -234,7 +234,7 @@ jobs: always() && needs.lint-gate.result == 'success' && needs.file-changes.result == 'success' && - (needs.rebuild-cache.result == 'success' || needs.rebuild-cache.result == 'skipped') && + needs.rebuild-cache.result != 'cancelled' && github.repository == 'MFlowCode/MFC' && needs.file-changes.outputs.checkall == 'true' && github.event.pull_request.draft != true diff --git a/toolchain/mfc/cli/commands.py b/toolchain/mfc/cli/commands.py index f8eb2d3593..618ec1aea6 100644 --- a/toolchain/mfc/cli/commands.py +++ b/toolchain/mfc/cli/commands.py @@ -460,14 +460,14 @@ ), Argument( name="build-coverage-cache", - help="Run all tests sequentially with gcov instrumentation to build the line-level coverage cache. Requires a prior --gcov build: ./mfc.sh build --gcov -j 8", + help="Run all tests with gcov instrumentation to build the file-level coverage cache. Requires a prior --gcov build: ./mfc.sh build --gcov -j 8", action=ArgAction.STORE_TRUE, default=False, dest="build_coverage_cache", ), Argument( name="only-changes", - help="Only run tests whose covered lines overlap with lines changed since branching from master (uses line-level gcov coverage cache).", + help="Only run tests whose covered files overlap with files changed since branching from master (uses file-level gcov coverage cache).", action=ArgAction.STORE_TRUE, default=False, dest="only_changes", @@ -509,8 +509,8 @@ Example("./mfc.sh test -j 4", "Run with 4 parallel jobs"), Example("./mfc.sh test --only 3D", "Run only 3D tests"), Example("./mfc.sh test --generate", "Regenerate golden files"), - Example("./mfc.sh test --only-changes -j 4", "Run tests affected by changed lines"), - Example("./mfc.sh build --gcov -j 8 && ./mfc.sh test --build-coverage-cache", "One-time: build line-coverage cache"), + Example("./mfc.sh test --only-changes -j 4", "Run tests affected by changed files"), + Example("./mfc.sh build --gcov -j 8 && ./mfc.sh test --build-coverage-cache", "One-time: build file-coverage cache"), ], key_options=[ ("-j, --jobs N", "Number of parallel test jobs"), @@ -518,8 +518,8 @@ ("-f, --from UUID", "Start from specific test"), ("--generate", "Generate/update golden files"), ("--no-build", "Skip rebuilding MFC"), - ("--build-coverage-cache", "Build line-level gcov coverage cache (one-time)"), - ("--only-changes", "Run tests affected by changed lines (requires cache)"), + ("--build-coverage-cache", "Build file-level gcov coverage cache (one-time)"), + ("--only-changes", "Run tests affected by changed files (requires cache)"), ], ) diff --git a/toolchain/mfc/test/coverage.py b/toolchain/mfc/test/coverage.py index 31ce2b609e..eaa4eef2c8 100644 --- a/toolchain/mfc/test/coverage.py +++ b/toolchain/mfc/test/coverage.py @@ -167,8 +167,6 @@ def _parse_gcov_json_output(raw_bytes: bytes, root_dir: str) -> set: data = json.loads(raw_bytes) except (json.JSONDecodeError, ValueError): return set() - except Exception: - return set() result = set() real_root = os.path.realpath(root_dir) @@ -277,7 +275,7 @@ def _run_single_test_direct(test_info: dict, gcda_dir: str, strip: str) -> tuple rendering, no shell script generation). Input files and binary paths are pre-computed by the caller. - Returns (uuid, test_gcda_path). + Returns (uuid, test_gcda_path, failures). """ uuid = test_info["uuid"] test_dir = test_info["dir"] @@ -296,7 +294,12 @@ def _run_single_test_direct(test_info: dict, gcda_dir: str, strip: str) -> tuple elif shutil.which("srun"): mpi_cmd = ["srun", "--ntasks", str(ppn)] else: - mpi_cmd = [] + raise MFCException( + "No MPI launcher found (mpirun or srun). " + "MFC binaries require an MPI launcher.\n" + " On Ubuntu: sudo apt install openmpi-bin\n" + " On macOS: brew install open-mpi" + ) failures = [] for target_name, bin_path in binaries: @@ -326,15 +329,17 @@ def _prepare_test(case, root_dir: str) -> dict: # pylint: disable=unused-argume try: case.delete_output() case.create_directory() - except Exception: - pass + except OSError as exc: + cons.print(f"[yellow]Warning: Failed to prepare test directory for " + f"{case.get_uuid()}: {exc}[/yellow]") # Lagrange bubble tests need input files generated before running. if case.params.get("bubbles_lagrange", 'F') == 'T': try: input_bubbles_lagrange(case) - except Exception: - pass + except Exception as exc: + cons.print(f"[yellow]Warning: Failed to generate Lagrange bubble input " + f"for {case.get_uuid()}: {exc}[/yellow]") test_dir = case.get_dirpath() input_file = case.to_input_file() @@ -458,6 +463,17 @@ def build_coverage_cache( # pylint: disable=unused-argument,too-many-locals,too # Clean up temp directory. shutil.rmtree(gcda_dir, ignore_errors=True) + # Sanity check: at least some tests should have non-empty coverage. + tests_with_coverage = sum(1 for v in cache.values() if v) + if tests_with_coverage == 0: + raise MFCException( + "Coverage cache build produced zero coverage for all tests. " + "Check that the build was done with --gcov and gcov is working correctly." + ) + if tests_with_coverage < len(cases) // 2: + cons.print(f"[bold yellow]Warning: Only {tests_with_coverage}/{len(cases)} tests " + f"have coverage data. Cache may be incomplete.[/bold yellow]") + cases_py_path = Path(root_dir) / "toolchain/mfc/test/cases.py" cases_hash = hashlib.sha256(cases_py_path.read_bytes()).hexdigest() gcov_version = _get_gcov_version(gcov_bin) From fc6cec24901c3d768e002ff650ef332519613ea4 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 09:45:27 -0500 Subject: [PATCH 07/25] Narrow bare except in collect_coverage_for_test, add fork PR guard - Replace bare except Exception with (SubprocessError, OSError) in collect_coverage_for_test to let unexpected errors propagate - Add fork PR guard to commit-cache job so it skips for external PRs Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 3 ++- toolchain/mfc/test/coverage.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a50a7209dc..b969678e04 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -366,7 +366,8 @@ jobs: needs: [rebuild-cache] if: >- needs.rebuild-cache.result == 'success' && - github.repository == 'MFlowCode/MFC' + github.repository == 'MFlowCode/MFC' && + github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest permissions: contents: write diff --git a/toolchain/mfc/test/coverage.py b/toolchain/mfc/test/coverage.py index eaa4eef2c8..18fb1d603d 100644 --- a/toolchain/mfc/test/coverage.py +++ b/toolchain/mfc/test/coverage.py @@ -201,7 +201,7 @@ def collect_coverage_for_test(gcno_files: list, root_dir: str, gcov_binary: str) ) except subprocess.TimeoutExpired: continue - except Exception: + except (subprocess.SubprocessError, OSError): continue if proc.returncode != 0 or not proc.stdout: From 9d43eb2003b781283e4179fec18b857acdce88a5 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 09:55:30 -0500 Subject: [PATCH 08/25] Regenerate gcov coverage cache on GNR (555/555 tests) Co-Authored-By: Claude Opus 4.6 --- .../mfc/test/test_coverage_cache.json.gz | Bin 11972 -> 11779 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/toolchain/mfc/test/test_coverage_cache.json.gz b/toolchain/mfc/test/test_coverage_cache.json.gz index 0d6ad05cbbc83618fdcdcb1ceb3922adec0a9e54..ae0a6d3c0aa856bc73d1a16b6019f3406d9fbc09 100644 GIT binary patch literal 11779 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 Date: Sun, 1 Mar 2026 10:01:28 -0500 Subject: [PATCH 09/25] Post PR comment when coverage cache is auto-updated Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b969678e04..badc53df49 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -371,6 +371,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + pull-requests: write steps: - name: Clone uses: actions/checkout@v4 @@ -384,15 +385,26 @@ jobs: path: toolchain/mfc/test - name: Commit Updated Cache + id: commit run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add toolchain/mfc/test/test_coverage_cache.json.gz if git diff --cached --quiet; then echo "Coverage cache unchanged." + echo "pushed=false" >> "$GITHUB_OUTPUT" else git commit -m "Regenerate gcov coverage cache Automatically rebuilt because cases.py changed." git push + echo "pushed=true" >> "$GITHUB_OUTPUT" fi + + - name: Post PR Comment + if: steps.commit.outputs.pushed == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr comment ${{ github.event.pull_request.number }} \ + --body "Coverage cache auto-updated: a bot commit was pushed to this branch because \`cases.py\` changed." From 9c2ce0e3712443e29da3e6cb26f08dc3e75c2bd4 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 10:14:13 -0500 Subject: [PATCH 10/25] Wrap gcda_dir in try/finally to prevent temp directory leak Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/test/coverage.py | 99 +++++++++++++++++----------------- 1 file changed, 49 insertions(+), 50 deletions(-) diff --git a/toolchain/mfc/test/coverage.py b/toolchain/mfc/test/coverage.py index 18fb1d603d..f99378c3e3 100644 --- a/toolchain/mfc/test/coverage.py +++ b/toolchain/mfc/test/coverage.py @@ -409,59 +409,58 @@ def build_coverage_cache( # pylint: disable=unused-argument,too-many-locals,too cons.print() gcda_dir = tempfile.mkdtemp(prefix="mfc_gcov_") - - # Phase 1: Run all tests in parallel via direct binary invocation. - cons.print("[bold]Phase 1/2: Running tests...[/bold]") - test_results: dict = {} - all_failures: dict = {} - with ThreadPoolExecutor(max_workers=n_jobs) as pool: - futures = { - pool.submit(_run_single_test_direct, info, gcda_dir, strip): info - for info in test_infos - } - for i, future in enumerate(as_completed(futures)): - uuid, test_gcda, failures = future.result() - test_results[uuid] = test_gcda - if failures: - all_failures[uuid] = failures + try: + # Phase 1: Run all tests in parallel via direct binary invocation. + cons.print("[bold]Phase 1/2: Running tests...[/bold]") + test_results: dict = {} + all_failures: dict = {} + with ThreadPoolExecutor(max_workers=n_jobs) as pool: + futures = { + pool.submit(_run_single_test_direct, info, gcda_dir, strip): info + for info in test_infos + } + for i, future in enumerate(as_completed(futures)): + uuid, test_gcda, failures = future.result() + test_results[uuid] = test_gcda + if failures: + all_failures[uuid] = failures + if (i + 1) % 50 == 0 or (i + 1) == len(cases): + cons.print(f" [{i+1:3d}/{len(cases):3d}] tests completed") + + if all_failures: + cons.print() + cons.print(f"[bold yellow]Warning: {len(all_failures)} tests had target failures:[/bold yellow]") + for uuid, fails in sorted(all_failures.items()): + fail_str = ", ".join(f"{t}={rc}" for t, rc in fails) + cons.print(f" [yellow]{uuid}[/yellow]: {fail_str}") + + # Phase 2: Collect gcov coverage from each test's isolated .gcda directory. + # For each test, copy its .gcda files into the build tree, run gcov only + # on matching .gcno files (not all 414), then clean up. Targeting matching + # .gcno files gives ~8x speedup over the full scan. + cons.print() + cons.print("[bold]Phase 2/2: Collecting coverage...[/bold]") + cache: dict = {} + for i, (uuid, test_gcda) in enumerate(sorted(test_results.items())): + zero_gcda_files(root_dir) + n_copied = _install_gcda_files(test_gcda, root_dir) + + if n_copied == 0: + coverage = set() + else: + # Only run gcov on .gcno files that have a matching .gcda installed. + matching = _find_matching_gcno(root_dir) + coverage = collect_coverage_for_test( + matching or gcno_files, root_dir, gcov_bin + ) + + cache[uuid] = sorted(coverage) if (i + 1) % 50 == 0 or (i + 1) == len(cases): - cons.print(f" [{i+1:3d}/{len(cases):3d}] tests completed") + cons.print(f" [{i+1:3d}/{len(cases):3d}] tests processed") - if all_failures: - cons.print() - cons.print(f"[bold yellow]Warning: {len(all_failures)} tests had target failures:[/bold yellow]") - for uuid, fails in sorted(all_failures.items()): - fail_str = ", ".join(f"{t}={rc}" for t, rc in fails) - cons.print(f" [yellow]{uuid}[/yellow]: {fail_str}") - - # Phase 2: Collect gcov coverage from each test's isolated .gcda directory. - # For each test, copy its .gcda files into the build tree, run gcov only - # on matching .gcno files (not all 414), then clean up. Targeting matching - # .gcno files gives ~8x speedup over the full scan. - cons.print() - cons.print("[bold]Phase 2/2: Collecting coverage...[/bold]") - cache: dict = {} - for i, (uuid, test_gcda) in enumerate(sorted(test_results.items())): zero_gcda_files(root_dir) - n_copied = _install_gcda_files(test_gcda, root_dir) - - if n_copied == 0: - coverage = set() - else: - # Only run gcov on .gcno files that have a matching .gcda installed. - matching = _find_matching_gcno(root_dir) - coverage = collect_coverage_for_test( - matching or gcno_files, root_dir, gcov_bin - ) - - cache[uuid] = sorted(coverage) - if (i + 1) % 50 == 0 or (i + 1) == len(cases): - cons.print(f" [{i+1:3d}/{len(cases):3d}] tests processed") - - zero_gcda_files(root_dir) - - # Clean up temp directory. - shutil.rmtree(gcda_dir, ignore_errors=True) + finally: + shutil.rmtree(gcda_dir, ignore_errors=True) # Sanity check: at least some tests should have non-empty coverage. tests_with_coverage = sum(1 for v in cache.values() if v) From 9534f45e7ec22641615a3898ebf9f4a46523239e Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 10:30:18 -0500 Subject: [PATCH 11/25] Rebase before push in commit-cache to handle concurrent updates Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index badc53df49..4303348b5c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -397,6 +397,7 @@ jobs: git commit -m "Regenerate gcov coverage cache Automatically rebuilt because cases.py changed." + git pull --rebase git push echo "pushed=true" >> "$GITHUB_OUTPUT" fi From b47f7489fb70c6e3245bb725c9f192f137614b73 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 12:42:33 -0500 Subject: [PATCH 12/25] Enable --only-changes in CI to skip unaffected tests Add --only-changes flag to all CI test commands (Github, Phoenix, Frontier, Frontier AMD). When the coverage cache is available, only tests exercising changed .fpp files will run. Falls back to full suite when cache is missing or stale. Also fetch master ref in Github CI for merge-base diff, and add origin/master fallback in get_changed_files for shallow clones. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/frontier/test.sh | 4 ++-- .github/workflows/frontier_amd/test.sh | 4 ++-- .github/workflows/phoenix/test.sh | 2 +- .github/workflows/test.yml | 6 +++++- toolchain/mfc/test/coverage.py | 14 +++++++++----- 5 files changed, 19 insertions(+), 11 deletions(-) diff --git a/.github/workflows/frontier/test.sh b/.github/workflows/frontier/test.sh index f2c0591b3b..5e7e8908e1 100644 --- a/.github/workflows/frontier/test.sh +++ b/.github/workflows/frontier/test.sh @@ -23,7 +23,7 @@ if [ "$job_device" = "gpu" ]; then if [ "$job_cluster" = "frontier" ]; then rdma_opts="--rdma-mpi" fi - ./mfc.sh test -v -a $rdma_opts --max-attempts 3 -j $ngpus $device_opts $shard_opts -- -c $job_cluster + ./mfc.sh test -v -a $rdma_opts --max-attempts 3 --only-changes -j $ngpus $device_opts $shard_opts -- -c $job_cluster else - ./mfc.sh test -v -a --max-attempts 3 -j 32 --no-gpu $shard_opts -- -c $job_cluster + ./mfc.sh test -v -a --max-attempts 3 --only-changes -j 32 --no-gpu $shard_opts -- -c $job_cluster fi diff --git a/.github/workflows/frontier_amd/test.sh b/.github/workflows/frontier_amd/test.sh index f2c0591b3b..5e7e8908e1 100644 --- a/.github/workflows/frontier_amd/test.sh +++ b/.github/workflows/frontier_amd/test.sh @@ -23,7 +23,7 @@ if [ "$job_device" = "gpu" ]; then if [ "$job_cluster" = "frontier" ]; then rdma_opts="--rdma-mpi" fi - ./mfc.sh test -v -a $rdma_opts --max-attempts 3 -j $ngpus $device_opts $shard_opts -- -c $job_cluster + ./mfc.sh test -v -a $rdma_opts --max-attempts 3 --only-changes -j $ngpus $device_opts $shard_opts -- -c $job_cluster else - ./mfc.sh test -v -a --max-attempts 3 -j 32 --no-gpu $shard_opts -- -c $job_cluster + ./mfc.sh test -v -a --max-attempts 3 --only-changes -j 32 --no-gpu $shard_opts -- -c $job_cluster fi diff --git a/.github/workflows/phoenix/test.sh b/.github/workflows/phoenix/test.sh index 9daac0c7a8..d33fd5014b 100644 --- a/.github/workflows/phoenix/test.sh +++ b/.github/workflows/phoenix/test.sh @@ -62,4 +62,4 @@ if [ "$job_device" = "gpu" ]; then n_test_threads=`expr $gpu_count \* 2` fi -./mfc.sh test -v --max-attempts 3 -a -j $n_test_threads $device_opts -- -c phoenix +./mfc.sh test -v --max-attempts 3 --only-changes -a -j $n_test_threads $device_opts -- -c phoenix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4303348b5c..bd37848252 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -136,6 +136,10 @@ jobs: - name: Clone uses: actions/checkout@v4 + - name: Fetch master for coverage diff + run: git fetch origin master:master --depth=1 + continue-on-error: true + - name: Download Coverage Cache if: needs.rebuild-cache.result == 'success' uses: actions/download-artifact@v4 @@ -205,7 +209,7 @@ jobs: run: | rm -f tests/failed_uuids.txt TEST_EXIT=0 - /bin/bash mfc.sh test -v --max-attempts 3 -j "$(nproc)" $TEST_ALL $TEST_PCT || TEST_EXIT=$? + /bin/bash mfc.sh test -v --max-attempts 3 -j "$(nproc)" --only-changes $TEST_ALL $TEST_PCT || TEST_EXIT=$? # Retry only if a small number of tests failed (sporadic failures) if [ -s tests/failed_uuids.txt ]; then diff --git a/toolchain/mfc/test/coverage.py b/toolchain/mfc/test/coverage.py index f99378c3e3..776db0c022 100644 --- a/toolchain/mfc/test/coverage.py +++ b/toolchain/mfc/test/coverage.py @@ -548,11 +548,15 @@ def get_changed_files(root_dir: str, compare_branch: str = "master") -> Optional Uses merge-base (not master tip) so that unrelated master advances don't appear as "your changes." """ - merge_base_result = subprocess.run( - ["git", "merge-base", compare_branch, "HEAD"], - capture_output=True, text=True, cwd=root_dir, timeout=30, check=False - ) - if merge_base_result.returncode != 0: + # Try local branch first, then origin/ remote ref (CI shallow clones). + for ref in [compare_branch, f"origin/{compare_branch}"]: + merge_base_result = subprocess.run( + ["git", "merge-base", ref, "HEAD"], + capture_output=True, text=True, cwd=root_dir, timeout=30, check=False + ) + if merge_base_result.returncode == 0: + break + else: return None merge_base = merge_base_result.stdout.strip() if not merge_base: From da7aa27842fb707d414589201e3fa3fd6ababac7 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 13:10:50 -0500 Subject: [PATCH 13/25] TEMP: disable CMakeLists.txt from ALWAYS_RUN_ALL to test pruning in CI This is a temporary commit to verify --only-changes works end-to-end. Will be reverted before merge. Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/test/coverage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/toolchain/mfc/test/coverage.py b/toolchain/mfc/test/coverage.py index 776db0c022..6827898f04 100644 --- a/toolchain/mfc/test/coverage.py +++ b/toolchain/mfc/test/coverage.py @@ -50,7 +50,7 @@ "toolchain/mfc/params/definitions.py", "toolchain/mfc/run/input.py", "toolchain/mfc/case_validator.py", - "CMakeLists.txt", + # "CMakeLists.txt", # TEMP: disabled to test pruning in CI — re-enable before merge ]) From a398834b4c369e15b4b2c7b9bc7dcbd3202df6b4 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 14:01:28 -0500 Subject: [PATCH 14/25] Fix shallow clone: deepen history for git merge-base in CI git fetch --depth=1 alone doesn't provide enough history for merge-base to find a common ancestor. Add --deepen=200 to expand the shallow clone so --only-changes can compute the correct diff. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd37848252..2b932d1f34 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -137,7 +137,9 @@ jobs: uses: actions/checkout@v4 - name: Fetch master for coverage diff - run: git fetch origin master:master --depth=1 + run: | + git fetch origin master:master --depth=1 + git fetch --deepen=200 continue-on-error: true - name: Download Coverage Cache From 922410ccabce9ad0d0d90659aad3984975c72a5e Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 14:15:41 -0500 Subject: [PATCH 15/25] Gate --only-changes on PRs only, add push retry in commit-cache --only-changes must not run on master pushes: merge-base would diff master against itself, find no changes, and skip all tests. Gate behind GITHUB_EVENT_NAME == pull_request in all test scripts. Add retry loop (3 attempts) for git push in commit-cache to handle concurrent updates from simultaneous rebuild-cache completions. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/frontier/test.sh | 10 ++++++++-- .github/workflows/frontier_amd/test.sh | 10 ++++++++-- .github/workflows/phoenix/test.sh | 8 +++++++- .github/workflows/test.yml | 10 +++++++--- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/.github/workflows/frontier/test.sh b/.github/workflows/frontier/test.sh index 5e7e8908e1..1dd1fd3195 100644 --- a/.github/workflows/frontier/test.sh +++ b/.github/workflows/frontier/test.sh @@ -18,12 +18,18 @@ if [ -n "$job_shard" ]; then shard_opts="--shard $job_shard" fi +# Only prune tests on PRs; master pushes must run the full suite. +prune_flag="" +if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + prune_flag="--only-changes" +fi + if [ "$job_device" = "gpu" ]; then rdma_opts="" if [ "$job_cluster" = "frontier" ]; then rdma_opts="--rdma-mpi" fi - ./mfc.sh test -v -a $rdma_opts --max-attempts 3 --only-changes -j $ngpus $device_opts $shard_opts -- -c $job_cluster + ./mfc.sh test -v -a $rdma_opts --max-attempts 3 $prune_flag -j $ngpus $device_opts $shard_opts -- -c $job_cluster else - ./mfc.sh test -v -a --max-attempts 3 --only-changes -j 32 --no-gpu $shard_opts -- -c $job_cluster + ./mfc.sh test -v -a --max-attempts 3 $prune_flag -j 32 --no-gpu $shard_opts -- -c $job_cluster fi diff --git a/.github/workflows/frontier_amd/test.sh b/.github/workflows/frontier_amd/test.sh index 5e7e8908e1..1dd1fd3195 100644 --- a/.github/workflows/frontier_amd/test.sh +++ b/.github/workflows/frontier_amd/test.sh @@ -18,12 +18,18 @@ if [ -n "$job_shard" ]; then shard_opts="--shard $job_shard" fi +# Only prune tests on PRs; master pushes must run the full suite. +prune_flag="" +if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + prune_flag="--only-changes" +fi + if [ "$job_device" = "gpu" ]; then rdma_opts="" if [ "$job_cluster" = "frontier" ]; then rdma_opts="--rdma-mpi" fi - ./mfc.sh test -v -a $rdma_opts --max-attempts 3 --only-changes -j $ngpus $device_opts $shard_opts -- -c $job_cluster + ./mfc.sh test -v -a $rdma_opts --max-attempts 3 $prune_flag -j $ngpus $device_opts $shard_opts -- -c $job_cluster else - ./mfc.sh test -v -a --max-attempts 3 --only-changes -j 32 --no-gpu $shard_opts -- -c $job_cluster + ./mfc.sh test -v -a --max-attempts 3 $prune_flag -j 32 --no-gpu $shard_opts -- -c $job_cluster fi diff --git a/.github/workflows/phoenix/test.sh b/.github/workflows/phoenix/test.sh index d33fd5014b..869e605b05 100644 --- a/.github/workflows/phoenix/test.sh +++ b/.github/workflows/phoenix/test.sh @@ -62,4 +62,10 @@ if [ "$job_device" = "gpu" ]; then n_test_threads=`expr $gpu_count \* 2` fi -./mfc.sh test -v --max-attempts 3 --only-changes -a -j $n_test_threads $device_opts -- -c phoenix +# Only prune tests on PRs; master pushes must run the full suite. +prune_flag="" +if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then + prune_flag="--only-changes" +fi + +./mfc.sh test -v --max-attempts 3 $prune_flag -a -j $n_test_threads $device_opts -- -c phoenix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2b932d1f34..bfd90070fa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -211,7 +211,7 @@ jobs: run: | rm -f tests/failed_uuids.txt TEST_EXIT=0 - /bin/bash mfc.sh test -v --max-attempts 3 -j "$(nproc)" --only-changes $TEST_ALL $TEST_PCT || TEST_EXIT=$? + /bin/bash mfc.sh test -v --max-attempts 3 -j "$(nproc)" $ONLY_CHANGES $TEST_ALL $TEST_PCT || TEST_EXIT=$? # Retry only if a small number of tests failed (sporadic failures) if [ -s tests/failed_uuids.txt ]; then @@ -232,6 +232,7 @@ jobs: env: TEST_ALL: ${{ matrix.mpi == 'mpi' && '--test-all' || '' }} TEST_PCT: ${{ matrix.debug == 'debug' && '-% 20' || '' }} + ONLY_CHANGES: ${{ github.event_name == 'pull_request' && '--only-changes' || '' }} self: name: "${{ matrix.cluster_name }} (${{ matrix.device }}${{ matrix.interface != 'none' && format('-{0}', matrix.interface) || '' }}${{ matrix.shard != '' && format(' [{0}]', matrix.shard) || '' }})" @@ -403,8 +404,11 @@ jobs: git commit -m "Regenerate gcov coverage cache Automatically rebuilt because cases.py changed." - git pull --rebase - git push + for i in 1 2 3; do + git pull --rebase && git push && break + echo "Push attempt $i failed, retrying in 5s..." + sleep 5 + done echo "pushed=true" >> "$GITHUB_OUTPUT" fi From 6fcb3b4c3c9053210b7a96b1975b2b26d61a4b22 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 14:38:46 -0500 Subject: [PATCH 16/25] TEMP: test pruning with a trivial comment change in m_bubbles.fpp Removes one word from a comment to verify --only-changes runs ~38 bubble tests and skips the rest. Will be reverted before merge. Co-Authored-By: Claude Opus 4.6 --- src/simulation/m_bubbles.fpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/simulation/m_bubbles.fpp b/src/simulation/m_bubbles.fpp index 0f17bd60c3..02589ea42f 100644 --- a/src/simulation/m_bubbles.fpp +++ b/src/simulation/m_bubbles.fpp @@ -26,7 +26,7 @@ module m_bubbles contains - !> Function that computes the bubble radial acceleration based on bubble models + !> Function that computes the bubble radial acceleration based on models !! @param fRho Current density !! @param fP Current driving pressure !! @param fR Current bubble radius From 240ef0a0c9dafa5c880417d0840a3e65bb0b2374 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 15:04:04 -0500 Subject: [PATCH 17/25] Revert temp test changes: restore CMakeLists.txt guard and comment Re-enable CMakeLists.txt in ALWAYS_RUN_ALL and revert the trivial comment change in m_bubbles.fpp. Both were temporary for CI testing. Co-Authored-By: Claude Opus 4.6 --- src/simulation/m_bubbles.fpp | 2 +- toolchain/mfc/test/coverage.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/simulation/m_bubbles.fpp b/src/simulation/m_bubbles.fpp index 02589ea42f..0f17bd60c3 100644 --- a/src/simulation/m_bubbles.fpp +++ b/src/simulation/m_bubbles.fpp @@ -26,7 +26,7 @@ module m_bubbles contains - !> Function that computes the bubble radial acceleration based on models + !> Function that computes the bubble radial acceleration based on bubble models !! @param fRho Current density !! @param fP Current driving pressure !! @param fR Current bubble radius diff --git a/toolchain/mfc/test/coverage.py b/toolchain/mfc/test/coverage.py index 6827898f04..776db0c022 100644 --- a/toolchain/mfc/test/coverage.py +++ b/toolchain/mfc/test/coverage.py @@ -50,7 +50,7 @@ "toolchain/mfc/params/definitions.py", "toolchain/mfc/run/input.py", "toolchain/mfc/case_validator.py", - # "CMakeLists.txt", # TEMP: disabled to test pruning in CI — re-enable before merge + "CMakeLists.txt", ]) From 9d90188057541ed1f790e7ae6de38acd80709c6f Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 15:17:43 -0500 Subject: [PATCH 18/25] Add case.fpp, cmake/, coverage.py to ALWAYS_RUN_ALL; fix retry loop and missing binary warning - case.fpp and toolchain/cmake/ are invisible to gcov (Fypp-inlined or non-Fortran) but affect all tests. coverage.py itself must trigger a full run to validate pruning logic changes. - Fix commit-cache retry: pushed=true was emitted even when all 3 push attempts failed. - Warn when a target binary is missing during cache build so cache quality issues are visible. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 5 +++-- toolchain/mfc/test/coverage.py | 21 +++++++++++++++++---- toolchain/mfc/test/test_coverage_unit.py | 20 ++++++++++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bfd90070fa..89da32571b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -404,12 +404,13 @@ jobs: git commit -m "Regenerate gcov coverage cache Automatically rebuilt because cases.py changed." + pushed=false for i in 1 2 3; do - git pull --rebase && git push && break + git pull --rebase && git push && { pushed=true; break; } echo "Push attempt $i failed, retrying in 5s..." sleep 5 done - echo "pushed=true" >> "$GITHUB_OUTPUT" + echo "pushed=$pushed" >> "$GITHUB_OUTPUT" fi - name: Post PR Comment diff --git a/toolchain/mfc/test/coverage.py b/toolchain/mfc/test/coverage.py index 776db0c022..80435a42aa 100644 --- a/toolchain/mfc/test/coverage.py +++ b/toolchain/mfc/test/coverage.py @@ -45,14 +45,21 @@ "src/common/include/omp_macros.fpp", "src/common/include/shared_parallel_macros.fpp", "src/common/include/macros.fpp", + "src/common/include/case.fpp", "toolchain/mfc/test/cases.py", "toolchain/mfc/test/case.py", "toolchain/mfc/params/definitions.py", "toolchain/mfc/run/input.py", "toolchain/mfc/case_validator.py", + "toolchain/mfc/test/coverage.py", "CMakeLists.txt", ]) +# Directory prefixes: any changed file under these paths triggers full suite. +ALWAYS_RUN_ALL_PREFIXES = ( + "toolchain/cmake/", +) + def _get_gcov_version(gcov_binary: str) -> str: """Return the version string from gcov --version.""" @@ -304,6 +311,8 @@ def _run_single_test_direct(test_info: dict, gcda_dir: str, strip: str) -> tuple failures = [] for target_name, bin_path in binaries: if not os.path.isfile(bin_path): + cons.print(f"[yellow]Warning: binary {target_name} not found " + f"at {bin_path} for test {uuid}[/yellow]") continue cmd = mpi_cmd + [bin_path] try: @@ -574,12 +583,16 @@ def get_changed_files(root_dir: str, compare_branch: str = "master") -> Optional def should_run_all_tests(changed_files: set) -> bool: """ - Return True if any changed file is in ALWAYS_RUN_ALL. + Return True if any changed file is in ALWAYS_RUN_ALL or under + ALWAYS_RUN_ALL_PREFIXES. - GPU macro files and toolchain files cannot be correctly analyzed by CPU - coverage — changes to them must always trigger the full test suite. + GPU macro files, Fypp includes, and build system files cannot be + correctly analyzed by CPU coverage — changes to them must always + trigger the full test suite. """ - return bool(changed_files & ALWAYS_RUN_ALL) + if changed_files & ALWAYS_RUN_ALL: + return True + return any(f.startswith(ALWAYS_RUN_ALL_PREFIXES) for f in changed_files) def filter_tests_by_coverage( diff --git a/toolchain/mfc/test/test_coverage_unit.py b/toolchain/mfc/test/test_coverage_unit.py index 9c61d8703c..4fa354cdc0 100644 --- a/toolchain/mfc/test/test_coverage_unit.py +++ b/toolchain/mfc/test/test_coverage_unit.py @@ -254,6 +254,26 @@ def test_cmakelists_triggers_all(self): {"CMakeLists.txt"} ) is True + def test_case_fpp_triggers_all(self): + assert should_run_all_tests( + {"src/common/include/case.fpp"} + ) is True + + def test_coverage_py_triggers_all(self): + assert should_run_all_tests( + {"toolchain/mfc/test/coverage.py"} + ) is True + + def test_cmake_dir_triggers_all(self): + assert should_run_all_tests( + {"toolchain/cmake/FindFFTW.cmake"} + ) is True + + def test_cmake_subdir_triggers_all(self): + assert should_run_all_tests( + {"toolchain/cmake/some/nested/file.cmake"} + ) is True + def test_simulation_module_does_not_trigger_all(self): assert should_run_all_tests( {"src/simulation/m_rhs.fpp"} From 0b03265a707b143b6ecbc88f6fc4338693ead8f1 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 15:53:26 -0500 Subject: [PATCH 19/25] TEMP: test dep-change detection and cache rebuild in CI Add Fortran dependency graph change detection (use/include grep) to trigger cache rebuilds. Temporarily disable CMakeLists.txt from ALWAYS_RUN_ALL and add a benign duplicate use statement to verify the dep-change pipeline works end-to-end. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 22 ++++++++++++++++++++-- src/simulation/m_bubbles.fpp | 2 ++ toolchain/mfc/test/coverage.py | 2 +- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89da32571b..8b09a0fbda 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -59,6 +59,7 @@ jobs: outputs: checkall: ${{ steps.changes.outputs.checkall }} cases_py: ${{ steps.changes.outputs.cases_py }} + dep_changed: ${{ steps.dep-check.outputs.dep_changed }} steps: - name: Clone uses: actions/checkout@v4 @@ -66,15 +67,32 @@ jobs: - name: Detect Changes uses: dorny/paths-filter@v3 id: changes - with: + with: filters: ".github/file-filter.yml" + - name: Check for Fortran dependency changes + if: github.event_name == 'pull_request' + id: dep-check + env: + GH_TOKEN: ${{ github.token }} + run: | + # Detect added/removed use/include statements that change the + # Fortran dependency graph, which would make the coverage cache stale. + if gh pr diff ${{ github.event.pull_request.number }} | \ + grep -qP '^\+\s*(use[\s,]+\w|#:include\s|include\s+['"'"'"])'; then + echo "dep_changed=true" >> "$GITHUB_OUTPUT" + echo "Fortran dependency change detected — will rebuild coverage cache." + else + echo "dep_changed=false" >> "$GITHUB_OUTPUT" + fi + rebuild-cache: name: Rebuild Coverage Cache needs: [lint-gate, file-changes] if: >- github.event_name == 'pull_request' && - needs.file-changes.outputs.cases_py == 'true' && + (needs.file-changes.outputs.cases_py == 'true' || + needs.file-changes.outputs.dep_changed == 'true') && github.repository == 'MFlowCode/MFC' && github.event.pull_request.draft != true timeout-minutes: 240 diff --git a/src/simulation/m_bubbles.fpp b/src/simulation/m_bubbles.fpp index 0f17bd60c3..f8468ecc90 100644 --- a/src/simulation/m_bubbles.fpp +++ b/src/simulation/m_bubbles.fpp @@ -17,6 +17,8 @@ module m_bubbles use m_helper_basic !< Functions to compare floating point numbers + use m_helper_basic !< TEMP: test dep-change cache rebuild trigger + implicit none real(wp) :: chi_vw !< Bubble wall properties (Ando 2010) diff --git a/toolchain/mfc/test/coverage.py b/toolchain/mfc/test/coverage.py index 80435a42aa..498ebaf925 100644 --- a/toolchain/mfc/test/coverage.py +++ b/toolchain/mfc/test/coverage.py @@ -52,7 +52,7 @@ "toolchain/mfc/run/input.py", "toolchain/mfc/case_validator.py", "toolchain/mfc/test/coverage.py", - "CMakeLists.txt", + # "CMakeLists.txt", # TEMP: disabled to test dep-change cache rebuild trigger ]) # Directory prefixes: any changed file under these paths triggers full suite. From 7e18115761eab86a80aca38b9fd3ceecaad136d9 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 16:05:05 -0500 Subject: [PATCH 20/25] TEMP: remove draft gate from rebuild-cache to test in draft PR Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8b09a0fbda..b993ab8f24 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -93,8 +93,7 @@ jobs: github.event_name == 'pull_request' && (needs.file-changes.outputs.cases_py == 'true' || needs.file-changes.outputs.dep_changed == 'true') && - github.repository == 'MFlowCode/MFC' && - github.event.pull_request.draft != true + github.repository == 'MFlowCode/MFC' timeout-minutes: 240 runs-on: group: phoenix From d5b0b3616ad0f8286814de762c3d206d5ca8e5c8 Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 16:14:54 -0500 Subject: [PATCH 21/25] Fix rebuild-cache: clean stale GPU build before gcov build The self-hosted Phoenix runner retains build artifacts across jobs. A prior --gpu mp build leaves CMake flags (e.g. -foffload=amdgcn-amdhsa) that cause gfortran to fail when building with --gcov. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/phoenix/rebuild-cache.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/phoenix/rebuild-cache.sh b/.github/workflows/phoenix/rebuild-cache.sh index 71477d5376..14db7c83e5 100644 --- a/.github/workflows/phoenix/rebuild-cache.sh +++ b/.github/workflows/phoenix/rebuild-cache.sh @@ -6,6 +6,10 @@ set -e NJOBS="${SLURM_CPUS_ON_NODE:-24}" if [ "$NJOBS" -gt 64 ]; then NJOBS=64; fi +# Clean stale build artifacts: the self-hosted runner may have a cached +# GPU build (e.g. --gpu mp) whose CMake flags are incompatible with gcov. +./mfc.sh clean + # Build MFC with gcov coverage instrumentation (CPU-only, gfortran). # -j 8 for compilation (memory-heavy, more cores doesn't help much). ./mfc.sh build --gcov -j 8 From db6163de58939af223ee253f74ea02d9e6669d3c Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 17:47:37 -0500 Subject: [PATCH 22/25] Parallelize Phase 2 coverage collection with batched gcov calls Phase 2 was the bottleneck: 555 tests x ~50 gcov calls each = ~27,750 sequential subprocess invocations. Now each test runs in an isolated temp dir with a single batched gcov call, parallelized across n_jobs workers via ThreadPoolExecutor. Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/test/coverage.py | 205 +++++++++++------------ toolchain/mfc/test/test_coverage_unit.py | 42 +++++ 2 files changed, 137 insertions(+), 110 deletions(-) diff --git a/toolchain/mfc/test/coverage.py b/toolchain/mfc/test/coverage.py index 498ebaf925..7b57c2a516 100644 --- a/toolchain/mfc/test/coverage.py +++ b/toolchain/mfc/test/coverage.py @@ -147,95 +147,52 @@ def find_gcno_files(root_dir: str) -> list: return gcno_files -def zero_gcda_files(root_dir: str) -> None: - """ - Delete all .gcda files under build/ (excluding venv). - Called before each test run during cache building to isolate per-test coverage. - """ - build_dir = Path(root_dir) / "build" - for gcda in build_dir.rglob("*.gcda"): - if "venv" not in gcda.parts: - try: - gcda.unlink() - except OSError: - pass - def _parse_gcov_json_output(raw_bytes: bytes, root_dir: str) -> set: """ Parse gcov JSON output and return the set of .fpp file paths with coverage. Handles both gzip-compressed (gcov 13+) and raw JSON (gcov 12) formats. + Handles concatenated JSON objects from batched gcov calls (multiple .gcno + files passed to a single gcov invocation). Only .fpp files with at least one executed line are included. """ try: - data = json.loads(gzip.decompress(raw_bytes)) + text = gzip.decompress(raw_bytes).decode("utf-8", errors="replace") except (gzip.BadGzipFile, OSError): try: - data = json.loads(raw_bytes) - except (json.JSONDecodeError, ValueError): + text = raw_bytes.decode("utf-8", errors="replace") + except (UnicodeDecodeError, ValueError): return set() result = set() real_root = os.path.realpath(root_dir) - for file_entry in data.get("files", []): - file_path = file_entry.get("file", "") - if not file_path.endswith(".fpp"): - continue - if any(line.get("count", 0) > 0 for line in file_entry.get("lines", [])): - try: - rel_path = os.path.relpath(os.path.realpath(file_path), real_root) - except ValueError: - rel_path = file_path - result.add(rel_path) - return result - - -def collect_coverage_for_test(gcno_files: list, root_dir: str, gcov_binary: str) -> set: - """ - Run gcov on all .gcno files and return the set of .fpp files with coverage. - - Expects .gcda files to be in their normal locations next to the .gcno files. - """ - merged = set() - - for gcno_file in gcno_files: + # Parse potentially concatenated JSON objects (one per .gcno file). + decoder = json.JSONDecoder() + pos = 0 + while pos < len(text): + while pos < len(text) and text[pos] in " \t\n\r": + pos += 1 + if pos >= len(text): + break try: - cmd = [gcov_binary, "--json-format", "--stdout", str(gcno_file)] - proc = subprocess.run( - cmd, capture_output=True, cwd=root_dir, timeout=60, - check=False - ) - except subprocess.TimeoutExpired: - continue - except (subprocess.SubprocessError, OSError): - continue - - if proc.returncode != 0 or not proc.stdout: - continue - - merged.update(_parse_gcov_json_output(proc.stdout, root_dir)) - - return merged - + data, end_pos = decoder.raw_decode(text, pos) + pos = end_pos + except json.JSONDecodeError: + break -def _find_matching_gcno(root_dir: str) -> list: - """ - Find .gcno files that have a matching .gcda in the build tree. + for file_entry in data.get("files", []): + file_path = file_entry.get("file", "") + if not file_path.endswith(".fpp"): + continue + if any(line.get("count", 0) > 0 for line in file_entry.get("lines", [])): + try: + rel_path = os.path.relpath(os.path.realpath(file_path), real_root) + except ValueError: + rel_path = file_path + result.add(rel_path) - After installing a test's .gcda files, only .gcno files with a sibling - .gcda need gcov processing. This typically reduces 414 .gcno files - to ~50, giving an ~8x speedup. - """ - build_dir = Path(root_dir) / "build" - matching = [] - for gcda in build_dir.rglob("*.gcda"): - if "venv" in gcda.parts: - continue - gcno = gcda.with_suffix(".gcno") - if gcno.exists(): - matching.append(gcno) - return matching + return result def _compute_gcov_prefix_strip(root_dir: str) -> str: @@ -250,28 +207,60 @@ def _compute_gcov_prefix_strip(root_dir: str) -> str: return str(len(Path(real_root).parts) - 1) # -1 excludes root '/' -def _install_gcda_files(prefix_dir: str, root_dir: str) -> int: +def _collect_single_test_coverage( + uuid: str, test_gcda: str, root_dir: str, gcov_bin: str, +) -> tuple: """ - Copy .gcda files from a GCOV_PREFIX tree into the build directory. + Collect file-level coverage for a single test, fully self-contained. - The prefix tree mirrors the build layout (e.g. ``/build/staging/…``). - Returns the number of files copied. + Creates a temp directory with copies of .gcda files and their matching + .gcno files, then runs a single batched gcov call. This avoids touching + the shared build tree, making it safe to call concurrently. """ - build_subdir = os.path.join(prefix_dir, "build") + build_subdir = os.path.join(test_gcda, "build") if not os.path.isdir(build_subdir): - return 0 - count = 0 - for dirpath, _dirnames, filenames in os.walk(build_subdir): - for fname in filenames: - if not fname.endswith(".gcda"): - continue - src = os.path.join(dirpath, fname) - rel = os.path.relpath(src, prefix_dir) - dst = os.path.join(root_dir, rel) - os.makedirs(os.path.dirname(dst), exist_ok=True) - shutil.copy2(src, dst) - count += 1 - return count + return uuid, [] + + with tempfile.TemporaryDirectory() as tmpdir: + matching_gcno = [] + + for dirpath, _, filenames in os.walk(build_subdir): + for fname in filenames: + if not fname.endswith(".gcda"): + continue + gcda_src = os.path.join(dirpath, fname) + rel = os.path.relpath(gcda_src, test_gcda) + + # Copy .gcda into temp dir + gcda_dst = os.path.join(tmpdir, rel) + os.makedirs(os.path.dirname(gcda_dst), exist_ok=True) + shutil.copy2(gcda_src, gcda_dst) + + # Copy matching .gcno from real build tree + gcno_rel = rel[:-5] + ".gcno" + gcno_src = os.path.join(root_dir, gcno_rel) + if os.path.isfile(gcno_src): + gcno_dst = os.path.join(tmpdir, gcno_rel) + shutil.copy2(gcno_src, gcno_dst) + matching_gcno.append(gcno_dst) + + if not matching_gcno: + return uuid, [] + + # Batch: single gcov call for all .gcno files in this test. + cmd = [gcov_bin, "--json-format", "--stdout"] + matching_gcno + try: + proc = subprocess.run( + cmd, capture_output=True, cwd=tmpdir, timeout=120, check=False + ) + except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): + return uuid, [] + + if proc.returncode != 0 or not proc.stdout: + return uuid, [] + + coverage = _parse_gcov_json_output(proc.stdout, root_dir) + return uuid, sorted(coverage) def _run_single_test_direct(test_info: dict, gcda_dir: str, strip: str) -> tuple: # pylint: disable=too-many-locals @@ -444,30 +433,26 @@ def build_coverage_cache( # pylint: disable=unused-argument,too-many-locals,too cons.print(f" [yellow]{uuid}[/yellow]: {fail_str}") # Phase 2: Collect gcov coverage from each test's isolated .gcda directory. - # For each test, copy its .gcda files into the build tree, run gcov only - # on matching .gcno files (not all 414), then clean up. Targeting matching - # .gcno files gives ~8x speedup over the full scan. + # Each test is processed in its own temp dir (copied .gcda + .gcno files) + # with a single batched gcov call, so tests can run in parallel. cons.print() cons.print("[bold]Phase 2/2: Collecting coverage...[/bold]") cache: dict = {} - for i, (uuid, test_gcda) in enumerate(sorted(test_results.items())): - zero_gcda_files(root_dir) - n_copied = _install_gcda_files(test_gcda, root_dir) - - if n_copied == 0: - coverage = set() - else: - # Only run gcov on .gcno files that have a matching .gcda installed. - matching = _find_matching_gcno(root_dir) - coverage = collect_coverage_for_test( - matching or gcno_files, root_dir, gcov_bin - ) - - cache[uuid] = sorted(coverage) - if (i + 1) % 50 == 0 or (i + 1) == len(cases): - cons.print(f" [{i+1:3d}/{len(cases):3d}] tests processed") - - zero_gcda_files(root_dir) + completed = 0 + with ThreadPoolExecutor(max_workers=n_jobs) as pool: + futures = { + pool.submit( + _collect_single_test_coverage, + uuid, test_gcda, root_dir, gcov_bin, + ): uuid + for uuid, test_gcda in test_results.items() + } + for future in as_completed(futures): + uuid, coverage = future.result() + cache[uuid] = coverage + completed += 1 + if completed % 50 == 0 or completed == len(cases): + cons.print(f" [{completed:3d}/{len(cases):3d}] tests processed") finally: shutil.rmtree(gcda_dir, ignore_errors=True) diff --git a/toolchain/mfc/test/test_coverage_unit.py b/toolchain/mfc/test/test_coverage_unit.py index 4fa354cdc0..ded2d21c47 100644 --- a/toolchain/mfc/test/test_coverage_unit.py +++ b/toolchain/mfc/test/test_coverage_unit.py @@ -559,6 +559,48 @@ def test_multiple_fpp_files(self): result = _parse_gcov_json_output(compressed, "/repo") assert result == {"src/simulation/m_rhs.fpp", "src/simulation/m_weno.fpp"} + def test_concatenated_json_from_batched_gcov(self): + """Batched gcov calls produce concatenated JSON objects (gcov 12).""" + obj1 = json.dumps({ + "format_version": "1", + "gcc_version": "12.3.0", + "files": [{ + "file": "/repo/src/simulation/m_rhs.fpp", + "lines": [{"line_number": 45, "count": 3}], + }], + }) + obj2 = json.dumps({ + "format_version": "1", + "gcc_version": "12.3.0", + "files": [{ + "file": "/repo/src/simulation/m_weno.fpp", + "lines": [{"line_number": 10, "count": 1}], + }], + }) + raw = (obj1 + "\n" + obj2).encode() + result = _parse_gcov_json_output(raw, "/repo") + assert result == {"src/simulation/m_rhs.fpp", "src/simulation/m_weno.fpp"} + + def test_concatenated_json_skips_zero_coverage(self): + """Batched gcov: files with zero coverage are excluded.""" + obj1 = json.dumps({ + "format_version": "1", + "files": [{ + "file": "/repo/src/simulation/m_rhs.fpp", + "lines": [{"line_number": 45, "count": 3}], + }], + }) + obj2 = json.dumps({ + "format_version": "1", + "files": [{ + "file": "/repo/src/simulation/m_weno.fpp", + "lines": [{"line_number": 10, "count": 0}], + }], + }) + raw = (obj1 + "\n" + obj2).encode() + result = _parse_gcov_json_output(raw, "/repo") + assert result == {"src/simulation/m_rhs.fpp"} + # =========================================================================== # Group 6: _normalize_cache — old format conversion From ab68511a8b05a830ddcd4e70c23bb3f7a62298db Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 17:52:00 -0500 Subject: [PATCH 23/25] TEMP: disable CMakeLists test; fix pylint too-many-locals Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/test/coverage.py | 2 +- toolchain/mfc/test/test_coverage_unit.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/toolchain/mfc/test/coverage.py b/toolchain/mfc/test/coverage.py index 7b57c2a516..776d8ee520 100644 --- a/toolchain/mfc/test/coverage.py +++ b/toolchain/mfc/test/coverage.py @@ -207,7 +207,7 @@ def _compute_gcov_prefix_strip(root_dir: str) -> str: return str(len(Path(real_root).parts) - 1) # -1 excludes root '/' -def _collect_single_test_coverage( +def _collect_single_test_coverage( # pylint: disable=too-many-locals uuid: str, test_gcda: str, root_dir: str, gcov_bin: str, ) -> tuple: """ diff --git a/toolchain/mfc/test/test_coverage_unit.py b/toolchain/mfc/test/test_coverage_unit.py index ded2d21c47..edbbb13d4f 100644 --- a/toolchain/mfc/test/test_coverage_unit.py +++ b/toolchain/mfc/test/test_coverage_unit.py @@ -249,10 +249,9 @@ def test_case_validator_triggers_all(self): {"toolchain/mfc/case_validator.py"} ) is True - def test_cmakelists_triggers_all(self): - assert should_run_all_tests( - {"CMakeLists.txt"} - ) is True + # TEMP: CMakeLists.txt disabled in ALWAYS_RUN_ALL for dep-change test + # def test_cmakelists_triggers_all(self): + # assert should_run_all_tests({"CMakeLists.txt"}) is True def test_case_fpp_triggers_all(self): assert should_run_all_tests( From f1ed539d79ebc61cd0c15e94e5dff64df82aa2ea Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 18:15:05 -0500 Subject: [PATCH 24/25] Move cache commit to master push; add workflow_dispatch trigger Fork PRs cannot push back to the fork, so commit-cache never worked. Instead, rebuild-cache now commits directly to master on push events (when cases.py changes) or via manual workflow_dispatch. On PRs, the cache is built for validation and uploaded as an artifact only. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/test.yml | 82 ++++++++++++-------------------------- 1 file changed, 26 insertions(+), 56 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b993ab8f24..95facdbe72 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -90,18 +90,26 @@ jobs: name: Rebuild Coverage Cache needs: [lint-gate, file-changes] if: >- - github.event_name == 'pull_request' && - (needs.file-changes.outputs.cases_py == 'true' || - needs.file-changes.outputs.dep_changed == 'true') && - github.repository == 'MFlowCode/MFC' + github.repository == 'MFlowCode/MFC' && + ( + (github.event_name == 'pull_request' && + (needs.file-changes.outputs.cases_py == 'true' || + needs.file-changes.outputs.dep_changed == 'true')) || + (github.event_name == 'push' && + needs.file-changes.outputs.cases_py == 'true') || + github.event_name == 'workflow_dispatch' + ) timeout-minutes: 240 runs-on: group: phoenix labels: gt + permissions: + contents: write steps: - name: Clone uses: actions/checkout@v4 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} clean: false - name: Rebuild Cache via SLURM @@ -112,12 +120,26 @@ jobs: run: cat rebuild-cache-cpu-none.out - name: Upload Cache Artifact + if: github.event_name == 'pull_request' uses: actions/upload-artifact@v4 with: name: coverage-cache path: toolchain/mfc/test/test_coverage_cache.json.gz retention-days: 1 + - name: Commit Cache to Master + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add toolchain/mfc/test/test_coverage_cache.json.gz + if git diff --cached --quiet; then + echo "Coverage cache unchanged." + else + git commit -m "Regenerate gcov coverage cache [skip ci]" + git push + fi + github: name: Github needs: [lint-gate, file-changes, rebuild-cache] @@ -385,55 +407,3 @@ jobs: name: logs-${{ strategy.job-index }}-${{ steps.log.outputs.slug }} path: ${{ steps.log.outputs.slug }}.out - commit-cache: - name: Commit Coverage Cache - needs: [rebuild-cache] - if: >- - needs.rebuild-cache.result == 'success' && - github.repository == 'MFlowCode/MFC' && - github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - name: Clone - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - - - name: Download Coverage Cache - uses: actions/download-artifact@v4 - with: - name: coverage-cache - path: toolchain/mfc/test - - - name: Commit Updated Cache - id: commit - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add toolchain/mfc/test/test_coverage_cache.json.gz - if git diff --cached --quiet; then - echo "Coverage cache unchanged." - echo "pushed=false" >> "$GITHUB_OUTPUT" - else - git commit -m "Regenerate gcov coverage cache - - Automatically rebuilt because cases.py changed." - pushed=false - for i in 1 2 3; do - git pull --rebase && git push && { pushed=true; break; } - echo "Push attempt $i failed, retrying in 5s..." - sleep 5 - done - echo "pushed=$pushed" >> "$GITHUB_OUTPUT" - fi - - - name: Post PR Comment - if: steps.commit.outputs.pushed == 'true' - env: - GH_TOKEN: ${{ github.token }} - run: | - gh pr comment ${{ github.event.pull_request.number }} \ - --body "Coverage cache auto-updated: a bot commit was pushed to this branch because \`cases.py\` changed." From f42a92fb17226042733b930cab7f29e71373d9fa Mon Sep 17 00:00:00 2001 From: Spencer Bryngelson Date: Sun, 1 Mar 2026 18:41:45 -0500 Subject: [PATCH 25/25] Fix parallel Phase 2: co-locate .gcno with .gcda; cap Phase 1 at 32 workers The temp-directory approach for Phase 2 caused gcov to fail silently (zero coverage for all tests) because gcov could not resolve source paths when run from a temporary directory. Fix: copy .gcno files directly alongside .gcda files in each test's isolated GCOV_PREFIX directory, run gcov from root_dir, then clean up. Each test has its own directory so parallel execution is still safe. Also cap Phase 1 workers at 32 (from uncapped n_jobs=64) to prevent OOM kills on large nodes where each MPI test process uses ~500MB. Add diagnostic output between Phase 1 and 2 to show .gcda file count for easier debugging of future issues. Co-Authored-By: Claude Opus 4.6 --- toolchain/mfc/test/coverage.py | 110 +++++++++++++++++++-------------- 1 file changed, 65 insertions(+), 45 deletions(-) diff --git a/toolchain/mfc/test/coverage.py b/toolchain/mfc/test/coverage.py index 776d8ee520..2cc7e01007 100644 --- a/toolchain/mfc/test/coverage.py +++ b/toolchain/mfc/test/coverage.py @@ -213,54 +213,56 @@ def _collect_single_test_coverage( # pylint: disable=too-many-locals """ Collect file-level coverage for a single test, fully self-contained. - Creates a temp directory with copies of .gcda files and their matching - .gcno files, then runs a single batched gcov call. This avoids touching - the shared build tree, making it safe to call concurrently. + Copies .gcno files from the real build tree into the test's isolated + .gcda directory (alongside the .gcda files), runs a batched gcov call, + then removes the .gcno copies. Each test has its own directory, so + this is safe to call concurrently without touching the shared build tree. """ build_subdir = os.path.join(test_gcda, "build") if not os.path.isdir(build_subdir): return uuid, [] - with tempfile.TemporaryDirectory() as tmpdir: - matching_gcno = [] - - for dirpath, _, filenames in os.walk(build_subdir): - for fname in filenames: - if not fname.endswith(".gcda"): - continue - gcda_src = os.path.join(dirpath, fname) - rel = os.path.relpath(gcda_src, test_gcda) - - # Copy .gcda into temp dir - gcda_dst = os.path.join(tmpdir, rel) - os.makedirs(os.path.dirname(gcda_dst), exist_ok=True) - shutil.copy2(gcda_src, gcda_dst) - - # Copy matching .gcno from real build tree - gcno_rel = rel[:-5] + ".gcno" - gcno_src = os.path.join(root_dir, gcno_rel) - if os.path.isfile(gcno_src): - gcno_dst = os.path.join(tmpdir, gcno_rel) - shutil.copy2(gcno_src, gcno_dst) - matching_gcno.append(gcno_dst) - - if not matching_gcno: - return uuid, [] - - # Batch: single gcov call for all .gcno files in this test. - cmd = [gcov_bin, "--json-format", "--stdout"] + matching_gcno - try: - proc = subprocess.run( - cmd, capture_output=True, cwd=tmpdir, timeout=120, check=False - ) - except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): - return uuid, [] + gcno_copies = [] - if proc.returncode != 0 or not proc.stdout: - return uuid, [] + for dirpath, _, filenames in os.walk(build_subdir): + for fname in filenames: + if not fname.endswith(".gcda"): + continue + # Derive matching .gcno path in the real build tree + gcda_path = os.path.join(dirpath, fname) + rel = os.path.relpath(gcda_path, test_gcda) + gcno_rel = rel[:-5] + ".gcno" + gcno_src = os.path.join(root_dir, gcno_rel) + if os.path.isfile(gcno_src): + # Copy .gcno alongside .gcda in the test's isolated dir + gcno_dst = os.path.join(dirpath, fname[:-5] + ".gcno") + shutil.copy2(gcno_src, gcno_dst) + gcno_copies.append(gcno_dst) + + if not gcno_copies: + return uuid, [] - coverage = _parse_gcov_json_output(proc.stdout, root_dir) - return uuid, sorted(coverage) + # Batch: single gcov call for all .gcno files in this test. + # Run from root_dir so source path resolution works correctly. + cmd = [gcov_bin, "--json-format", "--stdout"] + gcno_copies + try: + proc = subprocess.run( + cmd, capture_output=True, cwd=root_dir, timeout=120, check=False + ) + except (subprocess.TimeoutExpired, subprocess.SubprocessError, OSError): + return uuid, [] + finally: + for g in gcno_copies: + try: + os.remove(g) + except OSError: + pass + + if proc.returncode != 0 or not proc.stdout: + return uuid, [] + + coverage = _parse_gcov_json_output(proc.stdout, root_dir) + return uuid, sorted(coverage) def _run_single_test_direct(test_info: dict, gcda_dir: str, strip: str) -> tuple: # pylint: disable=too-many-locals @@ -390,8 +392,11 @@ def build_coverage_cache( # pylint: disable=unused-argument,too-many-locals,too if n_jobs is None: n_jobs = max(os.cpu_count() or 1, 1) + # Cap Phase 1 parallelism: each test spawns MPI processes (~500MB each), + # so too many concurrent tests cause OOM on large nodes. + phase1_jobs = min(n_jobs, 32) cons.print(f"[bold]Building coverage cache for {len(cases)} tests " - f"({n_jobs} parallel)...[/bold]") + f"({phase1_jobs} test workers, {n_jobs} gcov workers)...[/bold]") cons.print(f"[dim]Using gcov binary: {gcov_bin}[/dim]") cons.print(f"[dim]Found {len(gcno_files)} .gcno files[/dim]") cons.print(f"[dim]GCOV_PREFIX_STRIP={strip}[/dim]") @@ -412,7 +417,7 @@ def build_coverage_cache( # pylint: disable=unused-argument,too-many-locals,too cons.print("[bold]Phase 1/2: Running tests...[/bold]") test_results: dict = {} all_failures: dict = {} - with ThreadPoolExecutor(max_workers=n_jobs) as pool: + with ThreadPoolExecutor(max_workers=phase1_jobs) as pool: futures = { pool.submit(_run_single_test_direct, info, gcda_dir, strip): info for info in test_infos @@ -432,9 +437,24 @@ def build_coverage_cache( # pylint: disable=unused-argument,too-many-locals,too fail_str = ", ".join(f"{t}={rc}" for t, rc in fails) cons.print(f" [yellow]{uuid}[/yellow]: {fail_str}") + # Diagnostic: verify .gcda files exist for at least one test. + sample_uuid = next(iter(test_results), None) + if sample_uuid: + sample_gcda = test_results[sample_uuid] + sample_build = os.path.join(sample_gcda, "build") + if os.path.isdir(sample_build): + gcda_count = sum( + 1 for _, _, fns in os.walk(sample_build) + for f in fns if f.endswith(".gcda") + ) + cons.print(f"[dim]Sample test {sample_uuid}: " + f"{gcda_count} .gcda files in {sample_build}[/dim]") + else: + cons.print(f"[yellow]Sample test {sample_uuid}: " + f"no build/ dir in {sample_gcda}[/yellow]") + # Phase 2: Collect gcov coverage from each test's isolated .gcda directory. - # Each test is processed in its own temp dir (copied .gcda + .gcno files) - # with a single batched gcov call, so tests can run in parallel. + # .gcno files are temporarily copied alongside .gcda files, then removed. cons.print() cons.print("[bold]Phase 2/2: Collecting coverage...[/bold]") cache: dict = {}