build: add opt-in runtime IPO experiment with validation gates

This commit is contained in:
Matthew Stanley
2026-09-12 23:14:09 -07:00
parent ee1d72543c
commit 20ec948ab1
6 changed files with 259 additions and 0 deletions
+29
View File
@@ -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()
+73
View File
@@ -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.
+10
View File
@@ -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}
+62
View File
@@ -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")
+83
View File
@@ -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()
+2
View File
@@ -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")