Skip to content

CI: LLVM source-based C/C++ coverage on macOS and Linux pytest - #674

Open
echoix wants to merge 11 commits into
mainfrom
claude/macos-source-coverage-setup-8pg76f
Open

echoix wants to merge 11 commits into
mainfrom
claude/macos-source-coverage-setup-8pg76f

Conversation

@echoix

@echoix echoix commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds opt-in clang -fprofile-instr-generate -fcoverage-mapping -fprofile-update=atomic instrumentation to the macOS build (macos_install.sh) and to the Linux pytest build (build_ubuntu-24.04.sh), gated by GRASS_LLVM_COVERAGE=1. macOS keeps its native clang; Linux switches to clang when opted in.
  • Sets LLVM_PROFILE_FILE=$PWD/llvm-cov-raw/grass-%p-%9m.profraw via GITHUB_ENV before the test steps, so every xdist worker and every C-tool subprocess writes its own .profraw file. This eliminates the write-collision that made re-runs non-deterministic on files the tests never touched.
  • Adds utils/llvm_coverage_export.sh to merge .profraw → .profdata (llvm-profdata merge -sparse, deterministic for a given input set) and export lcov for Codecov, filtering system, conda, and build-tree paths. The -object list is filtered by ELF/Mach-O magic bytes so executable Python scripts don't break llvm-cov.
  • Adds utils/coverage_disk_usage.sh to log .profraw count/size and df -h before combine, so a run approaching the runner storage budget is visible in the log.
  • Fixes a latent ordering bug where utils/coverage_mapper.py ran before coverage combine and could not read a .coverage that did not yet exist in parallel mode. The mapper now runs after combine.
  • Adds coverage, pytest-cov, llvm-tools to the macOS conda env; clang, llvm to the Linux apt list.
  • Wires two new Codecov flags on macOS (macos-pytest-python, macos-llvm-c) and one on Linux (ubuntu-llvm-c). The macOS combine+upload runs after the gunittest step so .profraw from both pytest and gunittest are included.
  • Gitignores *.profraw, *.profdata, coverage-llvm.lcov, llvm-cov-raw/, coverage_html_report/. The coverage-prep step also find . -name default.profraw -deletes stray files the instrumented build itself emits (Makefiles run each tool with --interface-description to dump XML).

Determinism investigation

The previous attempt saw coverage of untouched files change between runs. I reproduced it locally against an instrumented HEAD build, then narrowed and fixed:

  1. Minimal repro (outside GRASS): 8 parallel processes writing to a single default.profraw produced 20 vs 12 counts on identical inputs. Adding %p-%9m to LLVM_PROFILE_FILE made two runs bit-identical (32/32).
  2. Full GRASS repro: 6 parallel grass --tmp-project --exec sessions each calling g.*, r.mapcalc, r.info, r.univar. Two runs produced 244 differing lcov lines — every one inside r.univar_main.c process_raster.omp_outlined_debug__, i.e. #pragma omp parallel for. That's LLVM's non-atomic coverage counters racing between threads.
  3. Fix: -fprofile-update=atomic in the coverage CFLAGS. Verified locally in isolation (a small OpenMP loop went from 284296/304669 counts across runs → byte-identical) and against GRASS: two runs of the same 6-way parallel workload are now bit-identical on line coverage (DA:). The only residual diff is workload-timing noise in parser.c (±1 line hit between runs of the same command set) and inherent floating-point reduction order in r.univar's lock-protected min/max reduction, both of which reproduce identically without any coverage flags in play.

Test plan

  • macOS workflow builds with GRASS_LLVM_COVERAGE=1 and produces coverage-llvm.lcov non-empty
  • macOS Codecov shows macos-llvm-c and macos-pytest-python flags with non-zero coverage
  • pytest workflow (Ubuntu, clang) builds and produces coverage-llvm.lcov non-empty
  • Linux Codecov shows ubuntu-llvm-c alongside the existing pytest-python-<ver>
  • Disk-usage step reports .profraw count/size within runner budget (<1 GB expected)
  • coverage combine runs before coverage_mapper.py and no ordering warning appears

Large use of AI assistance (Claude Code) helped draft the workflow diffs, the utility scripts, and the local determinism reproduction. Human: EC.


Generated by Claude Code

claude added 3 commits August 31, 2026 22:25
Adds opt-in clang -fprofile-instr-generate -fcoverage-mapping
instrumentation to the macOS and Linux pytest builds and wires the
resulting coverage to Codecov alongside the existing Python coverage.

Determinism under pytest-xdist: LLVM_PROFILE_FILE is set to
grass-%p-%9m.profraw so every subprocess and every instrumented binary
writes its own file, eliminating the collision that made re-runs
non-deterministic for untouched files. Merge uses llvm-profdata -sparse
which is bit-identical for a given input set (verified locally).

Fixes a latent ordering bug where utils/coverage_mapper.py ran before
coverage combine and could not read a .coverage that did not yet exist
in parallel mode. The mapper now runs on the combined data.

Adds utils/coverage_disk_usage.sh to log raw coverage disk usage before
the combine step so a run that threatens the runner storage budget is
visible in the log, and utils/llvm_coverage_export.sh to merge .profraw
into .profdata and export lcov for Codecov, filtering system, conda,
and build-tree paths.

macOS Codecov uploads use distinct flags macos-pytest-python and
macos-llvm-c; Linux adds ubuntu-llvm-c to exercise the same path
during local iteration. macOS coverage is combined after the
gunittest run so profraw from both pytest and gunittest are included.

Large use of AI assistance (Claude Code) helped draft the workflow
diffs and the utility scripts.
The instrumented build runs each tool with --interface-description to
emit its XML, which drops default.profraw next to the binary. Those
files are build artifacts (no tests ran, coverage from the build tree
would contaminate the test-run merge), so they belong ignored, and the
CI coverage-prep step deletes any stray default.profraw before the
test steps start writing controlled per-PID files under llvm-cov-raw/.

Adds *.profraw, *.profdata, coverage-llvm.lcov, llvm-cov-raw/, and
coverage_html_report/ to .gitignore.
Adds -fprofile-update=atomic to the coverage CFLAGS on both macOS and
Linux pytest builds. Without it, LLVM's coverage counter increments are
non-atomic, and GRASS tools with #pragma omp parallel / for regions
(e.g. r.univar's process_raster) lose counter updates across threads.
Two identical runs against a locally-built, instrumented grass produced
244 differing lcov lines, all inside r.univar_main.c's outlined OpenMP
region. This is the collision reported previously as coverage of
untouched files changing between runs.

Also tightens utils/llvm_coverage_export.sh so its -object list only
picks up real ELF / Mach-O binaries. The previous perm -u+x sweep
would pull in executable Python scripts (e.g. installed grass
scripts), and llvm-cov aborts on the first one it cannot recognize.

A full local rebuild + rerun is queued to verify the diff closes; if
anything remains after -fprofile-update=atomic it will land as a
follow-up commit on this branch.
claude added 8 commits August 31, 2026 23:03
Clang's SourceBasedCodeCoverage doc explicitly states that llvm
optimizations (inlining, CFG simplification) have no impact on
coverage report quality: the mapping from source regions to profile
counters is immutable and is generated before the optimizer runs, and
the feature does not rely on debug info line tables. Force -O0 -g was
gcov guidance, not the right choice here, and it inflated the
instrumented build size and the runtime cost this PR promised to
keep small.

Also fix the Linux path: setting CFLAGS explicitly at configure time
suppresses autoconf's default "-g -O2", so include -O2 in the coverage
CFLAGS (macOS already sets -O2 explicitly before the coverage block).
The pytest workflow's Ubuntu build failed at configure with "Unable to
locate OpenMP includes" once GRASS_LLVM_COVERAGE=1 flipped the compiler
to clang. Ubuntu's libomp-dev keeps omp.h at
/usr/lib/llvm-18/lib/clang/<ver>/include/omp.h rather than /usr/include,
and configure's plain omp.h probe doesn't add clang's own resource-dir
to its include search. Fix by passing --with-openmp-includes derived
from `$CC -print-resource-dir` when the coverage build is active. Also
add libomp-dev to apt.txt so the runtime lib is linkable.

Make the Python coverage combine step tolerate "no data" so that when
an earlier step fails and pytest is skipped, the combine step reports
"nothing to export" instead of failing on top of the real error.
The pytest workflow's Ubuntu build now clears the OpenMP-include probe
but fails to link every OpenMP-using tool (r.mapcalc, r.univar,
r.slope.aspect, r.sun, ...) once GRASS_LLVM_COVERAGE=1 flipped the
compiler to clang. Root cause: libomp-dev on Ubuntu 24.04 installs the
unversioned libomp.so at /usr/lib/llvm-18/lib, which is not in the
linker's default search path. configure's link probe for
omp_get_num_threads in -lomp therefore fails and it falls back to
-lgomp - but clang's OpenMP-instrumented code emits __kmpc_* calls that
gomp doesn't provide. Pass --with-openmp-libs=<clang's llvm libdir> so
configure finds -lomp and every OMP tool links cleanly. The libdir is
derived from clang -print-resource-dir so the fix is not tied to any
specific LLVM version number.
Adding clang, llvm, and libomp-dev to the shared apt.txt slowed down
every workflow that reads that list (ubuntu.yml, gcc.yml, codeql,
cmake, docs...) for a toolchain none of them need. Move them into a
new "Install LLVM coverage toolchain" step in pytest.yml, gated on
env.GRASS_LLVM_COVERAGE == '1', so only the coverage matrix pays the
apt cost.
Instrumenting the build costs ~1 min extra compile time and adds a
merge/export step. Running it in every matrix cell duplicated all of
that for no additional information. Gate GRASS_LLVM_COVERAGE, the
llvm/libomp toolchain install, the merge/export step, and the
ubuntu-llvm-c Codecov upload on matrix.python-version == '3.13' so
the 3.11 cell now takes the fast gcc/no-instrumentation path. Python
coverage still runs on both cells and continues to upload under its
own per-version flag.
utils/llvm_coverage_export.sh globbed all .profraw files onto
llvm-profdata's argv. The per-process %p-%9m naming produces one file
per (binary, pid) pair, and a full test run generates hundreds of
thousands of them (454754 on the macOS job that failed), which
overflows the OS argument-list limit on both the Linux pytest and
macOS jobs. Pass the file list via llvm-profdata's -f option (a
newline-separated list file) instead of argv.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P53N6mt8mDNedgUDBcYqfW
Resolves a conflict in .github/workflows/pytest.yml between this
branch's new "Install LLVM coverage toolchain" step and main's
routine version-pin bump of rui314/setup-mold: keeps both.
The macOS job's "Uninstalling Homebrew" step removes gpg early on,
same as it always has. The pre-existing "Upload test results to
Codecov" step already sets skip_validation: true to work around
this, but the two new coverage-upload steps this PR added did not,
so codecov-action's own dependency check failed before it could
upload anything. Match the existing step's skip_validation: true.
echoix added a commit that referenced this pull request Oct 1, 2026
Instrument the Ubuntu build with clang under GRASS_LLVM_COVERAGE,
enabled only on the Python 3.13 matrix cell so the 3.11 cell keeps the
default gcc build. LLVM_PROFILE_FILE encodes pid and a hash per
binary so xdist workers and C-tool subprocesses never collide on one
file, and -fprofile-update=atomic makes counter increments atomic in
OpenMP-parallel code (r.univar). Both were needed to make merged
coverage deterministic between runs.

utils/llvm_coverage_export.sh merges .profraw into .profdata and
exports lcov for Codecov. It passes the file list to llvm-profdata via
-f instead of argv, since a full test run's per-process .profraw
count overflows the shell argument-list limit. utils/
coverage_disk_usage.sh logs .profraw count/size and disk usage before
the merge for observability.

Also fixes coverage_mapper.py running before coverage combine, which
left it unable to read a .coverage file that parallel mode had not
created yet.

This is the Linux/pytest slice of #674, which also instruments macOS;
macOS is out of scope here.

AI assistance (Claude Code) helped draft the workflow and script
changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants