From 7897ba29cbc17cb2798f3582cd2730f092a1ef32 Mon Sep 17 00:00:00 2001 From: Edward Yang Date: Tue, 14 Jul 2026 10:05:27 +1000 Subject: [PATCH 01/10] Add GPU-port coverage tracking tool and CI workflow track_gpu_port.py cross-references gcov execution coverage against GPU-ported source regions (do concurrent, omp target directives, manual @gpu-ported/@start noport/toport markers) to report which executed, GPU-portable lines still need porting. gpu-port-report.yml runs it in CI against a short benchmark_ALE run built with --coverage instrumentation. --- .github/workflows/gpu-port-report.yml | 76 +++ .testing/tools/track_gpu_port.py | 809 ++++++++++++++++++++++++++ 2 files changed, 885 insertions(+) create mode 100644 .github/workflows/gpu-port-report.yml create mode 100644 .testing/tools/track_gpu_port.py diff --git a/.github/workflows/gpu-port-report.yml b/.github/workflows/gpu-port-report.yml new file mode 100644 index 0000000000..bf776e2818 --- /dev/null +++ b/.github/workflows/gpu-port-report.yml @@ -0,0 +1,76 @@ +# This file is part of MOM6, the Modular Ocean Model version 6. +# See the LICENSE file for licensing information. +# SPDX-License-Identifier: Apache-2.0 + +name: GPU port coverage report + +on: [push, pull_request] + +jobs: + gpu-port-report: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout this MOM6 revision + uses: actions/checkout@v4 + with: + path: mom6-src + + - uses: ./mom6-src/.github/actions/ubuntu-setup/ + + - name: Clone MOM6-gpu/MOM6-examples + run: git clone https://github.com/MOM6-gpu/MOM6-examples.git MOM6-examples + + - name: Fetch only the submodules needed for the ocean_only build + working-directory: MOM6-examples + run: git submodule update --init src/FMS2_gpu shared/tools/f90nml + + - name: Swap in this revision's MOM6 source + run: | + rm -rf MOM6-examples/src/MOM6 + cp -a mom6-src MOM6-examples/src/MOM6 + + - name: Build MOM6 with gcov instrumentation + working-directory: MOM6-examples/ocean_only + env: + FCFLAGS: --coverage + LDFLAGS: --coverage + PYTHON: python3 + run: make -j + + - name: Shorten benchmark_ALE to a single short run + run: echo '#override DAYMAX = 0.5' >> MOM6-examples/ocean_only/benchmark_ALE/MOM_override + + - name: Run benchmark_ALE + working-directory: MOM6-examples/ocean_only + env: + PYTHON: python3 + run: make run.benchmark_ALE + + - name: Generate gcov reports + working-directory: MOM6-examples/ocean_only/build + run: gcov -b *.gcda + + - name: Generate GPU port coverage report + run: | + python3 MOM6-examples/src/MOM6/.testing/tools/track_gpu_port.py \ + --src-root MOM6-examples/src/MOM6 \ + --gcov-dir MOM6-examples/ocean_only/build \ + --out-md gpu_port_report.md \ + --github-annotations + + - name: Publish report to job summary + if: always() + run: | + if [ -f gpu_port_report.md ]; then + cat gpu_port_report.md >> "$GITHUB_STEP_SUMMARY" + fi + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: gpu-port-report + path: gpu_port_report.md + retention-days: 14 + if-no-files-found: ignore diff --git a/.testing/tools/track_gpu_port.py b/.testing/tools/track_gpu_port.py new file mode 100644 index 0000000000..a45ba2010a --- /dev/null +++ b/.testing/tools/track_gpu_port.py @@ -0,0 +1,809 @@ +#!/usr/bin/env python3 +"""Cross-reference gcov execution coverage against GPU-ported source regions. + +Ported-region detection is automatic for: + - `do concurrent` ... `enddo`/`end do` blocks + - `!$omp target` / `!$omp target teams` block constructs (paired with their + `end target[ teams]`) + - `!$omp target teams loop`, `!$omp target teams distribute parallel do`, + and bare `!$omp loop` combined constructs (attached to the following + do-loop, which has no separate closing directive) + +`!$omp target data` / `end target data` brackets a data environment, not a +compute region, so its contents are NOT auto-marked ported (only nested +compute directives inside it are). `!$omp target enter/exit data` and +`!$omp target update` are single-statement data-movement directives, counted +separately from compute regions. + +For code with no directive at all (e.g. pure/elemental helpers invoked from +device code), wrap it manually: + + ! @gpu-ported-start + ... + ! @gpu-ported-end + +Executed-but-unported lines are further split into "portable" and +"not portable" so the completion metric isn't diluted by code that will +never move to the GPU: + - portable: lines inside any `do` loop nest (concurrent or not — a plain + do-loop is exactly the kind of code a port targets), or whole-array + Fortran assignment syntax (`h(:,:,:) = 0.0`) + - not portable: `allocate`/`deallocate`, I/O statements, block-if + control-flow keywords (`if (...) then`, `else if (...) then`, `else`, + `end if`), and `call` statements — always, even if they appear inside a + loop — plus everything else that executes outside any loop nest — + scalar setup, control-flow bookkeeping, subroutine-entry index + computation, and the like. A single-line `if (cond) stmt` is NOT + excluded — the statement half may be real work. + +The structural heuristic above is necessarily approximate, so it can be +overridden by hand in either direction: + + !@start noport a loop (or any region) that will never be ported + ... — e.g. serial halo-exchange bookkeeping, a tiny + !@end noport setup loop not worth offloading. Removed from + "portable" even if it structurally looks like a + loop/array-assignment. + + !@start toport non-loop code that genuinely needs porting work + ... — e.g. a scalar reduction to restructure for the + !@end toport device. Added to "portable" even though it + doesn't match the structural heuristic. + +If `!@start noport`/`!@start toport` is immediately followed by a do-loop +(nothing but comments/blank lines in between), it attaches to that loop and +needs no matching `!@end` at all — the loop's own `end do` closes it, same +as how `!$omp loop` attaches to the following loop with no closing +directive of its own: + + !@start noport + do i = 1, n + ... + enddo + +Anything else (not immediately followed by a loop) falls back to requiring +an explicit, balanced `!@end noport`/`!@end toport`. Writing an explicit end +after a marker that already auto-attached to a loop is an error (it's +orphaned — the loop already closed it). + +`noport` always wins over both the structural heuristic and `toport` if +they overlap. Markers nest like `@gpu-ported-start`/`-end` and must be +balanced within a file. + +Usage: + track_gpu_port.py --src-root --gcov-dir [--out-md FILE] [--out-json FILE] + track_gpu_port.py --src-root --validate-only + +Both the markdown and JSON reports carry exact line RANGES (not just counts) +for "portable, not yet ported" and "already ported" code, per file and per +routine — this is the authoritative "which lines" answer. + + - `--github-annotations` emits GitHub Actions `::notice + file=...,line=...,endLine=...::` workflow commands straight from the + same line ranges — these render natively on a PR's "Files changed" tab + with zero dependency on hosting an HTML artifact, and are the + recommended CI integration. +""" + +import argparse +import json +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path + +DO_OPEN_RE = re.compile(r'^\s*(?:\w+\s*:\s*)?do\b', re.IGNORECASE) +DO_CONCURRENT_RE = re.compile(r'^\s*(?:\w+\s*:\s*)?do\s+concurrent\b', re.IGNORECASE) +END_DO_RE = re.compile(r'^\s*end\s*do\b', re.IGNORECASE) + +OMP_RE = re.compile(r'^\s*!\$omp\s+(.*)$', re.IGNORECASE) +TAG_START_RE = re.compile(r'^\s*!\s*@gpu-ported-start\s*$', re.IGNORECASE) +TAG_END_RE = re.compile(r'^\s*!\s*@gpu-ported-end\s*$', re.IGNORECASE) +NOPORT_START_RE = re.compile(r'^\s*!\s*@start\s+noport\s*$', re.IGNORECASE) +NOPORT_END_RE = re.compile(r'^\s*!\s*@end\s+noport\s*$', re.IGNORECASE) +TOPORT_START_RE = re.compile(r'^\s*!\s*@start\s+toport\s*$', re.IGNORECASE) +TOPORT_END_RE = re.compile(r'^\s*!\s*@end\s+toport\s*$', re.IGNORECASE) + +SUBPROG_START_RE = re.compile( + r'^\s*(?:recursive\s+)?(?:pure\s+)?(?:elemental\s+)?(?:module\s+)?' + r'(subroutine|function)\s+(\w+)', re.IGNORECASE) +SUBPROG_END_RE = re.compile(r'^\s*end\s*(subroutine|function)\s*(\w+)?', re.IGNORECASE) + +# "Not portable" overrides: memory management and I/O never belong on the +# device, even when they happen to sit inside a loop nest. +ALLOC_RE = re.compile(r'^\s*(allocate|deallocate)\s*\(', re.IGNORECASE) +IO_RE = re.compile( + r'^\s*(write|read|open|close|inquire|rewind|backspace|endfile)\s*[\(\*]', re.IGNORECASE) +PRINT_RE = re.compile(r'^\s*print\b', re.IGNORECASE) + +# Block-if control-flow lines are pure branching, not work — never a porting target on +# their own, even inside a loop. Single-line "if (cond) stmt" is NOT matched here since +# the statement half may be real work; only the block-if keywords themselves are excluded. +IF_THEN_RE = re.compile(r'^\s*(?:\w+\s*:\s*)?if\s*\(.*\)\s*then\s*$', re.IGNORECASE) +ELSEIF_RE = re.compile(r'^\s*else\s*if\s*\(.*\)\s*then\s*$', re.IGNORECASE) +ELSE_RE = re.compile(r'^\s*else\s*$', re.IGNORECASE) +ENDIF_RE = re.compile(r'^\s*end\s*if(?:\s+\w+)?\s*$', re.IGNORECASE) + +# A "call" statement invokes a subroutine outside the current unit — not itself GPU +# work, even inside a loop (the callee, if it needs porting, is tracked separately +# where it's defined). +CALL_RE = re.compile(r'^\s*call\s+\w', re.IGNORECASE) + +# Whole-array Fortran assignment, e.g. "h(:,:,:) = 0.0" — a portable +# construct even outside an explicit do-loop. +ARRAY_ASSIGN_RE = re.compile(r'^\s*[A-Za-z_]\w*\s*\([^=()]*:[^=()]*\)\s*=[^=]') + + +@dataclass +class FileRegions: + ported: set = field(default_factory=set) + directive: set = field(default_factory=set) + manual: set = field(default_factory=set) + loop: set = field(default_factory=set) # any do-loop body, ported or not + array_syntax: set = field(default_factory=set) # whole-array assignment lines + nonportable: set = field(default_factory=set) # allocate/deallocate/IO override + manual_noport: set = field(default_factory=set) # "!@start noport" — force-excluded + manual_toport: set = field(default_factory=set) # "!@start toport" — force-included + warnings: list = field(default_factory=list) + + @property + def portable(self): + """Structurally portable lines (loop bodies or array syntax), overrides excluded. + + Manual markers win over the structural heuristic: `toport` force-includes lines + the heuristic would otherwise skip (e.g. scalar code); `noport` force-excludes + lines the heuristic would otherwise include, and wins if both are present. + """ + auto = (self.loop | self.array_syntax) - self.nonportable + return (auto | self.manual_toport) - self.manual_noport + + +class StructuralError(Exception): + pass + + +def strip_comment(text): + """Remove an unquoted trailing '!' comment from a plain Fortran statement.""" + in_quote = None + for i, ch in enumerate(text): + if in_quote: + if ch == in_quote: + in_quote = None + elif ch in ("'", '"'): + in_quote = ch + elif ch == '!': + return text[:i] + return text + + +def split_statements(text): + """Split a physical line on unquoted ';' into individual statements.""" + parts = [] + buf = [] + in_quote = None + for ch in text: + if in_quote: + buf.append(ch) + if ch == in_quote: + in_quote = None + elif ch in ("'", '"'): + in_quote = ch + buf.append(ch) + elif ch == ';': + parts.append(''.join(buf)) + buf = [] + else: + buf.append(ch) + parts.append(''.join(buf)) + return parts + + +def iter_logical_lines(lines): + """Join '&'-continued physical lines. Yields (start, end, joined_text), 1-indexed inclusive.""" + i = 0 + n = len(lines) + while i < n: + start = i + raw = lines[i].rstrip('\n') + text = raw + stripped = raw.strip() + is_omp = bool(OMP_RE.match(raw)) or stripped.lower().startswith('!$omp') + while text.rstrip().endswith('&') and i + 1 < n: + i += 1 + nxt = lines[i].rstrip('\n') + nxt_body = nxt + if is_omp: + m = re.match(r'^\s*!\$omp\s?', nxt, re.IGNORECASE) + if m: + nxt_body = nxt[m.end():] + text = text.rstrip()[:-1] + ' ' + nxt_body.strip() + yield start + 1, i + 1, text + i += 1 + + +def classify_omp(body): + """Classify the body of an '!$omp ...' directive (text after the sentinel).""" + norm = re.sub(r'\s+', ' ', body).strip().lower() + if norm.startswith('end target teams'): + return 'end_teams' + if norm.startswith('end target data'): + return 'end_data' + if norm.startswith('end target'): + return 'end_target' + if norm.startswith('target data'): + return 'open_data' + if norm.startswith('target enter data') or norm.startswith('target exit data') \ + or norm.startswith('target update'): + return 'directive_line' + if norm.startswith('target teams') and re.search(r'\b(loop|distribute)\b', norm): + return 'combined' + if norm.startswith('target teams'): + return 'open_teams' + if norm.startswith('target parallel') and re.search(r'\b(loop|do)\b', norm): + return 'combined' + if norm.startswith('target') and re.search(r'\bloop\b', norm): + return 'combined' + if norm.startswith('target'): + return 'open_target' + if norm.startswith('loop'): + return 'loop_only' + return 'other' + + +def parse_ported_regions(path): + """Return a FileRegions describing one source file's ported/portable/nonportable lines.""" + text = path.read_text(errors='replace') + lines = text.split('\n') + if lines and lines[-1] == '': + lines = lines[:-1] + + fr = FileRegions() + manual_regions = [] + noport_regions = [] + toport_regions = [] + + do_stack = [] # entries: {'concurrent', 'start', 'parent_start', 'combined_start', + # 'noport_start', 'toport_start'} + region_stack = [] # entries: (kind, start_line) for target/teams/data blocks + tag_stack = [] # entries: start_line for manual tags + noport_stack = [] # entries: start_line for "!@start noport" not yet closed/attached + toport_stack = [] # entries: start_line for "!@start toport" not yet closed/attached + pending_combined_start = None + # If "!@start noport"/"!@start toport" is immediately followed by a do-loop, it attaches + # to that loop (closed implicitly by "end do") and needs no matching "!@end" — mirrors how + # "!$omp loop" attaches to the following loop. Otherwise it falls back to requiring an + # explicit "!@end noport"/"!@end toport" (tracked via noport_stack/toport_stack above). + pending_noport_start = None + pending_toport_start = None + # Every closed do-loop's (start, end, parent_start), used to exclude nested loops from a + # noport/toport marking — the marker should cover the loop(s) it directly names, not loops + # nested inside them (those keep their own independent classification). + all_loop_records = [] + + def mark(lo, hi, dest): + for ln in range(lo, hi + 1): + dest.add(ln) + + def shallow_span(lo, hi): + """[lo, hi] minus lines belonging to any loop nested inside another loop that is + itself within [lo, hi] — i.e. keep the loop(s) directly named by the marker, drop + loops nested inside those.""" + excluded = set() + for rec in all_loop_records: + s, e, ps = rec['start'], rec['end'], rec['parent_start'] + if s < lo or e > hi: + continue + if ps is not None and ps >= lo: + excluded.update(range(s, e + 1)) + return set(range(lo, hi + 1)) - excluded + + for start, end, text_joined in iter_logical_lines(lines): + stripped = text_joined.strip() + if not stripped or stripped.startswith('!') and not OMP_RE.match(stripped): + if stripped.startswith('!') and not OMP_RE.match(stripped): + # comment-only line; still check for manual tags + if TAG_START_RE.match(stripped): + tag_stack.append(start) + elif TAG_END_RE.match(stripped): + if not tag_stack: + raise StructuralError( + f'{path}:{start}: @gpu-ported-end with no matching -start') + tstart = tag_stack.pop() + mark(tstart, end, fr.manual) + manual_regions.append((tstart, end)) + elif NOPORT_START_RE.match(stripped): + noport_stack.append(start) + pending_noport_start = start + elif NOPORT_END_RE.match(stripped): + if not noport_stack: + raise StructuralError( + f'{path}:{start}: "!@end noport" with no matching "!@start noport"') + nstart = noport_stack.pop() + span = shallow_span(nstart, end) + fr.manual_noport.update(span) + noport_regions.append((nstart, end, span)) + if pending_noport_start == nstart: + pending_noport_start = None + elif TOPORT_START_RE.match(stripped): + toport_stack.append(start) + pending_toport_start = start + elif TOPORT_END_RE.match(stripped): + if not toport_stack: + raise StructuralError( + f'{path}:{start}: "!@end toport" with no matching "!@start toport"') + tpstart = toport_stack.pop() + span = shallow_span(tpstart, end) + fr.manual_toport.update(span) + toport_regions.append((tpstart, end, span)) + if pending_toport_start == tpstart: + pending_toport_start = None + continue + + omp_m = OMP_RE.match(text_joined) + if omp_m: + kind = classify_omp(omp_m.group(1)) + if kind == 'end_teams': + if not region_stack or region_stack[-1][0] != 'teams': + raise StructuralError(f'{path}:{start}: "end target teams" without matching open') + _, rstart = region_stack.pop() + mark(rstart, end, fr.ported) + elif kind == 'end_data': + if not region_stack or region_stack[-1][0] != 'data': + raise StructuralError(f'{path}:{start}: "end target data" without matching open') + region_stack.pop() # data region contents are not auto-ported + elif kind == 'end_target': + if not region_stack or region_stack[-1][0] != 'target': + raise StructuralError(f'{path}:{start}: "end target" without matching open') + _, rstart = region_stack.pop() + mark(rstart, end, fr.ported) + elif kind == 'open_data': + region_stack.append(('data', start)) + elif kind == 'open_teams': + region_stack.append(('teams', start)) + elif kind == 'open_target': + region_stack.append(('target', start)) + elif kind == 'directive_line': + mark(start, end, fr.directive) + elif kind in ('combined', 'loop_only'): + if kind == 'loop_only' and region_stack and region_stack[-1][0] in ('teams', 'target'): + # already inside an open ported block; nothing further to attach + pass + else: + if pending_combined_start is not None: + fr.warnings.append( + f'{path}:{pending_combined_start}: combined directive not ' + f'immediately followed by a do-loop') + pending_combined_start = start + # 'other' (plain CPU omp directives): ignored + continue + + # plain Fortran: may contain several ';'-separated statements + for stmt in split_statements(strip_comment(text_joined)): + s = stmt.strip() + if not s: + continue + if END_DO_RE.match(s): + if not do_stack: + fr.warnings.append( + f'{path}:{end}: "end do" with no matching "do" (unbalanced; skipping)') + continue + entry = do_stack.pop() + mark(entry['start'], end, fr.loop) # any do-loop body is a portable candidate + if entry['concurrent']: + mark(entry['start'], end, fr.ported) + if entry['combined_start'] is not None: + mark(entry['combined_start'], end, fr.ported) + all_loop_records.append({ + 'start': entry['start'], 'end': end, 'parent_start': entry['parent_start']}) + if entry['noport_start'] is not None: + span = shallow_span(entry['noport_start'], end) + fr.manual_noport.update(span) + noport_regions.append((entry['noport_start'], end, span)) + if entry['toport_start'] is not None: + span = shallow_span(entry['toport_start'], end) + fr.manual_toport.update(span) + toport_regions.append((entry['toport_start'], end, span)) + elif DO_OPEN_RE.match(s): + do_stack.append({ + 'concurrent': bool(DO_CONCURRENT_RE.match(s)), + 'start': start, + 'parent_start': do_stack[-1]['start'] if do_stack else None, + 'combined_start': pending_combined_start, + 'noport_start': pending_noport_start, + 'toport_start': pending_toport_start, + }) + pending_combined_start = None + if pending_noport_start is not None: + noport_stack.pop() # attached to this loop; no "!@end noport" required + pending_noport_start = None + if pending_toport_start is not None: + toport_stack.pop() # attached to this loop; no "!@end toport" required + pending_toport_start = None + else: + # marker(s) weren't immediately followed by a loop; fall back to requiring + # an explicit "!@end noport"/"!@end toport" (still open on the stack) + pending_noport_start = None + pending_toport_start = None + if (ALLOC_RE.match(s) or IO_RE.match(s) or PRINT_RE.match(s) + or IF_THEN_RE.match(s) or ELSEIF_RE.match(s) + or ELSE_RE.match(s) or ENDIF_RE.match(s) or CALL_RE.match(s)): + mark(start, end, fr.nonportable) + elif ARRAY_ASSIGN_RE.match(s): + mark(start, end, fr.array_syntax) + + if region_stack: + kinds = ', '.join(f'{k}@{ln}' for k, ln in region_stack) + raise StructuralError(f'{path}: unclosed omp target region(s): {kinds}') + if tag_stack: + raise StructuralError(f'{path}: unclosed @gpu-ported-start at line(s) {tag_stack}') + if noport_stack: + raise StructuralError(f'{path}: unclosed "!@start noport" at line(s) {noport_stack}') + if toport_stack: + raise StructuralError(f'{path}: unclosed "!@start toport" at line(s) {toport_stack}') + if do_stack: + fr.warnings.append( + f'{path}: {len(do_stack)} unbalanced "do" opener(s) at EOF (parser limitation)') + + # Flag manual tags that are already fully covered by automatic detection. + for tstart, tend in manual_regions: + span = set(range(tstart + 1, tend)) + if span and span.issubset(fr.ported): + fr.warnings.append( + f'{path}:{tstart}-{tend}: @gpu-ported tag is redundant ' + f'(region already detected automatically)') + + # Flag "noport" regions that contradict already-ported code, and "toport" + # regions that are redundant with the structural heuristic. Both checks use the + # *shallow* span (nested loops excluded) — that's what actually got marked. + auto_portable = (fr.loop | fr.array_syntax) - fr.nonportable + for nstart, nend, span in noport_regions: + check = span - {nstart, nend} # exclude the marker comment line(s) themselves + if check & fr.ported: + fr.warnings.append( + f'{path}:{nstart}-{nend}: "noport" region overlaps automatically-detected ' + f'ported code (contradiction)') + for tpstart, tpend, span in toport_regions: + check = span - {tpstart, tpend} + if check and check.issubset(auto_portable): + fr.warnings.append( + f'{path}:{tpstart}-{tpend}: "toport" tag is redundant ' + f'(region already structurally portable)') + + return fr + + +def parse_routines(path): + """Best-effort (name, start, end) for subroutines/functions, for the per-routine report.""" + text = path.read_text(errors='replace') + lines = text.split('\n') + stack = [] + routines = [] + for lineno, raw in enumerate(lines, start=1): + s = raw.strip() + if not s or s.startswith('!'): + continue + m = SUBPROG_END_RE.match(s) + if m and stack: + name, start = stack.pop() + routines.append((name, start, lineno)) + continue + m = SUBPROG_START_RE.match(s) + if m: + stack.append((m.group(2), lineno)) + return routines + + +def parse_gcov(gcov_path): + """Return (source_path_str, {line_no: exec_count_or_None}).""" + source = None + counts = {} + line_re = re.compile(r'^\s*([0-9]+|#####|-)\*?:\s*([0-9]+):') + with open(gcov_path, errors='replace') as f: + for raw in f: + m = line_re.match(raw) + if not m: + continue + count_s, lineno_s = m.group(1), m.group(2) + lineno = int(lineno_s) + if lineno == 0: + sm = re.search(r'Source:(.*)$', raw) + if sm: + source = sm.group(1).strip() + continue + if count_s == '-': + continue # non-executable line + count = 0 if count_s == '#####' else int(count_s) + counts[lineno] = max(count, counts.get(lineno, 0)) + return source, counts + + +def resolve_source(gcov_source, src_root, index): + """Map a gcov Source: path onto a real file under src_root.""" + p = Path(gcov_source) + if p.is_absolute() and p.exists(): + try: + return p.resolve() + except OSError: + return p + candidates = index.get(p.name, []) + if len(candidates) == 1: + return candidates[0] + if len(candidates) > 1: + # prefer the one whose path shares the longest suffix with gcov_source + parts = p.parts + best, best_score = None, -1 + for c in candidates: + cparts = c.parts + score = 0 + for a, b in zip(reversed(parts), reversed(cparts)): + if a == b: + score += 1 + else: + break + if score > best_score: + best, best_score = c, score + return best + return None + + +def ranges_from_lines(line_set): + """Collapse a set of line numbers into sorted, contiguous [start, end] ranges.""" + if not line_set: + return [] + lines = sorted(line_set) + ranges = [] + start = prev = lines[0] + for ln in lines[1:]: + if ln == prev + 1: + prev = ln + else: + ranges.append([start, prev]) + start = prev = ln + ranges.append([start, prev]) + return ranges + + +def format_ranges(ranges): + return ', '.join(str(lo) if lo == hi else f'{lo}-{hi}' for lo, hi in ranges) + + +def build_index(src_root): + index = {} + for p in Path(src_root).rglob('*.F90'): + index.setdefault(p.name, []).append(p) + for p in Path(src_root).rglob('*.f90'): + index.setdefault(p.name, []).append(p) + return index + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--src-root', required=True, help='Root of the MOM6 source tree to scan for ported regions') + ap.add_argument('--gcov-dir', help='Directory containing .gcov files (recursively searched)') + ap.add_argument('--out-md', help='Write markdown report here (default: stdout)') + ap.add_argument('--out-json', help='Write JSON snapshot here') + ap.add_argument('--validate-only', action='store_true', + help='Only run the source-tree parser and report structural errors/warnings') + ap.add_argument('--github-annotations', action='store_true', + help='Emit GitHub Actions ::notice file=...,line=...,endLine=...:: workflow ' + 'commands for each executed, portable, not-yet-ported line range — shows ' + 'up natively on the PR "Files changed" diff, no HTML artifact needed') + args = ap.parse_args() + + src_root = Path(args.src_root).resolve() + all_warnings = [] + ported_by_file = {} # resolved Path -> FileRegions + errors = [] + + src_files = sorted(set(src_root.rglob('*.F90')) | set(src_root.rglob('*.f90'))) + for f in src_files: + try: + fr = parse_ported_regions(f) + except StructuralError as e: + errors.append(str(e)) + continue + ported_by_file[f] = fr + all_warnings.extend(fr.warnings) + + if errors: + print('STRUCTURAL ERRORS (must fix before this report is trustworthy):', file=sys.stderr) + for e in errors: + print(f' {e}', file=sys.stderr) + + if args.validate_only: + for w in all_warnings: + print(f'WARNING: {w}', file=sys.stderr) + total_ported = sum(len(fr.ported) for fr in ported_by_file.values()) + total_directive = sum(len(fr.directive) for fr in ported_by_file.values()) + total_manual = sum(len(fr.manual) for fr in ported_by_file.values()) + total_portable = sum(len(fr.portable) for fr in ported_by_file.values()) + total_nonportable = sum(len(fr.nonportable) for fr in ported_by_file.values()) + total_noport = sum(len(fr.manual_noport) for fr in ported_by_file.values()) + total_toport = sum(len(fr.manual_toport) for fr in ported_by_file.values()) + print(f'Parsed {len(ported_by_file)} files: ' + f'{total_ported} auto-ported compute lines, ' + f'{total_directive} data-movement directive lines, ' + f'{total_manual} manually tagged lines, ' + f'{total_portable} portable-structure lines (loop bodies / array syntax), ' + f'{total_nonportable} allocate/IO override lines, ' + f'{total_noport} manually excluded (noport) lines, ' + f'{total_toport} manually included (toport) lines, ' + f'{len(all_warnings)} warnings, {len(errors)} structural errors.') + sys.exit(1 if errors else 0) + + if not args.gcov_dir: + ap.error('--gcov-dir is required unless --validate-only is given') + + index = build_index(src_root) + gcov_files = sorted(Path(args.gcov_dir).rglob('*.gcov')) + if not gcov_files: + print(f'No .gcov files found under {args.gcov_dir}', file=sys.stderr) + sys.exit(1) + + exec_by_file = {} # resolved Path -> {line: count} + unresolved = [] + for gf in gcov_files: + source, counts = parse_gcov(gf) + if source is None or not counts: + continue + resolved = resolve_source(source, src_root, index) + if resolved is None: + unresolved.append(source) + continue + merged = exec_by_file.setdefault(resolved, {}) + for ln, cnt in counts.items(): + merged[ln] = max(cnt, merged.get(ln, 0)) + + def split_executed(executed, fr): + """Partition a file's executed lines into (ported, portable-remaining, not-portable).""" + ported_all = fr.ported | fr.manual + executed_ported = executed & ported_all + executed_portable_remaining = (executed & fr.portable) - executed_ported + executed_not_portable = executed - executed_ported - executed_portable_remaining + return executed_ported, executed_portable_remaining, executed_not_portable + + per_file = [] + total_executed = 0 + total_ported = 0 + total_portable_remaining = 0 + total_not_portable = 0 + routine_rows = [] + empty_fr = FileRegions() + + for resolved, counts in exec_by_file.items(): + executed = {ln for ln, c in counts.items() if c > 0} + if not executed: + continue + fr = ported_by_file.get(resolved, empty_fr) + executed_ported, executed_portable, executed_notportable = split_executed(executed, fr) + try: + rel = resolved.relative_to(src_root) + except ValueError: + rel = resolved + portable_total = len(executed_ported) + len(executed_portable) + pct_portable = 100.0 * len(executed_ported) / portable_total if portable_total else None + per_file.append({ + 'file': str(rel), + 'executed_lines': len(executed), + 'ported_lines': len(executed_ported), + 'portable_remaining_lines': len(executed_portable), + 'not_portable_lines': len(executed_notportable), + 'pct_of_portable': round(pct_portable, 1) if pct_portable is not None else None, + 'portable_remaining_ranges': ranges_from_lines(executed_portable), + 'ported_ranges': ranges_from_lines(executed_ported), + }) + total_executed += len(executed) + total_ported += len(executed_ported) + total_portable_remaining += len(executed_portable) + total_not_portable += len(executed_notportable) + + for name, rstart, rend in parse_routines(resolved): + rexec = {ln for ln in executed if rstart <= ln <= rend} + if not rexec: + continue + rported = rexec & (fr.ported | fr.manual) + rportable = (rexec & fr.portable) - rported + rtotal = len(rported) + len(rportable) + routine_rows.append({ + 'file': str(rel), + 'routine': name, + 'executed_lines': len(rexec), + 'ported_lines': len(rported), + 'portable_remaining_lines': len(rportable), + 'pct_of_portable': round(100.0 * len(rported) / rtotal, 1) if rtotal else None, + 'portable_remaining_ranges': ranges_from_lines(rportable), + 'ported_ranges': ranges_from_lines(rported), + }) + + # Rank by the biggest *portable* gap, not raw unported lines — allocate/IO/scalar + # setup code that will never be ported shouldn't crowd out real porting targets. + per_file.sort(key=lambda r: r['portable_remaining_lines'], reverse=True) + routine_rows.sort(key=lambda r: r['portable_remaining_lines'], reverse=True) + total_portable = total_ported + total_portable_remaining + overall_pct_portable = 100.0 * total_ported / total_portable if total_portable else 0.0 + overall_pct_executed = 100.0 * total_ported / total_executed if total_executed else 0.0 + + lines_out = [] + lines_out.append('# GPU Port Coverage Report\n') + lines_out.append(f'**Of portable code: {total_ported} / {total_portable} executed lines ' + f'ported ({overall_pct_portable:.1f}%)** ') + lines_out.append(f'({total_ported} / {total_executed} of *all* executed lines, ' + f'{overall_pct_executed:.1f}% — {total_not_portable} executed lines are ' + f'`allocate`/I/O/scalar setup code with no porting target and are excluded ' + f'from the portable metric above)\n') + if errors: + lines_out.append(f'\n:warning: **{len(errors)} structural error(s) in ported-region ' + f'annotations** — see job log. Report below excludes affected files.\n') + lines_out.append('\n## By file (biggest portable gap first)\n') + lines_out.append('| File | Executed | Ported | Portable remaining | Not portable | % of portable |') + lines_out.append('|---|---:|---:|---:|---:|---:|') + for r in per_file: + pct = f"{r['pct_of_portable']:.1f}%" if r['pct_of_portable'] is not None else '—' + lines_out.append(f"| {r['file']} | {r['executed_lines']} | {r['ported_lines']} | " + f"{r['portable_remaining_lines']} | {r['not_portable_lines']} | {pct} |") + + lines_out.append('\n## Top porting opportunities (by executed portable-remaining lines)\n') + lines_out.append('| Routine | File | Executed | Ported | Portable remaining | % of portable |') + lines_out.append('|---|---|---:|---:|---:|---:|') + for r in routine_rows[:25]: + pct = f"{r['pct_of_portable']:.1f}%" if r['pct_of_portable'] is not None else '—' + lines_out.append(f"| {r['routine']} | {r['file']} | {r['executed_lines']} | " + f"{r['ported_lines']} | {r['portable_remaining_lines']} | {pct} |") + + lines_out.append('\n## Line ranges for top porting opportunities\n') + lines_out.append('Exact executed, portable, not-yet-ported line ranges — this is the ' + 'authoritative "which lines" answer; any HTML/CI visualization is ' + 'derived from these same ranges, not the other way around.\n') + for r in routine_rows[:25]: + if not r['portable_remaining_ranges']: + continue + lines_out.append(f"- **{r['routine']}** (`{r['file']}`): lines " + f"{format_ranges(r['portable_remaining_ranges'])}") + + if unresolved: + lines_out.append(f'\n_{len(unresolved)} .gcov file(s) could not be matched to a source ' + f'file under {src_root} and were skipped._\n') + if all_warnings: + lines_out.append(f'\n_{len(all_warnings)} parser warning(s); see job log._\n') + + report = '\n'.join(lines_out) + if args.out_md: + Path(args.out_md).write_text(report + '\n') + else: + print(report) + + if args.out_json: + Path(args.out_json).write_text(json.dumps({ + 'overall': { + 'executed_lines': total_executed, + 'ported_lines': total_ported, + 'portable_remaining_lines': total_portable_remaining, + 'not_portable_lines': total_not_portable, + 'pct_of_portable': round(overall_pct_portable, 1), + 'pct_of_executed': round(overall_pct_executed, 1), + }, + 'by_file': per_file, + 'by_routine': routine_rows, + 'warnings': all_warnings, + 'errors': errors, + 'unresolved_gcov_sources': unresolved, + }, indent=2)) + + if args.github_annotations: + for r in per_file: + for lo, hi in r['portable_remaining_ranges']: + if lo == hi: + print(f"::notice file={r['file']},line={lo}::GPU-portable, not yet ported") + else: + print(f"::notice file={r['file']},line={lo},endLine={hi}::" + f"GPU-portable, not yet ported ({hi - lo + 1} lines)") + + for w in all_warnings: + print(f'WARNING: {w}', file=sys.stderr) + + if errors: + sys.exit(1) + + +if __name__ == '__main__': + main() From 4206afa500ae46b5ce8000db0eb3ec3418eb77c5 Mon Sep 17 00:00:00 2001 From: Edward Yang Date: Tue, 14 Jul 2026 10:12:21 +1000 Subject: [PATCH 02/10] Reuse build-coverage's binary for the GPU port report instead of a separate build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .testing's build/cov/MOM6 is already compiled with --coverage from the same PR source, the same FMS fork (edoyango/FMS) that MOM6-examples/src/FMS2_gpu points to, and the default dynamic_symmetric/solo_driver config that ocean_only uses — so it can run benchmark_ALE directly with no recompile. Moves the report job into verify-linux.yml (needs: build-coverage) since `needs` can only reference jobs in the same workflow file; the standalone gpu-port-report.yml is removed. Cloning MOM6-gpu/MOM6-examples is now only for the benchmark_ALE input files and the f90nml submodule (for get_nprocs.py) — no FMS/MOM6 build there at all. --- .github/workflows/gpu-port-report.yml | 76 --------------------------- .github/workflows/verify-linux.yml | 65 +++++++++++++++++++++++ 2 files changed, 65 insertions(+), 76 deletions(-) delete mode 100644 .github/workflows/gpu-port-report.yml diff --git a/.github/workflows/gpu-port-report.yml b/.github/workflows/gpu-port-report.yml deleted file mode 100644 index bf776e2818..0000000000 --- a/.github/workflows/gpu-port-report.yml +++ /dev/null @@ -1,76 +0,0 @@ -# This file is part of MOM6, the Modular Ocean Model version 6. -# See the LICENSE file for licensing information. -# SPDX-License-Identifier: Apache-2.0 - -name: GPU port coverage report - -on: [push, pull_request] - -jobs: - gpu-port-report: - runs-on: ubuntu-latest - timeout-minutes: 45 - - steps: - - name: Checkout this MOM6 revision - uses: actions/checkout@v4 - with: - path: mom6-src - - - uses: ./mom6-src/.github/actions/ubuntu-setup/ - - - name: Clone MOM6-gpu/MOM6-examples - run: git clone https://github.com/MOM6-gpu/MOM6-examples.git MOM6-examples - - - name: Fetch only the submodules needed for the ocean_only build - working-directory: MOM6-examples - run: git submodule update --init src/FMS2_gpu shared/tools/f90nml - - - name: Swap in this revision's MOM6 source - run: | - rm -rf MOM6-examples/src/MOM6 - cp -a mom6-src MOM6-examples/src/MOM6 - - - name: Build MOM6 with gcov instrumentation - working-directory: MOM6-examples/ocean_only - env: - FCFLAGS: --coverage - LDFLAGS: --coverage - PYTHON: python3 - run: make -j - - - name: Shorten benchmark_ALE to a single short run - run: echo '#override DAYMAX = 0.5' >> MOM6-examples/ocean_only/benchmark_ALE/MOM_override - - - name: Run benchmark_ALE - working-directory: MOM6-examples/ocean_only - env: - PYTHON: python3 - run: make run.benchmark_ALE - - - name: Generate gcov reports - working-directory: MOM6-examples/ocean_only/build - run: gcov -b *.gcda - - - name: Generate GPU port coverage report - run: | - python3 MOM6-examples/src/MOM6/.testing/tools/track_gpu_port.py \ - --src-root MOM6-examples/src/MOM6 \ - --gcov-dir MOM6-examples/ocean_only/build \ - --out-md gpu_port_report.md \ - --github-annotations - - - name: Publish report to job summary - if: always() - run: | - if [ -f gpu_port_report.md ]; then - cat gpu_port_report.md >> "$GITHUB_STEP_SUMMARY" - fi - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: gpu-port-report - path: gpu_port_report.md - retention-days: 14 - if-no-files-found: ignore diff --git a/.github/workflows/verify-linux.yml b/.github/workflows/verify-linux.yml index 39c902d743..fc9e8ab747 100644 --- a/.github/workflows/verify-linux.yml +++ b/.github/workflows/verify-linux.yml @@ -618,6 +618,70 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + gpu-port-report: + runs-on: ubuntu-latest + needs: build-coverage + + steps: + - uses: actions/checkout@v4 + + - uses: ./.github/actions/ubuntu-setup + + - name: Download coverage-instrumented MOM6 + uses: actions/download-artifact@v4 + with: + name: mom6-coverage-artifact + + - name: Unpack artifact + run: | + tar -xpvf mom6-coverage.tar + find .testing/build/cov -name "*.gcno" -exec touch {} \; + + # Only the benchmark_ALE inputs and the f90nml submodule (needed by + # get_nprocs.py) are required here — no MOM6/FMS build, since the + # coverage-instrumented binary from build-coverage is reused as-is. + - name: Clone MOM6-gpu/MOM6-examples for the benchmark_ALE case + run: | + git clone --depth 1 https://github.com/MOM6-gpu/MOM6-examples.git MOM6-examples + cd MOM6-examples + git submodule update --init shared/tools/f90nml + + - name: Shorten benchmark_ALE to a single short run + run: echo '#override DAYMAX = 0.5' >> MOM6-examples/ocean_only/benchmark_ALE/MOM_override + + - name: Run benchmark_ALE with the coverage-instrumented binary + working-directory: MOM6-examples/ocean_only/benchmark_ALE + run: | + NPROCS=$(python3 ../../shared/tools/get_nprocs.py .) + mpirun -np "$NPROCS" "$GITHUB_WORKSPACE/.testing/build/cov/MOM6" + + - name: Generate gcov reports + working-directory: .testing/build/cov + run: gcov -b *.gcda + + - name: Generate GPU port coverage report + run: | + python3 .testing/tools/track_gpu_port.py \ + --src-root . \ + --gcov-dir .testing/build/cov \ + --out-md gpu_port_report.md \ + --github-annotations + + - name: Publish report to job summary + if: always() + run: | + if [ -f gpu_port_report.md ]; then + cat gpu_port_report.md >> "$GITHUB_STEP_SUMMARY" + fi + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: gpu-port-report + path: gpu_port_report.md + retention-days: 14 + if-no-files-found: ignore + # These are most likely nonsense on a GitHub node, but someday it could work. run-timings: if: github.event_name != 'pull_request' @@ -735,6 +799,7 @@ jobs: #- test-openmp - test-repro - run-coverage + - gpu-port-report steps: - uses: geekyeggo/delete-artifact@v5 From d2b937b5689fddfceacafd340ad8cb6c8610f980 Mon Sep 17 00:00:00 2001 From: Edward Yang Date: Tue, 14 Jul 2026 11:42:42 +1000 Subject: [PATCH 03/10] Force gpu-port-report to a single MPI rank CI failed: get_nprocs.py computed 64 ranks from MOM6-gpu/MOM6-examples's checked-in MOM_parameter_doc.layout for benchmark_ALE, exceeding the runner's available slots. This is a coverage smoke run, not a scaling test, so hardcode -np 1 and force "#override LAYOUT = 1,1" so the runtime domain decomposition matches. Drops the now-unnecessary get_nprocs.py call and its f90nml submodule dependency. --- .github/workflows/verify-linux.yml | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/verify-linux.yml b/.github/workflows/verify-linux.yml index fc9e8ab747..9fc4c748eb 100644 --- a/.github/workflows/verify-linux.yml +++ b/.github/workflows/verify-linux.yml @@ -637,23 +637,25 @@ jobs: tar -xpvf mom6-coverage.tar find .testing/build/cov -name "*.gcno" -exec touch {} \; - # Only the benchmark_ALE inputs and the f90nml submodule (needed by - # get_nprocs.py) are required here — no MOM6/FMS build, since the - # coverage-instrumented binary from build-coverage is reused as-is. + # Only the benchmark_ALE inputs are needed here — no MOM6/FMS build, + # since the coverage-instrumented binary from build-coverage is reused + # as-is. - name: Clone MOM6-gpu/MOM6-examples for the benchmark_ALE case - run: | - git clone --depth 1 https://github.com/MOM6-gpu/MOM6-examples.git MOM6-examples - cd MOM6-examples - git submodule update --init shared/tools/f90nml + run: git clone --depth 1 https://github.com/MOM6-gpu/MOM6-examples.git MOM6-examples - - name: Shorten benchmark_ALE to a single short run - run: echo '#override DAYMAX = 0.5' >> MOM6-examples/ocean_only/benchmark_ALE/MOM_override + # Force a single-rank layout and a short run: this is a coverage smoke + # run, not a scaling test, and the runner may have far fewer cores + # than whatever layout benchmark_ALE happens to be checked in with. + - name: Shorten benchmark_ALE to a single-rank, single short run + run: | + { + echo '#override DAYMAX = 0.5' + echo '#override LAYOUT = 1,1' + } >> MOM6-examples/ocean_only/benchmark_ALE/MOM_override - name: Run benchmark_ALE with the coverage-instrumented binary working-directory: MOM6-examples/ocean_only/benchmark_ALE - run: | - NPROCS=$(python3 ../../shared/tools/get_nprocs.py .) - mpirun -np "$NPROCS" "$GITHUB_WORKSPACE/.testing/build/cov/MOM6" + run: mpirun -np 1 "$GITHUB_WORKSPACE/.testing/build/cov/MOM6" - name: Generate gcov reports working-directory: .testing/build/cov From ba40a2de78f622d5f5e9d8400795f172645d1e13 Mon Sep 17 00:00:00 2001 From: Edward Yang Date: Tue, 14 Jul 2026 11:50:32 +1000 Subject: [PATCH 04/10] Skip broken symlinks instead of crashing in track_gpu_port.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed with FileNotFoundError on src/equation_of_state/TEOS10/*.f90: those are git symlinks into the pkg/GSW-Fortran submodule, and gpu-port-report's checkout doesn't initialize submodules (the coverage build itself never compiles them — it links an externally-built libgsw.a instead via --with-gsw). rglob() still walks the dangling symlinks, so skip any path that isn't a real file, with a warning, instead of crashing the whole report. --- .testing/tools/track_gpu_port.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.testing/tools/track_gpu_port.py b/.testing/tools/track_gpu_port.py index a45ba2010a..8c93af0c9b 100644 --- a/.testing/tools/track_gpu_port.py +++ b/.testing/tools/track_gpu_port.py @@ -597,6 +597,13 @@ def main(): src_files = sorted(set(src_root.rglob('*.F90')) | set(src_root.rglob('*.f90'))) for f in src_files: + if not f.is_file(): + # Broken symlink — typically an uninitialized git submodule + # (e.g. src/equation_of_state/TEOS10 -> pkg/GSW-Fortran). Nothing + # to scan; not a parse error. + all_warnings.append(f'{f}: skipped (target does not exist, likely ' + f'an uninitialized submodule)') + continue try: fr = parse_ported_regions(f) except StructuralError as e: From 0eb64e2375212adaa42b12613d2ae844e12b5519 Mon Sep 17 00:00:00 2001 From: Edward Yang Date: Tue, 14 Jul 2026 12:05:52 +1000 Subject: [PATCH 05/10] Post GPU port coverage as a PR comment with a since-baseline delta track_gpu_port.py gains --baseline-json (diff current ported totals against a prior --out-json snapshot) and --changed-files + --out-comment (a short summary scoped to the PR's own files, for posting as a comment rather than the full per-file/per-routine report). verify-linux.yml's gpu-port-report job now uploads its JSON snapshot as a "gpu-port-json" artifact on every run (the next PR's baseline), and on pull_request events also computes the PR's changed .F90/.f90 files and looks up the base branch's latest snapshot to diff against. Actually posting the comment is split into a new gpu-port-comment.yml, triggered by workflow_run: gpu-port-report checks out and runs PR code (possibly from a fork) so it must stay read-only, while commenting needs write access that workflow_run grants safely since it runs with the base repo's permissions and never checks out the triggering code. The comment is updated in place across pushes via a hidden HTML marker. --- .github/workflows/gpu-port-comment.yml | 72 +++++++++++++++++++++ .github/workflows/verify-linux.yml | 86 +++++++++++++++++++++++++- .testing/tools/track_gpu_port.py | 63 +++++++++++++++++++ 3 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/gpu-port-comment.yml diff --git a/.github/workflows/gpu-port-comment.yml b/.github/workflows/gpu-port-comment.yml new file mode 100644 index 0000000000..e2b3c4dea7 --- /dev/null +++ b/.github/workflows/gpu-port-comment.yml @@ -0,0 +1,72 @@ +# This file is part of MOM6, the Modular Ocean Model version 6. +# See the LICENSE file for licensing information. +# SPDX-License-Identifier: Apache-2.0 + +# Posts/updates a PR comment summarizing GPU-port coverage progress. Split out from +# verify-linux.yml's gpu-port-report job on purpose: that job checks out and runs a PR's +# code (which may come from a fork) and so must stay read-only, while commenting needs +# write access. workflow_run always executes with the base repository's permissions and +# never checks out the triggering PR's code, so it's the safe place to do the write. + +name: GPU port PR comment + +on: + workflow_run: + workflows: ["Linux verification"] + types: [completed] + +permissions: + pull-requests: write + +jobs: + comment: + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + + steps: + - name: Download PR comment artifact + id: download + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: gpu-port-pr-comment + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Post or update PR comment + if: steps.download.outcome == 'success' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + if (!fs.existsSync('pr_number.txt') || !fs.existsSync('gpu_port_comment.md')) { + console.log('Comment artifact incomplete; nothing to post.'); + return; + } + const prNumber = parseInt(fs.readFileSync('pr_number.txt', 'utf8').trim(), 10); + const body = fs.readFileSync('gpu_port_comment.md', 'utf8'); + const marker = ''; + const fullBody = `${marker}\n${body}`; + + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: fullBody, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: fullBody, + }); + } diff --git a/.github/workflows/verify-linux.yml b/.github/workflows/verify-linux.yml index 9fc4c748eb..fb00ed2e3d 100644 --- a/.github/workflows/verify-linux.yml +++ b/.github/workflows/verify-linux.yml @@ -621,6 +621,13 @@ jobs: gpu-port-report: runs-on: ubuntu-latest needs: build-coverage + # Read-only: this job checks out and runs a PR's (possibly untrusted, fork-authored) + # code, so it must never hold write access. Posting the PR comment itself happens in + # the separate gpu-port-comment.yml workflow, triggered by workflow_run, which runs + # with the base repo's permissions and does not check out PR code. + permissions: + contents: read + actions: read steps: - uses: actions/checkout@v4 @@ -661,13 +668,63 @@ jobs: working-directory: .testing/build/cov run: gcov -b *.gcda + # List the .F90/.f90 files this PR actually touches, so the comment can report a + # "this PR's files" subset in addition to the repo-wide total. Paths are left + # repo-root-relative to match track_gpu_port.py's --src-root . output. + - name: List source files changed by this PR + if: github.event_name == 'pull_request' + run: | + git fetch --no-tags --depth=1 origin \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}" + git diff --name-only \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}" \ + -- '*.F90' '*.f90' > changed_files.txt + + # Baseline for the "since base branch" delta: the GPU port JSON snapshot uploaded + # by the most recent successful run of this workflow on the PR's base branch. This + # is an approximation of the merge-base (not an exact rebuild of it), traded off + # deliberately against not doubling CI cost with a second coverage build per PR. + - name: Find latest GPU port snapshot on the base branch + if: github.event_name == 'pull_request' + id: baseline + env: + GH_TOKEN: ${{ github.token }} + run: | + RUN_ID=$(gh run list --repo "${{ github.repository }}" \ + --workflow verify-linux.yml \ + --branch "${{ github.event.pull_request.base.ref }}" \ + --status success --limit 1 \ + --json databaseId --jq '.[0].databaseId') + echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" + + - name: Download baseline GPU port snapshot + if: github.event_name == 'pull_request' && steps.baseline.outputs.run_id != '' + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: gpu-port-json + run-id: ${{ steps.baseline.outputs.run_id }} + github-token: ${{ github.token }} + path: baseline + - name: Generate GPU port coverage report run: | + EXTRA_ARGS=() + if [ -f baseline/gpu_port_report.json ]; then + EXTRA_ARGS+=(--baseline-json baseline/gpu_port_report.json) + fi + if [ -f changed_files.txt ]; then + EXTRA_ARGS+=(--changed-files changed_files.txt --out-comment gpu_port_comment.md) + fi python3 .testing/tools/track_gpu_port.py \ --src-root . \ --gcov-dir .testing/build/cov \ --out-md gpu_port_report.md \ - --github-annotations + --out-json gpu_port_report.json \ + --github-annotations \ + "${EXTRA_ARGS[@]}" - name: Publish report to job summary if: always() @@ -684,6 +741,33 @@ jobs: retention-days: 14 if-no-files-found: ignore + # Becomes the baseline snapshot the *next* PR against this branch diffs against — + # uploaded on every run (push or PR) so pushes to the base branch keep it current. + - uses: actions/upload-artifact@v4 + if: always() + with: + name: gpu-port-json + path: gpu_port_report.json + retention-days: 30 + if-no-files-found: ignore + + # gpu_port_comment.md is picked up and posted/updated on the PR itself by + # gpu-port-comment.yml, which runs via workflow_run so it has write access even + # for PRs from forks (this job intentionally does not — see permissions: above). + - name: Save PR number for the comment workflow + if: always() && github.event_name == 'pull_request' + run: echo "${{ github.event.pull_request.number }}" > pr_number.txt + + - uses: actions/upload-artifact@v4 + if: always() && github.event_name == 'pull_request' + with: + name: gpu-port-pr-comment + path: | + gpu_port_comment.md + pr_number.txt + retention-days: 1 + if-no-files-found: ignore + # These are most likely nonsense on a GitHub node, but someday it could work. run-timings: if: github.event_name != 'pull_request' diff --git a/.testing/tools/track_gpu_port.py b/.testing/tools/track_gpu_port.py index 8c93af0c9b..a1558d54bf 100644 --- a/.testing/tools/track_gpu_port.py +++ b/.testing/tools/track_gpu_port.py @@ -588,6 +588,16 @@ def main(): help='Emit GitHub Actions ::notice file=...,line=...,endLine=...:: workflow ' 'commands for each executed, portable, not-yet-ported line range — shows ' 'up natively on the PR "Files changed" diff, no HTML artifact needed') + ap.add_argument('--baseline-json', help='A prior --out-json snapshot (e.g. from the PR base ' + 'branch) to diff the current totals against, reported as "since baseline" ' + 'in --out-comment') + ap.add_argument('--changed-files', help='File with one src-root-relative source path per ' + 'line (e.g. from `git diff --name-only`) — scopes an additional "this PR\'s ' + 'files" subset of the totals in --out-comment') + ap.add_argument('--out-comment', help='Write a short PR-comment-sized markdown summary here: ' + 'overall %% ported, delta vs --baseline-json, and the --changed-files subset ' + 'if given. Intended to be posted/updated on the PR itself; --out-md remains ' + 'the full per-file/per-routine report for the job summary.') args = ap.parse_args() src_root = Path(args.src_root).resolve() @@ -730,6 +740,34 @@ def split_executed(executed, fr): overall_pct_portable = 100.0 * total_ported / total_portable if total_portable else 0.0 overall_pct_executed = 100.0 * total_ported / total_executed if total_executed else 0.0 + baseline = None + if args.baseline_json: + try: + baseline = json.loads(Path(args.baseline_json).read_text()) + except (OSError, json.JSONDecodeError) as e: + all_warnings.append( + f'--baseline-json {args.baseline_json}: could not load ({e}); skipping delta') + + delta = None + if baseline is not None: + b = baseline.get('overall', {}) + delta = { + 'ported_lines': total_ported - b.get('ported_lines', 0), + 'pct_of_portable': round(overall_pct_portable - b.get('pct_of_portable', 0.0), 1), + } + + changed_files = None + pr_rows = [] + pr_ported = pr_portable_total = 0 + pr_pct = None + if args.changed_files: + changed_files = {line.strip() for line in Path(args.changed_files).read_text().splitlines() + if line.strip()} + pr_rows = [r for r in per_file if r['file'] in changed_files] + pr_ported = sum(r['ported_lines'] for r in pr_rows) + pr_portable_total = pr_ported + sum(r['portable_remaining_lines'] for r in pr_rows) + pr_pct = 100.0 * pr_ported / pr_portable_total if pr_portable_total else None + lines_out = [] lines_out.append('# GPU Port Coverage Report\n') lines_out.append(f'**Of portable code: {total_ported} / {total_portable} executed lines ' @@ -796,6 +834,31 @@ def split_executed(executed, fr): 'unresolved_gcov_sources': unresolved, }, indent=2)) + if args.out_comment: + c = [] + c.append('### GPU Port Coverage\n') + c.append(f'**Overall: {total_ported} / {total_portable} portable executed lines ported ' + f'({overall_pct_portable:.1f}%)**') + if delta is not None: + sign = '+' if delta['ported_lines'] >= 0 else '' + psign = '+' if delta['pct_of_portable'] >= 0 else '' + c.append(f"Since base branch: {sign}{delta['ported_lines']} ported lines " + f"({psign}{delta['pct_of_portable']:.1f} pp)") + if changed_files is not None: + c.append('') + if pr_rows: + pct_s = f'{pr_pct:.1f}%' if pr_pct is not None else '—' + c.append(f'**Files touched by this PR: {pr_ported} / {pr_portable_total} ' + f'portable executed lines ported ({pct_s})**') + else: + c.append('_No executed, GPU-portable lines in the files touched by this PR ' + '(nothing in the diff was exercised by the coverage run, or none of ' + "it is GPU-portable)._") + c.append('') + c.append('Full per-file / per-routine breakdown: see the "gpu-port-report" job ' + 'summary and artifact.') + Path(args.out_comment).write_text('\n'.join(c) + '\n') + if args.github_annotations: for r in per_file: for lo, hi in r['portable_remaining_ranges']: From 4c970e9d62e3e6e0651d79afc6a0fd543f597643 Mon Sep 17 00:00:00 2001 From: Edward Yang Date: Tue, 14 Jul 2026 12:07:24 +1000 Subject: [PATCH 06/10] Add --verbose to list every routine's porting opportunities in the report Without it, the "top porting opportunities" section in --out-md caps at 25 routines by portable-remaining lines, which is fine for a quick scan but drops routines once the codebase has more than 25 with remaining work. --verbose lists all of them, with their exact line ranges. Wired into the CI job's full report (job summary), not the PR comment, which stays short by design. --- .github/workflows/verify-linux.yml | 1 + .testing/tools/track_gpu_port.py | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/verify-linux.yml b/.github/workflows/verify-linux.yml index fb00ed2e3d..1351b120d2 100644 --- a/.github/workflows/verify-linux.yml +++ b/.github/workflows/verify-linux.yml @@ -724,6 +724,7 @@ jobs: --out-md gpu_port_report.md \ --out-json gpu_port_report.json \ --github-annotations \ + --verbose \ "${EXTRA_ARGS[@]}" - name: Publish report to job summary diff --git a/.testing/tools/track_gpu_port.py b/.testing/tools/track_gpu_port.py index a1558d54bf..4223f4410c 100644 --- a/.testing/tools/track_gpu_port.py +++ b/.testing/tools/track_gpu_port.py @@ -598,6 +598,10 @@ def main(): 'overall %% ported, delta vs --baseline-json, and the --changed-files subset ' 'if given. Intended to be posted/updated on the PR itself; --out-md remains ' 'the full per-file/per-routine report for the job summary.') + ap.add_argument('--verbose', action='store_true', + help='List every routine with executed, portable, not-yet-ported code (and ' + 'its exact line ranges) in --out-md, instead of just the top 25 by ' + 'portable-remaining lines') args = ap.parse_args() src_root = Path(args.src_root).resolve() @@ -787,19 +791,24 @@ def split_executed(executed, fr): lines_out.append(f"| {r['file']} | {r['executed_lines']} | {r['ported_lines']} | " f"{r['portable_remaining_lines']} | {r['not_portable_lines']} | {pct} |") - lines_out.append('\n## Top porting opportunities (by executed portable-remaining lines)\n') + shown_routines = routine_rows if args.verbose else routine_rows[:25] + heading = 'All routines with portable-remaining lines' if args.verbose \ + else 'Top porting opportunities (by executed portable-remaining lines)' + lines_out.append(f'\n## {heading}\n') lines_out.append('| Routine | File | Executed | Ported | Portable remaining | % of portable |') lines_out.append('|---|---|---:|---:|---:|---:|') - for r in routine_rows[:25]: + for r in shown_routines: pct = f"{r['pct_of_portable']:.1f}%" if r['pct_of_portable'] is not None else '—' lines_out.append(f"| {r['routine']} | {r['file']} | {r['executed_lines']} | " f"{r['ported_lines']} | {r['portable_remaining_lines']} | {pct} |") - lines_out.append('\n## Line ranges for top porting opportunities\n') + range_heading = 'Line ranges for all porting opportunities' if args.verbose \ + else 'Line ranges for top porting opportunities' + lines_out.append(f'\n## {range_heading}\n') lines_out.append('Exact executed, portable, not-yet-ported line ranges — this is the ' 'authoritative "which lines" answer; any HTML/CI visualization is ' 'derived from these same ranges, not the other way around.\n') - for r in routine_rows[:25]: + for r in shown_routines: if not r['portable_remaining_ranges']: continue lines_out.append(f"- **{r['routine']}** (`{r['file']}`): lines " From 4c8f1c518e03965231bc1523f7e6fefc337ab375 Mon Sep 17 00:00:00 2001 From: Edward Yang Date: Tue, 14 Jul 2026 12:25:06 +1000 Subject: [PATCH 07/10] Publish GPU port coverage as an lcov-style HTML report on GitHub Pages Adds --out-html to track_gpu_port.py, generating a self-contained line-by-line coverage/port-status page per source file (ported / portable-remaining / executed-not-portable / not-hit, plus an index). The read-only gpu-port-report job builds it as an artifact; the write-capable gpu-port-comment.yml workflow (via workflow_run) pushes it to gh-pages under pr-/ and links it from the sticky PR comment. A pull_request_target cleanup workflow removes the preview when the PR closes. --- .github/workflows/gpu-port-comment.yml | 92 ++++++++++++-- .github/workflows/gpu-port-pages-cleanup.yml | 61 ++++++++++ .github/workflows/verify-linux.yml | 14 +++ .testing/tools/track_gpu_port.py | 121 +++++++++++++++++++ 4 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/gpu-port-pages-cleanup.yml diff --git a/.github/workflows/gpu-port-comment.yml b/.github/workflows/gpu-port-comment.yml index e2b3c4dea7..b89d37a821 100644 --- a/.github/workflows/gpu-port-comment.yml +++ b/.github/workflows/gpu-port-comment.yml @@ -2,11 +2,13 @@ # See the LICENSE file for licensing information. # SPDX-License-Identifier: Apache-2.0 -# Posts/updates a PR comment summarizing GPU-port coverage progress. Split out from +# Posts/updates a PR comment summarizing GPU-port coverage progress, and publishes the +# line-by-line HTML report to GitHub Pages under pr-/. Split out from # verify-linux.yml's gpu-port-report job on purpose: that job checks out and runs a PR's -# code (which may come from a fork) and so must stay read-only, while commenting needs -# write access. workflow_run always executes with the base repository's permissions and -# never checks out the triggering PR's code, so it's the safe place to do the write. +# code (which may come from a fork) and so must stay read-only, while commenting and +# pushing to gh-pages needs write access. workflow_run always executes with the base +# repository's permissions and never checks out the triggering PR's code, so it's the +# safe place to do those writes. name: GPU port PR comment @@ -15,13 +17,83 @@ on: workflows: ["Linux verification"] types: [completed] -permissions: - pull-requests: write +permissions: {} jobs: + publish-pages: + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + url: ${{ steps.publish.outputs.url }} + + steps: + - uses: actions/checkout@v4 + + - name: Download HTML report artifact + id: download + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: gpu-port-html + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + path: report + + - name: Publish report to gh-pages + id: publish + if: steps.download.outcome == 'success' + run: | + if [ ! -f report/pr_number.txt ] || [ ! -d report/gpu_port_html ]; then + echo 'Report artifact incomplete; nothing to publish.' + exit 0 + fi + PR_NUMBER=$(cat report/pr_number.txt) + + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + + if git fetch origin gh-pages --depth=1 2>/dev/null; then + git checkout -B gh-pages FETCH_HEAD + else + git checkout --orphan gh-pages + git rm -rf . -q + fi + + rm -rf "pr-$PR_NUMBER" + mkdir -p "pr-$PR_NUMBER" + cp -r report/gpu_port_html/. "pr-$PR_NUMBER/" + + { + echo '' + echo 'GPU port coverage previews' + echo '

GPU port coverage previews

    ' + for d in pr-*/; do + n="${d%/}" + echo "
  • $n
  • " + done + echo '
' + } > index.html + + git add -A + if git diff --cached --quiet; then + echo 'No changes to publish.' + else + git commit -q -m "Publish GPU port report for PR #$PR_NUMBER" + git push origin HEAD:gh-pages + fi + + OWNER="${GITHUB_REPOSITORY%%/*}" + REPO="${GITHUB_REPOSITORY#*/}" + echo "url=https://${OWNER}.github.io/${REPO}/pr-$PR_NUMBER/" >> "$GITHUB_OUTPUT" + comment: if: github.event.workflow_run.event == 'pull_request' + needs: publish-pages runs-on: ubuntu-latest + permissions: + pull-requests: write steps: - name: Download PR comment artifact @@ -36,6 +108,8 @@ jobs: - name: Post or update PR comment if: steps.download.outcome == 'success' uses: actions/github-script@v7 + env: + PAGES_URL: ${{ needs.publish-pages.outputs.url }} with: script: | const fs = require('fs'); @@ -44,7 +118,11 @@ jobs: return; } const prNumber = parseInt(fs.readFileSync('pr_number.txt', 'utf8').trim(), 10); - const body = fs.readFileSync('gpu_port_comment.md', 'utf8'); + let body = fs.readFileSync('gpu_port_comment.md', 'utf8'); + const pagesUrl = process.env.PAGES_URL; + if (pagesUrl) { + body += `\n[Full line-by-line coverage report](${pagesUrl})\n`; + } const marker = ''; const fullBody = `${marker}\n${body}`; diff --git a/.github/workflows/gpu-port-pages-cleanup.yml b/.github/workflows/gpu-port-pages-cleanup.yml new file mode 100644 index 0000000000..8ceb6209cc --- /dev/null +++ b/.github/workflows/gpu-port-pages-cleanup.yml @@ -0,0 +1,61 @@ +# This file is part of MOM6, the Modular Ocean Model version 6. +# See the LICENSE file for licensing information. +# SPDX-License-Identifier: Apache-2.0 + +# Removes a closed PR's GPU port coverage preview (pr-/) from the gh-pages +# branch published by gpu-port-comment.yml. Uses pull_request_target (not +# pull_request) so this has write access even when the PR came from a fork — safe here +# because the job never checks out or executes any PR-supplied code, it only deletes a +# directory named after the PR number. + +name: GPU port pages cleanup + +on: + pull_request_target: + types: [closed] + +permissions: + contents: write + +jobs: + cleanup: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Remove PR preview from gh-pages + run: | + PR_NUMBER="${{ github.event.pull_request.number }}" + + if ! git fetch origin gh-pages --depth=1 2>/dev/null; then + echo 'gh-pages branch does not exist yet; nothing to clean up.' + exit 0 + fi + git checkout -B gh-pages FETCH_HEAD + + if [ ! -d "pr-$PR_NUMBER" ]; then + echo "No preview for PR #$PR_NUMBER; nothing to clean up." + exit 0 + fi + + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + + git rm -rf -q "pr-$PR_NUMBER" + + { + echo '' + echo 'GPU port coverage previews' + echo '

GPU port coverage previews

    ' + for d in pr-*/; do + [ -d "$d" ] || continue + n="${d%/}" + echo "
  • $n
  • " + done + echo '
' + } > index.html + git add index.html + + git commit -q -m "Remove GPU port preview for closed PR #$PR_NUMBER" + git push origin HEAD:gh-pages diff --git a/.github/workflows/verify-linux.yml b/.github/workflows/verify-linux.yml index 1351b120d2..7d5d1777a9 100644 --- a/.github/workflows/verify-linux.yml +++ b/.github/workflows/verify-linux.yml @@ -723,6 +723,7 @@ jobs: --gcov-dir .testing/build/cov \ --out-md gpu_port_report.md \ --out-json gpu_port_report.json \ + --out-html gpu_port_html \ --github-annotations \ --verbose \ "${EXTRA_ARGS[@]}" @@ -769,6 +770,19 @@ jobs: retention-days: 1 if-no-files-found: ignore + # Picked up by gpu-port-comment.yml (via workflow_run, so it has write access) and + # published to the gh-pages branch under pr-/, then linked from the PR + # comment above. + - uses: actions/upload-artifact@v4 + if: always() && github.event_name == 'pull_request' + with: + name: gpu-port-html + path: | + gpu_port_html + pr_number.txt + retention-days: 1 + if-no-files-found: ignore + # These are most likely nonsense on a GitHub node, but someday it could work. run-timings: if: github.event_name != 'pull_request' diff --git a/.testing/tools/track_gpu_port.py b/.testing/tools/track_gpu_port.py index 4223f4410c..5ff206c934 100644 --- a/.testing/tools/track_gpu_port.py +++ b/.testing/tools/track_gpu_port.py @@ -86,6 +86,7 @@ """ import argparse +import html import json import re import sys @@ -567,6 +568,108 @@ def format_ranges(ranges): return ', '.join(str(lo) if lo == hi else f'{lo}-{hi}' for lo, hi in ranges) +HTML_STYLE = """ +:root { color-scheme: light dark; } +body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + margin: 0; padding: 1rem 1.5rem; } +h1 { font-size: 1rem; font-weight: 600; } +a { color: #2b6cb0; } +p.back { margin-top: 0; } +p.legend span { display: inline-block; padding: 1px 8px; margin-right: 4px; border-radius: 3px; } +table.summary { border-collapse: collapse; width: 100%; } +table.summary th, table.summary td { padding: 4px 10px; border-bottom: 1px solid #8883; text-align: left; } +table.summary td.num, table.summary th.num { text-align: right; } +table.src { border-collapse: collapse; font-size: 12.5px; width: 100%; } +table.src td { padding: 0 6px; white-space: pre; vertical-align: top; } +table.src td.ln { color: #888; text-align: right; user-select: none; } +table.src td.cnt { color: #888; text-align: right; min-width: 2.5em; user-select: none; } +.ported { background: #d9f2d9; } +.portable { background: #ffe6a8; } +.executed { background: #eaeaea; } +.nothit { background: #fad2d2; } +tr.nodata { background: transparent; } +@media (prefers-color-scheme: dark) { + body { background: #1e1e1e; color: #ddd; } + .ported { background: #234d23; } + .portable { background: #5c4a13; } + .executed { background: #333333; } + .nothit { background: #5c2323; } +} +""" + +LEGEND_HTML = ( + '

' + 'ported' + 'portable, not yet ported' + 'executed, not portable' + 'executable, not hit by this run' + '

' +) + + +def html_slug(rel_path): + """Turn a src-root-relative path into a flat, collision-safe .html filename.""" + return re.sub(r'[^A-Za-z0-9_.-]', '_', str(rel_path).replace('/', '__')) + '.html' + + +def render_file_html(rel_path, resolved, counts, fr): + """Render one source file as an lcov-style line-by-line coverage/port-status page.""" + try: + lines = resolved.read_text(errors='replace').splitlines() + except OSError: + lines = [] + ported_all = fr.ported | fr.manual + portable = fr.portable + rows = [] + for lineno, text in enumerate(lines, start=1): + count = counts.get(lineno) + if count is None: + cls, marker = 'nodata', '' + elif count == 0: + cls, marker = 'nothit', '0' + elif lineno in ported_all: + cls, marker = 'ported', str(count) + elif lineno in portable: + cls, marker = 'portable', str(count) + else: + cls, marker = 'executed', str(count) + rows.append(f'{lineno}{marker}' + f'{html.escape(text)}') + title = html.escape(str(rel_path)) + return (f'{title}' + f'' + f'

← back to index

' + f'

{title}

{LEGEND_HTML}' + f'{"".join(rows)}
') + + +def render_index_html(per_file, total_ported, total_portable, total_executed, + total_not_portable, overall_pct_portable, overall_pct_executed): + rows = [] + for r in per_file: + pct = f"{r['pct_of_portable']:.1f}%" if r['pct_of_portable'] is not None else '—' + link = r.get('html_file') + file_cell = f'{html.escape(r["file"])}' if link else html.escape(r['file']) + rows.append(f'{file_cell}{r["executed_lines"]}' + f'{r["ported_lines"]}' + f'{r["portable_remaining_lines"]}' + f'{r["not_portable_lines"]}' + f'{pct}') + return (f'GPU Port Coverage Report' + f'' + f'

GPU Port Coverage Report

' + f'

Of portable code: {total_ported} / {total_portable} executed lines ported ' + f'({overall_pct_portable:.1f}%)
' + f'({total_ported} / {total_executed} of all executed lines, {overall_pct_executed:.1f}% ' + f'— {total_not_portable} executed lines are allocate/I/O/scalar setup code with no ' + f'porting target and are excluded from the portable metric above)

' + f'{LEGEND_HTML}' + f'' + f'' + f'' + f'{"".join(rows)}
FileExecutedPortedPortable remainingNot portable% of portable
') + + def build_index(src_root): index = {} for p in Path(src_root).rglob('*.F90'): @@ -602,6 +705,10 @@ def main(): help='List every routine with executed, portable, not-yet-ported code (and ' 'its exact line ranges) in --out-md, instead of just the top 25 by ' 'portable-remaining lines') + ap.add_argument('--out-html', help='Write a self-contained, lcov-style line-by-line HTML ' + 'report (index.html plus one page per executed file, colored by ported / ' + 'portable-remaining / executed-not-portable / not-hit) into this directory ' + '— e.g. for publishing to GitHub Pages') args = ap.parse_args() src_root = Path(args.src_root).resolve() @@ -683,6 +790,11 @@ def split_executed(executed, fr): executed_not_portable = executed - executed_ported - executed_portable_remaining return executed_ported, executed_portable_remaining, executed_not_portable + html_dir = None + if args.out_html: + html_dir = Path(args.out_html) + html_dir.mkdir(parents=True, exist_ok=True) + per_file = [] total_executed = 0 total_ported = 0 @@ -713,6 +825,10 @@ def split_executed(executed, fr): 'portable_remaining_ranges': ranges_from_lines(executed_portable), 'ported_ranges': ranges_from_lines(executed_ported), }) + if html_dir is not None: + html_name = html_slug(rel) + (html_dir / html_name).write_text(render_file_html(rel, resolved, counts, fr)) + per_file[-1]['html_file'] = html_name total_executed += len(executed) total_ported += len(executed_ported) total_portable_remaining += len(executed_portable) @@ -843,6 +959,11 @@ def split_executed(executed, fr): 'unresolved_gcov_sources': unresolved, }, indent=2)) + if html_dir is not None: + (html_dir / 'index.html').write_text(render_index_html( + per_file, total_ported, total_portable, total_executed, + total_not_portable, overall_pct_portable, overall_pct_executed)) + if args.out_comment: c = [] c.append('### GPU Port Coverage\n') From 3c301c9e39f71657ade97b549b2c071bfd8a93d5 Mon Sep 17 00:00:00 2001 From: Edward Yang Date: Tue, 14 Jul 2026 12:32:18 +1000 Subject: [PATCH 08/10] Recolor the HTML port-coverage report so severity maps to warmth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Green stays "done", but "portable, not yet ported" (the actual action item) is now red, "executed, not portable" (benign — no porting target) is blue instead of yellow to avoid reading as a warning, and "executable, not hit" is grey. --- .testing/tools/track_gpu_port.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.testing/tools/track_gpu_port.py b/.testing/tools/track_gpu_port.py index 5ff206c934..8fdbbc813b 100644 --- a/.testing/tools/track_gpu_port.py +++ b/.testing/tools/track_gpu_port.py @@ -584,16 +584,16 @@ def format_ranges(ranges): table.src td.ln { color: #888; text-align: right; user-select: none; } table.src td.cnt { color: #888; text-align: right; min-width: 2.5em; user-select: none; } .ported { background: #d9f2d9; } -.portable { background: #ffe6a8; } -.executed { background: #eaeaea; } -.nothit { background: #fad2d2; } +.portable { background: #fad2d2; } +.executed { background: #cfe2ff; } +.nothit { background: #eaeaea; } tr.nodata { background: transparent; } @media (prefers-color-scheme: dark) { body { background: #1e1e1e; color: #ddd; } .ported { background: #234d23; } - .portable { background: #5c4a13; } - .executed { background: #333333; } - .nothit { background: #5c2323; } + .portable { background: #5c2323; } + .executed { background: #1f3a5c; } + .nothit { background: #333333; } } """ From efba06f297fe459d6f6b48c638d8fbab1abb4501 Mon Sep 17 00:00:00 2001 From: Edward Yang Date: Tue, 14 Jul 2026 13:01:00 +1000 Subject: [PATCH 09/10] Publish a persistent porting_progress/ page on merges to dev/gpu Previously the GPU port HTML report was only ever published per-PR (pr-/) and got deleted again once the PR closed, so merging lost the report instead of updating a durable one. gpu-port-report now also runs on pushes to dev/gpu, and gpu-port-comment.yml publishes those to a fixed porting_progress/ path on gh-pages instead of a PR-numbered one. --- .github/workflows/gpu-port-comment.yml | 28 +++++++++++++------- .github/workflows/gpu-port-pages-cleanup.yml | 6 ++++- .github/workflows/verify-linux.yml | 17 +++++++++--- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/.github/workflows/gpu-port-comment.yml b/.github/workflows/gpu-port-comment.yml index b89d37a821..a39b20a127 100644 --- a/.github/workflows/gpu-port-comment.yml +++ b/.github/workflows/gpu-port-comment.yml @@ -3,7 +3,8 @@ # SPDX-License-Identifier: Apache-2.0 # Posts/updates a PR comment summarizing GPU-port coverage progress, and publishes the -# line-by-line HTML report to GitHub Pages under pr-/. Split out from +# line-by-line HTML report to GitHub Pages: under pr-/ for a PR, or under +# porting_progress/ for a push to dev/gpu (i.e. once a PR merges). Split out from # verify-linux.yml's gpu-port-report job on purpose: that job checks out and runs a PR's # code (which may come from a fork) and so must stay read-only, while commenting and # pushing to gh-pages needs write access. workflow_run always executes with the base @@ -21,7 +22,9 @@ permissions: {} jobs: publish-pages: - if: github.event.workflow_run.event == 'pull_request' + if: >- + github.event.workflow_run.event == 'pull_request' || + (github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'dev/gpu') runs-on: ubuntu-latest permissions: contents: write @@ -45,11 +48,11 @@ jobs: id: publish if: steps.download.outcome == 'success' run: | - if [ ! -f report/pr_number.txt ] || [ ! -d report/gpu_port_html ]; then + if [ ! -f report/gpu_port_publish_path.txt ] || [ ! -d report/gpu_port_html ]; then echo 'Report artifact incomplete; nothing to publish.' exit 0 fi - PR_NUMBER=$(cat report/pr_number.txt) + PUBLISH_PATH=$(cat report/gpu_port_publish_path.txt) git config user.name 'github-actions[bot]' git config user.email 'github-actions[bot]@users.noreply.github.com' @@ -61,15 +64,20 @@ jobs: git rm -rf . -q fi - rm -rf "pr-$PR_NUMBER" - mkdir -p "pr-$PR_NUMBER" - cp -r report/gpu_port_html/. "pr-$PR_NUMBER/" + rm -rf "$PUBLISH_PATH" + mkdir -p "$PUBLISH_PATH" + cp -r report/gpu_port_html/. "$PUBLISH_PATH/" { echo '' echo 'GPU port coverage previews' - echo '

GPU port coverage previews

    ' + echo '

    GPU port coverage previews

    ' + if [ -d porting_progress ]; then + echo '

    Latest on dev/gpu

    ' + fi + echo '
      ' for d in pr-*/; do + [ -d "$d" ] || continue n="${d%/}" echo "
    • $n
    • " done @@ -80,13 +88,13 @@ jobs: if git diff --cached --quiet; then echo 'No changes to publish.' else - git commit -q -m "Publish GPU port report for PR #$PR_NUMBER" + git commit -q -m "Publish GPU port report for $PUBLISH_PATH" git push origin HEAD:gh-pages fi OWNER="${GITHUB_REPOSITORY%%/*}" REPO="${GITHUB_REPOSITORY#*/}" - echo "url=https://${OWNER}.github.io/${REPO}/pr-$PR_NUMBER/" >> "$GITHUB_OUTPUT" + echo "url=https://${OWNER}.github.io/${REPO}/$PUBLISH_PATH/" >> "$GITHUB_OUTPUT" comment: if: github.event.workflow_run.event == 'pull_request' diff --git a/.github/workflows/gpu-port-pages-cleanup.yml b/.github/workflows/gpu-port-pages-cleanup.yml index 8ceb6209cc..187a13a4e5 100644 --- a/.github/workflows/gpu-port-pages-cleanup.yml +++ b/.github/workflows/gpu-port-pages-cleanup.yml @@ -47,7 +47,11 @@ jobs: { echo '' echo 'GPU port coverage previews' - echo '

      GPU port coverage previews

        ' + echo '

        GPU port coverage previews

        ' + if [ -d porting_progress ]; then + echo '

        Latest on dev/gpu

        ' + fi + echo '
          ' for d in pr-*/; do [ -d "$d" ] || continue n="${d%/}" diff --git a/.github/workflows/verify-linux.yml b/.github/workflows/verify-linux.yml index 7d5d1777a9..41413f237f 100644 --- a/.github/workflows/verify-linux.yml +++ b/.github/workflows/verify-linux.yml @@ -771,15 +771,24 @@ jobs: if-no-files-found: ignore # Picked up by gpu-port-comment.yml (via workflow_run, so it has write access) and - # published to the gh-pages branch under pr-/, then linked from the PR - # comment above. + # published to the gh-pages branch: under pr-/ for a PR, or under + # porting_progress/ for a push to dev/gpu (i.e. once a PR merges). + - name: Determine gh-pages publish path + if: always() && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/dev/gpu')) + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "pr-${{ github.event.pull_request.number }}" > gpu_port_publish_path.txt + else + echo "porting_progress" > gpu_port_publish_path.txt + fi + - uses: actions/upload-artifact@v4 - if: always() && github.event_name == 'pull_request' + if: always() && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/dev/gpu')) with: name: gpu-port-html path: | gpu_port_html - pr_number.txt + gpu_port_publish_path.txt retention-days: 1 if-no-files-found: ignore From cafce4dbe1aafe0e46a8db1620c3331ddec613b2 Mon Sep 17 00:00:00 2001 From: Edward Yang Date: Tue, 14 Jul 2026 15:33:47 +1000 Subject: [PATCH 10/10] gpu-port-comment.yml: download report artifact outside the git workdir The "publish-pages" job downloaded the report artifact into "report/" (inside the checked-out repo), then later ran "git add -A" when committing to gh-pages. That staged the whole working tree, not just the intended publish path and index.html, so the first successful run accidentally committed "report/" itself into gh-pages. Every subsequent run then failed: the fresh, untracked "report/" download collided with the tracked copy already on gh-pages, and "git checkout -B gh-pages FETCH_HEAD" aborted rather than overwrite it. Downloading to $RUNNER_TEMP instead means the artifact can never again collide with whatever's checked out, and scoping "git add" to exactly the publish path plus index.html means a stray leftover elsewhere in the workdir can't get swept into a gh-pages commit either. Does not by itself unblock the branch: gh-pages already has "report/" committed to it from the earlier bad run, so a one-time cleanup commit removing it from gh-pages is still needed before publishing will succeed again. --- .github/workflows/gpu-port-comment.yml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/gpu-port-comment.yml b/.github/workflows/gpu-port-comment.yml index a39b20a127..0856f6fda2 100644 --- a/.github/workflows/gpu-port-comment.yml +++ b/.github/workflows/gpu-port-comment.yml @@ -42,17 +42,23 @@ jobs: name: gpu-port-html run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} - path: report + # Outside the checked-out repo on purpose: this directory must survive + # switching the working tree to gh-pages below, which a path inside the repo + # (e.g. "report/") would not — an untracked "report/" would collide with a + # same-named tracked path if one ever existed on gh-pages, aborting the checkout. + path: ${{ runner.temp }}/gpu-port-report - name: Publish report to gh-pages id: publish if: steps.download.outcome == 'success' + env: + REPORT_DIR: ${{ runner.temp }}/gpu-port-report run: | - if [ ! -f report/gpu_port_publish_path.txt ] || [ ! -d report/gpu_port_html ]; then + if [ ! -f "$REPORT_DIR/gpu_port_publish_path.txt" ] || [ ! -d "$REPORT_DIR/gpu_port_html" ]; then echo 'Report artifact incomplete; nothing to publish.' exit 0 fi - PUBLISH_PATH=$(cat report/gpu_port_publish_path.txt) + PUBLISH_PATH=$(cat "$REPORT_DIR/gpu_port_publish_path.txt") git config user.name 'github-actions[bot]' git config user.email 'github-actions[bot]@users.noreply.github.com' @@ -66,7 +72,7 @@ jobs: rm -rf "$PUBLISH_PATH" mkdir -p "$PUBLISH_PATH" - cp -r report/gpu_port_html/. "$PUBLISH_PATH/" + cp -r "$REPORT_DIR/gpu_port_html/." "$PUBLISH_PATH/" { echo '' @@ -84,7 +90,10 @@ jobs: echo '
        ' } > index.html - git add -A + # Scoped on purpose (not "git add -A"): the working tree may contain other + # untracked leftovers from earlier steps, and this must only ever commit the + # publish path plus the regenerated index. + git add -- "$PUBLISH_PATH" index.html if git diff --cached --quiet; then echo 'No changes to publish.' else