diff --git a/projects/miopen/script/dependency-parser/main.py b/projects/miopen/script/dependency-parser/main.py index bfed4893fefe..4e95e9b2462d 100644 --- a/projects/miopen/script/dependency-parser/main.py +++ b/projects/miopen/script/dependency-parser/main.py @@ -18,15 +18,67 @@ """ import argparse +import importlib import os import subprocess +import time -def run_dependency_parser(args): - from src.enhanced_ninja_parser import main as ninja_main +# Bridge registry: name -> (module, callable). A "bridge" is an additive +# attribution pass that runs after the ninja-deps mapping and only unions extra +# edges into the parser's in-memory file->executables map (never modifying the +# base include graph). Modules live on the gap-fix branches +# (symbol -> src/symbol_graph, future runtime -> ...) and are imported lazily so +# the base branch works with no bridge selected. +BRIDGE_REGISTRY = { + "symbol": ("src.symbol_graph", "apply"), +} - sys.argv = ["enhanced_ninja_parser.py"] + args - ninja_main() +# Supersession: selecting the key drops the listed bridges (a superseding bridge +# makes the superseded one redundant). Empty until multiple bridges coexist. +BRIDGE_SUPERSEDES = {} + + +def resolve_bridges(bridges_arg): + """Parse the --bridges list, dropping bridges superseded by a selected one.""" + selected = [b.strip() for b in (bridges_arg or "").split(",") if b.strip()] + for superseding, disabled in BRIDGE_SUPERSEDES.items(): + if superseding in selected: + for name in disabled: + if name in selected: + selected.remove(name) + print(f"bridge '{name}' disabled by '{superseding}'") + seen = set() + return [b for b in selected if not (b in seen or seen.add(b))] + + +def apply_bridges(parser, bridges_arg): + """Run each selected additive bridge over the in-memory mapping, with timing.""" + for name in resolve_bridges(bridges_arg): + if name not in BRIDGE_REGISTRY: + sys.exit( + f"Unknown bridge '{name}'. Known bridges: {sorted(BRIDGE_REGISTRY)}" + ) + module_name, func_name = BRIDGE_REGISTRY[name] + try: + module = importlib.import_module(module_name) + except ImportError as e: + sys.exit( + f"Bridge '{name}' is not available on this branch " + f"(module {module_name} missing): {e}" + ) + print(f"[bridge:{name}] running...") + t0 = time.monotonic() + getattr(module, func_name)(parser) + print(f"[bridge:{name}] completed in {time.monotonic() - t0:.1f}s") + + +def run_dependency_parser(build_ninja, ninja, workspace_root, bridges): + from src.enhanced_ninja_parser import build_mapping, export_mapping + + parser = build_mapping(build_ninja, ninja, workspace_root or "..") + apply_bridges(parser, bridges) + export_mapping(parser, os.path.dirname(build_ninja)) def run_selective_test_filter(args): @@ -70,11 +122,17 @@ def get_git_origin_url(repo_path="."): return None -def write_shas_file(context, shas_file): - origin = get_git_origin_url() - print(f"{context}: origin={origin}") - feature_sha = get_git_sha(["git", "rev-parse", "HEAD"]) - base_sha = get_git_sha(["git", "merge-base", "HEAD", "origin/develop"]) +def write_shas_file(context, shas_file, base_ref="origin/develop", source_dir="."): + """Write base (merge-base with base_ref) and feature (HEAD) SHAs. + + source_dir points at the project's git worktree. For an in-source build (CI) + this is the default '.'; for an out-of-source build (TheRock) the build dir is + not a git repo, so the caller passes the MIOpen source dir. + """ + origin = get_git_origin_url(source_dir) + print(f"{context}: origin={origin} base_ref={base_ref} source_dir={source_dir}") + feature_sha = get_git_sha(["git", "-C", source_dir, "rev-parse", "HEAD"]) + base_sha = get_git_sha(["git", "-C", source_dir, "merge-base", "HEAD", base_ref]) with open(shas_file, "w") as file: file.write(f"{base_sha}\n") file.write(f"{feature_sha}\n") @@ -89,6 +147,148 @@ def read_shas_file(context, shas_file): return (base_sha, feature_sha) +def _finalize_truthy(value): + return value is not None and str(value).strip().lower() in ( + "1", + "true", + "yes", + "on", + ) + + +def _atomic_write(path, text): + """Write text to path via a temp file + os.replace so a concurrent reader on the + shared filesystem never observes a half-written file.""" + tmp = f"{path}.tmp" + with open(tmp, "w") as f: + f.write(text) + os.replace(tmp, path) + + +def run_finalize_ctest(args): + """TheRock builder step: burn each Dapper-enabled category's union filter into the + install CTestTestfile, and retain the full category as a '_unfiltered_suite'. + + For each Dapper-enabled category (yaml 'enable_dapper'), the existing '_suite' + keeps its name but its --gtest_filter is replaced with the subtractive union (honoring + fallback_mode); a '_unfiltered_suite' entry is added that keeps the full original + filter. Both the original and union filters are recorded in the dapper JSON for + reference (downloadable record). All computation happens here, at build time, in one + process; the runner just runs ctest with the burned-in filters (no dapper code ships). + + Fails open: if the yaml or dapper JSON can't be read, the CTestTestfile is copied + through unchanged so the full categories still run. + """ + import json + import re + + from src.dapper_union import resolve_filter + + def _passthrough(reason): + print(f"finalize-ctest: {reason}; leaving CTestTestfile unmodified.") + with open(args.ctest_in, "r") as fin: + _atomic_write(args.ctest_out, fin.read()) + + try: + import yaml + + with open(args.yaml, "r") as f: + cfg = yaml.safe_load(f) or {} + except Exception as e: # noqa: BLE001 - fail open on any yaml problem + _passthrough(f"cannot read yaml '{args.yaml}' ({e})") + return + + dapper_cats = { + name + for name, info in (cfg.get("test_categories") or {}).items() + if _finalize_truthy((info or {}).get("enable_dapper")) + } + if not dapper_cats: + _passthrough("no Dapper-enabled categories in yaml") + return + + try: + with open(args.dapper_json, "r") as f: + data = json.load(f) + except (OSError, ValueError) as e: + _passthrough(f"cannot read dapper json '{args.dapper_json}' ({e})") + return + dapper_filter = data.get("dapper_filter", "") + fallback_mode = data.get("fallback_mode", "union") + + add_test_re = re.compile(r"^\s*add_test\((\S+)\s") + setprops_re = re.compile(r"^\s*set_tests_properties\((\S+)\s") + filter_re = re.compile(r"--gtest_filter=([^\s)]+)") + + def match_category(name): + # Suite names are '__suite'; match by category suffix so we do + # not depend on the prefix. Prefer the longest matching category name. + if not name.endswith("_suite"): + return None + base = name[: -len("_suite")] + best = None + for cat in dapper_cats: + if (base == cat or base.endswith("_" + cat)) and ( + best is None or len(cat) > len(best) + ): + best = cat + return best + + def unfiltered_name(name): + return name[: -len("_suite")] + "_unfiltered_suite" + + with open(args.ctest_in, "r") as f: + lines = f.readlines() + + rewritten = {} # union-suite name -> unfiltered-suite name + processed = set() # category names finalized + out = [] + for line in lines: + m = add_test_re.match(line) + if m: + name = m.group(1) + cat = match_category(name) + fm = filter_re.search(line) if cat else None + if cat and fm: + original_filter = fm.group(1) + union = resolve_filter( + dapper_filter, fallback_mode, cat, original_filter + ) + name_unfiltered = unfiltered_name(name) + out.append( + line.replace( + f"--gtest_filter={original_filter}", + f"--gtest_filter={union}", + 1, + ) + ) + out.append( + line.replace(f"add_test({name} ", f"add_test({name_unfiltered} ", 1) + ) + rewritten[name] = name_unfiltered + processed.add(cat) + data[f"category_{cat}_filter"] = original_filter + data[f"category_{cat}_union"] = union + continue + sm = setprops_re.match(line) + if sm and sm.group(1) in rewritten: + name = sm.group(1) + out.append(line) # properties for the union suite (name unchanged) + out.append( + line.replace(name, rewritten[name], 1) + ) # ...and the _unfiltered suite + continue + out.append(line) + + _atomic_write(args.ctest_out, "".join(out)) + data["dapper_categories"] = sorted(processed) + _atomic_write(args.dapper_json, json.dumps(data, indent=2)) + print( + f"finalize-ctest: burned union into {len(rewritten)} dapper suite(s) " + f"({', '.join(sorted(processed)) or 'none'}); wrote {args.ctest_out}" + ) + + def main(): parser = argparse.ArgumentParser( description="Unified Ninja Dependency & Selective Testing Tool" @@ -100,6 +300,16 @@ def main(): "shas", help="Retrieve sha for merge-base and feature branch and storing in miopen_gtest_shas.txt.", ) + parser_shas.add_argument( + "--base-ref", + default="origin/develop", + help="Git ref to merge-base against for the impact diff (default origin/develop).", + ) + parser_shas.add_argument( + "--source-dir", + default=".", + help="Project git worktree (for out-of-source builds, e.g. TheRock).", + ) # Dependency parsing parser_parse = subparsers.add_parser( @@ -112,6 +322,12 @@ def main(): parser_parse.add_argument( "--workspace-root", help="Path to workspace root", default=None ) + parser_parse.add_argument( + "--bridges", + default="", + help="Comma-separated additive attribution bridges to run after the " + "ninja-deps mapping (e.g. 'symbol'). Empty = none.", + ) # Selective testing parser_test = subparsers.add_parser( @@ -149,6 +365,11 @@ def main(): help="Optional path to file containing a list of gtest shard output files", default="", ) + parser_test.add_argument( + "--source-dir", + default=".", + help="Project git worktree for the impact diff (out-of-source builds, e.g. TheRock).", + ) # Code auditing parser_audit = subparsers.add_parser( @@ -163,18 +384,38 @@ def main(): parser_opt.add_argument("depmap_json", help="Path to dependency mapping JSON") parser_opt.add_argument("changed_files", nargs="+", help="List of changed files") + # TheRock: burn per-category union filters into the install CTestTestfile. + parser_finalize = subparsers.add_parser( + "finalize-ctest", + help="Burn per-category Dapper union filters into the install CTestTestfile " + "and add '_unfiltered_suite' entries retaining the full filters (TheRock).", + ) + parser_finalize.add_argument( + "--ctest-in", required=True, help="Configure-generated install CTestTestfile" + ) + parser_finalize.add_argument( + "--ctest-out", required=True, help="Path to write the finalized CTestTestfile" + ) + parser_finalize.add_argument( + "--yaml", required=True, help="test_categories.yaml (for 'enable_dapper')" + ) + parser_finalize.add_argument( + "--dapper-json", + required=True, + help="miopen_dapper_tests.json (dapper_filter + fallback_mode; augmented in place)", + ) + args = parser.parse_args() shas_file = "miopen_dapper_shas.txt" if args.command == "shas": - write_shas_file("MAIN SHAS: ", shas_file) + write_shas_file("MAIN SHAS: ", shas_file, args.base_ref, args.source_dir) elif args.command == "parse": if not os.path.isfile(shas_file): write_shas_file("MAIN PARSE: ", shas_file) - parse_args = [args.build_ninja, args.ninja] - if args.workspace_root: - parse_args.append(args.workspace_root) - run_dependency_parser(parse_args) + run_dependency_parser( + args.build_ninja, args.ninja, args.workspace_root, args.bridges + ) elif args.command == "select": filter_args = [args.depmap_json] (base_sha, feature_sha) = read_shas_file("MAIN SELECT", shas_file) @@ -191,6 +432,8 @@ def main(): if args.shardsfile: print(f"main: ADDED SHARDSFILE: {args.shardsfile}") filter_args += ["--shardsfile", args.shardsfile] + if args.source_dir: + filter_args += ["--source-dir", args.source_dir] run_selective_test_filter(filter_args) elif args.command == "audit": run_selective_test_filter([args.depmap_json, "--audit"]) @@ -198,6 +441,8 @@ def main(): run_selective_test_filter( [args.depmap_json, "--optimize-build"] + args.changed_files ) + elif args.command == "finalize-ctest": + run_finalize_ctest(args) else: parser.print_help() diff --git a/projects/miopen/script/dependency-parser/src/all_gtest_fixtures.py b/projects/miopen/script/dependency-parser/src/all_gtest_fixtures.py index 5ceae26984da..2a74759364c9 100644 --- a/projects/miopen/script/dependency-parser/src/all_gtest_fixtures.py +++ b/projects/miopen/script/dependency-parser/src/all_gtest_fixtures.py @@ -6,11 +6,17 @@ import os import stat -import resource import subprocess import json from pathlib import Path +# 'resource' is a Unix-only stdlib module (absent on Windows). Import defensively so +# this module loads on Windows; core-dump limiting is a Unix concept and is skipped there. +try: + import resource +except ImportError: + resource = None + def is_executable(file_path: Path) -> bool: """Check if a file is an executable (not a directory).""" @@ -22,8 +28,9 @@ def is_executable(file_path: Path) -> bool: def disable_core_dump(): - """Disable core dump generation.""" - resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) + """Disable core dump generation (no-op on platforms without the 'resource' module).""" + if resource is not None: + resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) def list_gtest_fixtures(executable: Path): diff --git a/projects/miopen/script/dependency-parser/src/dapper_diff.py b/projects/miopen/script/dependency-parser/src/dapper_diff.py index 5368834357a3..b5b2a205185d 100644 --- a/projects/miopen/script/dependency-parser/src/dapper_diff.py +++ b/projects/miopen/script/dependency-parser/src/dapper_diff.py @@ -6,7 +6,7 @@ import json import os import re -from miopen_gtest_runner import calc_union_filter +from miopen_gtest_runner import calc_union_filter, abort_missing_shards def fixture_filter_to_regex(filter): @@ -25,6 +25,12 @@ def analyze_sharded_gtest(input_file): if not shard_log_files: print(f"Warning: No shard logs found in {input_file} (json key=gtest_shards)") + # Every shard must have produced output. If any are missing (e.g. a crashed shard), + # report exactly which ones and abort -- no partial analysis. + absent = [s for s in shard_log_files if not os.path.exists(s)] + if absent: + abort_missing_shards(absent, len(shard_log_files)) + def parse_gtest_filter(filt): positives = set() negatives = set() @@ -65,10 +71,7 @@ def is_in_dapper(fixture_name): other_fixtures = {} for log_file in shard_log_files: - if not os.path.exists(log_file): - print(f"Warning: Shard json file {log_file} not found. Skipping.") - continue - + # Presence already guaranteed by the up-front check above (missing shards abort). print(f"Parsing log file {log_file}..") with open(log_file, "r") as f: data = json.load(f) diff --git a/projects/miopen/script/dependency-parser/src/dapper_union.py b/projects/miopen/script/dependency-parser/src/dapper_union.py new file mode 100644 index 000000000000..ea56f57ac070 --- /dev/null +++ b/projects/miopen/script/dependency-parser/src/dapper_union.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# Copyright (c) Advanced Micro Devices, Inc., or its affiliates. +# SPDX-License-Identifier: MIT +"""Compute the Dapper union filter for a test category, honoring fallback_mode. + +Single source of truth for the pure union math (pattern splitting, overlap, and the +subtractive intersection). The native pipeline (miopen_gtest_runner.calc_union_filter) +imports these helpers directly; TheRock installs this file next to the test binary so +the GPU runner can compute the filter with only the installed artifact. + +Kept import-free of the rest of the dependency-parser package (stdlib only) so it can +stand alone once installed. Used by the per-project gtest_runner wrapper (e.g. +run_miopen_gtest.py) that the generated CTestTestfile invokes. + +Dapper is strictly subtractive: the returned positive set is always a subset of +the category's positives (or a minimal default), never a superset. The category's +negative patterns are always preserved. + +The dapper JSON (produced on the builder by `main.py select`) carries: +- dapper_filter : impact-derived positive fixture patterns (may be empty) +- fallback_mode : 'union' | 'entire_category' | 'minimal' +""" + +import fnmatch +import json + +# Super-minimal default when there is nothing meaningful to run in this category. +DEFAULT_MINIMAL_FILTER = "CPU_HandleHipDevice_NONE*" + + +def split_gtest_filter_includes(filter_str): + """Split a --gtest_filter string into (positives, negatives). + + Example: "A.*:B.*-C.*:D.*" -> (['A.*','B.*'], ['C.*','D.*']). + A negative-only filter yields positives == ['*'] (gtest runs all then subtracts). + """ + if not filter_str: + return [], [] + if "-" in filter_str: + positive_part, *negative_part = filter_str.split("-") + positives = [p for p in positive_part.split(":") if p] + negatives = [n for n in ":".join(negative_part).split(":") if n] + else: + positives = [p for p in filter_str.split(":") if p] + negatives = [] + if not positives: + positives = ["*"] + return positives, negatives + + +def _fixed_prefix(pattern): + """Literal portion of a wildcard pattern up to the first metacharacter.""" + for i, ch in enumerate(pattern): + if ch in "*?[": + return pattern[:i] + return pattern + + +def patterns_overlap(dapper_pattern, category_pattern): + """True if a dapper (prefix-style) and category (arbitrary wildcard) pattern + could match a common fixture. Tested both directions since fnmatch needs a + concrete string on one side.""" + return fnmatch.fnmatch( + _fixed_prefix(dapper_pattern), category_pattern + ) or fnmatch.fnmatch(_fixed_prefix(category_pattern), dapper_pattern) + + +def compute_union_filter(dapper_filter, category_filter): + """Intersect dapper positives with category positives; keep category negatives. + + Returns the gtest filter string to run. Empty overlap -> minimal default. + """ + dapper_positives, _ = split_gtest_filter_includes(dapper_filter) + category_positives, category_exclude = split_gtest_filter_includes(category_filter) + + union_positives = [ + dp + for dp in dapper_positives + if any(patterns_overlap(dp, cp) for cp in category_positives) + ] + # de-dupe, preserve order + seen = set() + union_positives = [p for p in union_positives if not (p in seen or seen.add(p))] + + if not union_positives: + print( + "dapper_union: no overlap between dapper filter and category " + f"'{category_filter}'; using minimal default '{DEFAULT_MINIMAL_FILTER}'." + ) + union_positives = [DEFAULT_MINIMAL_FILTER] + + result = ":".join(union_positives) + if category_exclude: + result = result + "-" + ":".join(category_exclude) + return result + + +def resolve_filter(dapper_filter, fallback_mode, category_name, category_filter): + """Resolve the effective gtest filter for a category from already-loaded dapper data. + + fallback_mode: + - 'minimal' -> minimal default (nothing test-relevant changed) + - 'entire_category' -> the category filter as-is (unattributable change; safe) + - 'union' (default) -> dapper impact filter intersected with the category + Never returns a superset of the category (subtractive-only). Pure (no file I/O), so + the build-time finalize step can reuse it without re-reading the JSON per category. + """ + if fallback_mode == "minimal": + final = DEFAULT_MINIMAL_FILTER + elif fallback_mode == "entire_category": + final = category_filter + else: # 'union' + final = compute_union_filter(dapper_filter, category_filter) + + print( + f"dapper_union: category='{category_name}' fallback_mode='{fallback_mode}' " + f"-> --gtest_filter={final}" + ) + return final + + +def compute_filter(dapper_json_path, category_name, category_filter): + """Read the dapper JSON and resolve the effective filter for a category. + + If the dapper JSON is missing or unreadable, fail open to the entire category + (safe; never skips). + """ + try: + with open(dapper_json_path, "r") as f: + data = json.load(f) + except (OSError, ValueError) as e: + print( + f"dapper_union: cannot read '{dapper_json_path}' ({e}); " + f"falling back to entire category for '{category_name}'." + ) + return category_filter + return resolve_filter( + data.get("dapper_filter", ""), + data.get("fallback_mode", "union"), + category_name, + category_filter, + ) diff --git a/projects/miopen/script/dependency-parser/src/enhanced_ninja_parser.py b/projects/miopen/script/dependency-parser/src/enhanced_ninja_parser.py index 82e0ffbbd3e3..87f08f9b5bb5 100644 --- a/projects/miopen/script/dependency-parser/src/enhanced_ninja_parser.py +++ b/projects/miopen/script/dependency-parser/src/enhanced_ninja_parser.py @@ -161,23 +161,54 @@ def _get_object_dependencies(self, object_file): print(f"Error getting dependencies for {object_file}: {e}") return [] + @staticmethod + def _project_relative(path): + """Normalize a build path to a project-relative path (strip up to 'miopen/').""" + return ( + path[path.find("miopen") + len("miopen/") :] if "miopen" in path else path + ) + + @staticmethod + def _is_gtest_source(source_path): + """True if a source path lives under a .../test/gtest/ directory.""" + parts = Path(source_path).parts + return any( + parts[i] == "test" and parts[i + 1] == "gtest" + for i in range(len(parts) - 1) + ) + + def _attribute_object_deps(self, obj_file, key): + """Attribute all project-file dependencies of one object to `key`.""" + for dep_file in self.object_to_all_deps.get(obj_file, []): + # Filter out system files and focus on project files + if self._is_project_file(dep_file): + self.file_to_executables[self._project_relative(dep_file)].add(key) + + def _add_single_gtest_synthetic_keys(self): + """Attribute each test/gtest source object to a synthetic bin/test_ key. + + The aggregated ``miopen_gtest`` executable links every test source, so the + real-executable mapping collapses to ``{bin/miopen_gtest}`` and loses + per-test granularity in a single-gtest build. Emitting the synthetic + ``bin/test_`` key -- the same key ``extract_gtest_fixtures`` uses -- + restores that granularity so selection joins the fixture map whether the + build is single-gtest or discrete. In discrete builds the synthetic key + equals the real discrete executable name, so this is idempotent. + """ + for obj_file, source in self.object_to_source.items(): + if self._is_gtest_source(source): + self._attribute_object_deps(obj_file, f"bin/test_{Path(source).stem}") + def _build_file_to_executable_mapping(self): """Build the final mapping from files to executables.""" print("Building file-to-executable mapping...") for exe, object_files in self.executable_to_objects.items(): for obj_file in object_files: - # Add all dependencies of this object file - if obj_file in self.object_to_all_deps: - for dep_file in self.object_to_all_deps[obj_file]: - project_dep_file = ( - dep_file[dep_file.find("miopen") + len("miopen/") :] - if "miopen" in dep_file - else dep_file - ) - # Filter out system files and focus on project files - if self._is_project_file(dep_file): - self.file_to_executables[project_dep_file].add(exe) + self._attribute_object_deps(obj_file, exe) + + # Single-gtest support (additive; idempotent for discrete builds). + self._add_single_gtest_synthetic_keys() print(f"Built mapping for {len(self.file_to_executables)} files") @@ -253,6 +284,12 @@ def export_to_json(self, output_file): "executable_to_files": { exe: sorted(files) for exe, files in exe_to_files.items() }, + # Every source compiled anywhere in the build (project-relative). Used by + # selective_test_filter to classify a changed source as compiled-in (and + # thus test-relevant) even when it maps to no fixtures. + "compiled_sources": sorted( + {self._project_relative(s) for s in self.object_to_source.values()} + ), "statistics": { "total_files": len(self.file_to_executables), "total_executables": len(self.executable_to_objects), @@ -302,26 +339,12 @@ def print_summary(self): print(f" {file_path}: {len(exes)} executables") -def main(): - # Accept: build_file, ninja_path, workspace_root - default_workspace_root = ".." - if len(sys.argv) > 3: - build_file = sys.argv[1] - ninja_path = sys.argv[2] - workspace_root = sys.argv[3] - elif len(sys.argv) > 2: - build_file = sys.argv[1] - ninja_path = sys.argv[2] - workspace_root = default_workspace_root - elif len(sys.argv) > 1: - build_file = sys.argv[1] - ninja_path = "ninja" - workspace_root = default_workspace_root - else: - build_file = f"{default_workspace_root}/build-ninja/build.ninja" - ninja_path = "ninja" - workspace_root = default_workspace_root +def build_mapping(build_file, ninja_path="ninja", workspace_root=".."): + """Parse build.ninja into a dependency mapping and return the parser. + Kept separate from export so callers (e.g. main.py) can run additive bridge + passes over the in-memory maps before the JSON is written. + """ if not os.path.exists(build_file): print(f"Error: Build file not found: {build_file}") sys.exit(1) @@ -336,9 +359,11 @@ def main(): parser.workspace_root = workspace_root # Attach for use in _get_object_dependencies parser.parse_dependencies() parser.print_summary() + return parser + - # Export results - output_dir = os.path.dirname(build_file) +def export_mapping(parser, output_dir): + """Write the CSV and JSON mapping outputs; return the JSON path.""" csv_file = os.path.join(output_dir, "enhanced_file_executable_mapping.csv") json_file = os.path.join(output_dir, "miopen_dapper_mapping.json") @@ -348,6 +373,31 @@ def main(): print(f"\nResults exported to:") print(f" CSV: {csv_file}") print(f" JSON: {json_file}") + return json_file + + +def main(): + # Accept: build_file, ninja_path, workspace_root + default_workspace_root = ".." + if len(sys.argv) > 3: + build_file = sys.argv[1] + ninja_path = sys.argv[2] + workspace_root = sys.argv[3] + elif len(sys.argv) > 2: + build_file = sys.argv[1] + ninja_path = sys.argv[2] + workspace_root = default_workspace_root + elif len(sys.argv) > 1: + build_file = sys.argv[1] + ninja_path = "ninja" + workspace_root = default_workspace_root + else: + build_file = f"{default_workspace_root}/build-ninja/build.ninja" + ninja_path = "ninja" + workspace_root = default_workspace_root + + parser = build_mapping(build_file, ninja_path, workspace_root) + export_mapping(parser, os.path.dirname(build_file)) if __name__ == "__main__": diff --git a/projects/miopen/script/dependency-parser/src/extract_gtest_fixtures.py b/projects/miopen/script/dependency-parser/src/extract_gtest_fixtures.py index d16b0a980cd8..f81fa8fb8edf 100644 --- a/projects/miopen/script/dependency-parser/src/extract_gtest_fixtures.py +++ b/projects/miopen/script/dependency-parser/src/extract_gtest_fixtures.py @@ -9,7 +9,6 @@ import json import os import re -import resource import shlex import stat import subprocess @@ -257,10 +256,12 @@ def extract_gtext_fixtures(compile_commands: str, output_file: str, pp_folder: s def main(): - if len(sys.argv) < 2: - compile_commands = "compile_commands.json" - else: - compile_commands = sys.argv[1] + positional = sys.argv[1:] + + compile_commands = positional[0] if positional else "compile_commands.json" + output_file = ( + positional[1] if len(positional) > 1 else "miopen_dapper_fixtures.json" + ) compile_commands_path = Path(compile_commands) if not compile_commands_path.is_file(): @@ -270,11 +271,6 @@ def main(): ) sys.exit(1) - if len(sys.argv) < 3: - output_file = "miopen_dapper_fixtures.json" - else: - output_file = sys.argv[2] - pp_folder = "test/gtest/pp" t0 = time.monotonic() extract_gtext_fixtures(compile_commands, output_file, pp_folder) diff --git a/projects/miopen/script/dependency-parser/src/miopen_gtest_runner.py b/projects/miopen/script/dependency-parser/src/miopen_gtest_runner.py index 6985a4f896e3..6e0dbc894a9f 100644 --- a/projects/miopen/script/dependency-parser/src/miopen_gtest_runner.py +++ b/projects/miopen/script/dependency-parser/src/miopen_gtest_runner.py @@ -3,83 +3,58 @@ if sys.version_info < (3, 10): sys.exit("Python 3.10 or later is required.") -import fnmatch import json import os import subprocess from pathlib import Path +# The pure union math (pattern splitting/overlap + subtractive intersection) is shared +# with the TheRock runner; dapper_union.py is the single source of truth for it. +from dapper_union import compute_union_filter -def split_gtest_filter_includes(filter_str): - """ - Splits a --gtest_filter style string into positive and negative filter lists. - - Example: - "ABC.*:DEF.*:-XYZ.*:-123.*" - -> (['ABC.*', 'DEF.*'], ['XYZ.*', '123.*']) - """ - if not filter_str: - return [], [] - - # Split into positive and negative parts - if "-" in filter_str: - positive_part, *negative_part = filter_str.split("-") - positives = [p for p in positive_part.split(":") if p] - negatives = negative_part - else: - positives = [p for p in filter_str.split(":") if p] - negatives = [] - # If filter is negative-only, gtest includes all tests - if not positives: - positives = ["*"] +def abort_missing_shards(missing, total): + """Report every shard that produced no output and abort with a non-zero exit. - return positives, negatives - - -def matches_any_filter(s, filters): + A shard with neither its .xml nor .json means the gtest process exited before + writing results (typically a crash). Dapper does NOT continue with a partial set + of shards -- a partial analysis is misleading -- so it lists exactly which shards + failed and fails the whole run. """ - Checks if a string 's' matches any of the wildcard patterns in 'filters'. - """ - return any(fnmatch.fnmatch(s, pattern) for pattern in filters) - + bar = "=" * 72 + print(bar, file=sys.stderr) + print( + f"DAPPER FATAL: {len(missing)} of {total} gtest shard(s) produced no output " + "(the shard's gtest process exited before writing its XML, e.g. it crashed).", + file=sys.stderr, + ) + print("Failed shard(s):", file=sys.stderr) + for shard in missing: + p = Path(shard) + print( + f" - {p.stem}: no {p.with_suffix('.json').name} or {p.name} in {p.parent}", + file=sys.stderr, + ) + print( + "Aborting: dapper will not produce a partial analysis from an incomplete " + "set of shards.", + file=sys.stderr, + ) + print(bar, file=sys.stderr) + sys.exit(1) -def _fixed_prefix(pattern): - """ - Return the literal portion of a wildcard pattern up to the first wildcard - metacharacter ('*', '?', '['). Dapper patterns only wildcard at the end, so - for them this is the full fixture name; category patterns may wildcard - anywhere, so this is just their leading literal. - """ - for i, ch in enumerate(pattern): - if ch in "*?[": - return pattern[:i] - return pattern +def _convert_xml_shards(json_data): + """Convert XML shard paths to JSON, preferring an existing .json over the .xml source. -def patterns_overlap(dapper_pattern, category_pattern): - """ - Return True if a dapper (prefix-style) pattern and a category (arbitrary - wildcard) pattern could match a common gtest fixture name. - - fnmatch needs a concrete string on one side and a pattern on the other, so a - single stripped comparison is asymmetric and misses real overlaps. We test - both directions: the dapper pattern's literal prefix against the category - glob, and the category pattern's literal prefix against the dapper glob. - Either match means gtest would run at least one shared fixture, so the dapper - pattern belongs in the union. + If any shard has neither output, ALL missing shards are reported and the run is + aborted (see abort_missing_shards) -- no partial analysis. """ - return fnmatch.fnmatch( - _fixed_prefix(dapper_pattern), category_pattern - ) or fnmatch.fnmatch(_fixed_prefix(category_pattern), dapper_pattern) - - -def _convert_xml_shards(json_data): - """Convert XML shard paths to JSON, preferring an existing .json over the .xml source.""" from selective_test_filter import _xml_to_gtest_json shards = json_data.get("gtest_shards", []) converted = [] + missing = [] changed = False for shard in shards: p = Path(shard) @@ -97,29 +72,28 @@ def _convert_xml_shards(json_data): converted.append(str(json_path)) changed = True else: - print( - f"Error: shard '{p.stem}' is missing both its .json and .xml outputs." - ) - print( - "Either run the tests to generate shard outputs, or copy valid shard" - ) - print(f"files ({json_path.name} or {p.name}) into: {p.parent}") - sys.exit(1) + missing.append(shard) + converted.append(shard) else: converted.append(shard) + if missing: + abort_missing_shards(missing, len(shards)) if changed: json_data["gtest_shards"] = converted def calc_union_filter(gtest_filter_json: str, category_name: str, category_filter: str): + """Native (validate-mode) union: convert the shard XML, compute the subtractive + union via the shared dapper_union helper, and record it back into the shards JSON. + + The union math itself lives in dapper_union.compute_union_filter (shared with the + TheRock runner); here we only own the shard-JSON I/O and the annotations that + dapper_diff reads back. + """ with open(gtest_filter_json, "r") as f: json_data = json.load(f) _convert_xml_shards(json_data) - # super-minimal default test if there's nothing to do: - default_filter = "CPU_HandleHipDevice_NONE*" - dapper_filter = default_filter - if "dapper_filter" in json_data: - dapper_filter = json_data["dapper_filter"] + dapper_filter = json_data.get("dapper_filter", "") json_data["category_name"] = category_name category_filter_name = ( @@ -127,42 +101,7 @@ def calc_union_filter(gtest_filter_json: str, category_name: str, category_filte ) json_data[category_filter_name] = category_filter - # The category filter can contain wildcards anywhere, but dapper only does at the - # end of each fixture, so it's easy to compare each dapper item for a category match. - # Also, dapper does not define negatives, so enforce this by ignoring them. - dapper_positives, _ = split_gtest_filter_includes(dapper_filter) - category_positives, category_exclude = split_gtest_filter_includes(category_filter) - - union_positives = [ - df - for df in dapper_positives - if any(patterns_overlap(df, cp) for cp in category_positives) - ] - deduped = list(dict.fromkeys(union_positives)) - duplicates_removed = len(union_positives) - len(deduped) - if duplicates_removed: - print(f"Removed {duplicates_removed} duplicate entries from union_positives") - union_positives = deduped - - # If the Dapper filter and the category filter share no fixtures, there is - # nothing meaningful to run. Fall back to the super-minimal default test and - # warn that coverage may be missing. This is a PASS and COMPLIANT situation, - # not a failure -- an empty positive filter would otherwise make gtest run - # everything, which is the opposite of what's intended. - if not union_positives: - print( - "WARNING: no overlap between the Dapper filter and category filter " - "'{0}'; falling back to super-minimal default test " - "'{1}'. Testing may be missing for this category, but " - "this is a PASS and COMPLIANT.".format(category_name, default_filter) - ) - union_positives = [default_filter] - - union_filter = ":".join(union_positives) - if category_exclude: - category_exclude_filter = ":".join(category_exclude) - union_filter = union_filter + "-" + category_exclude_filter - + union_filter = compute_union_filter(dapper_filter, category_filter) json_data["union_filter"] = union_filter with open(gtest_filter_json, "w") as f: diff --git a/projects/miopen/script/dependency-parser/src/selective_test_filter.py b/projects/miopen/script/dependency-parser/src/selective_test_filter.py index a06203e9dc3a..cecfcea72d2f 100644 --- a/projects/miopen/script/dependency-parser/src/selective_test_filter.py +++ b/projects/miopen/script/dependency-parser/src/selective_test_filter.py @@ -35,24 +35,36 @@ import xml.etree.ElementTree as ET -def get_changed_files(ref1, ref2, path_to_folder): - """Return a set of files changed between two git refs.""" - base_commit = subprocess.run( - ["git", "show", "-s", '--format="%h %ad %s"', "--date=iso", f"{ref1}"], - capture_output=True, - text=True, - check=True, - ) - feat_commit = subprocess.run( - ["git", "show", "-s", '--format="%h %ad %s"', "--date=iso", f"{ref2}"], - capture_output=True, - text=True, - check=True, - ) +def get_changed_files(ref1, ref2, path_to_folder, source_dir="."): + """Return the set of files changed between two git refs, or None if the refs or + the diff cannot be resolved. + + source_dir is the project's git worktree; for an out-of-source build (TheRock) + the caller passes the MIOpen source dir since the build dir is not a git repo. + Returning None lets the caller fail open (run the entire category) rather than + crash when the base ref / history is unavailable. + """ + git = ["git", "-C", source_dir] + try: + base_commit = subprocess.run( + git + ["show", "-s", '--format="%h %ad %s"', "--date=iso", f"{ref1}"], + capture_output=True, + text=True, + check=True, + ) + feat_commit = subprocess.run( + git + ["show", "-s", '--format="%h %ad %s"', "--date=iso", f"{ref2}"], + capture_output=True, + text=True, + check=True, + ) + except subprocess.CalledProcessError as e: + print(f"DAPPER: could not resolve refs {ref1}..{ref2} in {source_dir}: {e}") + return None print(f"DAPPER branches: MERGE-BASE: {base_commit.stdout.strip()}") print(f" FEATURE: {feat_commit.stdout.strip()}") - args = ["git", "diff", "--name-only", ref1, ref2] + args = git + ["diff", "--name-only", ref1, ref2] if path_to_folder: args += ["--", path_to_folder] try: @@ -65,7 +77,7 @@ def get_changed_files(ref1, ref2, path_to_folder): return files except subprocess.CalledProcessError as e: print(f"Error running git diff: {e}") - sys.exit(1) + return None def load_depmap(depmap_json): @@ -104,10 +116,9 @@ def select_tests(file_to_executables, changed_files, filter_mode): return sorted(affected) -def create_gtest_filter(tests_to_run, fixturemap_json): +def create_gtest_filter(tests_to_run, fixturemap): gtest_filter = "" - if fixturemap_json: - fixturemap = load_fixturemap(fixturemap_json) + if fixturemap: for file in tests_to_run: if file not in fixturemap: if file != "bin/miopen_gtest": @@ -123,6 +134,34 @@ def create_gtest_filter(tests_to_run, fixturemap_json): return gtest_filter +def classify_fallback( + changed_files, file_to_executables, fixturemap, compiled_sources, gtest_filter +): + """Decide how the runner should behave when attribution is incomplete. + + - 'union' : changes were attributed to fixtures -> run the subtractive + union (dapper's normal, time-saving mode). + - 'entire_category' : >=1 changed file is part of the build graph but attributed to + NO fixtures (e.g. a common .cpp body the include graph cannot + reach). Run the entire category filter -- safe, never misses, + and never the near-empty minimal default. Bridges move such + files back to 'union'. + - 'minimal' : no test-relevant change -> minimal smoke default. + """ + if not changed_files: + return "minimal" + saw_attributed = bool(gtest_filter) + for f in changed_files: + exes = file_to_executables.get(f, []) + if any(e in fixturemap for e in exes): + continue # attributed to a fixture-bearing test + # Unattributed. Treat as test-relevant if it is compiled anywhere in the + # build, or is a dependency (e.g. header) of some compiled object. + if f in compiled_sources or exes: + return "entire_category" + return "union" if saw_attributed else "minimal" + + def _xml_timestamp(ts): return ts.split(".")[0] + "Z" @@ -277,29 +316,62 @@ def main(): idx = sys.argv.index("--shardsfile") if idx + 1 < len(sys.argv): shardsfile = sys.argv[idx + 1] + source_dir = "." + if "--source-dir" in sys.argv: + idx = sys.argv.index("--source-dir") + if idx + 1 < len(sys.argv): + source_dir = sys.argv[idx + 1] if not os.path.exists(depmap_json): print(f"Dependency map JSON not found: {depmap_json}") sys.exit(1) - changed_files = get_changed_files(ref1, ref2, path_to_folder) - if not changed_files: + changed_files = get_changed_files(ref1, ref2, path_to_folder, source_dir) + + # Load the mapping once: file->executables plus the compiled-source set used to + # classify unattributed-but-compiled changes. + with open(depmap_json, "r") as f: + depmap_raw = json.load(f) + file_to_executables = depmap_raw.get("file_to_executables", depmap_raw) + compiled_sources = set(depmap_raw.get("compiled_sources", [])) + fixturemap = load_fixturemap(fixturemap_json) if fixturemap_json else None + + if changed_files is None: + # Could not determine the diff (missing base ref / shallow history). Fail open: + # run the entire category so nothing is silently skipped. + print("DAPPER: changed files undetermined; using entire_category fallback.") + tests = [] + gtest_filter = "" + fallback_mode = "entire_category" + elif not changed_files: print("No changed files detected.") tests = [] gtest_filter = "" + fallback_mode = "minimal" else: - file_to_executables = load_depmap(depmap_json) tests = select_tests(file_to_executables, changed_files, filter_mode) - gtest_filter = create_gtest_filter(tests, fixturemap_json) + gtest_filter = create_gtest_filter(tests, fixturemap) if shardsfile: gtest_shards = load_shards(shardsfile) + if fixturemap: + fallback_mode = classify_fallback( + changed_files, + file_to_executables, + fixturemap, + compiled_sources, + gtest_filter, + ) + else: + # No fixture map -> gtest_filter is "*" (run all); union is the safe label. + fallback_mode = "union" with open(output_json, "w") as f: json.dump( { "tests_to_run": tests, "dapper_filter": gtest_filter, - "changed_files": sorted(changed_files), + "fallback_mode": fallback_mode, + "changed_files": sorted(changed_files) if changed_files else [], "gtest_shards": gtest_shards, }, f, diff --git a/projects/miopen/script/dependency-parser/src/symbol_graph.py b/projects/miopen/script/dependency-parser/src/symbol_graph.py new file mode 100644 index 000000000000..e5c2a825903a --- /dev/null +++ b/projects/miopen/script/dependency-parser/src/symbol_graph.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +# Copyright (c) Advanced Micro Devices, Inc., or its affiliates. +# SPDX-License-Identifier: MIT +"""Symbol bridge (Option B) for the dependency parser. + +Additive attribution pass that runs after the ninja-deps (include-graph) mapping. +It closes the common-`.cpp`-body gap precisely, using the linker's own view. + +A change to a ``.cpp`` body can only affect another translation unit through an +out-of-line symbol that unit references, so we attribute each source to the test +sources whose objects reference a symbol that source defines -- exactly what the +linker pulls in. This is semantically exact for compiled bodies and composes with +the include graph, which covers header-only / template / inline / macro code (none +of which has an out-of-line symbol for ``nm`` to see). + +Not a replacement for the include graph -- only an additive layer. Additive and +idempotent; never removes edges. Costs one ``nm`` per project-compiled object +(parallelized); heavier than the stem bridge but does not over- or under-attribute +the way the sibling-header heuristic can. +""" + +import os +import subprocess +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +# nm type codes. Undefined = a reference the linker must resolve. Defined-global = +# a symbol other objects can link against (uppercase = external/global; 'u'/'i' are +# unique/indirect globals). Local (lowercase t/d/b/r/s) symbols are not cross-TU +# linkable and are intentionally excluded as providers. +_UNDEFINED_TYPES = frozenset("Uw") +_DEFINED_GLOBAL_TYPES = frozenset("TDBRWVGSui") + + +def _norm(parser, path): + """Project-relative path via whatever normalizer the base parser exposes.""" + fn = getattr(parser, "_to_project_relative", None) or getattr( + parser, "_project_relative" + ) + return fn(path) + + +def _nm_symbols(obj_path): + """Return (defined_global, undefined) symbol sets for an object file via nm.""" + defined, undefined = set(), set() + try: + result = subprocess.run( + ["nm", "--no-demangle", obj_path], + capture_output=True, + text=True, + timeout=120, + ) + except (FileNotFoundError, subprocess.SubprocessError): + return defined, undefined + if result.returncode != 0: + return defined, undefined + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) == 2: # "U symbol" (undefined: no address) + type_code, symbol = parts + elif len(parts) == 3: # "address T symbol" + _, type_code, symbol = parts + else: + continue + if type_code in _UNDEFINED_TYPES: + undefined.add(symbol) + elif type_code in _DEFINED_GLOBAL_TYPES: + defined.add(symbol) + return defined, undefined + + +def apply(parser): + """Attribute each defining source to the test sources referencing its symbols.""" + objects = list(parser.object_to_source.keys()) + if not objects: + print("[bridge:symbol] no objects to scan") + return + + def abs_obj(obj): + return obj if os.path.isabs(obj) else os.path.join(parser.build_dir, obj) + + with ThreadPoolExecutor(max_workers=min(16, len(objects))) as executor: + symbols = dict( + zip(objects, executor.map(lambda o: _nm_symbols(abs_obj(o)), objects)) + ) + + # symbol -> objects that define it (global) + definers = {} + for obj, (defined, _undef) in symbols.items(): + for sym in defined: + definers.setdefault(sym, set()).add(obj) + + # For each test object, route its undefined symbols back to the defining source + # and attribute that source to this test's synthetic bin/test_ key. + f2e = parser.file_to_executables + added = 0 + for test_obj, (_defined, undefined) in symbols.items(): + test_src = parser.object_to_source.get(test_obj) + if not test_src or not parser._is_gtest_source(test_src): + continue + test_key = f"bin/test_{Path(test_src).stem}" + for sym in undefined: + for def_obj in definers.get(sym, ()): + if def_obj == test_obj: + continue + def_src = parser.object_to_source.get(def_obj) + if not def_src: + continue + rel = _norm(parser, def_src) + if rel.startswith(".."): + continue # outside the project source tree + if test_key not in f2e[rel]: + f2e[rel].add(test_key) + added += 1 + print(f"[bridge:symbol] symbol-graph attribution: {added} edges added") diff --git a/projects/miopen/test/gtest/CMakeLists.txt b/projects/miopen/test/gtest/CMakeLists.txt index e520c4b6800c..7bbd413e79da 100644 --- a/projects/miopen/test/gtest/CMakeLists.txt +++ b/projects/miopen/test/gtest/CMakeLists.txt @@ -3,29 +3,22 @@ find_package(GTest REQUIRED) include(GoogleTest) # Detect a TheRock build: TheRock injects THEROCK_SUBPROJECT_TARGET into every sub-project -# configure (via CMAKE_PROJECT_TOP_LEVEL_INCLUDES). Dapper must be completely inert there. +# configure (via CMAKE_PROJECT_TOP_LEVEL_INCLUDES). if(DEFINED THEROCK_SUBPROJECT_TARGET) set(MIOPEN_BUILD_IN_THEROCK ON) else() set(MIOPEN_BUILD_IN_THEROCK OFF) endif() -if(MIOPEN_BUILD_IN_THEROCK) - # dapper_init() normally force-sets MIOPEN_TEST_SINGLE_GTEST / MIOPEN_TEST_DISCRETE. With - # Dapper skipped, restore the pre-Dapper default so the single aggregated gtest is built - # when discrete binaries are not (old `if(MIOPEN_TEST_DISCRETE) ... else() ... endif()`). - if(NOT DEFINED MIOPEN_TEST_SINGLE_GTEST) - if(MIOPEN_TEST_DISCRETE) - set(MIOPEN_TEST_SINGLE_GTEST OFF) - else() - set(MIOPEN_TEST_SINGLE_GTEST ON) - endif() - endif() -else() - find_package(Python 3 REQUIRED COMPONENTS Interpreter) - include(${CMAKE_CURRENT_LIST_DIR}/dapper.cmake) - dapper_init() -endif() +# Gates the native shard-file / dapper_dev_filters / dapper_add_sharded_test machinery +# below. dapper_init() flips it ON for an enabled native build; it stays OFF in TheRock and +# when Dapper is off. +set(MIOPEN_ENABLE_DAPPER_NATIVE OFF) + +# Dapper mode selection, single-gtest defaulting, and all native/TheRock wiring live in +# dapper_init() (see dapper.cmake) to keep Dapper's footprint here minimal. +include(${CMAKE_CURRENT_LIST_DIR}/dapper.cmake) +dapper_init() if(MIOPEN_USE_HIPBLASLT) find_package(hipblaslt REQUIRED PATHS /opt/rocm $ENV{HIP_PATH}) @@ -284,7 +277,7 @@ function(add_gtest TEST_NAME TEST_CPP) set(MIOPEN_GTEST_SHARDS "") - if(NOT MIOPEN_BUILD_IN_THEROCK) + if(MIOPEN_ENABLE_DAPPER_NATIVE) file(REMOVE "${SHARDS_FILE}") file(TOUCH "${SHARDS_FILE}") dapper_dev_filters() @@ -302,7 +295,7 @@ function(add_gtest TEST_NAME TEST_CPP) --gtest_output=xml:${MIOPEN_GTEST_SHARD_XML} WORKING_DIRECTORY "${KERNELS_BINARY_DIR}" ) - if(NOT MIOPEN_BUILD_IN_THEROCK) + if(MIOPEN_ENABLE_DAPPER_NATIVE) file(APPEND ${SHARDS_FILE} "${MIOPEN_GTEST_SHARD_XML}\n") endif() list(APPEND MIOPEN_GTEST_SHARDS ${MIOPEN_GTEST_SHARD}) @@ -316,19 +309,24 @@ function(add_gtest TEST_NAME TEST_CPP) # pressure). Register it as a single RUN_SERIAL entry so ctest runs it alone, never alongside # another test on the GPU. Skipped when there is no GPU, since these are *GPU* tests. if(NOT MIOPEN_NO_GPU) + set(SERIAL_TEST_XML ${PROJECT_BINARY_DIR}/test_results/${TEST_NAME}_shard_hip_graph_serial.xml) add_test(NAME ${TEST_NAME}_hip_graph_serial COMMAND ${TEST_NAME} --gtest_filter=*HipGraphExist* - --gtest_output=xml:${PROJECT_BINARY_DIR}/test_results/${TEST_NAME}_shard_hip_graph_serial.xml + --gtest_output=xml:${SERIAL_TEST_XML} WORKING_DIRECTORY "${KERNELS_BINARY_DIR}" ) set_tests_properties(${TEST_NAME}_hip_graph_serial PROPERTIES RUN_SERIAL TRUE ENVIRONMENT "MIOPEN_USER_DB_PATH=${CMAKE_CURRENT_BINARY_DIR};MIOPEN_INVOKED_FROM_CTEST=1" ) + if(MIOPEN_ENABLE_DAPPER_NATIVE) + # Serial runs are also analyzed by dapper. + file(APPEND ${SHARDS_FILE} "${SERIAL_TEST_XML}\n") + endif() endif() - if(NOT MIOPEN_BUILD_IN_THEROCK) + if(MIOPEN_ENABLE_DAPPER_NATIVE) dapper_add_sharded_test() endif() @@ -426,20 +424,29 @@ message(STATUS "YAML-based test categorization:") list(APPEND CMAKE_MESSAGE_INDENT " ") if (EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/test_categories.yaml") message(STATUS "YAML-based test categorization") - message(STATUS "apply_test_category_labels(miopen_gtest ${CMAKE_CURRENT_SOURCE_DIR}/test_categories.yaml ${PROJECT_BINARY_DIR}/${DATABASE_INSTALL_DIR} ${INSTALL_TEST_FILE})") apply_test_category_labels(miopen_gtest "${CMAKE_CURRENT_SOURCE_DIR}/test_categories.yaml" "${KERNELS_BINARY_DIR}" "${INSTALL_TEST_FILE}") + # Active (union) mode: produce the impact JSON and burn each Dapper-enabled category's + # union filter into the install CTestTestfile (retaining the full filter as a + # '_unfiltered_suite'). All heavy lifting is in dapper.cmake. + if(MIOPEN_DAPPER_MODE STREQUAL "union") + dapper_therock_generate_json("${INSTALL_TEST_FILE}" "${CMAKE_CURRENT_SOURCE_DIR}/test_categories.yaml") + endif() else() message(STATUS "No test categorization (${MIOPEN_TEST_CATEGORIES_CMAKE} missing)") endif() list(POP_BACK CMAKE_MESSAGE_INDENT) -# Install CTestTestfile.cmake to bin/MIOpen/ subdirectory for TheRock distribution +# Install CTestTestfile.cmake to bin/MIOpen/ subdirectory for TheRock distribution. +# In union mode the finalized CTestTestfile (union burned in) is installed instead, by +# dapper_therock_generate_json(); otherwise install the CTestTestfile as generated. if( NOT ENABLE_ASAN_PACKAGING ) - install( - FILES "${INSTALL_TEST_FILE}" - DESTINATION "${CMAKE_INSTALL_BINDIR}/${PROJECT_NAME}" - COMPONENT tests - RENAME "CTestTestfile.cmake" - ) + if(NOT MIOPEN_DAPPER_MODE STREQUAL "union") + install( + FILES "${INSTALL_TEST_FILE}" + DESTINATION "${CMAKE_INSTALL_BINDIR}/${PROJECT_NAME}" + COMPONENT tests + RENAME "CTestTestfile.cmake" + ) + endif() endif() diff --git a/projects/miopen/test/gtest/DAPPER.md b/projects/miopen/test/gtest/DAPPER.md new file mode 100644 index 000000000000..2300a983d83c --- /dev/null +++ b/projects/miopen/test/gtest/DAPPER.md @@ -0,0 +1,199 @@ +# Dapper — selective gtest filtering for MIOpen + +Dapper narrows the set of MIOpen gtest fixtures that run to those a change could +actually affect, computed from the git diff and the build's dependency graph. It is +**strictly subtractive**: the set it runs is always a subset of the user/category +filter (or a minimal default) — it never adds fixtures beyond what was requested. + +The expensive part (deciding *what could be affected*) is done at build time on a +GPU-less machine; only the actual gtest execution needs a GPU. This makes Dapper a +natural fit for TheRock's split CI (a builder that defines artifacts + a separate GPU +runner that installs and executes them). + +- [Modes](#modes) +- [Core concepts](#core-concepts) +- [Full test cycle (TheRock)](#full-test-cycle-therock) +- [Native MIOpen-CI cycle](#native-miopen-ci-cycle) +- [Attribution bridges](#attribution-bridges) +- [Configuration](#configuration) +- [Files](#files) +- [Known limitations](#known-limitations) + +## Modes + +Selected with the CMake cache variable `MIOPEN_DAPPER_MODE`: + +| Mode | Meaning | Default in | +|------|---------|------------| +| `off` | Dapper disabled. The single gtest and its shard tests still build/run; no impact analysis. | — | +| `validate` | Native shard run uses the full category; Dapper computes the union only to *validate* coverage (`dapper_diff`). | native / MIOpen-CI | +| `union` | **Active** — the reduced subtractive union filter actually runs. | TheRock | + +## Core concepts + +- **user / category filter** — the fixtures the caller asked for. In TheRock these + come from a *category* in `test_categories.yaml` (e.g. `standard`), selected at + test launch. +- **dapper_filter** — the impact set: fixtures reachable from the files changed + between the merge-base and HEAD, via the dependency graph. Computed on the builder. +- **union_filter** — `dapper_filter` ∩ category positives, plus the category's + negatives. Always ⊆ the category (subtractive). Computed on the builder and burned + into the install `CTestTestfile.cmake` (TheRock) / computed by `dapper_diff` (native). +- **fallback_mode** — how the effective filter is chosen when the impact set is + unusable, decided on the builder and applied when the union is computed: + - `union` — attributed changes exist → run the intersection. + - `entire_category` — a change is compiled in but unattributable (common `.cpp` + body a bridge could not resolve, a runtime-compiled kernel, or an + undeterminable diff) → run the whole category. Safe: never skips. + - `minimal` — nothing test-relevant changed → a smoke default. + +## Full test cycle (TheRock) + +Principle: **everything needed to decide what to run is computed on the GPU-less +builder; only gtest execution happens on the GPU runner.** + +### Builder (no GPU) + +1. **CMake configure** — `test/gtest/CMakeLists.txt` detects TheRock + (`THEROCK_SUBPROJECT_TARGET`), defaults `MIOPEN_DAPPER_MODE=union`, and + `apply_test_category_labels(...)` generates the normal install `CTestTestfile.cmake` + (each `miopen_gtest__suite` invokes the binary directly with the full + category `--gtest_filter`). In `union` mode it then calls + `dapper_therock_generate_json()` (dapper.cmake) to set up the build-time steps below. +2. **Compile** — build `miopen_gtest`, producing the final `build.ninja`, + `compile_commands.json`, object files, and the `.ninja_deps` log. +3. **Impact analysis + finalize** — the `dapper_therock_json` target + (`DEPENDS miopen_gtest` and the configure-generated CTestTestfile) runs, in one + command: + - `main.py shas --base-ref --source-dir ` — git merge-base + + HEAD (git runs in the source worktree; the build dir is not a git repo). + - `extract_gtest_fixtures.py` — `compile_commands.json` → per-source fixtures + (keyed `bin/test_`). + - `main.py parse build.ninja --bridges=<...>` — `ninja -t deps` per object, plus the + selected attribution bridge(s) → `file → {tests}` mapping. + - `main.py select ... --output miopen_dapper_tests.json` — git diff → changed files → + affected fixtures → **`dapper_filter` + `fallback_mode`**. + - `main.py finalize-ctest --ctest-in --ctest-out + --yaml test_categories.yaml --dapper-json miopen_dapper_tests.json` — for each + Dapper-enabled category (`enable_dapper`), compute the union (honoring + `fallback_mode`) and **burn it into `_suite`'s `--gtest_filter`**, add a + `_unfiltered_suite` that retains the full filter, and record + `category__filter` (original) + `category__union` (effective) in the JSON. + + All CPU-only: git, `ninja -t deps`, `nm`, C-preprocessing. All dapper computation + happens here, single-process, atomic writes. +4. **Install** — `bin/miopen_gtest` and, under `bin//`: the **finalized** + `CTestTestfile.cmake` (union burned in) and `miopen_dapper_tests.json` (reference / + downloadable record). **No** python is installed to the runner. +5. **Package** — `miopen_test` (`bin/miopen_gtest*`) and `miopen_run` + (`bin//**`, via the artifact catch-all). No TheRock-repo change is needed. + +### Runner (GPU) + +6. **Install artifacts** — fetched and flattened to `./build/`, so `bin/miopen_gtest` + and `bin//*` sit next to each other. +7. **Dispatch** — `test_runner.py` runs + `ctest -L ^$ [-L ^ex_gpu_$] --test-dir ./build/bin/`. +8. **Run** — `ctest` invokes the selected suite directly: + `../miopen_gtest --gtest_filter=` (the union was burned in at build time). No + dapper code runs at ctest time; this is exactly develop's direct-binary invocation, + only the filter value differs. Running `_unfiltered_suite` runs the full + category. **This is the only step that uses the GPU.** + +`fallback_mode=entire_category` (unattributable change, or a missing/unreadable JSON at +finalize) makes the burned-in filter the full category — it never silently skips. + +**Mental model:** builder = "diff → intersect with each category → burn the reduced +filter into the CTestTestfile"; runner = "just run ctest." + +## Native MIOpen-CI cycle + +One machine, GPU present, `MIOPEN_DAPPER_MODE=validate` (default). `dapper_init()` +wires the impact targets into `check`; `cmake` → build → `ctest`/`check`: + +1. Shard tests run the **full category** on the GPU (`miopen_gtest_shardN`). +2. `dapper_tests_generate` (`select`) then `miopen_gtest_sharded_dapper` (`dapper_diff`) + run afterward to *validate* that the shard run covered the impact set. The union is + computed but not used to reduce the run. + +Flipping native CI to active `union` is a follow-up (today exercised via the +`diff_check` target). + +## Attribution bridges + +The base mapping is the include graph (`ninja -t deps`). It attributes changed +**headers** to every test that includes them, but a change confined to a common +`.cpp` **body** (nothing `#include`s a `.cpp`) is not attributed. Bridges are additive +passes that close that gap; select one with `MIOPEN_DAPPER_BRIDGES` (comma list). + +| Bridge | Module | How | Notes | +|--------|--------|-----|-------| +| `symbol` | `src/symbol_graph.py` | `nm` provider→consumer symbol graph: attribute a source to the tests that reference the out-of-line symbols it defines. | Precise (mirrors the linker); also handles library `.cpp`. | + +Bridges only *add* edges to the mapping; the include graph is never modified. +`symbol` runs by default; set `MIOPEN_DAPPER_BRIDGES` to empty to disable all +bridges. A future runtime-kernel bridge plugs into the same registry. When +multiple bridges can coexist, a superseding bridge drops the ones it makes +redundant (see `BRIDGE_SUPERSEDES` in `main.py`). + +## Configuration + +| CMake cache var | Default | Purpose | +|-----------------|---------|---------| +| `MIOPEN_DAPPER_MODE` | `union` (TheRock) / `validate` (native) | `off` \| `validate` \| `union` | +| `MIOPEN_DAPPER_BASE_REF` | `origin/develop` | Ref to compute the impact diff against | +| `MIOPEN_DAPPER_BRIDGES` | `symbol` | Additive attribution bridges: `symbol` (set to empty to disable) | + +Per category, `test_categories.yaml` sets `enable_dapper: "True"` to opt in. A category's +suite gets its union burned in only when `MIOPEN_DAPPER_MODE=union` **and** that category +has `enable_dapper` truthy; otherwise it runs the full category unchanged. + +## Files + +Tooling (`script/dependency-parser/`, all builder-side): +- `main.py` — CLI: `shas`, `parse` (with `--bridges`), `select`, `finalize-ctest`, + `audit`, `optimize`. `finalize-ctest` burns the per-category union into the install + CTestTestfile and records the filters in the JSON (TheRock). +- `src/enhanced_ninja_parser.py` — build.ninja + `ninja -t deps` → mapping; single-gtest + synthetic `bin/test_` keys; `compiled_sources`. +- `src/extract_gtest_fixtures.py` — compile_commands → per-source fixtures. +- `src/selective_test_filter.py` — git diff → affected fixtures → `dapper_filter` + + `fallback_mode`. +- `src/symbol_graph.py` (`symbol` bridge). +- `src/miopen_gtest_runner.py`, `src/dapper_diff.py` — native validate-mode analysis. +- `src/dapper_union.py` — single source of truth for the pure union math (pattern + splitting/overlap + subtractive intersection + `fallback_mode` resolution). Used by + `miopen_gtest_runner.py` (native) and by `main.py finalize-ctest` (TheRock). Not shipped + to the runner. + +Shared (`/shared/ctest/`): unchanged from develop — dapper adds nothing +here. `parse_test_categories.py` / `TestCategories.cmake` generate the normal (direct +binary) install CTestTestfile; dapper rewrites it afterward on the builder. + +Build/runtime artifacts installed to `bin//` on the runner (union mode): +`CTestTestfile.cmake` (union filters burned in, plus `_unfiltered_suite` entries) +and `miopen_dapper_tests.json` (`dapper_filter`, `fallback_mode`, and per-category +`category__filter` / `category__union` — the downloadable record). No python +ships to the runner; `ctest` invokes the binary directly. + +## Known limitations + +- **Runtime-compiled GPU kernels** (HIPRTC/COMGR) have no build-time edge (include or + symbol) to the fixtures that exercise them, so a kernel-only change is not attributed + and falls back to `entire_category`. A future data-derived (coverage/trace) map is the + intended fix. +- **Native `union`** is not yet a drop-in for `check`; it runs via `diff_check` today. +- **No compliance report on TheRock (future work).** The `dapper_diff` coverage check + (COMPLIANT / FAIL / NOT VIABLE) runs only in native `validate` mode, which has the full + shard run to compare against. TheRock `union` mode just runs the burned-in reduced set — + nothing verifies that the reduction covered what a full run would have. Producing an + equivalent compliance/coverage report for the TheRock path is left as future work. +- **Windows is unsupported (dapper is forced off).** `dapper_init()` sets + `MIOPEN_DAPPER_MODE=off` on Windows hosts, so the build falls back to the normal + full-category test flow. The build-time tooling is not yet Windows-ready; future work to + enable it must address: + - `symbol_graph.py` shells out to `nm`; Windows toolchains provide `llvm-nm` instead + (make the tool configurable / add a fallback). + - `extract_gtest_fixtures.py` parses `compile_commands.json` with `shlex` in POSIX mode + and invokes the compiler's preprocessor via subprocess; both need Windows-aware handling. + - Audit for other Unix-only assumptions (e.g. the `resource` module, path separators). diff --git a/projects/miopen/test/gtest/dapper.cmake b/projects/miopen/test/gtest/dapper.cmake index 276eb5c7afb6..7e15fa4d169e 100644 --- a/projects/miopen/test/gtest/dapper.cmake +++ b/projects/miopen/test/gtest/dapper.cmake @@ -1,6 +1,81 @@ +# Restore the pre-Dapper single-gtest default that _dapper_native_init() would otherwise +# force. Used when Dapper does not run the native init (TheRock, or native mode=off). +macro(_dapper_default_single_gtest) + if(NOT DEFINED MIOPEN_TEST_SINGLE_GTEST) + if(MIOPEN_TEST_DISCRETE) + set(MIOPEN_TEST_SINGLE_GTEST OFF) + else() + set(MIOPEN_TEST_SINGLE_GTEST ON) + endif() + endif() +endmacro() + +# Dapper entry point. Selects the mode, wires up the native or TheRock pipeline, and (for an +# enabled native build) flips MIOPEN_ENABLE_DAPPER_NATIVE ON in the caller's scope. +# +# Dapper master switch (read by both the native/Jenkins flow and TheRock): +# off : Dapper disabled (no analysis / no shard-file / no dapper ctest tests; +# the single gtest and its shard tests still build and run) +# validate : native shard + dapper_diff coverage validation (Jenkins/MICI default) +# union : ACTIVE -- the reduced subtractive union filter actually runs (TheRock default) macro(dapper_init) + # Dapper's build-time tooling (Python + nm / C preprocessor) is not Windows-ready yet + # (see DAPPER.md "Known limitations"). Force the mode off on Windows; the rest of this + # macro then takes the 'off' path -- no python, no dapper wiring -- so the build falls + # back to the normal full-category / non-dapper test flow. + if(WIN32 OR CMAKE_HOST_WIN32) + set(MIOPEN_DAPPER_MODE "off" CACHE STRING + "Dapper mode: off | validate | union" FORCE) + message(STATUS "Dapper: disabled on Windows (build-time tooling not yet ported)") + endif() + if(MIOPEN_BUILD_IN_THEROCK) + set(_MIOPEN_DAPPER_MODE_DEFAULT "union") + else() + set(_MIOPEN_DAPPER_MODE_DEFAULT "validate") + endif() + set(MIOPEN_DAPPER_MODE "${_MIOPEN_DAPPER_MODE_DEFAULT}" CACHE STRING + "Dapper mode: off | validate | union") + set_property(CACHE MIOPEN_DAPPER_MODE PROPERTY STRINGS off validate union) + set(MIOPEN_DAPPER_BASE_REF "origin/develop" CACHE STRING + "Git ref to compute the Dapper impact diff against") + # Additive attribution bridges run during 'parse' (see dependency-parser/main.py). + # Default 'symbol' (nm-based, correctness-dominant); set to "" to disable all bridges. + # Applies to both native and TheRock. The bridge module must exist on the current branch. + set(MIOPEN_DAPPER_BRIDGES "symbol" CACHE STRING + "Comma-separated dapper attribution bridges to run during 'parse' (symbol)") + message(STATUS "Dapper: MIOPEN_DAPPER_MODE=${MIOPEN_DAPPER_MODE} (TheRock=${MIOPEN_BUILD_IN_THEROCK})") + + if(MIOPEN_BUILD_IN_THEROCK) + _dapper_default_single_gtest() + # The TheRock impact JSON + CTestTestfile finalize are produced later, once the install + # CTestTestfile exists, via dapper_therock_generate_json() (called from CMakeLists.txt + # after apply_test_category_labels). Here we only ensure the Python interpreter is found. + if(NOT MIOPEN_DAPPER_MODE STREQUAL "off") + find_package(Python 3 REQUIRED COMPONENTS Interpreter) + endif() + else() + # Native/Jenkins build. 'validate'/'union' set up the native pipeline; 'off' disables it. + # (Full native check->union activation is a follow-up; today 'union' here is exercised via + # the existing `diff_check` target.) + if(NOT MIOPEN_DAPPER_MODE STREQUAL "off") + set(MIOPEN_ENABLE_DAPPER_NATIVE ON) + find_package(Python 3 REQUIRED COMPONENTS Interpreter) + _dapper_native_init() + else() + _dapper_default_single_gtest() + endif() + endif() +endmacro() + +# Native/Jenkins Dapper setup: single-gtest mapping targets (shas/fixtures/mapping) plus the +# diff_check convenience target. dapper_dev_filters()/dapper_add_sharded_test() add the ctest +# analysis tests later, once the shard tests are registered. +macro(_dapper_native_init) set(MIOPEN_TEST_SINGLE_GTEST 1) - set(MIOPEN_TEST_DISCRETE 1) + # Dapper no longer forces the discrete test build. The dependency mapping is now + # derived from the single aggregated miopen_gtest (per-source synthetic bin/test_ + # keys in enhanced_ninja_parser), so the ~280 discrete test binaries are unnecessary. + # Respect whatever MIOPEN_TEST_DISCRETE the user/CI set (default off) instead of forcing it. # TRJS message(STATUS "------------------------------------ CMAKE_CURRENT_LIST_DIR: ${CMAKE_CURRENT_LIST_DIR}") @@ -40,7 +115,7 @@ macro(dapper_init) # mapping: file -> test-executable mapping; needs tests built so build.ninja is final. add_custom_target(dapper_mapping COMMENT "Generating ${MAPPING_JSON}" - COMMAND ${Python_EXECUTABLE} ${PY_MAIN} parse ${BUILD_NINJA} + COMMAND ${Python_EXECUTABLE} ${PY_MAIN} parse ${BUILD_NINJA} --bridges=${MIOPEN_DAPPER_BRIDGES} WORKING_DIRECTORY ${MIOPEN_DAPPER_OUT_DIR} VERBATIM ) @@ -64,7 +139,7 @@ macro(dapper_init) endmacro() macro(dapper_dev_filters) -# TODO Fix Dapper default minimal run if no work to do. It's in calc_union_filter but in mici this is ran by dapper_diff which does not run until after miopen-check +# TODO: This is a development feature that will be removed following phase 3. message(STATUS "================== DAPPER DEVELOPMENT FILTERS") @@ -144,8 +219,15 @@ macro(dapper_add_sharded_test) # (MIOPEN_GTEST_SHARDS); the remaining tests are defined in the parent test/ directory, which is # fully processed before this subdirectory is added (add_subdirectory(gtest) is its last # statement), so the parent's TESTS directory property is complete and safe to read here. + # Also depend on the tests registered in THIS directory so far (the shards and the + # separately-registered ${TEST_NAME}_hip_graph_serial), not just the parent directory -- + # otherwise hip_graph_serial can run after the dapper analysis and bury its summary. + # (TheRock-only category suites are added later by apply_test_category_labels and do not + # run in MICI, so they are intentionally not required here.) get_property(_dapper_parent_tests DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/.. PROPERTY TESTS) - set(_dapper_predecessors ${_dapper_parent_tests} ${MIOPEN_GTEST_SHARDS}) + get_property(_dapper_curdir_tests DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} PROPERTY TESTS) + set(_dapper_predecessors + ${_dapper_parent_tests} ${_dapper_curdir_tests} ${MIOPEN_GTEST_SHARDS}) # dapper_tests_generate is already ordered before dapper via dapper_tests_fixture; exclude it and # dapper itself from the dependency list. list(REMOVE_ITEM _dapper_predecessors miopen_gtest_sharded_dapper dapper_tests_generate) @@ -154,6 +236,7 @@ macro(dapper_add_sharded_test) DEPENDS "${_dapper_predecessors}") endif() unset(_dapper_parent_tests) + unset(_dapper_curdir_tests) unset(_dapper_predecessors) # CMake target equivalent to miopen_gtest_sharded_dapper @@ -171,7 +254,7 @@ macro(dapper_add_sharded_test) COMMAND ${Python_EXECUTABLE} -c "import sys, pathlib; f=pathlib.Path('${FIXTURES_JSON}'); f.exists() or (print(f'Error: {f.name} not found. Run dapper_fix_diff to regenerate it via the preprocessor, or copy a valid file into: {f.parent}'), sys.exit(1))" COMMAND ${Python_EXECUTABLE} ${PY_MAIN} shas - COMMAND ${Python_EXECUTABLE} ${PY_MAIN} parse ${BUILD_NINJA} + COMMAND ${Python_EXECUTABLE} ${PY_MAIN} parse ${BUILD_NINJA} --bridges=${MIOPEN_DAPPER_BRIDGES} COMMAND ${Python_EXECUTABLE} ${PY_MAIN} select ${MAPPING_JSON} --fixturemap=${FIXTURES_JSON} --shardsfile=${SHARDS_FILE} COMMAND ${Python_EXECUTABLE} ${MIOPEN_DAPPER_DIFF} @@ -186,7 +269,7 @@ macro(dapper_add_sharded_test) COMMENT "Running full dapper pipeline, regenerating fixtures (no rebuild)..." COMMAND ${Python_EXECUTABLE} ${PY_MAIN} shas COMMAND ${Python_EXECUTABLE} ${PY_FIXTURES} - COMMAND ${Python_EXECUTABLE} ${PY_MAIN} parse ${BUILD_NINJA} + COMMAND ${Python_EXECUTABLE} ${PY_MAIN} parse ${BUILD_NINJA} --bridges=${MIOPEN_DAPPER_BRIDGES} COMMAND ${Python_EXECUTABLE} ${PY_MAIN} select ${MAPPING_JSON} --fixturemap=${FIXTURES_JSON} --shardsfile=${SHARDS_FILE} COMMAND ${Python_EXECUTABLE} ${MIOPEN_DAPPER_DIFF} @@ -195,3 +278,59 @@ macro(dapper_add_sharded_test) VERBATIM ) endmacro() + +# Build-time production of the Dapper artifacts for a single-gtest TheRock build. GPU-less +# (git diff + ninja deps + nm + fixture extraction). Produces, in one build-time command: +# - miopen_dapper_tests.json : dapper_filter + fallback_mode, plus per dapper category +# category__filter (original) and category__union (effective) -- the +# downloadable record. +# - a finalized CTestTestfile: for each Dapper-enabled category the existing '_suite' +# runs the subtractive union (burned directly into the add_test, exactly as develop's +# direct-binary invocation), and a '_unfiltered_suite' is added retaining the full +# filter. Nothing dapper runs at ctest time; no runner/helper is installed. +# +# Called from CMakeLists.txt AFTER apply_test_category_labels (so install_ctest_file exists). +# Args: install_ctest_file = the configure-generated install CTestTestfile; test_yaml = the +# category yaml (for enable_dapper). Uses source-tree script paths (PROJECT_SOURCE_DIR) since +# TheRock builds out-of-source, and --source-dir so git runs in the MIOpen source worktree. +macro(dapper_therock_generate_json install_ctest_file test_yaml) + set(_dapper_src "${PROJECT_SOURCE_DIR}/script/dependency-parser") + set(_dapper_out "${CMAKE_BINARY_DIR}") + set(_dapper_tests_json "${_dapper_out}/miopen_dapper_tests.json") + set(_dapper_mapping_json "${_dapper_out}/miopen_dapper_mapping.json") + set(_dapper_fixtures_json "${_dapper_out}/miopen_dapper_fixtures.json") + set(_dapper_build_ninja "${_dapper_out}/build.ninja") + set(_dapper_ctest_final "${_dapper_out}/dapper_CTestTestfile.cmake") + + add_custom_command( + OUTPUT ${_dapper_tests_json} ${_dapper_ctest_final} + COMMENT "Dapper: impact JSON + burning union into CTestTestfile (mode=${MIOPEN_DAPPER_MODE}, bridges=${MIOPEN_DAPPER_BRIDGES})" + COMMAND ${Python_EXECUTABLE} ${_dapper_src}/main.py shas + --base-ref ${MIOPEN_DAPPER_BASE_REF} --source-dir ${PROJECT_SOURCE_DIR} + COMMAND ${Python_EXECUTABLE} ${_dapper_src}/src/extract_gtest_fixtures.py + COMMAND ${Python_EXECUTABLE} ${_dapper_src}/main.py parse ${_dapper_build_ninja} + --bridges=${MIOPEN_DAPPER_BRIDGES} + COMMAND ${Python_EXECUTABLE} ${_dapper_src}/main.py select ${_dapper_mapping_json} + --fixturemap=${_dapper_fixtures_json} --source-dir ${PROJECT_SOURCE_DIR} + --output ${_dapper_tests_json} + COMMAND ${Python_EXECUTABLE} ${_dapper_src}/main.py finalize-ctest + --ctest-in ${install_ctest_file} --ctest-out ${_dapper_ctest_final} + --yaml ${test_yaml} --dapper-json ${_dapper_tests_json} + WORKING_DIRECTORY ${_dapper_out} + DEPENDS miopen_gtest ${install_ctest_file} + VERBATIM + ) + add_custom_target(dapper_therock_json ALL + DEPENDS ${_dapper_tests_json} ${_dapper_ctest_final}) + + if(NOT ENABLE_ASAN_PACKAGING) + # Install the reference JSON and the finalized CTestTestfile (with union burned in). + install(FILES ${_dapper_tests_json} + DESTINATION "${CMAKE_INSTALL_BINDIR}/${PROJECT_NAME}" + COMPONENT tests) + install(FILES ${_dapper_ctest_final} + DESTINATION "${CMAKE_INSTALL_BINDIR}/${PROJECT_NAME}" + COMPONENT tests + RENAME "CTestTestfile.cmake") + endif() +endmacro() diff --git a/projects/miopen/test/gtest/test_categories.yaml b/projects/miopen/test/gtest/test_categories.yaml index 137b96283924..28db4e24b636 100644 --- a/projects/miopen/test/gtest/test_categories.yaml +++ b/projects/miopen/test/gtest/test_categories.yaml @@ -2,13 +2,6 @@ # Time-based test organization for CI/CD pipeline efficiency # This file defines test categories by execution time and criticality -# Some information is not available at configure time. For example, the -# list of all gtest fixtures must be extracted from the binaries. Defining -# 'gtest_runner' informs CMake to have the target run the given script -# of running the gtest command directly. -# The path is relative to ${PROJECT_SOURCE_DIR}. -gtest_runner: scripts/run_miopen_gtest.py - # Common comprehensive patterns - used by both standard and comprehensive # Standard applies exclude list to filter out slower tests # Comprehensive runs all patterns without exclusion @@ -108,8 +101,10 @@ test_categories: standard: description: "Core functionality - Run on every PR (target: < 30 min)" - enable_dapper: "False" - dapper_json_file: "" + # Opt this category into Dapper. Active only when the build is configured with + # -DMIOPEN_DAPPER_MODE=union (TheRock default), which passes --dapper-json to the + # category generator; otherwise this is a no-op and the category runs in full. + enable_dapper: "True" test_patterns: *positive_patterns exclude: