From 732a1207662ab06415a8d0897062a91cdd3c6ae2 Mon Sep 17 00:00:00 2001 From: YuutaTsubasa <81959102+YuutaTsubasa@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:26:39 +0800 Subject: [PATCH 1/2] Compile the generated code with loop-head checkpoints only The 2026-10-20 work showed that checkpointing only loop heads takes about 8% off an undrawn race frame on the AYN Thor, but releases kept compiling the shipped generated directory, whose label checkpoints are mostly at labels reached only forwards (143,424 of 172,524). The build now converts each ppc_recomp.*.cpp into the build directory through scripts/loop_head_checkpoints.py --file (CMake option SFR_LOOP_HEAD_CHECKPOINTS, on by default) and compiles the copies; the generated directory is untouched and current generator output passes through unchanged. The result is byte-identical .text to a build pointed at a hand-made copy. AYN Thor, forced skipping, three runs each: undrawn_ms 7.11 -> 6.76 ms, the main thread's own work in an undrawn frame 5.74 -> 4.92 ms, its CPU per frame 8.77 -> 7.95 ms. Windows unchanged. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 22 ++++++++++- docs/checkpoint-interval.md | 5 ++- docs/performance.md | 61 ++++++++++++++++++++++++++++- scripts/loop_head_checkpoints.py | 21 +++++++++- tests/test_loop_head_checkpoints.py | 14 +++++++ 5 files changed, 117 insertions(+), 6 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index f4b72d82..3a641973 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1150,6 +1150,26 @@ if(SFR_BUILD_DIAGNOSTIC) message(FATAL_ERROR "Run scripts/generate_diagnostic.py first; raw output is not safe to execute.") endif() file(GLOB SFR_DIAGNOSTIC_SOURCES CONFIGURE_DEPENDS "${SFR_DIAGNOSTIC_DIR}/ppc_recomp.*.cpp") + # Label checkpoints only at loop heads (scripts/loop_head_checkpoints.py, + # docs/performance.md): older generator output checkpoints after every + # label, and the shipped generated code is not regenerated, so the game + # compiles copies made in the build directory without the forward ones. + # Output of the current generator passes through unchanged. + option(SFR_LOOP_HEAD_CHECKPOINTS "Compile the generated code with label checkpoints only at loop heads" ON) + set(SFR_DIAGNOSTIC_COMPILED_SOURCES ${SFR_DIAGNOSTIC_SOURCES}) + if(SFR_LOOP_HEAD_CHECKPOINTS) + find_package(Python3 COMPONENTS Interpreter REQUIRED) + set(SFR_DIAGNOSTIC_COMPILED_SOURCES) + foreach(source IN LISTS SFR_DIAGNOSTIC_SOURCES) + get_filename_component(name "${source}" NAME) + set(copy "${CMAKE_CURRENT_BINARY_DIR}/loop-head-checkpoints/${name}") + add_custom_command(OUTPUT "${copy}" + COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/scripts/loop_head_checkpoints.py" --file "${source}" "${copy}" + DEPENDS "${source}" "${CMAKE_CURRENT_SOURCE_DIR}/scripts/loop_head_checkpoints.py" + VERBATIM) + list(APPEND SFR_DIAGNOSTIC_COMPILED_SOURCES "${copy}") + endforeach() + endif() # The title's depth view (82439998, 82439D80) as generated, copied out of # those sources into the build directory and run on test images against # the offline extractor (kinect_depth_features.h): bit for bit. @@ -1214,7 +1234,7 @@ if(SFR_BUILD_DIAGNOSTIC) endif() endif() target_sources(sfr_cpu_diagnostic PRIVATE src/diagnostic_main.cpp src/guest_memory.cpp src/xex_module.cpp src/virtual_memory.cpp src/critical_section.cpp src/hardware_info.cpp src/thread_local_storage.cpp src/system_time.cpp src/guest_clock.cpp src/timestamp_bundle.cpp src/vector_memory.cpp src/optional_import_policy.cpp src/native_modules.cpp src/integer_arithmetic.cpp src/memory_statistics.cpp - ${SFR_DIAGNOSTIC_SOURCES} "${SFR_DIAGNOSTIC_DIR}/ppc_func_mapping.cpp" "${SFR_DIAGNOSTIC_DIR}/imports.cpp") + ${SFR_DIAGNOSTIC_COMPILED_SOURCES} "${SFR_DIAGNOSTIC_DIR}/ppc_func_mapping.cpp" "${SFR_DIAGNOSTIC_DIR}/imports.cpp") if(APPLE) # the generated names as calls, as Mach-O has no aliases if(NOT EXISTS "${SFR_DIAGNOSTIC_DIR}/aliases.cpp") message(FATAL_ERROR "Run scripts/generate_diagnostic.py again; this output has no aliases.cpp.") diff --git a/docs/checkpoint-interval.md b/docs/checkpoint-interval.md index 2c7050c9..ab8e000f 100644 --- a/docs/checkpoint-interval.md +++ b/docs/checkpoint-interval.md @@ -1,8 +1,9 @@ # Guest checkpoint interval Generated guest code visits a checkpoint at function entries and loop labels. -Output of the current generator (or of `scripts/loop_head_checkpoints.py`) -checkpoints only labels a loop returns to, so the same interval spans more +Output of the current generator (or of `scripts/loop_head_checkpoints.py`, +which the build applies to older output unless `SFR_LOOP_HEAD_CHECKPOINTS` is +off) checkpoints only labels a loop returns to, so the same interval spans more guest work than with checkpoints at every label (performance.md, 2026-10-20: generated code). Calling the execution permit less often reduces host CPU overhead. Every platform defaults to 256 entries between permit calls: Windows since Issue #33 diff --git a/docs/performance.md b/docs/performance.md index 947dd7e5..6ec3283d 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -2626,11 +2626,13 @@ present limit, on both the shipped and the loop-head-only code; unit tests pass on Windows (165) and Linux (158), Python 356 (11 skipped for want of the game files). -**Shipping the loop-head part** needs the release's generated directory +**Shipping the loop-head part** needed the release's generated directory copied through `python scripts/loop_head_checkpoints.py ` (`--check` verifies the copy) and the build pointed at the copy with `-DSFR_DIAGNOSTIC_DIR`. The generator now emits only loop-head checkpoints -itself, so new output needs nothing. +itself, so new output needs nothing. Releases up to 0.10.25 still compiled +the shipped directory as it was; since 2026-10-23 the build converts it +itself (below). **Proposals not done, by expected gain and risk:** @@ -2647,3 +2649,58 @@ itself, so new output needs nothing. instead of through the GOT), after exporting `SDL_main` and the JNI entries. 5. A hot-first symbol order or PGO layout for the 50 MB text (untested here: the Thor time went to the checkpoint A/B). + +## 2026-10-23: Loop-head checkpoints in every build + +The 2026-10-20 pass measured that checkpointing only loop heads takes about +8% off an undrawn race frame on the AYN Thor, but its release step (pointing +the build at a converted copy of the generated directory) was never taken: +the releases kept compiling the shipped generated code, whose 172,524 label +checkpoints include 143,424 at labels only reached forwards. The build now +does the conversion. With `SFR_LOOP_HEAD_CHECKPOINTS` (CMake option, on by +default) each `ppc_recomp.*.cpp` is copied into the build directory +(`loop-head-checkpoints/`) through `scripts/loop_head_checkpoints.py --file`, +and the game compiles the copies; the generated directory itself is +untouched, and output of the current generator passes through unchanged. A +build configured this way from the shipped directory produces a `.text` +byte-identical to one pointed at a copy made by hand (Android, 43.8 MB +against 52.6 MB before). Python 3 is needed at build time when the option is +on. + +**Thor A/B** (this branch's source on the shipped generated code, versionCode +73, Dolphin Resort moving race `CLAUDE_INPUT=kicks`, forced skipping +`SFR_RACE_FRAMESKIP=2 SFR_TEST_RACE_DRAW_MS=20`, frames 14,000-20,500, three +runs each, interleaved). "Own" is an undrawn frame's time less its +`main_blocked_ms` (the main thread's waits for the guests it depends on), +from the NATIVE_PRESENT lines of undrawn frames: + +| Build | undrawn_ms (HOST_THREADS) | cpu_ms | undrawn: blocked | undrawn: own | +| --- | --- | --- | --- | --- | +| shipped code (every label) | 7.23 / 7.24 / 6.85 | 8.87 / 8.77 / 8.68 | 1.39 / 1.45 / 1.32 | 5.87 / 5.80 / 5.55 | +| loop heads only | 6.70 / 6.86 / 6.73 | 7.95 / 7.87 / 8.02 | 1.77 / 2.01 / 1.76 | 4.94 / 4.87 / 4.95 | + +The main thread's own work in an undrawn frame falls by 0.82 ms (14%) and its +CPU per frame by 0.83 ms (9%); the undrawn frame itself by 0.35 ms (5%), +because the main thread now reaches its waits for the job workers earlier and +waits there longer (blocked +0.45 ms). All runs ran to the present limit at +speed ~0.998 and drew normally. On Windows (i9, D3D12, the same race and +forced skipping, three runs each) nothing changed: own 2.88 / 2.46 / 2.60 ms +before, 2.74 / 2.82 / 2.46 after; x86-64 lacks the hidden checkpoint call, so +a label's checkpoint there costs a decrement more than a register spill. + +**Tried and not kept:** reading the entry's four flags and the hook and +graphics stack pointers as one word each in `enter_function` (three loads and +one taken branch instead of seven loads and two taken branches for a guest +beside the permit, the main thread's case), together with a hidden symbol +for the hook filter (an `adrp` instead of a GOT load). The text was 1 MB +smaller, but on the Thor the main thread's own undrawn work came out at 5.26 +/ 5.00 / 5.67 ms against 4.94 / 4.87 / 4.95 for loop heads alone: no gain. + +**Where the rest goes.** With the forward checkpoints gone, an undrawn frame +on the Thor is ~4.9 ms of the main thread's own work and ~1.9 ms of waiting +for the job workers (the collision job, `RACE_JOBS collision_wait_ms`), so +the workers' side now bounds the frame as much as the generated code on the +main thread. The 2026-10-20 statement shares still describe the generated +code; the larger remaining levers are the ones listed there (local-register +regeneration, the save/restore helpers, PGO or a hot-first layout). + diff --git a/scripts/loop_head_checkpoints.py b/scripts/loop_head_checkpoints.py index faf835c2..a98fb826 100644 --- a/scripts/loop_head_checkpoints.py +++ b/scripts/loop_head_checkpoints.py @@ -14,9 +14,12 @@ usage: python scripts/loop_head_checkpoints.py python scripts/loop_head_checkpoints.py --check + python scripts/loop_head_checkpoints.py --file The output holds every file of the input; only the ppc_recomp.*.cpp files lose checkpoint lines, and --check verifies that the output is exactly the input -less those lines. +less those lines. --file converts one source (the build does this for each, +CMake option SFR_LOOP_HEAD_CHECKPOINTS); output that has only loop-head +checkpoints already comes out unchanged. """ import re import shutil @@ -68,7 +71,23 @@ def check(source, output): return j == len(b) +def convert_file(source, output): + result, _, _ = transform(source.read_bytes().decode('utf-8')) + data = result.encode('utf-8') + output.parent.mkdir(parents=True, exist_ok=True) + # Written in full every time, so the build sees it newer than its source. + output.write_bytes(data) + + def main(argv): + if argv[:1] == ['--file']: + if len(argv) != 3: + sys.exit(__doc__) + source, output = Path(argv[1]), Path(argv[2]) + if source.resolve() == output.resolve(): + sys.exit('the output must be another file') + convert_file(source, output) + return verify = argv[:1] == ['--check'] if verify: argv = argv[1:] diff --git a/tests/test_loop_head_checkpoints.py b/tests/test_loop_head_checkpoints.py index 9f1658db..5e9d82cd 100644 --- a/tests/test_loop_head_checkpoints.py +++ b/tests/test_loop_head_checkpoints.py @@ -72,6 +72,20 @@ def test_directory_copy_and_check(self): with self.assertRaises(SystemExit): loops.main(['--check', str(source), str(output)]) + def test_single_file_as_the_build_converts_it(self): + with tempfile.TemporaryDirectory() as work: + source, output, again = Path(work) / 'ppc_recomp.0.cpp', Path(work) / 'build/ppc_recomp.0.cpp', Path(work) / 'again.cpp' + source.write_text(SOURCE, newline='') + loops.main(['--file', str(source), str(output)]) + written = output.read_bytes().decode('utf-8') + self.assertEqual(written, loops.transform(SOURCE)[0]) + self.assertTrue(loops.check(SOURCE, written)) + # current generator output (loop heads only) passes through unchanged + loops.main(['--file', str(output), str(again)]) + self.assertEqual(again.read_bytes(), output.read_bytes()) + with self.assertRaises(SystemExit): + loops.main(['--file', str(source), str(source)]) + if __name__ == '__main__': unittest.main() From de90e48f98af612f06c2df810b4257c4ea96d33e Mon Sep 17 00:00:00 2001 From: YuutaTsubasa <81959102+YuutaTsubasa@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:17:21 +0800 Subject: [PATCH 2/2] docs: Thor profile split and the ThinLTO trial Co-Authored-By: Claude Opus 5.5 --- docs/performance.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/performance.md b/docs/performance.md index 6ec3283d..0a389d34 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -2695,8 +2695,15 @@ beside the permit, the main thread's case), together with a hidden symbol for the hook filter (an `adrp` instead of a GOT load). The text was 1 MB smaller, but on the Thor the main thread's own undrawn work came out at 5.26 / 5.00 / 5.67 ms against 4.94 / 4.87 / 4.95 for loop heads alone: no gain. - -**Where the rest goes.** With the forward checkpoints gone, an undrawn frame +ThinLTO over the whole game library (`-flto=thin`, with loop heads; text +44.8 MB) came out at 5.07 / 5.27 ms: no gain either. + +**Where the rest goes.** simpleperf on the Thor (shipped code, forced +skipping, 30 s from about frame 14,000, main thread): generated guest code +42.6%, graphics HLE and renderer 15.3%, kernel and clock reads 8.8% (mostly +the pacing), libc/libc++/locks/TLS 7.3%, entries, permit, indirect calls and +imports 4.7%, GPR/FPR save-restore helpers 4.5%, hand-written hooks 3.9%, +guest memory helpers 3.1%, other 8.7%. With the forward checkpoints gone, an undrawn frame on the Thor is ~4.9 ms of the main thread's own work and ~1.9 ms of waiting for the job workers (the collision job, `RACE_JOBS collision_wait_ms`), so the workers' side now bounds the frame as much as the generated code on the