diff --git a/cmake/psx_runtime_ipo.cmake b/cmake/psx_runtime_ipo.cmake new file mode 100644 index 00000000..4ab86eca --- /dev/null +++ b/cmake/psx_runtime_ipo.cmake @@ -0,0 +1,29 @@ +include_guard(GLOBAL) + +option(PSX_RUNTIME_IPO + "Experimental IPO/LTO for optimized runtime targets (requires supported C/C++ toolchain)" + OFF) + +function(psxrecomp_apply_runtime_ipo target) + # OFF must not alter a parent project's explicit IPO policy or add flags. + if(NOT PSX_RUNTIME_IPO) + return() + endif() + include(CheckIPOSupported) + check_ipo_supported(RESULT _psx_ipo_ok OUTPUT _psx_ipo_error LANGUAGES C CXX) + if(NOT _psx_ipo_ok) + message(FATAL_ERROR + "PSX_RUNTIME_IPO=ON was requested, but C/C++ IPO is unsupported.\n" + "Use a compatible compiler/linker or configure PSX_RUNTIME_IPO=OFF.\n" + "${_psx_ipo_error}") + endif() + # Target/configuration-scoped: do not silently enable IPO in third-party + # libraries, build helpers, Debug, or custom configurations. + set_property(TARGET ${target} PROPERTY INTERPROCEDURAL_OPTIMIZATION FALSE) + set_property(TARGET ${target} PROPERTY INTERPROCEDURAL_OPTIMIZATION_DEBUG FALSE) + foreach(_psx_ipo_config RELEASE RELWITHDEBINFO MINSIZEREL) + set_property(TARGET ${target} + PROPERTY INTERPROCEDURAL_OPTIMIZATION_${_psx_ipo_config} TRUE) + endforeach() + message(STATUS "psxrecomp: experimental runtime IPO enabled for ${target} (optimized configurations)") +endfunction() diff --git a/docs/internal/RUNTIME_IPO_EXPERIMENT.md b/docs/internal/RUNTIME_IPO_EXPERIMENT.md new file mode 100644 index 00000000..5a4061ac --- /dev/null +++ b/docs/internal/RUNTIME_IPO_EXPERIMENT.md @@ -0,0 +1,73 @@ +# Draft: opt-in runtime IPO/LTO validation + +Status: experimental, OFF by default. Tracking: `beads-eio.3.149`. +The PE report is supplemental evidence, not a locally reproduced benchmark. +No IRQ, device deadline, I-cache, guest codegen or overlay ABI behavior changes. + +## Build contract + +`-DPSX_RUNTIME_IPO=ON` checks C and C++ IPO support and enables it only on runtime +targets, for Release, RelWithDebInfo and MinSizeRel. Debug and custom configurations +are not enabled by this option. Unsupported toolchains fail with an explicit +diagnostic rather than silently ignoring a requested experiment. + +The default OFF path changes no target property, including an explicit parent +project IPO policy. This is not a project-wide +`CMAKE_INTERPROCEDURAL_OPTIMIZATION=ON`: third-party libraries, emitters and other +targets are not opted in. This narrower scope is deliberate for validation and +may not reproduce the contributor's project-wide result. + +CMake provides the target/configuration properties and support probe used here: +[IPO target property](https://cmake.org/cmake/help/latest/prop_tgt/INTERPROCEDURAL_OPTIMIZATION.html), +[CheckIPOSupported](https://cmake.org/cmake/help/latest/module/CheckIPOSupported.html). + +## PGO is a separate variable + +The existing `pgo-train` command configures generate/debug-ON, builds, trains, +then configures use/debug-OFF and builds again. It reports failures. The supplied +early-stop report does not match that successful control path; do not change +the pipeline without the failing revision, command and exit/error evidence. + +Use the existing `--cmake-extra` interface to pass the IPO option to both PGO +build phases if testing the combination. Inspect actual compile/link commands; +an ON cache entry alone does not prove every target was built with IPO. + +Runtime CMake PGO and this IPO option affect linked runtime/generated C/C++. +`compile_overlays.py` launches a separate `-shared -O2` compiler command with +neither profile flags nor IPO. They do not directly profile/optimize PE's dynamic +decoder DLL. Gains may come from linked code or callbacks, which must be measured. + +Do not replace a user's working profiles or cache when running experiments. +Use isolated project/build/save directories and record profile identities. + +## Automated checks + +- Default OFF leaves target properties unset; parent IPO choice preserved. +- Injected unsupported toolchain fails only when IPO is requested. +- Real supported-toolchain two-file C/C++ IPO build/link/execution. +- Debug compilation emits no IPO flags; unrelated target remains unchanged. +- Mocked PGO success/failure orchestration and direct overlay compiler command. + These mocks do not validate real profile collection or performance. + +The real IPO smoke check explicitly reports a skip when the tested compiler +cannot support IPO; that does not count as a positive link/run validation. + +## Evidence needed before promotion + +1. Repeated content-aligned LTO off/on trials with the same PGO profile, compiler, + debug flags, assets, input and cache state. LTO-only/full-factorial trials can + answer broader attribution questions, but are not required merely to measure + LTO's incremental effect with one fixed profile. +2. Per-frame tails/maxima and audio underruns, not only periodic average FPS. + The reported small incremental gain overlaps the earlier run-to-run spread. +3. Regenerated title regression: Tomba, MMX6 and Ape, including LLE boot, FMV, + menus and gameplay. Do not infer unchanged behavior from frame counts alone. +4. Shipping compiler/linker/platform and launcher-enabled coverage, with profile + mismatch/missing-function warnings visible during profile validation. Record + binary size, build/link time and peak memory as well as host performance. +5. Exact PE configuration, profile/capture identities and raw measurements from + the contributor. Their project-wide IPO result is not a guarantee for this + narrower runtime-only option or other titles. + +Leave this PR in draft pending that feedback. Do not claim a performance win or +enable IPO by default merely because the isolated compiler test passes. diff --git a/recompiler/CMakeLists.txt b/recompiler/CMakeLists.txt index fa35779f..d44933fe 100644 --- a/recompiler/CMakeLists.txt +++ b/recompiler/CMakeLists.txt @@ -502,11 +502,21 @@ if(BUILD_TESTING) add_test(NAME reachable_discovery_test COMMAND reachable_discovery_test) find_package(Python3 COMPONENTS Interpreter) + if(Python3_Interpreter_FOUND AND CMAKE_GENERATOR STREQUAL "Ninja") + add_test(NAME runtime_ipo_configuration + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_runtime_ipo.py + --cmake ${CMAKE_COMMAND} --cc ${CMAKE_C_COMPILER} + --cxx ${CMAKE_CXX_COMPILER} --ninja ${CMAKE_MAKE_PROGRAM}) + endif() if(Python3_Interpreter_FOUND) add_test(NAME optional_netplay_auth COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_optional_netplay_auth.py --cxx ${CMAKE_CXX_COMPILER}) + add_test(NAME pgo_rebuild_flow + COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_pgo_rebuild_flow.py) add_test( NAME cli_project_packaging COMMAND ${Python3_EXECUTABLE} diff --git a/recompiler/tests/test_pgo_rebuild_flow.py b/recompiler/tests/test_pgo_rebuild_flow.py new file mode 100644 index 00000000..0edfdaf0 --- /dev/null +++ b/recompiler/tests/test_pgo_rebuild_flow.py @@ -0,0 +1,62 @@ +"""No real build, game launch, cache cleanup, or profile mutation.""" +import argparse +import contextlib +import io +from pathlib import Path +import sys +from unittest import mock + +root = Path(__file__).resolve().parents[2] +sys.path[:0] = [str(root), str(root / "tools")] +import psxrecomp_cli as cli +import compile_overlays as overlays + +# Existing source paths satisfy the file guards; ALL consumers of their +# contents and all mutating/build/process operations are mocked below. +def check_flow(fail_training=False): + args = argparse.Namespace(config=str(root / "CLAUDE.md"), project_root=str(root), + build_dir="review-not-created", target="psx-runtime", exe_basename="psx-runtime", + no_pgo=True, force_pgo=False, disc=str(root / "CLAUDE.md"), cmake_extra=[], + train_secs=100, train_runs=1) + progress = mock.Mock() + events = [] + def configure(*a, **kw): + events.append(("configure", kw["pgo"], kw["extra"][-1])) + def train(*a, **kw): + events.append(("train", kw["train_secs"], kw["train_runs"])) + if fail_training: + raise RuntimeError("synthetic training failure") + with contextlib.ExitStack() as stack: + replacements = dict(load_sections=lambda _: {}, + activate_embedded_toolchain=lambda *a: True, + clamp_future_mtimes=lambda *a, **kw: 0, + _cmake_configure=configure, + _cmake_build=lambda *a: events.append(("build",)), + run_pgo_train=train, + _resolve_runtime_exe=lambda *a: (root / "fake-runtime.exe", None)) + for name, replacement in replacements.items(): + stack.enter_context(mock.patch.object(cli, name, replacement)) + result = cli.cmd_pgo_train(args, progress) + expected = [("configure", "generate", "-DPSX_DEBUG_TOOLS=ON"), ("build",), ("train", 100, 1)] + if not fail_training: + expected += [("configure", "use", "-DPSX_DEBUG_TOOLS=OFF"), ("build",)] + assert result == cli.EXIT_OK + assert progress.result.call_args.kwargs["ok"] is True + else: + assert result == cli.EXIT_ERROR + progress.result.assert_not_called() + progress.error.assert_called_once() + assert events == expected, events + print("PGO flow", "failure" if fail_training else "success", events) + +check_flow() +check_flow(True) +with mock.patch.object(overlays, "_toolchain_env", return_value=({}, "fake-toolchain")), \ + mock.patch.object(overlays.subprocess, "run", return_value=mock.Mock(returncode=0)) as run, \ + contextlib.redirect_stdout(io.StringIO()): + assert overlays._compile_dll_direct("fake.c", "fake.dll", [], gcc="fake-gcc") +command = run.call_args.args[0] +assert "-O2" in command and "-shared" in command +assert not any("profile" in arg.lower() for arg in command) +print("Overlay DLL command (mocked subprocess):", command) +print("PASS: successful PGO finishes use/debug-OFF; failures report failure; overlay DLL command has no PGO flags") diff --git a/recompiler/tests/test_runtime_ipo.py b/recompiler/tests/test_runtime_ipo.py new file mode 100644 index 00000000..e8726fc7 --- /dev/null +++ b/recompiler/tests/test_runtime_ipo.py @@ -0,0 +1,83 @@ +"""Isolated configure/build checks: no BIOS, game or network required.""" +import argparse +import os +from pathlib import Path +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[2] + +def run(command, success=True): + p = subprocess.run(list(map(str, command)), capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=180) + if success is not None and (p.returncode == 0) != success: + raise AssertionError(f"unexpected exit {p.returncode}: {command}\n{p.stdout}\n{p.stderr}") + return p + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--cmake", required=True) + ap.add_argument("--cc", required=True) + ap.add_argument("--cxx", required=True) + ap.add_argument("--ninja", required=True) + args = ap.parse_args() + with tempfile.TemporaryDirectory(prefix="psx-ipo-") as temp: + root = Path(temp) + (root / "first.c").write_text("int other(void); int main(void) { return other() != 42; }\n") + (root / "second.cpp").write_text('extern "C" int other(void) { return 42; }\n') + (root / "fake_modules").mkdir() + (root / "fake_modules/CheckIPOSupported.cmake").write_text(''' +function(check_ipo_supported) + set(_psx_ipo_ok FALSE PARENT_SCOPE) + set(_psx_ipo_error "synthetic unsupported toolchain" PARENT_SCOPE) +endfunction() +''') + (root / "CMakeLists.txt").write_text(''' +cmake_minimum_required(VERSION 3.20) +project(IPOSmoke C CXX) +if(SIMULATE_UNSUPPORTED) + list(PREPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/fake_modules") +endif() +include("''' + (ROOT / "cmake/psx_runtime_ipo.cmake").as_posix() + '''") +add_executable(subject first.c second.cpp) +add_library(unrelated OBJECT first.c) +if(PARENT_IPO) + set_property(TARGET subject PROPERTY INTERPROCEDURAL_OPTIMIZATION TRUE) +endif() +psxrecomp_apply_runtime_ipo(subject) +get_target_property(base subject INTERPROCEDURAL_OPTIMIZATION) +get_target_property(release subject INTERPROCEDURAL_OPTIMIZATION_RELEASE) +get_target_property(debug subject INTERPROCEDURAL_OPTIMIZATION_DEBUG) +get_target_property(others unrelated INTERPROCEDURAL_OPTIMIZATION_RELEASE) +file(WRITE "${CMAKE_BINARY_DIR}/properties.txt" "${base}|${release}|${debug}|${others}") +''', encoding="utf-8") + def configure(name, *options, ok=True): + dest = root / name + result = run([args.cmake, "-S", root, "-B", dest, "-G", "Ninja", + f"-DCMAKE_MAKE_PROGRAM={args.ninja}", f"-DCMAKE_C_COMPILER={args.cc}", + f"-DCMAKE_CXX_COMPILER={args.cxx}", "-DCMAKE_BUILD_TYPE=Release", *options], ok) + return dest, result + off, _ = configure("off", "-DSIMULATE_UNSUPPORTED=ON") + assert (off / "properties.txt").read_text() == "base-NOTFOUND|release-NOTFOUND|debug-NOTFOUND|others-NOTFOUND" + parent, _ = configure("parent", "-DPARENT_IPO=ON", "-DSIMULATE_UNSUPPORTED=ON") + assert (parent / "properties.txt").read_text().startswith("TRUE|release-NOTFOUND|") + _, failure = configure("unsupported", "-DPSX_RUNTIME_IPO=ON", "-DSIMULATE_UNSUPPORTED=ON", ok=False) + assert "PSX_RUNTIME_IPO=ON was requested" in failure.stderr + on, result = configure("on", "-DPSX_RUNTIME_IPO=ON", ok=None) + if result.returncode != 0: + assert "PSX_RUNTIME_IPO=ON was requested" in result.stderr, result.stderr + print("PASS: default/parent/unsupported guards; SKIP actual IPO link: toolchain reports unsupported") + return + assert (on / "properties.txt").read_text() == "FALSE|TRUE|FALSE|others-NOTFOUND" + build = run([args.cmake, "--build", on, "--verbose"]) + # MSVC and GCC/Clang spell IPO flags differently; this test's explicit + # Ninja/compiler arguments cover either native toolchain. + assert any(flag in build.stdout for flag in ("-flto", "/GL", "-GL")), build.stdout + run([on / ("subject.exe" if os.name == "nt" else "subject")]) + debug, _ = configure("debug", "-DPSX_RUNTIME_IPO=ON", "-DCMAKE_BUILD_TYPE=Debug") + dbg_build = run([args.cmake, "--build", debug, "--verbose"]) + assert not any(flag in dbg_build.stdout for flag in ("-flto", "/GL", "-GL")) + print("PASS: default OFF, parent policy, unsupported failure, scoped ON, actual IPO link/run, Debug OFF") + +if __name__ == "__main__": + main() diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 25e34500..db6d8c48 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -14,6 +14,7 @@ endif() # costly PGO rebuild/train cycle a silent no-op. set(PSX_PGO "" CACHE STRING "PGO mode: empty, generate, or use") set_property(CACHE PSX_PGO PROPERTY STRINGS "" generate use) +include("${PSXRECOMP_ROOT}/cmake/psx_runtime_ipo.cmake") include("${PSXRECOMP_ROOT}/cmake/psx_dependency_archive.cmake") include("${PSXRECOMP_ROOT}/runtime/chd_dependency.cmake") @@ -1389,6 +1390,7 @@ function(psxrecomp_add_runtime_target target) # CMAKE_C_STANDARD setting. cxx_std_17 likewise — game CMakeLists may omit # CMAKE_CXX_STANDARD; mod_packages.cpp must not compile as a pre-17 dialect. target_compile_features(${target} PRIVATE c_std_11 cxx_std_17) + psxrecomp_apply_runtime_ipo(${target}) if(NOT PSX_PGO STREQUAL "") if(NOT PSX_PGO STREQUAL "generate" AND NOT PSX_PGO STREQUAL "use")