blob: c916114f5084a106b1d99a1b2257303a8971b106 [file] [log] [blame]
#!/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)