| #!/usr/bin/env python3 | |
| # Copyright lowRISC contributors. | |
| # Licensed under the Apache License, Version 2.0, see LICENSE for details. | |
| # SPDX-License-Identifier: Apache-2.0 | |
| from collections import namedtuple | |
| from enum import Enum | |
| from typing import ( | |
| Callable, | |
| List, | |
| ) | |
| import logging as log | |
| import os | |
| from pathlib import Path, PurePath | |
| from pprint import pformat | |
| import subprocess | |
| # Commands used by the coverage scripts. | |
| BAZEL: str = "./bazelisk.sh" | |
| LLVM_PROFDATA: str = "llvm-profdata" | |
| LLVM_COV: str = "llvm-cov" | |
| LLD_HOST: str = "ld.lld" | |
| LLD_TARGET: str = "/tools/riscv/bin/riscv32-unknown-elf-ld" | |
| # Query for device libraries to be instrumented. | |
| DEVICE_LIBS_INC: List[str] = [ | |
| "//sw/device/silicon_creator/...", | |
| "//sw/device/lib/...", | |
| ] | |
| DEVICE_LIBS_EXC: List[str] = [ | |
| "//sw/device/lib/dif/...", | |
| "//sw/device/lib/testing/... ", | |
| "//sw/device/lib/runtime/...", | |
| "//sw/device/lib/crypto/...", | |
| "//sw/device/lib/arch/...", | |
| "//sw/device/lib/ujson/...", | |
| "//sw/device/lib/base:hardened_status", | |
| "//sw/device/lib/base:status", | |
| "//sw/device/lib/base:mmio_on_device_do_not_use_directly", | |
| "//sw/device/lib/base:mmio_on_host_do_not_use_directly", | |
| "//sw/device/silicon_creator/rom_ext/...", | |
| "//sw/device/silicon_creator/rom/keys/real:real", | |
| ] | |
| DEVICE_LIBS_QUERY: str = ( | |
| f"kind('^cc_library rule$', ({' + '.join(DEVICE_LIBS_INC)}) " | |
| f"- ({' + '.join(DEVICE_LIBS_EXC)}))") | |
| class LogLevel(str, Enum): | |
| NONE: str = "none" | |
| INFO: str = "info" | |
| DEBUG: str = "debug" | |
| class CoverageType(str, Enum): | |
| UNITTEST: str = "unit" | |
| FUNCTEST: str = "func" | |
| E2ETEST: str = "e2e" | |
| class BazelTestType(str, Enum): | |
| CC_TEST: str = "cc_test" | |
| SH_TEST: str = "sh_test" | |
| def run(*args) -> List[str]: | |
| """Run the given command in a subprocess. | |
| Args: | |
| args: Command (the first parameter) and arguments (remaining arguments). | |
| Returns: | |
| Stdout lines in a list. Empty lines are filtered out. | |
| """ | |
| log.debug(f"command: {' '.join(args)}") | |
| try: | |
| res = subprocess.run(args, | |
| env=os.environ.copy(), | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| encoding='ascii', | |
| errors='ignore', | |
| check=True) | |
| except subprocess.CalledProcessError as e: | |
| log.error(f"stdout: {e.stdout if e.stdout else '(empty)'}") | |
| log.error(f"stderr: {e.stderr if e.stderr else '(empty)'}") | |
| raise | |
| log.debug(f"stdout: {res.stdout if res.stdout else '(empty)'}") | |
| log.debug(f"stderr: {res.stderr if res.stderr else '(empty)'}") | |
| return [line for line in res.stdout.splitlines() if line] | |
| def create_out_dir(out_root_dir: Path, out_sub_dir: PurePath) -> Path: | |
| """Create the directory for coverage artifacts. | |
| Coverage artifacts will be saved in `out_root_dir`/<HEAD_TIMESTAMP>-<HEAD_HASH>/`out_sub_dir`/. | |
| Args: | |
| out_root_dir: Root of the output directory. | |
| out_sub_dir: Directory to create under <HEAD_TIMESTAMP>-<HEAD_HASH>. | |
| Returns: | |
| Path where to save the coverage artifacts. | |
| """ | |
| [head_hash] = run("git", "rev-parse", "HEAD") | |
| [head_timestamp] = run("git", "show", "-s", "--format=%ct", "HEAD") | |
| # Put timestamp first to be able to sort chronologically. | |
| out_dir = out_root_dir / f"{head_timestamp}-{head_hash}" / out_sub_dir | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| return out_dir | |
| def instrument_device_libs(device_libs: List[str], config: str) -> List[str]: | |
| """Instrument device libraries. | |
| A coverage report can be created from multiple object files as long as they have | |
| coverage data. However, unused inline functions can trigger confusing hash-mismatch | |
| warnings and functions that appear in more than one object file can cause | |
| undercounting. This function builds the given device libraries with coverage | |
| instrumentation, gathers and returns their object files which can then be combined | |
| into a single library to avoid such problems. | |
| Args: | |
| device_libs: List of device libraries to be instrumented. | |
| config: Bazel configuration to use. | |
| Returns: | |
| Instrumented object files of the given device libraries. | |
| """ | |
| log.info(f"libraries to be instrumented: {pformat(device_libs)}") | |
| run(BAZEL, "build", f"--config={config}", *device_libs) | |
| starlark_list = "[f.path for f in target.output_groups.compilation_outputs.to_list()]" | |
| obj_files = run(BAZEL, "cquery", f"--config={config}", | |
| f"({' + '.join(device_libs)})", "--output=starlark", | |
| f"--starlark:expr='\\n'.join({starlark_list})") | |
| log.info(f"object files with coverage data: {pformat(obj_files)}") | |
| return obj_files | |
| def get_test_log_dirs(test_targets: List[str]) -> List[Path]: | |
| """Get log directories for the given test targets. | |
| Args: | |
| test_targets: Test targets. | |
| Returns: | |
| Log directories of the the given test targets. | |
| """ | |
| [test_log_dir_root] = run(BAZEL, "info", "bazel-testlogs") | |
| test_log_dirs = [] | |
| for t in test_targets: | |
| # Test targets are of the form: //foo/bar:test_name. Drop the first two | |
| # characters and replace ':' to get a path relative to `test_log_dir_root`. | |
| test_log_dir = Path(test_log_dir_root) / t[2:].replace(':', '/') | |
| test_log_dirs.append(test_log_dir) | |
| return test_log_dirs | |
| def generate_report(out_dir: Path, merged_profile: Path, merged_library: Path, | |
| report_title: str, print_text_report: bool) -> None: | |
| """Generate a coverage report. | |
| This function generates a coverage report from the given merged profile and library. | |
| Args: | |
| out_dir: Path where to save the html report. | |
| merged_profile: Path of merged profile data. | |
| merged_library: Path of the merged library with coverage data. | |
| print_text_report: Whether to print the text report. | |
| """ | |
| # Generate html coverage report. | |
| run(LLVM_COV, "show", "--show-line-counts", "--show-regions", | |
| f"--project-title={report_title}", "--format=html", | |
| f"--output-dir=./{out_dir}", "--instr-profile", str(merged_profile), | |
| str(merged_library)) | |
| # Generate text coverage report. | |
| with (out_dir / "report.txt").open("w") as f: | |
| f.write("\n".join( | |
| run(LLVM_COV, "report", "--instr-profile", str(merged_profile), | |
| str(merged_library)))) | |
| if print_text_report: | |
| print("\n".join( | |
| run(LLVM_COV, "report", "--use-color", "--instr-profile", | |
| str(merged_profile), str(merged_library)))) | |
| print(f"Saved coverage artifacts in {out_dir}") | |
| # Query for unit test targets | |
| TEST_TARGETS_INC = [ | |
| "//sw/device/silicon_creator/...", | |
| ] | |
| TEST_TARGETS_EXC = [ | |
| "//sw/device/silicon_creator/rom_ext/...", | |
| "attr(tags, '[\\[ ]dv[,\\]]', //sw/device/silicon_creator/...)", | |
| "attr(tags, '[\\[ ]verilator[,\\]]', //sw/device/silicon_creator/...)", | |
| "attr(tags, '[\\[ ]broken[,\\]]', //sw/device/silicon_creator/...)", | |
| ] | |
| def test_targets_query(test_type: BazelTestType) -> str: | |
| return (f"kind('^{test_type} rule$', ({' + '.join(TEST_TARGETS_INC)})" | |
| f" - ({' + '.join(TEST_TARGETS_EXC)}))") | |
| CoverageParams = namedtuple("CoverageParams", [ | |
| "config", | |
| "libs_fn", | |
| "objs_fn", | |
| "test_targets_fn", | |
| "test_log_dirs_fn", | |
| "bazel_test_type", | |
| "report_title", | |
| ]) | |
| def measure_coverage(*, log_level: LogLevel, out_root_dir: Path, | |
| out_sub_dir: PurePath, config: str, | |
| libs_fn: Callable[[List[str]], List[str]], | |
| objs_fn: Callable[[Path, List[str]], None], | |
| test_targets_fn: Callable[[List[str]], List[str]], | |
| test_log_dirs_fn: Callable[[List[Path]], List[Path]], | |
| bazel_test_type: BazelTestType, report_title: str, | |
| print_text_report: bool) -> None: | |
| """Measure test coverage. | |
| Args: | |
| log_level: Log level. | |
| out_root_dir: Root of the output directory. | |
| out_sub_dir: Directory to create under <HEAD_TIMESTAMP>-<HEAD_HASH>. | |
| config: Bazel configuration to use. | |
| libs_fn: A callable for modifying the set of device libraries if needed. | |
| objs_fn: A callable for modifying the set of object files if needed. | |
| test_targets_fn: A callable for modifying the set of tests if needed. | |
| test_log_dirs_fn: A callable that returns profile files for the given tests. | |
| bazel_test_type: Type of bazel test to search for. | |
| report_title: Title of the HTML report. | |
| print_text_report: Whether to print the text report. | |
| """ | |
| if log_level != LogLevel.NONE: | |
| log.basicConfig(level=log.getLevelName(log_level.upper())) | |
| out_dir = create_out_dir(out_root_dir, out_sub_dir) | |
| # Create a merged library to avoid potential issues due to inline functions or | |
| # duplicate definitions. | |
| merged_library = out_dir / "merged.so" | |
| device_libs_all = run( | |
| BAZEL, | |
| "query", | |
| DEVICE_LIBS_QUERY, | |
| ) | |
| device_libs = libs_fn(device_libs_all) | |
| obj_files = instrument_device_libs(device_libs, config) | |
| objs_fn(merged_library, obj_files) | |
| # Gather, filter, and run tests. | |
| test_targets_all = run( | |
| BAZEL, | |
| "query", | |
| test_targets_query(bazel_test_type), | |
| ) | |
| test_targets = test_targets_fn(test_targets_all) | |
| # Instrumented ROM overflows the space allocated for ROM. `test_targets_fn` for | |
| # functional tests programs the FPGA with the non-instrumented test ROM and we skip | |
| # bitstream loading during tests. | |
| run( | |
| BAZEL, | |
| "coverage", | |
| "--define", | |
| "bitstream=skip", | |
| f"--config={config}", | |
| "--test_output=all", | |
| *test_targets, | |
| ) | |
| # Merge profile data of individual tests to be able to generate a single report | |
| # for all targets that we are interested in. | |
| merged_profile = out_dir / "merged.profdata" | |
| test_log_dirs = get_test_log_dirs(test_targets) | |
| profile_files = test_log_dirs_fn(test_log_dirs) | |
| run( | |
| LLVM_PROFDATA, | |
| "merge", | |
| "--sparse", | |
| "--output", | |
| str(merged_profile), | |
| *[str(p) for p in profile_files], | |
| ) | |
| # Generate a report from the merged profile data and the merged library. | |
| generate_report(out_dir, merged_profile, merged_library, report_title, | |
| print_text_report) |