diff --git a/script/cpp_benchmark.py b/script/cpp_benchmark.py index 36980fff88..3dc6e279b9 100755 --- a/script/cpp_benchmark.py +++ b/script/cpp_benchmark.py @@ -2,12 +2,18 @@ """Build and run C++ benchmarks for ESPHome components using Google Benchmark.""" import argparse +import json import os from pathlib import Path import sys from helpers import root_path -from test_helpers import PLATFORMIO_GOOGLE_BENCHMARK_LIB, build_and_run +from test_helpers import ( + BASE_CODEGEN_COMPONENTS, + PLATFORMIO_GOOGLE_BENCHMARK_LIB, + USE_TIME_TIMEZONE_FLAG, + build_and_run, +) # Path to /tests/benchmarks/components BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "components" @@ -15,15 +21,14 @@ BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "components" # Path to /tests/benchmarks/core (always included, not a component) CORE_BENCHMARKS_DIR: Path = Path(root_path) / "tests" / "benchmarks" / "core" -# Components whose to_code should run during benchmark builds. -# core/host/logger are infrastructure. json is needed because its -# to_code adds the ArduinoJson library (it's auto-loaded by api but -# cpp_testing suppresses to_code for components not in this set). -BENCHMARK_CODEGEN_COMPONENTS = {"core", "host", "logger", "json"} +# Additional codegen components beyond the base set. +# json is needed because its to_code adds the ArduinoJson library +# (auto-loaded by api, but cpp_testing suppresses to_code unless listed). +BENCHMARK_CODEGEN_COMPONENTS = BASE_CODEGEN_COMPONENTS | {"json"} PLATFORMIO_OPTIONS = { "build_flags": [ - "-DUSE_TIME_TIMEZONE", # enable timezone code paths + USE_TIME_TIMEZONE_FLAG, "-g", # debug symbols for profiling ], # Use deep+ LDF mode to ensure PlatformIO detects the benchmark @@ -39,8 +44,6 @@ def run_benchmarks(selected_components: list[str], build_only: bool = False) -> lib_config_json = os.environ.get("BENCHMARK_LIB_CONFIG") if lib_config_json: - import json - lib_config = json.loads(lib_config_json) benchmark_lib = f"benchmark=symlink://{lib_config['lib_path']}" else: diff --git a/script/cpp_unit_test.py b/script/cpp_unit_test.py index c0a6d9647d..81c56b82da 100755 --- a/script/cpp_unit_test.py +++ b/script/cpp_unit_test.py @@ -4,18 +4,16 @@ from pathlib import Path import sys from helpers import get_all_components, root_path -from test_helpers import PLATFORMIO_GOOGLE_TEST_LIB, build_and_run +from test_helpers import ( + BASE_CODEGEN_COMPONENTS, + PLATFORMIO_GOOGLE_TEST_LIB, + USE_TIME_TIMEZONE_FLAG, + build_and_run, +) # Path to /tests/components COMPONENTS_TESTS_DIR: Path = Path(root_path) / "tests" / "components" -# Components whose to_code should run during C++ test builds. -# Most components don't need code generation for tests; only these -# essential ones (platform setup, logging, core config) are needed. -# Note: "core" is the esphome core config module (esphome/core/config.py), -# which registers under package name "core" not "esphome". -CPP_TESTING_CODEGEN_COMPONENTS = {"core", "host", "logger"} - PLATFORMIO_OPTIONS = { "build_type": "debug", "build_unflags": [ @@ -23,7 +21,7 @@ PLATFORMIO_OPTIONS = { ], "build_flags": [ "-Og", # optimize for debug - "-DUSE_TIME_TIMEZONE", # enable timezone code paths for testing + USE_TIME_TIMEZONE_FLAG, "-DESPHOME_DEBUG", # enable debug assertions # Enable the address and undefined behavior sanitizers "-fsanitize=address", @@ -41,7 +39,7 @@ def run_tests(selected_components: list[str]) -> int: return build_and_run( selected_components=selected_components, tests_dir=COMPONENTS_TESTS_DIR, - codegen_components=CPP_TESTING_CODEGEN_COMPONENTS, + codegen_components=BASE_CODEGEN_COMPONENTS, config_prefix="cpptests", friendly_name="CPP Unit Tests", libraries=PLATFORMIO_GOOGLE_TEST_LIB, diff --git a/script/setup_codspeed_lib.py b/script/setup_codspeed_lib.py index 322a8069a6..b827610102 100755 --- a/script/setup_codspeed_lib.py +++ b/script/setup_codspeed_lib.py @@ -2,14 +2,22 @@ """Set up CodSpeed's google_benchmark fork as a PlatformIO library. CodSpeed requires their codspeed-cpp fork for CPU simulation instrumentation. -This script clones the repo and creates a flat PlatformIO-compatible library -by combining google_benchmark sources and codspeed core sources. +This script clones the repo and assembles a flat PlatformIO-compatible library +by combining google_benchmark sources, codspeed core, and instrument-hooks. + +PlatformIO quirks addressed: + - .cc files renamed to .cpp (PlatformIO ignores .cc) + - All sources merged into one src/ dir (PlatformIO can't compile from + multiple source directories in a single library) + - library.json created with required CodSpeed preprocessor defines Usage: python script/setup_codspeed_lib.py [--output-dir DIR] -Prints JSON to stdout with lib_path and build_flags for cpp_benchmark.py. +Prints JSON to stdout with lib_path for cpp_benchmark.py. Git output goes to stderr. + +See https://codspeed.io/docs/benchmarks/cpp#custom-build-systems """ from __future__ import annotations @@ -27,95 +35,110 @@ CODSPEED_CPP_SHA = "d6b4111428ae1f1667fec9bff009522378d5d347" DEFAULT_OUTPUT_DIR = "/tmp/codspeed-cpp" +# Well-known paths within the codspeed-cpp repository +GOOGLE_BENCHMARK_SUBDIR = "google_benchmark" +CORE_SUBDIR = "core" +INSTRUMENT_HOOKS_SUBDIR = Path(CORE_SUBDIR) / "instrument-hooks" +INSTRUMENT_HOOKS_INCLUDES = INSTRUMENT_HOOKS_SUBDIR / "includes" +INSTRUMENT_HOOKS_DIST = INSTRUMENT_HOOKS_SUBDIR / "dist" / "core.c" +CORE_CMAKE = Path(CORE_SUBDIR) / "CMakeLists.txt" -def setup_codspeed_lib(output_dir: Path) -> None: - """Clone codspeed-cpp and create a flat PlatformIO library. - Args: - output_dir: Directory to clone into - """ - if not (output_dir / ".git").exists(): - subprocess.run( - ["git", "clone", CODSPEED_CPP_REPO, str(output_dir)], - check=True, - stdout=sys.stderr, - stderr=sys.stderr, - ) - # Checkout pinned SHA and init submodules in one pass - subprocess.run( - ["git", "-C", str(output_dir), "checkout", CODSPEED_CPP_SHA], - check=True, - capture_output=True, - ) - subprocess.run( - [ - "git", - "-C", - str(output_dir), - "submodule", - "update", - "--init", - "--recursive", - ], - check=True, - stdout=sys.stderr, - stderr=sys.stderr, - ) +def _git(args: list[str], **kwargs: object) -> None: + """Run a git command, sending output to stderr.""" + subprocess.run( + ["git", *args], + check=True, + stdout=kwargs.pop("stdout", sys.stderr), + stderr=kwargs.pop("stderr", sys.stderr), + **kwargs, + ) - benchmark_dir = output_dir / "google_benchmark" - core_dir = output_dir / "core" - instrument_hooks_dir = core_dir / "instrument-hooks" / "includes" - # Read version from core/CMakeLists.txt (needed by walltime.cpp) - version = "0.0.0" - cmake_file = core_dir / "CMakeLists.txt" - if cmake_file.exists(): - for line in cmake_file.read_text().splitlines(): - if line.startswith("set(CODSPEED_VERSION"): - version = line.split()[1].rstrip(")") - break +def _clone_repo(output_dir: Path) -> None: + """Clone codspeed-cpp at the pinned SHA with submodules.""" + _git( + [ + "clone", + "--recurse-submodules", + "--shallow-submodules", + CODSPEED_CPP_REPO, + str(output_dir), + ] + ) + _git( + [ + "-C", + str(output_dir), + "-c", + "advice.detachedHead=false", + "checkout", + CODSPEED_CPP_SHA, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) - # PlatformIO doesn't compile .cc files — rename to .cpp - for cc_file in (benchmark_dir / "src").glob("*.cc"): + +def _read_codspeed_version(cmake_path: Path) -> str: + """Extract CODSPEED_VERSION from core/CMakeLists.txt.""" + if not cmake_path.exists(): + return "0.0.0" + for line in cmake_path.read_text().splitlines(): + if line.startswith("set(CODSPEED_VERSION"): + return line.split()[1].rstrip(")") + return "0.0.0" + + +def _rename_cc_to_cpp(src_dir: Path) -> None: + """Rename .cc files to .cpp so PlatformIO compiles them.""" + for cc_file in src_dir.glob("*.cc"): cpp_file = cc_file.with_suffix(".cpp") if not cpp_file.exists(): cc_file.rename(cpp_file) - # Copy codspeed core sources and headers into google_benchmark/src/ - # so PlatformIO compiles everything as one library. - # .cpp files get a codspeed_ prefix to avoid name collisions. - # .h files keep their original names since they're referenced by includes. - for src_file in (core_dir / "src").glob("*"): + +def _copy_if_missing(src: Path, dest: Path) -> None: + """Copy a file only if the destination doesn't already exist.""" + if not dest.exists(): + shutil.copy2(src, dest) + + +def _merge_codspeed_core_into_lib(core_src: Path, lib_src: Path) -> None: + """Copy codspeed core sources into the benchmark library src/. + + .cpp files get a ``codspeed_`` prefix to avoid name collisions with + google_benchmark's own sources. .h files keep their original names + since they're referenced by ``#include "walltime.h"`` etc. + """ + for src_file in core_src.iterdir(): if src_file.suffix == ".cpp": - dest = benchmark_dir / "src" / f"codspeed_{src_file.name}" + _copy_if_missing(src_file, lib_src / f"codspeed_{src_file.name}") elif src_file.suffix == ".h": - dest = benchmark_dir / "src" / src_file.name - else: - continue - if not dest.exists(): - shutil.copy2(src_file, dest) + _copy_if_missing(src_file, lib_src / src_file.name) - # Copy instrument-hooks C source (provides instrument_hooks_* symbols) - hooks_c = instrument_hooks_dir.parent / "dist" / "core.c" - if hooks_c.exists(): - dest = benchmark_dir / "src" / "instrument_hooks.c" - if not dest.exists(): - shutil.copy2(hooks_c, dest) - # Resolve the ESPHome project root for CODSPEED_ROOT_DIR - project_root = Path(__file__).resolve().parent.parent - - # Create library.json +def _write_library_json( + benchmark_dir: Path, + core_include: Path, + hooks_include: Path, + version: str, + project_root: Path, +) -> None: + """Write a PlatformIO library.json with CodSpeed build flags.""" library_json = { "name": "benchmark", "version": "0.0.0", "build": { "flags": [ - f"-I{core_dir / 'include'}", - f"-I{instrument_hooks_dir}", + f"-I{core_include}", + f"-I{hooks_include}", + # google benchmark build flags "-DHAVE_STD_REGEX", "-DHAVE_STEADY_CLOCK", "-DBENCHMARK_STATIC_DEFINE", + # CodSpeed instrumentation flags + # https://codspeed.io/docs/benchmarks/cpp#custom-build-systems "-DCODSPEED_ENABLED", "-DCODSPEED_SIMULATION", f'-DCODSPEED_VERSION=\\"{version}\\"', @@ -125,16 +148,52 @@ def setup_codspeed_lib(output_dir: Path) -> None: "includeDir": "include", }, } - (benchmark_dir / "library.json").write_text( json.dumps(library_json, indent=2) + "\n" ) + +def setup_codspeed_lib(output_dir: Path) -> None: + """Clone codspeed-cpp and assemble a flat PlatformIO library. + + The resulting library at ``output_dir/google_benchmark/`` contains: + - google_benchmark sources (.cc renamed to .cpp) + - codspeed core sources (prefixed ``codspeed_``) + - instrument-hooks C source (as ``instrument_hooks.c``) + - library.json with all required CodSpeed defines + + Args: + output_dir: Directory to clone the repository into + """ + if not (output_dir / ".git").exists(): + _clone_repo(output_dir) + + benchmark_dir = output_dir / GOOGLE_BENCHMARK_SUBDIR + lib_src = benchmark_dir / "src" + core_dir = output_dir / CORE_SUBDIR + core_include = core_dir / "include" + hooks_include = output_dir / INSTRUMENT_HOOKS_INCLUDES + hooks_dist_c = output_dir / INSTRUMENT_HOOKS_DIST + project_root = Path(__file__).resolve().parent.parent + + # 1. Rename .cc → .cpp (PlatformIO doesn't compile .cc) + _rename_cc_to_cpp(lib_src) + + # 2. Merge codspeed core sources into the library + _merge_codspeed_core_into_lib(core_dir / "src", lib_src) + + # 3. Copy instrument-hooks C source (provides instrument_hooks_* symbols) + if hooks_dist_c.exists(): + _copy_if_missing(hooks_dist_c, lib_src / "instrument_hooks.c") + + # 4. Write library.json + version = _read_codspeed_version(output_dir / CORE_CMAKE) + _write_library_json( + benchmark_dir, core_include, hooks_include, version, project_root + ) + # Output JSON config for cpp_benchmark.py - result = { - "lib_path": str(benchmark_dir), - } - print(json.dumps(result)) + print(json.dumps({"lib_path": str(benchmark_dir)})) def main() -> None: @@ -146,7 +205,6 @@ def main() -> None: help=f"Directory to clone codspeed-cpp into (default: {DEFAULT_OUTPUT_DIR})", ) args = parser.parse_args() - setup_codspeed_lib(args.output_dir) diff --git a/script/test_helpers.py b/script/test_helpers.py index fa79210172..5ca2e4e408 100644 --- a/script/test_helpers.py +++ b/script/test_helpers.py @@ -36,6 +36,15 @@ LOGGER_KEY = "logger" # by component benchmark.yaml files. esphome: allows sub-key merging. BASE_CONFIG_KEYS = frozenset({ESPHOME_KEY, HOST_KEY, LOGGER_KEY}) +# Shared build flag — enables timezone code paths for testing/benchmarking. +USE_TIME_TIMEZONE_FLAG = "-DUSE_TIME_TIMEZONE" + +# Components whose to_code should always run during C++ test/benchmark builds. +# These are the minimal infrastructure components needed for host compilation. +# Note: "core" is the esphome core config module (esphome/core/config.py), +# which registers under package name "core" not "esphome". +BASE_CODEGEN_COMPONENTS = {"core", "host", "logger"} + # Exit codes EXIT_OK = 0 EXIT_SKIPPED = 1