mirror of
https://github.com/ROCm/composable_kernel.git
synced 2026-05-04 05:31:24 +00:00
[CK] [CK_TILE] Improve build and test time of CI with smart dependency parser (#5249) ## Motivation Existing dependency parser needs full build of tests to determine which tests are affected by code changes in a PR. This still takes 2-4 hours for building the tests which slows down the CI as the number of tests grow. To resolve this issue we implemented a smart dependency parser which uses CMake Configure to parse dependencies and build only the affected test cases. We have ensured that two approaches are available 1) CMake pre-build analysis for each PR to ensure fast build and test. 2) Ninja post-build analysis to enable full build for nightly tests. ## Technical Details ```bash ### 1. Configure the project with CMake cmake -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=ON .. ### 2. Analyze dependencies (no build required!) python3 ../script/dependency-parser/main.py cmake-parse compile_commands.json build.ninja \ --workspace-root .. --output cmake_dependency_mapping.json --parallel 8 ### 3. Find tests affected by changes python3 ../script/dependency-parser/main.py select cmake_dependency_mapping.json origin/develop \ HEAD --test-prefix --output tests_to_run.json ### 4. Build only affected tests ninja $(jq -r '.executables[]' tests_to_run.json | tr '\n' ' ') ### 5. Run affected tests ctest -R "$(jq -r '.regex' tests_to_run.json)" ``` ### Jenkins Integration - Added `buildMode` to jenkinsfile to integrate both `selective` and `full` build methods ### Known Limitations ### 1. Build-Time Generated Headers (HIGH RISK) **Problem:** Files generated during the build process (e.g., via `add_custom_command`) cannot be analyzed before building. **Example:** ```cmake add_custom_command( OUTPUT ${CMAKE_BINARY_DIR}/generated/config.hpp COMMAND generate_config.sh DEPENDS template.hpp.in ) ``` **Impact:** If a source file includes `generated/config.hpp`, the dependency won't be detected until after building. **Mitigation:** - CK analysis shows **no generated headers** currently used - If generated headers are added in the future, they must be built first - Recommendation: Generate headers in CMake configure phase (not build phase) when possible ## Test Plan **1. Modified Files:** ``` include/ck_tile/ops/common.hpp include/ck_tile/ops/gemm.hpp include/ck_tile/ops/gemm/warp/warp_gemm.hpp ``` **2. Compare tests selected between `build.ninja` and `cmake-parse` methods** ## Test Result - 1. The test completed in 5-6 minutes finding about 8000+ executables that should be built. - 2. We selected a commit 5ccc1387ea which resulted in same 7 tests with both legacy and new methods. - PR | Legacy tests | Smart tests | Notes -- | -- | -- | -- 5261 | 453 | 455 | Only 2 tests (test_amdgcn_mma and test_amdgcn_sparse_mma) 5168 | 0 | 0 | Changes in dispatcher only. No CK tests invoked. 5249 | 0 | 0 | Changes to dependency parser. No CK tests invoked 5260 | 0 | 0 | Changes in dispatcher only. No CK tests invoked. 5174 | 1 | 1 | One test from FMHA affected by this PR in both cases 5383 | 0 | 0 | Changes are only in benchmark files. Did not trigger any tests 5445 | 1 | 1 | Changes are only to tests/ck_tile/gemm_streamk. Only triggered one streamk test in both cases. 5454 | 3 | 3 | Both methods identified same test_grouped_conv_bwd tests 5427 | 234 | 234 | Core infrastructure header changes. Detected exactly same tests 5388 | 85 | 85 | modifies warp-level GEMM operations (warp_gemm.hpp, warp_gemm_dispatcher.hpp). Correctly identified all the streamK gemm tests ## Submission Checklist - [x ] Look over the contributing guidelines at https://github.com/ROCm/ROCm/blob/develop/CONTRIBUTING.md#pull-requests.
265 lines
9.2 KiB
Python
265 lines
9.2 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright (c) Advanced Micro Devices, Inc., or its affiliates.
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""
|
|
Integration tests for CMake Dependency Analyzer.
|
|
|
|
These tests use real compile_commands.json and actual AMD clang compiler
|
|
to verify the analyzer works correctly in production environment.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
# Add parent directory to path for imports
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
# Skip all tests if compile_commands.json doesn't exist
|
|
CK_ROOT = Path(__file__).parent.parent.parent.parent
|
|
BUILD_DIR = CK_ROOT / "build"
|
|
COMPILE_COMMANDS = BUILD_DIR / "compile_commands.json"
|
|
BUILD_NINJA = BUILD_DIR / "build.ninja"
|
|
|
|
SKIP_INTEGRATION = not COMPILE_COMMANDS.exists()
|
|
SKIP_REASON = f"compile_commands.json not found at {COMPILE_COMMANDS}"
|
|
|
|
|
|
@unittest.skipIf(SKIP_INTEGRATION, SKIP_REASON)
|
|
class TestRealCompileCommands(unittest.TestCase):
|
|
"""Tests using real compile_commands.json."""
|
|
|
|
def test_parse_real_compile_commands(self):
|
|
"""Should parse real CK compile_commands.json."""
|
|
from cmake_dependency_analyzer import CompileCommandsParser
|
|
|
|
parser = CompileCommandsParser(str(COMPILE_COMMANDS))
|
|
commands = parser.parse()
|
|
|
|
# CK has thousands of source files
|
|
self.assertGreater(len(commands), 100)
|
|
|
|
# Verify structure
|
|
for cmd in commands[:5]:
|
|
self.assertIn("file", cmd)
|
|
self.assertIn("directory", cmd)
|
|
self.assertIn("command", cmd)
|
|
|
|
def test_filter_cpp_files_only(self):
|
|
"""Should correctly filter to only .cpp files."""
|
|
from cmake_dependency_analyzer import CompileCommandsParser
|
|
|
|
parser = CompileCommandsParser(str(COMPILE_COMMANDS))
|
|
commands = parser.parse(extensions=[".cpp"])
|
|
|
|
for cmd in commands:
|
|
self.assertTrue(
|
|
cmd["file"].endswith(".cpp"),
|
|
f"Expected .cpp file, got {cmd['file']}",
|
|
)
|
|
|
|
|
|
@unittest.skipIf(SKIP_INTEGRATION, SKIP_REASON)
|
|
class TestRealDependencyExtraction(unittest.TestCase):
|
|
"""Tests using real AMD clang for dependency extraction."""
|
|
|
|
def test_extract_real_dependencies(self):
|
|
"""Should extract dependencies using real AMD clang."""
|
|
from cmake_dependency_analyzer import CompileCommandsParser, DependencyExtractor
|
|
|
|
parser = CompileCommandsParser(str(COMPILE_COMMANDS))
|
|
commands = parser.parse(extensions=[".cpp"])
|
|
|
|
# Test with first command
|
|
if not commands:
|
|
self.skipTest("No compile commands found")
|
|
|
|
cmd = commands[0]
|
|
extractor = DependencyExtractor()
|
|
deps = extractor.extract(cmd["directory"], cmd["command"], cmd["file"])
|
|
|
|
# Should have at least the source file itself
|
|
self.assertGreater(len(deps), 0, f"No deps found for {cmd['file']}")
|
|
|
|
# Should include the source file
|
|
source_basename = os.path.basename(cmd["file"])
|
|
found_source = any(source_basename in d for d in deps)
|
|
self.assertTrue(found_source, f"Source file not in deps: {deps[:5]}")
|
|
|
|
def test_extract_header_dependencies(self):
|
|
"""Should find CK header dependencies."""
|
|
from cmake_dependency_analyzer import CompileCommandsParser, DependencyExtractor
|
|
|
|
parser = CompileCommandsParser(str(COMPILE_COMMANDS))
|
|
commands = parser.parse(extensions=[".cpp"])
|
|
|
|
# Find a test file that includes CK headers
|
|
test_cmd = None
|
|
for cmd in commands:
|
|
if "test_" in cmd["file"] or "example_" in cmd["file"]:
|
|
test_cmd = cmd
|
|
break
|
|
|
|
if not test_cmd:
|
|
self.skipTest("No test file found")
|
|
|
|
extractor = DependencyExtractor()
|
|
deps = extractor.extract(test_cmd["directory"], test_cmd["command"], test_cmd["file"])
|
|
|
|
# Should include CK headers
|
|
ck_headers = [d for d in deps if "include/ck" in d or "include/ck_tile" in d]
|
|
self.assertGreater(
|
|
len(ck_headers), 0,
|
|
f"No CK headers found in deps for {test_cmd['file']}"
|
|
)
|
|
|
|
|
|
@unittest.skipIf(SKIP_INTEGRATION, SKIP_REASON)
|
|
@unittest.skipIf(not BUILD_NINJA.exists(), "build.ninja not found")
|
|
class TestRealNinjaParsing(unittest.TestCase):
|
|
"""Tests using real build.ninja."""
|
|
|
|
def test_parse_real_executables(self):
|
|
"""Should parse real executable mappings from build.ninja."""
|
|
from cmake_dependency_analyzer import NinjaTargetParser
|
|
|
|
parser = NinjaTargetParser(str(BUILD_NINJA))
|
|
exe_to_objects = parser.parse_executable_mappings()
|
|
|
|
# CK has many test executables
|
|
test_exes = [e for e in exe_to_objects if "test_" in e]
|
|
self.assertGreater(len(test_exes), 10, "Expected many test executables")
|
|
|
|
# Each executable should have at least one object file
|
|
for exe, objs in list(exe_to_objects.items())[:5]:
|
|
self.assertGreater(len(objs), 0, f"No objects for {exe}")
|
|
|
|
def test_parse_real_object_sources(self):
|
|
"""Should parse real object -> source mappings."""
|
|
from cmake_dependency_analyzer import NinjaTargetParser
|
|
|
|
parser = NinjaTargetParser(str(BUILD_NINJA))
|
|
obj_to_source = parser.parse_object_to_source()
|
|
|
|
# Should have many object files
|
|
self.assertGreater(len(obj_to_source), 100)
|
|
|
|
# Each mapping should have valid source file
|
|
for obj, src in list(obj_to_source.items())[:5]:
|
|
self.assertTrue(
|
|
src.endswith((".cpp", ".cc", ".cu", ".hip")),
|
|
f"Invalid source for {obj}: {src}",
|
|
)
|
|
|
|
|
|
@unittest.skipIf(SKIP_INTEGRATION, SKIP_REASON)
|
|
@unittest.skipIf(not BUILD_NINJA.exists(), "build.ninja not found")
|
|
class TestFullIntegration(unittest.TestCase):
|
|
"""Full integration test of the analyzer."""
|
|
|
|
def test_small_batch_analysis(self):
|
|
"""Should analyze a small batch of files correctly."""
|
|
from cmake_dependency_analyzer import (
|
|
CompileCommandsParser,
|
|
DependencyExtractor,
|
|
NinjaTargetParser,
|
|
DependencyMapper,
|
|
)
|
|
|
|
# Parse compile commands (limit to 10 for speed)
|
|
parser = CompileCommandsParser(str(COMPILE_COMMANDS))
|
|
all_commands = parser.parse(extensions=[".cpp"])
|
|
commands = all_commands[:10]
|
|
|
|
# Extract dependencies
|
|
extractor = DependencyExtractor()
|
|
source_to_deps = extractor.extract_batch(commands)
|
|
|
|
self.assertEqual(len(source_to_deps), len(commands))
|
|
|
|
# Parse ninja
|
|
ninja_parser = NinjaTargetParser(str(BUILD_NINJA))
|
|
exe_to_objects = ninja_parser.parse_executable_mappings()
|
|
obj_to_source = ninja_parser.parse_object_to_source()
|
|
|
|
# Build mapping
|
|
mapper = DependencyMapper(workspace_root=str(CK_ROOT))
|
|
file_to_exes = mapper.build_mapping(exe_to_objects, obj_to_source, source_to_deps)
|
|
|
|
# Should have some mappings (depends on which files were analyzed)
|
|
# This test mainly verifies no crashes occur
|
|
self.assertIsInstance(file_to_exes, dict)
|
|
|
|
def test_output_json_format(self):
|
|
"""Should produce JSON compatible with selective_test_filter.py."""
|
|
from cmake_dependency_analyzer import CMakeDependencyAnalyzer
|
|
|
|
# Create analyzer with limited scope
|
|
analyzer = CMakeDependencyAnalyzer(
|
|
compile_commands_path=str(COMPILE_COMMANDS),
|
|
ninja_path=str(BUILD_NINJA),
|
|
workspace_root=str(CK_ROOT),
|
|
parallel_workers=1,
|
|
)
|
|
|
|
# Manually set minimal data for output test
|
|
analyzer.file_to_executables = {
|
|
"include/ck/ck.hpp": {"bin/test_gemm"},
|
|
}
|
|
analyzer.executable_to_files = {
|
|
"bin/test_gemm": {"include/ck/ck.hpp"},
|
|
}
|
|
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
output_path = f.name
|
|
|
|
try:
|
|
analyzer.export_to_json(output_path)
|
|
|
|
with open(output_path) as f:
|
|
data = json.load(f)
|
|
|
|
# Verify format matches selective_test_filter.py expectations
|
|
self.assertIn("file_to_executables", data)
|
|
self.assertIn("statistics", data)
|
|
|
|
# Values should be lists, not sets
|
|
for key, value in data["file_to_executables"].items():
|
|
self.assertIsInstance(value, list)
|
|
finally:
|
|
os.unlink(output_path)
|
|
|
|
|
|
@unittest.skipIf(SKIP_INTEGRATION, SKIP_REASON)
|
|
class TestPerformance(unittest.TestCase):
|
|
"""Performance tests."""
|
|
|
|
def test_extraction_speed(self):
|
|
"""Single file extraction should be fast (<1s)."""
|
|
import time
|
|
from cmake_dependency_analyzer import CompileCommandsParser, DependencyExtractor
|
|
|
|
parser = CompileCommandsParser(str(COMPILE_COMMANDS))
|
|
commands = parser.parse(extensions=[".cpp"])
|
|
|
|
if not commands:
|
|
self.skipTest("No compile commands")
|
|
|
|
cmd = commands[0]
|
|
extractor = DependencyExtractor()
|
|
|
|
start = time.time()
|
|
deps = extractor.extract(cmd["directory"], cmd["command"], cmd["file"])
|
|
elapsed = time.time() - start
|
|
|
|
self.assertLess(elapsed, 1.0, f"Extraction took {elapsed:.2f}s, expected <1s")
|
|
self.assertGreater(len(deps), 0, "No dependencies extracted")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|