Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.")
Expand Down
5 changes: 3 additions & 2 deletions docs/checkpoint-interval.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
68 changes: 66 additions & 2 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <diagnostic> <copy>`
(`--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:**

Expand All @@ -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).

21 changes: 20 additions & 1 deletion scripts/loop_head_checkpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@

usage: python scripts/loop_head_checkpoints.py <generated dir> <output dir>
python scripts/loop_head_checkpoints.py --check <generated dir> <output dir>
python scripts/loop_head_checkpoints.py --file <ppc_recomp.N.cpp> <output 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
Expand Down Expand Up @@ -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:]
Expand Down
14 changes: 14 additions & 0 deletions tests/test_loop_head_checkpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading