#!/usr/bin/env python3 # MIT License # # Copyright (c) 2024-2025 Advanced Micro Devices, Inc. All rights reserved. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. import os import sys import json import pytest import glob import subprocess def get_gpu_node_count(): """ Detect the number of GPU nodes/agents available in the system. Returns the count of GPU nodes, or None if detection fails. """ try: # Method 2: Check /sys/class/kfd/kfd/topology/nodes/ for GPU nodes # This follows the logic from rocprofiler-sdk/source/lib/rocprofiler-sdk/agent.cpp nodes_path = "/sys/class/kfd/kfd/topology/nodes" if os.path.exists(nodes_path): gpu_count = 0 node_id = 0 # Nodes are numbered monotonically starting from 0 while True: node_path = os.path.join(nodes_path, str(node_id)) # Once we're missing a node folder, there are no more nodes if not os.path.exists(node_path): break properties_file = os.path.join(node_path, "properties") if os.path.exists(properties_file) and os.access( properties_file, os.R_OK ): try: with open(properties_file, "r") as f: content = f.read() # Properties file must be non-empty if not content.strip(): node_id += 1 continue # Parse properties to find cpu_cores_count and simd_count cpu_cores_count = 0 simd_count = 0 for line in content.split("\n"): line = line.strip() if line.startswith("cpu_cores_count"): cpu_cores_count = int(line.split()[1]) elif line.startswith("simd_count"): simd_count = int(line.split()[1]) # A node is a GPU if cpu_cores_count == 0 AND simd_count > 0 if cpu_cores_count == 0 and simd_count > 0: gpu_count += 1 except (IOError, ValueError, IndexError): pass node_id += 1 if gpu_count > 0: return gpu_count except (OSError, ValueError): pass # If detection fails, return None return None def load_json_file(filepath): """ Load JSON from a file, handling cases where multiple MPI ranks may have written to the same file (resulting in concatenated JSON objects). Returns the first valid JSON object found, or None if no valid JSON exists. """ try: with open(filepath, "r") as f: content = f.read() # Try to load as a single JSON object first try: return json.loads(content) except json.JSONDecodeError as e: # If that fails, it might be multiple JSON objects concatenated # Try to extract the first valid JSON object decoder = json.JSONDecoder() try: obj, idx = decoder.raw_decode(content) # Successfully decoded the first JSON object return obj except json.JSONDecodeError: # Even the first object is malformed return None except (IOError, OSError): return None def get_sdk_data(data): """ Extract rocprofiler-sdk-tool data from JSON, handling both dict and list structures. Some JSON files have rocprofiler-sdk-tool as a list, others as a dict. """ if data is None or "rocprofiler-sdk-tool" not in data: return None sdk_data = data["rocprofiler-sdk-tool"] # Handle list structure - take the first element if isinstance(sdk_data, list): return sdk_data[0] if len(sdk_data) > 0 else {} # Already a dict return sdk_data def validate_json_file_has_profiling_data(json_file): """ Validate that a JSON file contains valid profiling data. Loads the JSON file, extracts rocprofiler-sdk-tool data, and verifies that it contains either kernel_dispatch or hip_api profiling data. Raises AssertionError if validation fails. """ data = load_json_file(json_file) assert data is not None, f"Failed to load JSON from {json_file}" sdk_data = get_sdk_data(data) assert sdk_data is not None, f"Missing rocprofiler-sdk-tool data in {json_file}" buffer_records = sdk_data.get("buffer_records", {}) # Should have some kernel or HIP API data has_data = ( len(buffer_records.get("kernel_dispatch", [])) > 0 or len(buffer_records.get("hip_api", [])) > 0 ) assert has_data, f"No profiling data found in {json_file}" def test_mpi_ranks_feature(output_dir, test_mode): """ Test the --mpi-ranks feature with different scenarios using simple-transpose application. The simple-transpose application runs a simple matrix transpose kernel using HIP. It uses the default stream (stream_id == 0) and executes a single matrixTranspose kernel. Test modes: - with-mpi-single: MPI run with 4 ranks, profiling only rank 0 - with-mpi-multiple: MPI run with 4 ranks, profiling ranks 0-1,3 - without-mpi: Non-MPI run, should generate output regardless """ # Detect the number of GPU nodes in the system gpu_node_count = get_gpu_node_count() is_single_node = gpu_node_count is not None and gpu_node_count <= 1 # Find JSON output files # For MPI tests: Look only in rank.* subdirectories (to avoid stale files) # For non-MPI tests: Look everywhere in the output directory if test_mode in ["with-mpi-single", "with-mpi-multiple"]: # MPI test - only look in rank.* subdirectories json_files = [] for rank_dir in glob.glob(os.path.join(output_dir, "rank.*")): json_files.extend( glob.glob(os.path.join(rank_dir, "**/out_results.json"), recursive=True) ) else: # Non-MPI test - look everywhere json_files = glob.glob( os.path.join(output_dir, "**/out_results.json"), recursive=True ) if test_mode == "with-mpi-single": # With --mpi-ranks 0 and 4 MPI ranks, only rank 0 should generate output in rank.0/ # So we should have exactly 1 JSON file assert ( len(json_files) == 1 ), f"Expected 1 JSON file for rank 0 only, but found {len(json_files)}: {json_files}" # Verify the file is from rank 0 validate_json_file_has_profiling_data(json_files[0]) elif test_mode == "with-mpi-multiple": # With --mpi-ranks 0-1,3 and 4 MPI ranks, ranks 0, 1, and 3 should generate output # Each rank uses a separate output directory (via the MPI rank env var) # so we should always get exactly 3 files expected_files = 3 if gpu_node_count is not None: print(f"INFO: Detected {gpu_node_count} GPU node(s)") # With separate output directories per rank, we should have exactly the expected number assert len(json_files) == expected_files, ( f"Expected {expected_files} JSON files for ranks 0, 1, and 3, " f"but found {len(json_files)}: {json_files}" ) # Verify each file has valid profiling data for json_file in json_files: validate_json_file_has_profiling_data(json_file) elif test_mode == "without-mpi": # Without MPI environment, --mpi-ranks should be ignored and output generated # We should have at least 1 JSON file assert ( len(json_files) >= 1 ), f"Expected at least 1 JSON file for non-MPI run, but found {len(json_files)}: {json_files}" # Verify the file has valid profiling data validate_json_file_has_profiling_data(json_files[0]) else: pytest.fail(f"Unknown test mode: {test_mode}") def test_csv_output_consistency(output_dir, test_mode): """ Verify that CSV files are also correctly generated/not generated based on rank filtering. """ # Find CSV files # For MPI tests: Look only in rank.* subdirectories # For non-MPI tests: Look everywhere in the output directory if test_mode in ["with-mpi-single", "with-mpi-multiple"]: # MPI test - only look in rank.* subdirectories csv_files = [] for rank_dir in glob.glob(os.path.join(output_dir, "rank.*")): csv_files.extend( glob.glob( os.path.join(rank_dir, "**/out_kernel_trace.csv"), recursive=True ) ) else: # Non-MPI test - look everywhere csv_files = glob.glob( os.path.join(output_dir, "**/out_kernel_trace.csv"), recursive=True ) if test_mode == "with-mpi-single": # Only rank 0 should have CSV output assert ( len(csv_files) == 1 ), f"Expected 1 CSV file for rank 0 only, but found {len(csv_files)}: {csv_files}" elif test_mode == "with-mpi-multiple": # Each rank has separate output directory, so expect exactly 3 CSV files expected_files = 3 assert len(csv_files) == expected_files, ( f"Expected {expected_files} CSV files for ranks 0, 1, and 3, " f"but found {len(csv_files)}: {csv_files}" ) elif test_mode == "without-mpi": # Non-MPI run should have CSV output assert ( len(csv_files) >= 1 ), f"Expected at least 1 CSV file for non-MPI run, but found {len(csv_files)}: {csv_files}" def test_no_output_for_filtered_ranks(output_dir, test_mode): """ Verify that ranks not in the --mpi-ranks list do not generate output. Since each rank has a separate output directory (via the MPI rank env var), we can check that rank 2's directory doesn't exist or is empty. """ if test_mode != "with-mpi-multiple": pytest.skip("This test only applies to with-mpi-multiple mode") # Check for rank.2 directory (which should NOT have been created or should be empty) rank_2_dir = os.path.join(output_dir, "rank.2") if os.path.exists(rank_2_dir): # Directory exists - check if it has any JSON files json_files_in_rank_2 = glob.glob( os.path.join(rank_2_dir, "**/out_results.json"), recursive=True ) assert ( len(json_files_in_rank_2) == 0 ), f"Rank 2 should not generate output, but found {len(json_files_in_rank_2)} files: {json_files_in_rank_2}" # Verify that ranks 0, 1, and 3 directories exist with output for rank in [0, 1, 3]: rank_dir = os.path.join(output_dir, f"rank.{rank}") assert os.path.exists( rank_dir ), f"Expected directory for rank {rank} at {rank_dir}" json_files = glob.glob( os.path.join(rank_dir, "**/out_results.json"), recursive=True ) assert ( len(json_files) >= 1 ), f"Expected output files for rank {rank} in {rank_dir}" if __name__ == "__main__": exit_code = pytest.main(["-x", __file__] + sys.argv[1:]) sys.exit(exit_code)