Files
Catch2/benchmarks/discover_tests/benchmark_discovery.py
T
Martin Hořeňovský 97ec4e8e2e catch_discover_tests: Escape test-invariant parts of CTest script only once
Previously, the `catch_discover_tests` would prepare the entire CTest
command (e.g. `add_test(...)` or `set_tests_properties(...)`) first, and
then escape it when finished. However, this caused lot of the command args
to be escaped over and over again (e.g. executable name or Catch2's reporter
args), for no reason, as they were always the same, and thus their escaping
was always the same.

Until recently, the performance overhead didn't matter as there were many
spots which had quadratic runtime in number of tests. However, the recent
refactorings fixed these, and this commit now improves the throughput by
10-20%.

Also extended the benchmarked COUNTS in `benchmark_discovery.py`,
because the performance is now good enough that it is reasonable
to benchmark 16k tests.
2026-08-10 11:53:22 +02:00

207 lines
6.6 KiB
Python

#!/usr/bin/env python3
# Copyright Catch2 Authors
# Distributed under the Boost Software License, Version 1.0.
# (See accompanying file LICENSE.txt or copy at
# https://www.boost.org/LICENSE_1_0.txt)
# SPDX-License-Identifier: BSL-1.0
import argparse
import copy
import json
import os
import re
import statistics
import subprocess
import sys
import tempfile
import time
HERE = os.path.dirname(os.path.abspath(__file__))
TEMPLATE = os.path.join(HERE, "listing_template.json")
SHIM = os.path.join(HERE, "copy_shim.cmake")
COUNTS = [1, 10, 500, 1000, 2000, 4000, 8000, 16000]
#COUNTS = [1, 10, 100]
REPEATS = 5
def load_template(path: str) -> dict:
with open(path, encoding="utf-8") as f:
catch_out = json.load(f)
if 'listings' not in catch_out or 'tests' not in catch_out['listings']:
sys.exit(f"Template '{path}' does not contain the expected 'listings.tests' data")
return catch_out
def synthesize_listing(template_doc: dict, count: int, file, with_tags: bool) -> str:
"""Writes JSON listing with exactly `count` test cases into `file`.
The template entries are cycled through, and each name is made unique by
appending an index, so that every registered CTest test has a distinct name.
"""
# To avoid messing up the template tests by deletion, we have
# to take a deepcopy before the changes.
doc_copy = copy.deepcopy(template_doc)
template_tests = doc_copy['listings']['tests']
if not with_tags:
for test in template_tests:
del test['tags']
num_original = len(template_tests)
out_tests = []
for i in range(count):
entry = template_tests[i % num_original].copy()
entry["name"] = f"{entry['name']} #{i // num_original}"
out_tests.append(entry)
doc_copy['listings']['tests'] = out_tests
# indent=2 matches Catch2's pretty printing
json.dump(doc_copy, file, indent=2)
file.write('\n')
def build_command(args, listing_path: str, ctest_file: str, add_tags: bool) -> list[str]:
# The executor needs to be a CMake list, so it has to be separated by ';'
executor = ";".join(['cmake', f"-DBENCH_LISTING={listing_path}", "-P", SHIM, "--"])
return [
'cmake',
"-DTEST_TARGET=benchmark",
f"-DTEST_EXECUTABLE={listing_path}",
f"-DTEST_EXECUTOR={executor}",
f"-DTEST_WORKING_DIR={os.path.dirname(ctest_file)}",
f"-DCTEST_FILE={ctest_file}",
f"-DADD_TAGS_AS_LABELS={'TRUE' if add_tags else 'FALSE'}",
"-P",
args.script,
]
def run_once(cmd: list[str]) -> float:
start = time.monotonic()
result = subprocess.run(cmd, capture_output=True, text=True)
elapsed = time.monotonic() - start
if result.returncode != 0:
sys.stderr.write("Benchmark invocation failed:\n")
sys.stderr.write(f" cmd: {' '.join(cmd)}\n")
sys.stderr.write(f" stdout: {result.stdout}\n")
sys.stderr.write(f" stderr: {result.stderr}\n")
sys.exit(1)
return elapsed
def verify_output(ctest_file: str, count: int, add_tags: bool):
"""Sanity-check the generated CTest script and return its add_test count."""
with open(ctest_file, encoding="utf-8") as f:
content = f.read()
add_test_lines = sum(1 for line in content.splitlines() if line.startswith("add_test("))
if add_test_lines != count:
sys.exit(
f"Sanity check failed: expected {count} add_test() lines, "
f"found {add_test_lines} in {ctest_file}"
)
if add_tags and count > 0 and "LABELS" not in content:
sys.exit(
f"Sanity check failed: ADD_TAGS_AS_LABELS was on but no LABELS found "
f"in {ctest_file}"
)
def cmake_version() -> str:
stdout = subprocess.run(
['cmake', "--version"], capture_output=True, text=True, check=True
).stdout
match = re.match("cmake version (.+)", stdout)
return match.group(1)
def print_table(counts, tag_modes, results):
timing_by_ct = {(count, tags): median_time for (count, tags, median_time) in results}
headers = ["N"]
if False in tag_modes:
headers.append("tags:off")
if True in tag_modes:
headers.append("tags:on")
def fmt_cell(count, tags_on):
median_time = timing_by_ct[(count, tags_on)]
return f"{median_time:8.1f} ms"
# Header
row = f"{headers[0]:>6}"
if False in tag_modes:
row += f" {headers[1]:>14}"
if True in tag_modes:
row += f" {headers[2]:>14}"
print()
print("-" * len(row))
print(row)
print("-" * len(row))
for count in counts:
row = f"{count:>6}"
if False in tag_modes:
row += f" {fmt_cell(count, False):>14}"
if True in tag_modes:
row += f" {fmt_cell(count, True):>14}"
print(row)
def main():
parser = argparse.ArgumentParser(
description="Benchmark the CMake-side cost of catch_discover_tests."
)
parser.add_argument(
"--script",
required=True,
help="Path to the CatchAddTests.cmake to benchmark.",
)
args = parser.parse_args()
if not os.path.exists(args.script):
sys.exit(f"Script to benchmark '{args.script}' does not exist")
if not os.path.exists(SHIM):
sys.exit(f"Copy-shim '{SHIM}' does not exist")
tag_modes = [False, True]
template_doc = load_template(TEMPLATE)
print(f"Benchmarking: {os.path.abspath(args.script)}")
print(f"CMake version: {cmake_version()}")
print(f"Ns: {COUNTS}")
print(f"Repeats: {REPEATS}")
print()
results = [] # (count, tags_on, median_ms)
with tempfile.TemporaryDirectory() as workdir:
os.makedirs(workdir, exist_ok=True)
for count in COUNTS:
listing_path = os.path.join(workdir, f"listing-{count}.json")
for add_tags in tag_modes:
with open(listing_path, "w", encoding="utf-8") as f:
synthesize_listing(template_doc, count, f, add_tags)
mode = "on" if add_tags else "off"
ctest_file = os.path.join(workdir, f"ctest-{count}-{mode}.cmake")
cmd = build_command(args, listing_path, ctest_file, add_tags)
# Untimed warmup run (populates any filesystem/OS caches).
run_once(cmd)
verify_output(ctest_file, count, add_tags)
timings = [run_once(cmd) for _ in range(REPEATS)]
median_ms = statistics.median(timings) * 1000.0
results.append((count, add_tags, median_ms))
print(f'N = {count} done in ~{len(tag_modes) * sum(timings):.2f} s')
print_table(COUNTS, tag_modes, results)
if __name__ == "__main__":
main()