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..0a389d34 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,65 @@ 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. +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 +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()