Plan: LLVM source-based code coverage for ponyc #5536
SeanTAllen
started this conversation in
pony-cov
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
This supersedes the earlier rough outline (discussion #4848). It corrects a
load-bearing error in that outline and lays out a staged, de-risked plan.
What this delivers
ponyc --coverage ./my-testsproduces a binary that, when run, records whichPony expressions and branches executed. Standard LLVM tooling turns that into a
per-line, per-region, per-branch report:
This is LLVM source-based coverage (the
llvm-cov showecosystem), chosenover gcov-style coverage. Source-based gives per-region and true branch
coverage and embeds its mapping in the binary; the cost is that ponyc has to
produce that mapping itself (see below). gcov would be a smaller change but
gives only line-level coverage derived from debug info, and we want the richer
report.
The correction to the earlier outline
The earlier outline proposed adding only LLVM's
InstrProfilingLoweringPassplus linking the profiling runtime, estimated at "50-150 lines." That is not
sufficient, and the reason matters for the whole plan.
InstrProfilingLoweringPassis a lowering pass. Its own header says it"lowers the profile instrumented code generated by FE or the IR based
instrumentation pass." It lowers
llvm.instrprof.*intrinsics that somethingelse already inserted. ponyc's IR contains no such intrinsics today, so the
pass would be a no-op and the profile would be empty.
llvm-cov showneeds two things that ponyc does not currently produce:llvm.instrprof.incrementintrinsics placed at the entry of eachcounted region, so the runtime knows what to count.
__llvm_covmap/__llvm_covfunsections thatrecord, for each function, which counter covers which source span and how
counters combine for branches (e.g. else-count = body-count − if-count).
Both are produced by the compiler frontend. In clang this is
clang/lib/CodeGen/CoverageMappingGen.cpp(~2700 lines) plusCodeGenPGO.cpp(~1600 lines), and it is tied to clang's C/C++ AST, so it isnot reusable. ponyc must grow its own equivalent against the Pony AST, using
LLVM's coverage-mapping writer API (
llvm/ProfileData/Coverage/).So the real shape is: a new coverage-emission component in ponyc's codegen
(the bulk of the work), plus the pass wiring and runtime linking the outline
already identified (the easy part). The outline described the easy part and
called it the whole job.
DWARF debug info is not the foundation here. ponyc's existing DWARF is what a
gcov-style approach would have reused; source-based coverage carries its own
source locations in the coverage mapping and does not derive them from DWARF.
Prior art that de-risks the runtime half
The runtime-and-linking half of this work is not new ground. ponyc already
builds itself with coverage instrumentation via the
use=coverageMake option(
CMakeLists.txtcompiles ponyc with-fprofile-instr-generate -fcoverage-mappingunderPONY_USE_COVERAGE), and #5528 added CI smoke teststhat confirm the instrumented binary emits
default.profraw. Separately,genexe.ccalready links a coverage runtime through embedded LLD: itsPONY_COVERAGEblock adds-lgcovto programs when ponyc was built with gcovcoverage, because libponyrt then carries gcov constructors. Neither path is the
feature proposed here — both instrument ponyc/libponyrt, not user Pony
programs, and the new work is the coverage-mapping emission against the Pony AST.
What they establish is narrower than "the profile runtime is ready": they prove
coverage-style runtimes already link into Pony programs (libgcov via embedded
LLD; clang's profile runtime via the compiler driver in ponyc's own build) and
that vendored compiler-rt sources build (the crt objects come from
compiler-rt/lib/builtins). The vendoredcompiler-rt/lib/profileis notbuilt anywhere today — whether it compiles and links via embedded LLD is
exactly what Stage 0 pins down. So this is real risk reduction on the linking
approach, not proof the runtime is in hand.
Note the naming adjacency: the build-time
use=coverage/PONY_USE_COVERAGE(coverage of ponyc) is a different thing from the proposed
--coveragecompiler flag (coverage of a user's program). Docs must keep the two distinct
so users and contributors don't conflate them.
How the pieces fit
At compile time, for each Pony function ponyc emits:
mapping),
llvm.instrprof.increment(name, hash, num_counters, index)at each regionentry,
Then:
InstrProfilingLoweringPasslowers the intrinsics into the counter datasections (
__profc_*,__profd_*,__llvm_prf_names).lib/llvm/src/compiler-rt/lib/profile/) writesdefault.profrawat exit.llvm-profdata+llvm-covread the binary's coverage mapping plus theprofile to produce the report.
Design decisions
The flag
Add
bool coveragetopass_opt_t(src/libponyc/pass/pass.h) and a--coverageoption insrc/libponyc/options/options.c, following the existingOPT_STRIP/OPT_LINT_LLVMboolean-flag pattern.A bare
--coverage(not--coverage=<format>) is the right v1 surface. We havechosen source-based; gcov is a possible future format. It is easier to add a
format selector later than to remove one, so we start with the bare flag.
Interaction with
--releaseand optimization (decided: error)genopt.ccforcesstrip_debug = trueand builds theO3pipeline for--release; otherwise it builds theO0pipeline. Optimization degradescoverage accuracy (inlining and region merging blur which source region ran).
strip_debugis unlikely to remove the coverage-mapping sections (they are notdebug metadata) — but this is unverified and only matters if optimized coverage
is pursued later; it should be confirmed as part of that future work, not
assumed now. An optimized build also produces misleading coverage regardless.
Options for
--coverage+--release:does something other than what
--releaseasked.-O2 -fcoverage-mapping;results are valid but coarser. Most work, least clear for v1.
Decided: (a) error for v1.
--coveragecombined with--releaseis a harderror. This avoids the optimized-accuracy rabbit hole and keeps semantics
obvious; optimized coverage is possible later work.
Independently: when
--coverageis set, the lowering pass must run. It isregistered via
registerOptimizerLastEPCallback— the same extension point theexisting strip pass uses. That extension point does fire in the O0 pipeline
(
buildO0DefaultPipelineinvokes the optimizer-last callbacks, the same reason--stripworks in debug builds), so registering it there suffices and keeps thewiring consistent with the strip pass rather than special-casing it.
Which constructs get regions
This is the bulk of the work and the main correctness surface. Counted regions
are needed for, at minimum:
if/elseif/elsearms,matchcases, including guards,while,repeat,forbodies,try/else/thenblocks,and/oroperands.Pony's implicit control flow also needs handling: partial calls (
?),return,error,break,continuecreate gap/skipped regions that the mapping mustrepresent so counts after them are correct. Getting this complete and correct is
where bugs in coverage output come from, so it is built and validated
incrementally (see stages), not all at once.
Source files and the stdlib
A Pony program is whole-program compiled into one LLVM module spanning many
files and packages. The coverage mapping supports multiple file IDs per
function, so ponyc maintains a file-id registry and tags each region with its
file. v1 emits mapping for all compiled code, including the stdlib; users filter
the report with
llvm-cov's-ignore-filename-regex. A ponyc-side stdlibfilter is possible future work. (Without filtering, reports are dominated by
stdlib noise — this is documented, not silently left as a surprise.)
Tooling availability — building the LLVM tools (decided)
The workflow needs
llvm-covandllvm-profdata. ponyc's CI currently buildsLLVM with
llvm_tools=false(an option added solely to keep the old build cachesmall by omitting the ~1.6 GB of tool binaries ponyc never links). That reason
is obsolete: the libs are cached in GHCR now, where the size doesn't matter.
Decided: remove the
llvm_toolsoption entirely as part of this work andbuild the tools everywhere. That gives CI
llvm-cov/llvm-profdataso thecoverage tests can run end to end (see testing), and it incidentally fixes the
runtime-bitcodebuild, which needsllvm-linkand fails today with the toolsoff. The removal touches:
Makefile,make.ps1,lib/CMakeLists.txt, everyworkflow libs-build invocation (
llvm_tools=false/-LlvmTools falseacrosspr,nightlies,release,ponyc-tier2/tier3,ponyc-weekly-checks,update-lib-cache, and the Windows variants), and the two coupling notes thatdocument the flag (
AGENTS.md,.github/workflows/AGENTS.md). Because the editchanges hashed build files (
Makefile,make.ps1,lib/CMakeLists.txt), thelibs-cache key rotates, so new tools-on artifacts don't collide with the cached
tools-off ones — the change busts the cache cleanly.
This resolves tooling for CI. Shipping the tools to end users (so a released
ponyc carries
llvm-cov) is a separate packaging question and remains futurework; for now a user viewing coverage reports supplies matching LLVM tools.
Staged implementation
Each stage is independently mergeable and independently validated. Stage 0 is a
throwaway spike; the rest are real.
Stage 0 — Spike (throwaway, de-risks everything)
Prove the end-to-end pipeline before building the region emitter. Hand-emit, for
a trivial Pony program, function-entry
instrprof.incrementintrinsics and aminimal coverage mapping covering just function bodies. Wire the lowering pass,
build and link the profiling runtime, run the program, and confirm
llvm-cov showreports function execution against the.ponysource.What the spike pins down (all specific to the vendored LLVM major, currently
22):
encoding the
CoverageMappingWriterproduces,non-empty
.profraw,llvm-covreads our mapping.If any of these fight us, we learn it here, cheaply, before investing in region
emission. The spike is deleted once Stage 1 reimplements it properly.
Stage 1 — Plumbing + function-level coverage (real)
Remove the
llvm_toolsoption and build the tools everywhere (see"Tooling availability" above). This lands first because the Stage 1 end-state
check and all later coverage tests need
llvm-cov. Edit the hashed build files(
Makefile,make.ps1,lib/CMakeLists.txt) so the libs-cache key rotates,strip
llvm_tools=false/-LlvmTools falsefrom every workflow libs build,and update the two coupling notes (
AGENTS.md,.github/workflows/AGENTS.md).--coverageflag andpass_opt_t.coverage.New C++ codegen component (e.g.
src/libponyc/codegen/gencoverage.cc/.h,C++ because the LLVM coverage APIs are C++) exposing C-callable hooks the
C codegen calls. It owns the file-id registry, per-function counter
allocation, intrinsic emission, and the module-level coverage-mapping global.
Function-entry counters and mapping only (no inner regions yet).
genopt.cc: registerInstrProfilingLoweringPassviaregisterOptimizerLastEPCallbackgated onopt->coverage— the sameextension point the strip pass uses (the pipeline is built and run wholesale,
so passes are injected through these callbacks, not appended afterward).
Handle the
--releaseinteraction per the decision above.New
src/profile_rt/CMakeLists.txtbuilding the profiling runtime fromlib/llvm/src/compiler-rt/lib/profile/(the right platform files per OS),following the
src/crt/CMakeLists.txtpattern, shipped alongside libponyrt.Install/packaging (not uniform across platforms — read carefully): a
build artifact that ponyc links must be both shipped by
make installandlocatable at link time, or it works in the build tree and vanishes from a
released ponyc (the binary-relative-artifact failure mode the gencshim "Clang
resource directory" coupling warns about). The precedents differ by platform:
installtarget copies shipped artifacts intothe ponyc lib dir (as it does for the crt objects), and the ELF linker
locates them via
find_ponyc_crt_dirover the program lib search path.Follow that precedent for the profile runtime.
src/crtis Linux-gated and theCOFF linker has no
find_ponyc_crt_dir; it resolves libs by bare name over/LIBPATHentries. So Windows needs its own packaging + discovery: a rootCMakeLists.txtinstall()rule (the pattern the clang-headers/clang-dirinstalls already use for cmake-driven Windows installs) plus a
/LIBPATHentry for the ponyc lib dir in
link_exe_lld_coff. Treat this as its ownsub-step, not parity with crt.
Verify a
make install(and Windowsmake.ps1install) tree can link a--coverageprogram.genexe.cc: link the profiling runtime in each of the three embedded-LLDlinker paths —
link_exe_lld_elf(Linux/BSDs),link_exe_lld_macho(macOS),link_exe_lld_coff(Windows). There is no external-linker path; everynon-LLD target is already a hard error. The existing
PONY_COVERAGE-lgcovblock in
link_exe_lld_elfis the model for where this slots in on ELF.End state:
llvm-cov showreports which functions ran. This is already usefuland proves the kept (non-spike) path.
Stage 2 — Control-flow regions (real, incremental)
Add region emission one construct family at a time, validating each with
llvm-cov showbefore moving on:if/elseif/else, thenmatch(+ guards),then loops, then
try, then short-circuitand/or, then theimplicit-control-flow gap regions (
?,return,error,break,continue).Per-construct counterfactual tests (see below). This staging keeps each change
small and its coverage output checkable.
Stage 3 — Polish and ship
--coverage+--releaseerror (the decided behavior).--helptext; mention in relevant build/usage docs.examples/entry: a small program plus a README showing thecompile/run/report workflow (per the project convention that feature plans
include an example, and that examples directories keep a README).
.release-notes/file (user-facing "added"; VERSION isnot 0.0.0).
Testing strategy
Because the
llvm_toolsremoval putsllvm-cov/llvm-profdatain every CIbuild (see "Tooling availability"), the coverage tests run end to end in CI —
no special tool-acquisition step, no two-tier split. Tests at three levels:
CodegenTestinlibponyc.tests): compile a snippetwith coverage on and inspect the module —
instrprof.incrementintrinsicspresent, a
__llvm_covmapglobal present, counter count matches theconstruct. Catches instrumentation regressions without running anything.
--coverage, runthe binary, then
llvm-profdata merge+llvm-cov exportand assert thepipeline produces a non-empty report. This is the Add use=coverage smoke tests to CI #5528-style smoke test for
this feature; it exercises the compile → run → profdata → cov path.
where one path runs and another does not, asserting via
llvm-cov exportthatthe unexecuted region reports zero. This is the assertion that actually proves
the region mapping is correct, and now that CI has the tools it is
regression-protected rather than local-only.
The version coupling still matters: the
llvm-covused must match thecoverage-mapping format ponyc emits — which it does by construction now, since
both come from the same vendored LLVM.
Risks and open questions
llvm-covmust match. Pinned in the spike; revisited on every LLVM upgrade(worth a note in the upgrade-llvm process).
partiality and early exits are the most error-prone part; counterfactual
tests per construct are the guard.
platform-specific files and the link must work across all three embedded-LLD
paths (
link_exe_lld_elf,link_exe_lld_macho,link_exe_lld_coff). Linuxfirst; macOS and Windows validated before claiming support. On Windows,
whether
InstrProfilingPlatformWindows.cneeds SDK symbols beyond whatvcvars.default_libs/ kernel32 already provide is a spike unknown (it doesnot trip the vcvars discovery coupling — adding a library to the link consumes
existing
/LIBPATHentries without changing howvcvars_getpopulates them).--coverage+--releaseis a hard error for v1;(2) CI gets
llvm-covby removing thellvm_toolsoption and building thetools everywhere (the option's cache-size purpose is obsolete under GHCR
caching), done by editing the hashed build files so the libs-cache key rotates
cleanly.
Explicitly out of scope (future work)
metadata; separate effort.
--coverageformat.pony-covtool that understands Pony semantics (stdlib filtering, groupingby package) and/or wraps the LLVM tools.
llvm-cov export -format=lcov.--release) coverage.All reactions