diff --git a/AGENTS.md b/AGENTS.md index ea63c80..7bfac21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,6 +74,20 @@ from the code. Performance tests state the GPU profile they ran at, and check th overrides the port's clocks. The target is 720p (1280x720 internal) at 30 fps. +## Faster builds: PGO and the recompiler passes + +Optional profile-guided optimisation (`-DWWHD_PGO=GEN` to train, `USE` to build; [docs/pgo.md](docs/pgo.md)), **off by +default**: a normal build and every release are not profiled (turning it on for players: issue #90). Results and dead ends in +[docs/pgo-progress.md](docs/pgo-progress.md). A profile fits the exact +code it was made from: remake it after a recompile with a changed recompiler or a change to a profiled runtime file. Never +judge speed on a training build (several times slower), and never deploy one to a console nobody can restart: a crashing +start-up needs the NRO put back by hand (FTP :5000 works when the game is not running). The write-back elision +(`tools/recomp/calleeregs.py`, `python3 tools/recomp/test_calleeregs.py`) assumes function hooks read only argument +registers; the recompiler checks that against the runtime's sources. + +Before any timing, check the log's `[dk] clocks:` line: sys-clk or a saved GPU profile other than the build's default makes +numbers meaningless (docs/perf-test.md). + ## The SD card without the debug server - Best: hekate's USB mass storage (UMS). The SD mounts at `/Volumes/SWITCH SD` (FAT32): plain `cp`, then diff --git a/cmake/SwitchPgo.cmake b/cmake/SwitchPgo.cmake new file mode 100644 index 0000000..aad68b0 --- /dev/null +++ b/cmake/SwitchPgo.cmake @@ -0,0 +1,83 @@ +# SwitchWakerHD (this fork): profile-guided optimisation of the Switch build (docs/pgo.md). +# +# The recompiled game is machine-translated PowerPC: its branches carry no layout information, so GCC lays +# blocks out, allocates registers and decides what is cold from static guesses. A profile of the real game +# on the console tells it which paths run. +# +# WWHD_PGO=OFF (default) nothing changes: a normal build. +# WWHD_PGO=GEN a training build: the hot game files (code_hot_*.c) and the runtime's frame-thread code are +# instrumented (-fprofile-generate, counters not atomic). It is slower and larger and is never +# released. The debug server gets "pgo dump|reset|status" (runtime/src/platform/pgo_switch.cpp), +# which writes the counters to sdmc:/switch/wwhd/pgo/*.gcda (tools/switch/pgo_train.py). +# WWHD_PGO=USE the same files compiled with that profile (WWHD_PGO_DIR). Code the training never reached +# is compiled as usual (-fprofile-partial-training), not for size. +# +# The profile only fits the exact sources it was made from: GCC compares a checksum of every function's flow +# graph and ignores (with -Wno-coverage-mismatch) the profile of a function that changed. Make it again after a +# recompile with a different recompiler, or a change to a profiled runtime file. +set(WWHD_PGO "OFF" CACHE STRING "Switch: profile-guided optimisation: OFF, GEN (instrumented training build) or USE (docs/pgo.md)") +set_property(CACHE WWHD_PGO PROPERTY STRINGS OFF GEN USE) +set(WWHD_PGO_DIR "${CMAKE_SOURCE_DIR}/build/pgo" CACHE PATH "WWHD_PGO=USE: the folder with the .gcda files fetched from the console") +set(WWHD_PGO_SCOPE "game;runtime" CACHE STRING "what the profile covers: game (code_hot_*.c) and/or runtime (runtime/src, the frame threads' code)") +# extra flags for the USE build only, to try variants without editing this file (e.g. -fno-tracer -fno-peel-loops) +set(WWHD_PGO_USE_FLAGS "" CACHE STRING "WWHD_PGO=USE: extra compiler flags for the profiled files") + +macro(switch_pgo_configure) + if(WWHD_SWITCH AND NOT WWHD_PGO STREQUAL "OFF") + if(NOT WWHD_PGO STREQUAL "GEN" AND NOT WWHD_PGO STREQUAL "USE") + message(FATAL_ERROR "WWHD_PGO must be OFF, GEN or USE") + endif() + set(_pgo_files) + if("game" IN_LIST WWHD_PGO_SCOPE) + file(GLOB _pgo_hot CONFIGURE_DEPENDS ${GEN_DIR}/code_hot_*.c) + list(APPEND _pgo_files ${_pgo_hot}) + endif() + if("runtime" IN_LIST WWHD_PGO_SCOPE) + get_target_property(_pgo_all wwhd SOURCES) + foreach(_s ${_pgo_all}) + # the runtime's own C++ (not the generated build info, the vendored libraries or the diagnostics) + if(_s MATCHES "runtime/src/.*\\.cpp$" AND NOT _s MATCHES "third_party") + list(APPEND _pgo_files ${_s}) + endif() + endforeach() + endif() + # the object paths (and so the .gcda names) are relative to the build folder: a profile made in one build + # folder is found by another + set(_pgo_common -fprofile-prefix-path=${CMAKE_BINARY_DIR}) + if(WWHD_PGO STREQUAL "GEN") + # not atomic: three cores increment the same counters, a few lost increments do not change a layout + # -fno-profile-values: no value profiling (indirect-call targets, divisors). Its counters are thread-local, and the + # runtime's first code (libnx's init hooks, the heap) runs before the console has set up thread-local storage; + # the generated code's indirect calls go through our own call-site caches anyway + set(_pgo_flags ${_pgo_common} -fprofile-generate=/pgo -fprofile-update=single -fno-profile-values) + target_compile_definitions(wwhd PRIVATE WWHD_PGO_GEN=1) + target_sources(wwhd PRIVATE runtime/src/platform/pgo_switch.cpp) + target_link_options(wwhd PRIVATE -fprofile-generate) + else() + if(NOT IS_DIRECTORY "${WWHD_PGO_DIR}") + message(FATAL_ERROR "WWHD_PGO=USE: ${WWHD_PGO_DIR} is not a folder (fetch the profile: tools/switch/pgo_train.py)") + endif() + # -fno-reorder-functions / -fno-profile-reorder-functions: the profile would rename the functions' sections + # (.text.hot.*, .text.sorted.*) and tools/switch/hot_sections.ld, which lists .text., would no + # longer put them together. Cold basic blocks are still moved out of the functions (.text.unlikely.*). + set(_pgo_flags ${_pgo_common} -fprofile-use=${WWHD_PGO_DIR} -fprofile-correction -fprofile-partial-training + -fno-reorder-functions -fno-profile-reorder-functions -Wno-missing-profile -Wno-coverage-mismatch + # -fprofile-use turns on loop unrolling, peeling and tail duplication: on one hot file (code_hot_012) + # the object grew 29% (40,180 -> 51,900 bytes), and the hot code is already bigger than the A57's + # 2 MiB L2. Without them it is +3%; the block partitioning moves cold blocks out (.text.unlikely). + -fno-tracer -fno-unroll-loops -fno-peel-loops -freorder-blocks-and-partition + ${WWHD_PGO_USE_FLAGS}) + endif() + string(REPLACE ";" " " _pgo_flags_text "${_pgo_flags}") + foreach(_f ${_pgo_files}) + set_property(SOURCE ${_f} APPEND_STRING PROPERTY COMPILE_FLAGS " ${_pgo_flags_text}") + # the instrumented allocator wrappers leave a relocation against posix_memalign in .debug_info, which --wrap does + # not redirect (undefined reference at the link): no debug information for that one file in a training build + if(WWHD_PGO STREQUAL "GEN" AND _f MATCHES "thread_heap_switch\\.cpp$") + set_property(SOURCE ${_f} APPEND_STRING PROPERTY COMPILE_FLAGS " -g0") + endif() + endforeach() + list(LENGTH _pgo_files _pgo_n) + message(STATUS "WWHD_PGO=${WWHD_PGO}: ${_pgo_n} files (${WWHD_PGO_SCOPE})") + endif() +endmacro() diff --git a/cmake/SwitchPort.cmake b/cmake/SwitchPort.cmake index dbe3f85..1d014a3 100644 --- a/cmake/SwitchPort.cmake +++ b/cmake/SwitchPort.cmake @@ -7,6 +7,9 @@ else() set(WWHD_SWITCH OFF) endif() +# (this fork) profile-guided optimisation: WWHD_PGO=GEN|USE (docs/pgo.md); off by default +include(${CMAKE_SOURCE_DIR}/cmake/SwitchPgo.cmake) + if(WWHD_SWITCH) # DEKO3D: deko3d (the Switch's own GPU API, docs/deko3d-plan.md) with the libnx host, the only renderer # there (the OpenGL renderer it replaced was removed after dev 207349b). Upstream's renderer choice is skipped. @@ -95,6 +98,14 @@ macro(switch_port_executable) target_compile_options(wwhd PRIVATE -ffixed-x28) target_compile_definitions(wwhd PRIVATE PPC_BASE_REG=1) endif() + # (this fork) the runtime's extern symbols hidden: devkitPro's toolchain is -fPIC, which loads every extern symbol + # with default visibility through the GOT (runtime/include/switch_visibility.h); the game code gets the same + # from switch_code_align.h. WWHD_SWITCH_HIDDEN=OFF for a build without it. + option(WWHD_SWITCH_HIDDEN "Switch: hidden symbol visibility in the runtime (no GOT loads of extern symbols)" ON) + if(WWHD_SWITCH AND WWHD_SWITCH_HIDDEN) + target_compile_options(wwhd PRIVATE -fvisibility=hidden + "SHELL:-include \"${CMAKE_SOURCE_DIR}/runtime/include/switch_visibility.h\"") + endif() # guest mods built into the NRO (tools/switch/guest_mods.py, docs/guest-mods-switch-plan.md): their modules are # compiled as game code (same flags, x28); without build/guest-mods/registry.c the NRO has none set(WWHD_GUEST_MODS_DIR "${CMAKE_SOURCE_DIR}/build/guest-mods" CACHE PATH "guest mods for the NRO (guest_mods.py)") @@ -261,4 +272,5 @@ macro(switch_port_executable) nx_create_nro(wwhd NACP "${WWHD_NACP_FILE}" ${WWHD_ICON_ARG} OUTPUT ${WWHD_NRO_OUTPUT}) endif() + switch_pgo_configure() # (this fork) cmake/SwitchPgo.cmake: after all the target's sources are known endmacro() diff --git a/docs/cpu-threading-plan.md b/docs/cpu-threading-plan.md index 7ac32e3..358c3ba 100644 --- a/docs/cpu-threading-plan.md +++ b/docs/cpu-threading-plan.md @@ -503,3 +503,10 @@ Newest last. Each entry: what was done, what was measured, what it decided. main f1f3132 27.9-28.0 / 24.4-24.5 / 28.9-29.0; this branch's build (O12) 29.1 / 25.35 / 29.0 (one boot so far). PR #60 is not the cause: per draw before and after it 4.4 us, the same components; its single-boot bisect step was the boot-to-boot spread. +- 2026-10-23, **CPU work at the stock 1020 MHz: PR #87** (frame-free leaves, hidden symbols, interprocedural write-back elision, + PGO). Phase 2.2's "write-back elision" is done with the callees' real read/write sets (the earlier callee-saved-only version, + K/L above, was too small to see at the time; the A/B then ran on a console whose clocks were not stock, which is also why it + read as noise). Stock-clock A/B, 4 boots per build: the three code changes +1.8% to +3.3% fps in the CPU-bound scenes, + PGO on top another +2.6% to +7.5% (outset_village 25.35 -> 27.98 fps in all; lava 27.94 -> 29.28; windfall and + dragonroost reach the 30 fps cap). Phase 4 (the render thread) got its first win from the runtime half of the PGO profile + rather than from a restructuring. Write-up: docs/switch-port.md "Round 49", docs/pgo.md, docs/pgo-progress.md. diff --git a/docs/perf-test.md b/docs/perf-test.md index 761e3ea..ce3815e 100644 --- a/docs/perf-test.md +++ b/docs/perf-test.md @@ -73,6 +73,11 @@ baseline built before that option: for a like-for-like comparison leave `switchS ## Rules for agents +- **Check the clocks first.** Read the `[dk] clocks:` line of the session log (debug server `raw logtext`): it must say CPU 1020 MHz + and the GPU profile of the build under test (307 MHz before 2026-10-23, 384 MHz default since PR #85). sys-clk or a saved + 460 MHz GPU profile makes the scenes sit at the 30 fps cap and hides real differences (2026-10-23: the same changes read + "inside the noise" at 1224/460 and +2% to +7% at stock). The lava room at stock should be near 27.9 fps (reference below). + - Check first that the console answers and runs the USA game (`tools/switch/wwhd_debug.py info`). - Tell the user before the first deploy (the game restarts) and say how long it will take. If the console does not answer, ask them to start the game with the debug server on; do not guess. diff --git a/docs/pgo-progress.md b/docs/pgo-progress.md new file mode 100644 index 0000000..32cd01e --- /dev/null +++ b/docs/pgo-progress.md @@ -0,0 +1,103 @@ +# CPU work for 30 fps at 1020 MHz: progress log (branch `perf/cpu-1020`, worktree SwitchWakerHD-perf) + +Newest entries last. Each entry says what was done, the evidence, and what it means. Console numbers are from +192.168.0.27 (handheld, stock clocks, `tools/switch/perf_test.py`, ABBA order, 4 boots per build). + +> **CORRECTION (user, 2026-10-23 ~13:40): sys-clk was on and the game's GPU profile was 460 MHz during everything below +> that has console timings** (the training session log shows `CPU 1224 MHz, GPU 460 MHz`). So the "Baseline", "Round A2" and +> "Round B" console numbers are NOT stock-clock numbers and are void; the static results (machine code, lockstep) and the PGO +> profile (counts per frame) are unaffected. GPU profile reset to `default` in settings.ini (only that line; the original file +> is build/console_settings.ini.orig-user); sys-clk off. Log now shows `CPU 1020 MHz, GPU 307 MHz, memory 1331 MHz`. +> Other settings on that console that differ from a fresh install and were left alone: Bloom off, Distance blur off, dynamic +> resolution off in handheld, dev options on, mods on (first person, ladder speed, move speed, target letterbox, wall climb). +> All timings are being redone from here. + +## Baseline (main 113fa63, this console) [VOID: non-stock clocks] +lava 29.85 fps main 30.51 ms; windfall 29.97 / 29.85; dragonroost 29.97 / 29.28 (at the 30 fps cap, so main-thread ms is the +signal). Demanding Outset set, 1 boot: grass 29.97 (render 28.9 ms), village 28.57 (main 33.6, render 33.2, GPU 33.5 ms: all +three at the limit), lookout 28.77 (GPU 34.3 ms, behind 100%), cliff 29.95. + +## Round A2: frame-free leaf functions + hidden visibility +- Machine code: functions with a stack frame 35,648 -> 24,696; GOT loads at indirect-call sites 26,203 -> 0. +- Console: main ms per frame lava -0.48 (+-0.45), windfall -0.19 (+-0.43), dragonroost -0.24 (+-0.46). fps unchanged (cap). + +## Round B: write-back elision before calls (callee-saved + volatile registers by callee read/write sets) +- tools/recomp/calleeregs.py (new), leaflocal.py, fork_passes.py, ppc.h `PPC_RELOAD_SKIPPED`. Desktop keeps the old behaviour. +- Static: 33% of dirty volatile register stores before direct calls left out; stores per call 7.0 -> 5.3; hot code -1.9% instructions. +- Lockstep (macOS, Switch form, tools/gametest), 2 scenarios (20 s, 35 s): identical to A2 at equal boot alignment in Link, + camera, rng, sound starts (1,230 / 2,150), all process hashes (40,280 / 70,430), save. +- Console: main ms lava -0.22 (+-0.51), windfall -0.48 (+-0.50), dragonroost -0.33 (+-0.69): same direction, inside noise. +- Reading: A2 + B are worth about 1-3% of main-thread time; verified and safe, not enough for 15%. + +## PGO (GCC -fprofile-generate on the console, -fprofile-use for the final build) +- Plumbing: cmake/SwitchPgo.cmake, runtime/src/platform/pgo_switch.cpp (debug cmd `pgo dump|reset|status`), + tools/switch/pgo_train.py, build.sh WWHD_PGO / WWHD_BUILD_DIR / WWHD_PGO_SCOPE. +- Attempt 1 (full scope, 366 files): black screen at launch. Attempt 2 (game code only): boots, then data abort in the main + guest thread at frame 0. +- **Evidence (2026-10-23 ~13:30):** crash lr = the instruction after `bl __gcov_indirect_call_profiler_v4` in f_028EFFD0. libgcov's + value-profiling counter is thread-local and libgcov reads `tpidr_el0`, which Horizon leaves 0 (libnx uses `tpidrro_el0`); + fault address 0x2fd8 = the TLS offset. `-fno-profile-values` had been added but the objects were stale (built before the + flag). A test compile with the flag has 0 value-profiler calls. The full-scope black screen is probably the same cause. +- Rebuilt game-only training build: 0 value-profiler calls, 0 `tpidr_el0` reads in the ELF, 3,669 counter arrays. + Not known yet: whether libgcov's dump (pthread mutex, file I/O) works on Horizon. +- **Evidence (13:35):** corrected game-only training build deployed to 192.168.0.27: boots, stays up 40+ s, `pgo status` answers. + `pgo reset` + `pgo dump` work: 13 .gcda files in sdmc:/switch/wwhd/pgo (58-68 KB each), game still alive. libgcov's pthread + mutex and file I/O are fine on Horizon. Next: training run over the scenes (`pgo_train.py train --no-deploy`), held-out + scenes lava / windfall / dragonroost / outset_village (+ warps 2, 3, 4) left out of the training. +- **Evidence (13:12):** training ran 13 dumps (outset, foresthaven, forbidden, sea_totg, hyrule at day and night; outset_grass day) with + the game stable, ~55 s per scene. It then stopped at `outset_grass` with the clock forced to night (20 deg): the session log + ends with the game's own assertion `PROGRAM HALT d_event_data.cpp:1103 "demo_data"` -> abort() (frame 21029). Not the + instrumentation: the Outset benchmark states are made for the afternoon; night triggers an event their progress lacks. + Fix: night variants only for the states that survived them; resume the plan after the 11 finished items (`--skip-first`). +- **Evidence (13:25):** resumed training finished: 15 of 15 scenes, no crash (warps 0,1,5-11,13-16 at afternoon; outset_lookout, + outset_cliff). Total 26 scenes: outset/foresthaven/forbidden/sea_totg/hyrule day+night, outset_grass/lookout/cliff, 13 warps. + 13 .gcda files fetched to build/pgo (0.8 MB). Held out (never trained): lava, windfall, dragonroost, outset_village, warps 2/3/4. +- **Evidence (13:50), profile use on one file (code_hot_012):** GCC finds and matches the profile when compiled from the build + directory layout (object path relative, `-fprofile-prefix-path` = build dir). With -fprofile-use's default options the object + grows 29% (plain 40,180 -> 51,900 bytes): loop unrolling/peeling/tail duplication on code that is already larger than L2. + `-fno-tracer -fno-unroll-loops -fno-peel-loops` -> 41,448 (+3%); adding `-freorder-blocks-and-partition` -> 41,824 with 11 cold + parts split out. These are now the USE defaults (cmake/SwitchPgo.cmake). +- **Fetched main (13:55):** 113fa63 -> 754ab3b: #78 zcull on by default (+ dual-issue test switch), #80 NACP version. No recompiler or + generated-code change, so the PGO profile (keyed to code_hot_*.c functions) stays valid. Plan: measure on 113fa63 as the + common base, then rebase onto main and re-verify the build. +- **Evidence (13:55):** PGO USE build (game code only, 13 files, profile found for all: 0 "profile file not found" warnings) + built: build/switch-pgo-use/wwhd.nro. Stock-clock baseline on this console: lava 27.85 fps / main 34.3 ms (maintainer's + reference 27.9), windfall 28.61 / 33.5 ms. + +## STOCK-CLOCK RESULTS (CPU 1020, GPU 307, memory 1331 MHz; 192.168.0.27; 4 boots per build, ABBA x2, 900 frames/scene) +- **Round B (A2 + write-back elision) vs main 113fa63, 14:30:** significant gains where the scene is CPU-bound: + lava +0.51 fps (+-0.43, +1.8%, main thread 34.19 -> 33.37 ms, -2.4%); windfall +0.72 (+-0.40, +2.5%, -2.6%); dragonroost +0.51 + (+-0.38, +1.8%, -2.0%); outset_village +0.84 (+-0.38, +3.3%, main 37.73 -> 36.30 ms, -3.8%); outset_lookout and outset_grass + fps unchanged (GPU-bound: 20.7 / 23.4) but main thread -1.13 ms (-3.5%) / -1.01 ms (-4.5%). 0 DSP kills. + Lesson: the earlier "inside the noise" reading came from non-stock clocks where the scenes sat at the 30 fps cap. +- **Evidence (14:42):** clean full-scope training build (game + runtime, 366 files) built in build/switch-pgo-gen: 0 value-profiler + calls, 0 tpidr_el0 reads, 57,756 counter arrays, bss 9.4 MB. Not deployed yet (console busy with the B vs B+PGO A/B, started 14:30). +- **B + PGO (game code only; profile from 26 scenes, held-out: lava, windfall, dragonroost, outset_village) vs B, 15:32, stock clocks, + 4 boots each:** main thread lava -0.59 ms (-1.8%), windfall -0.89 (-2.7%), dragonroost -0.45 (-1.4%), outset_village -0.75 + (-2.1%), outset_lookout -0.82 (-2.6%), outset_grass -0.38 (-1.8%); fps +0.37 / +0.56 / +0.35 / +0.11 in the four CPU-bound + scenes. Same direction in all six, but each inside its 95% interval (the B boots spread 0.4-0.9 ms; the PGO boots only + 0.02-0.17: the profile-ordered code is also steadier from boot to boot). The held-out scenes gain as much as any, so it is not + overfitting to the training scenes. + Cumulative against main (different sessions, indicative): lava 27.94 -> 28.87, windfall 28.52 -> 29.94, dragonroost 28.61 -> + 29.57, outset_village 25.35 -> 26.25 fps. +- **Evidence (16:00):** full-scope training (game + runtime, clean build, value profiling really off) ran on the console at stock + clocks: 26 of 26 scenes, no crash, ~62 s per scene; 49 .gcda files (1.2 MB) fetched to build/pgo-full. Confirms the black-screen + diagnosis: the stale objects (compiled before -fno-profile-values) were the cause, not the runtime instrumentation itself. +- **16:25** full-scope PGO NRO built (build/switch-pgo-use-full): text +1.9% vs B, 4,895 cold parts split out, hot-function order intact. A/B vs round B started. +- **Worktree fast-forwarded to origin/main 59a4741 (16:10).** New since 113fa63: #78 zcull on, #80 NACP, #79 settings-menu rework, #85 the + handheld GPU fixed at 384 MHz by default (the owner; 307 -> 384 +4.7 fps at the tall grass in their bench). None touches tools/recomp, + runtime/include/ppc.h consumers or the generated code, so the rounds and the profile stay valid; GPU-bound scenes (lookout, grass) + will move with #78/#85 independently of this work. All measurements here keep 113fa63 + my changes as one side and 113fa63 as the other. +- **B + full PGO (game + runtime profile, 26 scenes) vs B, 17:08, stock clocks, 4 boots each, all differences beyond the 95% interval + in the CPU-bound scenes:** lava 28.36 -> 29.28 fps (+0.93 +-0.14, +3.3%; main thread 33.51 -> 31.72 ms, -5.4%); windfall 29.22 -> + 29.97 (+0.75 +-0.15, +2.6%, at the 30 fps cap; main -4.7%); dragonroost 28.98 -> 29.92 (+0.94 +-0.23, +3.2%; main -6.1%); + outset_village 26.04 -> 27.98 (+1.95 +-0.30, +7.5%; main 36.44 -> 34.27 ms, -5.9%); outset_lookout and outset_grass fps unchanged + (GPU-bound) but main thread -3.2% / -2.0%. 0 DSP kills. Boot-to-boot spread of the PGO build is tiny (sd 0.00-0.03 fps). + The runtime half of the profile (render thread, GX2 command path) is what moved the Outset village (+1.95 fps vs +0.11 for the + game-code-only profile). +- **Cumulative vs main 113fa63 (separate sessions, indicative):** lava 27.94 -> 29.28 (+4.8%), windfall 28.52 -> 29.97 (+5.1%), + dragonroost 28.61 -> 29.92 (+4.6%), outset_village 25.35 -> 27.98 (+10.4%); main-thread time lava -7.2%, village -9.2%. +- **Rebased build (17:30):** the same changes + the full profile built on origin/main 59a4741 (zcull, GPU 384 MHz, menu rework): + build/switch-final/wwhd.nro, 52,136,133 bytes, no instrumentation, 4,898 cold blocks split out. It compiles and links; it has NOT + been timed on a console (the measurements above are on 113fa63 + these changes). The profile still applies to every function whose + code did not change. diff --git a/docs/pgo.md b/docs/pgo.md new file mode 100644 index 0000000..1fda7ad --- /dev/null +++ b/docs/pgo.md @@ -0,0 +1,59 @@ +# Profile-guided optimisation of the Switch build + +The recompiled game is machine-translated PowerPC. Its branches carry no layout information, so GCC lays blocks out +and allocates registers from static guesses. A profile of the real game on the console tells it which paths run. +Measured results and the history of what failed: [pgo-progress.md](pgo-progress.md). + +**A profile only fits the exact sources it was made from.** GCC compares a checksum of each function's flow graph and +ignores the profile of a function that changed (`-Wno-coverage-mismatch`). Make the profile again after a recompile with a +different recompiler, or after changing a profiled runtime file. It is derived from the player's own recompile, so it is +not committed. PGO is off by default and no release ships a PGO build; distributing profiles is issue #90. + +## Result (stock clocks, console, 4 boots per build; full table in [switch-port.md](switch-port.md) "Round 49") + +PGO with a game + runtime profile, on top of the recompiler changes: lava 28.36 -> 29.28 fps (+3.3%), windfall 29.22 -> 29.97 +(+2.6%, the cap), dragonroost 28.98 -> 29.92 (+3.2%), outset_village 26.04 -> 27.98 (+7.5%); main thread -4.7% to -6.1%. +The scenes the profile was made on exclude those four. The runtime half of the profile (render thread, GX2 command path) is +what moves the Outset village. + +## The three modes (`cmake -DWWHD_PGO=...`, `tools/switch/build.sh` passes it through) + +| Mode | What | Use | +|---|---|---| +| `OFF` (default) | a normal build | releases | +| `GEN` | the hot game files (`code_hot_*.c`) and, with scope `runtime`, the runtime's own C++ are instrumented | training only, never released | +| `USE` | the same files compiled with the profile in `WWHD_PGO_DIR` | the optimised build | + +`WWHD_PGO_SCOPE` is `game`, `runtime` or `game;runtime` (default both). + +## Procedure + +```sh +# 1. the training build, in its own folder so the normal build stays +WWHD_BUILD_DIR=build/switch-pgo-gen WWHD_PGO=GEN tools/switch/build.sh + +# 2. train on the console (debug server on, game started from the HOME icon); it deploys the NRO, visits 26 scenes and +# fetches the .gcda files (about 30 minutes; the instrumented game is slow, do not judge speed on it) +tools/switch/pgo_train.py train --nro build/switch-pgo-gen/wwhd.nro --out build/pgo + +# 3. the optimised build +WWHD_BUILD_DIR=build/switch-pgo-use WWHD_PGO=USE WWHD_PGO_DIR=$PWD/build/pgo tools/switch/build.sh +``` + +`pgo_train.py` plans: `default` leaves out four scenes (lava, windfall, dragonroost, outset_village and warps 2, 3, 4) so +that the performance test can judge the result on places the profile never saw; `all` trains on everything. + +## Traps found while making this work + +- **No value profiling** (`-fno-profile-values`, set for GEN). libgcov's value-profiling counters are thread-local and it + reads the hardware thread pointer `tpidr_el0`, which Horizon leaves 0 (libnx uses `tpidrro_el0`): the first indirect + call in the instrumented game faults at a tiny address. After changing compile flags, delete the stale objects: `make` + does not always notice, and an object compiled without the flag crashes the game. +- **Profile names come from the object's relative path** (`-fprofile-prefix-path` = the build folder). Compile from the + build folder with relative `-o`, as CMake does, or the profile is not found (`-Wmissing-profile` says so). +- **GCC's default `-fprofile-use` options inflate the hot code 29%** (unrolling, peeling, tail duplication). The USE defaults + turn them off and enable block partitioning (`cmake/SwitchPgo.cmake`); `WWHD_PGO_USE_FLAGS` appends more. +- `-fno-reorder-functions` keeps the sections named `.text.`, which `tools/switch/hot_sections.ld` orders. +- Forcing night on the Outset benchmark states makes the game halt on its own assertion (`d_event_data.cpp:1103`); the + trainer uses night only for states that survive it. +- Training needs the console at any clock, but compare speed only at stock clocks (sys-clk off, GPU profile default). diff --git a/docs/switch-port.md b/docs/switch-port.md index 25db2d8..8865482 100644 --- a/docs/switch-port.md +++ b/docs/switch-port.md @@ -1723,3 +1723,40 @@ warp: all OK (log `logs-switch/wwhd_2026-10-09_21-36-50_EU.log`). USA game, same 29.9 fps in the session's reports), save states, a warp and the sun at sea (peekZ falls back to the game's function, no hang): all OK (`logs-switch/wwhd_2026-10-09_22-22-57_USA_r48.log`). + +### Round 49: CPU at the stock 1020 MHz: frame-free leaves, hidden symbols, write-back elision, profile-guided build (PR #87, hardware-tested) + +Goal: hold 30 fps at the stock CPU where 1200 MHz runs fine. Measured on a console at stock clocks (CPU 1020, GPU 307, +memory 1331 MHz), `perf_test.py ab`, 4 boots per build, 900 frames per scene. Details, every measurement and the dead ends: +[pgo-progress.md](pgo-progress.md); how to make a profile: [pgo.md](pgo.md). + +| Change | What it does | Evidence | +|---|---|---| +| Frame-free leaf functions (`PPC_ENTER_FN`) | the cold yield path of the entry check is a tail call, so a function with no other call has no stack frame | framed functions 35,648 -> 24,696 | +| Hidden symbol visibility | `-fPIC` loaded `ppc_dispatch` through the GOT at every indirect call (26,203 sites) | GOT loads at call sites -> 0 | +| Write-back elision before direct calls (`tools/recomp/calleeregs.py`) | callee read/write sets over the call graph: a store before a call stays only if the callee reads the register or the caller needs it afterwards | stores per call 7.0 -> 5.3, hot code -1.9% instructions; lockstep identical (70,430 process executions) | +| PGO (`-DWWHD_PGO=GEN/USE`) | GCC profile of 26 scenes on the console, game code **and** runtime (render thread, GX2 path) | below | + +| Scene | main (113fa63) | + frame-free, hidden, elision | + PGO (game + runtime profile) | +|---|---|---|---| +| lava | 27.94 fps, main thread 34.2 ms | 28.36 | **29.28**, 31.7 ms | +| windfall | 28.52 | 29.22 | **29.97** (the cap) | +| dragonroost | 28.61 | 28.98 | **29.92** | +| outset_village | 25.35, 37.7 ms | 26.04 | **27.98**, 34.3 ms | +| outset_lookout / grass | 20.7 / 23.4 | GPU-bound, fps unchanged | main thread -3.2% / -2.0% | + +The PGO step alone is +3.3% / +2.6% / +3.2% / +7.5% fps (lava, windfall, dragonroost, village), all beyond the 95% interval, on +scenes the profile was not made on. The runtime half of the profile is what moves the Outset village (+1.95 fps; the game-code +profile alone gave +0.11). The PGO build is also steadier from boot to boot (sd 0.00-0.03 fps against 0.1-0.5). + +What we learned (so it is not repeated): +1. **Check the clocks before any timing.** The first console numbers of this work were taken with sys-clk on and the game's GPU + profile at 460 MHz: the scenes sat at the 30 fps cap and the same changes looked "inside the noise". At stock clocks the + baseline reproduced the maintainer's reference (lava 27.9 fps). `[dk] clocks:` in the log header says what ran. +2. **A guess about the ABI is not a measurement.** The game was compiled with Green Hills, which allocates registers across + calls; the elision therefore uses the callees' real read/write sets and treats everything unknown as "reads everything". +3. **PGO traps:** libgcov's value profiling reads `tpidr_el0` (0 on Horizon) and crashes the first indirect call; stale objects + keep the old flags; GCC's default `-fprofile-use` options inflate the hot code 29%; the profile is found by the object's + relative path. See pgo.md. +4. A profile fits the exact generated code it was made from: remake it after a recompiler or profiled-runtime change. A release + build with PGO needs its own profile; for now PGO stays off by default and releases are not profiled (issue #90). diff --git a/runtime/include/ppc.h b/runtime/include/ppc.h index 19430a0..63fbce1 100644 --- a/runtime/include/ppc.h +++ b/runtime/include/ppc.h @@ -75,6 +75,11 @@ void ppc_host_call(Cpu* c, PpcFunc fn); /* guarded native hook/site en #else #define PPC_HOOK_CALL(c, fn) ppc_host_call(c, fn) #endif +#ifdef __SWITCH__ +/* hidden: its address is the base of every cached target (PPC_SITE_FN); a default-visibility symbol is loaded + * through the GOT in this -fPIC build, an extra load at each of the 15,000 indirect call sites */ +__attribute__((visibility("hidden"))) +#endif void ppc_dispatch(Cpu* c); /* call/jump to c->pc */ /* Indirect call (bctrl/blrl) with a one-entry cache per call site: most are virtual calls that * always reach the same function, and the dispatch table (32 MB) costs a cache miss per lookup. @@ -126,11 +131,22 @@ void ppc_preempt(Cpu* c); #define PPC_ENTER(a) do { \ if (__builtin_expect(c->preempt, 0)) ppc_preempt(c); \ } while (0) +/* (this fork) The function entry of a recompiled game function (recomp.py): the same check, but the slow path is a + * tail call that starts the function again after the yield, so the function itself makes no call that needs a stack + * frame (11,000 of the 39,700 functions built a frame, x30 saved and restored on every call, for this one cold + * call). ppc_preempt_again (threads.cpp) yields and then runs `self` from the top. Not in the inlined copies of + * small leaves (a tail call cannot be inlined: PPC_ENTER stays there) nor off the Switch (the desktop's + * entry parking walks the host frames of a function that called ppc_preempt). */ +void ppc_preempt_again(Cpu* c, PpcFunc self); +#define PPC_ENTER_FN(a, self) do { \ + if (__builtin_expect(c->preempt, 0)) MUSTTAIL return ppc_preempt_again(c, self); \ + } while (0) #else #define PPC_ENTER(a) do { \ if (__builtin_expect(g_ppc_trace, 0)) ppc_trace_enter(a); \ if (__builtin_expect(c->preempt, 0)) ppc_preempt(c); \ } while (0) +#define PPC_ENTER_FN(a, self) PPC_ENTER(a) #endif /* Callee-saved registers held in C locals across a call (tools/recomp/leaflocal.py @@ -144,7 +160,7 @@ void ppc_keep_failed(Cpu* c, int reg, uint32_t fn); double a_ = (c)->f[n].ps0, b_ = (c)->f[n].ps1, x_ = (v0), y_ = (v1); \ if (memcmp(&a_, &x_, 8) || memcmp(&b_, &y_, 8)) ppc_keep_failed(c, 32 + (n), fn); \ } while (0) -#elif defined(__SWITCH__) +#elif defined(__SWITCH__) || defined(PPC_ELIDE_SAVED_WB) /* PPC_ELIDE_SAVED_WB: a desktop test of the Switch's form */ #define PPC_KEEP_R(c, n, v, fn) ((void)0) #define PPC_KEEP_F(c, n, v0, v1, fn) ((void)0) #else @@ -152,6 +168,24 @@ void ppc_keep_failed(Cpu* c, int reg, uint32_t fn); #define PPC_KEEP_F(c, n, v0, v1, fn) ((v0) = (c)->f[n].ps0, (v1) = (c)->f[n].ps1) #endif +/* (this fork) a callee-saved register's write-back before a direct call to an ordinary game function + * (tools/recomp/leaflocal.py ELIDE_SAVED_REGS): such a callee only saves and restores it, and the caller keeps + * its own copy (PPC_KEEP_R above), so the Switch leaves the store out. Desktop builds read the registers again + * after a call and keep it; so does the checking build. PPC_ELIDE_SAVED_WB: a desktop test of the Switch's form. */ +#if (defined(__SWITCH__) || defined(PPC_ELIDE_SAVED_WB)) && !defined(PPC_NONLEAF_CHECK) +#define PPC_WB_SAVED(x) ((void)0) +#else +#define PPC_WB_SAVED(x) ((void)(x)) +#endif +/* (this fork) The volatile registers a caller reads back after a direct call to an ordinary game function although the + * callee cannot have written them (tools/recomp/calleeregs.py): the local is still right, so the Switch leaves the load + * out; the desktop reads them again with the rest, as before (save states). Same switches as PPC_WB_SAVED. */ +#if (defined(__SWITCH__) || defined(PPC_ELIDE_SAVED_WB)) && !defined(PPC_NONLEAF_CHECK) +#define PPC_RELOAD_SKIPPED(...) +#else +#define PPC_RELOAD_SKIPPED(...) __VA_ARGS__ +#endif + /* the return address of a direct call to an ordinary game function (tools/recomp/recomp.py link): * the recompiled code returns with C returns, so the value matters only to guest code that reads * its saved copy on the stack (stack walkers: the watchdog's traces, desktop save states). The diff --git a/runtime/include/switch_code_align.h b/runtime/include/switch_code_align.h index 84317d4..9cce54c 100644 --- a/runtime/include/switch_code_align.h +++ b/runtime/include/switch_code_align.h @@ -6,3 +6,11 @@ A runtime change that moved all of it by 16 bytes (mod 64) cost ~1% in the main-thread-bound lava scene (2026-10-13, 10 boots per build). */ __asm__(".pushsection .text.0_align, \"axR\", @progbits\n\t.balign 64\n\t.popsection"); + +/* (this fork) Everything declared from here on has hidden visibility. The Switch build is -fPIC (devkitPro's toolchain + file), and GCC loads the address of an extern symbol with default visibility through the GOT, because in a shared + object it might be replaced: `adrp` + `ldr` where `adrp` + `add` would do. Here it was one load more at each of the + 15,000 indirect-call sites (the address of ppc_dispatch, ppc.h PPC_SITE_FN: 26,203 of the game code's 26,508 GOT + loads) and more in the runtime. Nothing in the NRO is ever preempted or exported, so hidden costs nothing. + (-fvisibility=hidden does not reach declarations, only this pragma does.) */ +#pragma GCC visibility push(hidden) diff --git a/runtime/include/switch_visibility.h b/runtime/include/switch_visibility.h new file mode 100644 index 0000000..68c62f8 --- /dev/null +++ b/runtime/include/switch_visibility.h @@ -0,0 +1,4 @@ +/* (this fork) Force-included into the runtime's own sources on the Switch (cmake/SwitchPort.cmake, + WWHD_SWITCH_HIDDEN): see switch_code_align.h for why. Declarations from the headers that follow, the project's and + the toolchain's, are hidden; libstdc++'s own `namespace std` keeps default visibility by its attributes. */ +#pragma GCC visibility push(hidden) diff --git a/runtime/src/platform/debug_switch.cpp b/runtime/src/platform/debug_switch.cpp index 0827647..2275148 100644 --- a/runtime/src/platform/debug_switch.cpp +++ b/runtime/src/platform/debug_switch.cpp @@ -31,7 +31,10 @@ namespace gx2 { extern uint32_t g_render_thread_handle; } // (gx2_core.cpp: rtc #include "background_shaders_switch.h" #include "audio_guard_switch.h" #include "thread_sampler.h" -#include "bench_switch.h" // (this fork) the performance test +#include "bench_switch.h" +#ifdef WWHD_PGO_GEN +namespace pgo { void register_commands(); } +#endif // (this fork) the performance test #include "shader_wait_switch.h" #include "host.h" #include "settings_switch.h" @@ -424,6 +427,9 @@ void start() { debugsrv::add_command("evict", "evict [now | idle ] texture eviction: now, or after n unused frames", evict); debugsrv::add_command("bgshaders", "bgshaders [start] background shaders: start / status", bgshaders); bench::register_commands(); // (this fork) "bench": docs/perf-test.md +#ifdef WWHD_PGO_GEN + pgo::register_commands(); // (this fork) a training build (cmake WWHD_PGO=GEN, docs/pgo.md): "pgo" +#endif debugsrv::add_command("quit", "quit end the program", quit); debugsrv::add_command("peek", "peek [bytes] game memory (MEM2) as hex words", peek); debugsrv::add_command("poke", "poke write a word of game memory (MEM2)", poke); diff --git a/runtime/src/platform/pgo_switch.cpp b/runtime/src/platform/pgo_switch.cpp new file mode 100644 index 0000000..67a141e --- /dev/null +++ b/runtime/src/platform/pgo_switch.cpp @@ -0,0 +1,60 @@ +// (this fork) Training builds only (cmake -DWWHD_PGO=GEN, docs/pgo.md): the debug server's "pgo" command. +// +// The hot game files and the runtime's frame-thread code are instrumented (-fprofile-generate); libgcov keeps the +// counters in memory. "pgo dump" writes them to sdmc:/switch/wwhd/pgo/*.gcda, adding to what an earlier dump of +// the same session left there (libgcov merges), and then zeroes them, so every scene's training is dumped once +// and counted once. tools/switch/pgo_train.py visits scenes and fetches the files. +#ifdef WWHD_PGO_GEN +#include + +#include +#include +#include + +#include "debug_server.h" + +extern "C" { +void __gcov_dump(void); +void __gcov_reset(void); +} + +namespace pgo { +namespace { +constexpr const char* kPrefix = "sdmc:/switch/wwhd"; +constexpr const char* kDir = "sdmc:/switch/wwhd/pgo"; + +int g_dumps = 0; + +// the folder libgcov writes into must exist: its own mkdir does not cope with the "sdmc:" device +void prepare_dir() { + setenv("GCOV_PREFIX", kPrefix, 1); + setenv("GCOV_PREFIX_STRIP", "0", 1); + mkdir(kDir, 0777); +} + +debugsrv::Reply command(const debugsrv::Args& a) { + const std::string what = a.empty() ? "status" : a[0]; + if (what == "reset") { + __gcov_reset(); + return debugsrv::ok("pgo: counters zeroed"); + } + if (what == "dump") { + prepare_dir(); + __gcov_dump(); // adds to the files already in sdmc:/switch/wwhd/pgo/ + __gcov_reset(); + ++g_dumps; + return debugsrv::ok("pgo: dumped to " + std::string(kDir) + " (dump " + std::to_string(g_dumps) + + " of this session); counters zeroed"); + } + if (what == "status") return debugsrv::ok("pgo: training build, " + std::to_string(g_dumps) + " dumps this session; files in " + kDir); + return debugsrv::err("pgo dump | reset | status"); +} +} // namespace + +void register_commands() { + prepare_dir(); + debugsrv::add_command("pgo", "pgo dump | reset | status (training build) write / zero the profile counters (gcda in pgo/)", + command); +} +} // namespace pgo +#endif // WWHD_PGO_GEN diff --git a/runtime/src/threads.cpp b/runtime/src/threads.cpp index ebfc7cc..3e592d3 100644 --- a/runtime/src/threads.cpp +++ b/runtime/src/threads.cpp @@ -298,6 +298,25 @@ extern "C" void ppc_preempt(Cpu* c) { core_acquire(t); } +#ifdef __SWITCH__ +// (this fork) The slow path of PPC_ENTER_FN (ppc.h): yield as ppc_preempt does, then run the function from the top +// (the function entered here has done nothing yet: the check is its first statement). Called as a tail call, so +// the function itself needs no stack frame for this. +extern "C" void ppc_preempt_again(Cpu* c, PpcFunc self) { + ppc_preempt(c); + if (__builtin_expect(c->preempt, 0)) { + // ppc_preempt kept the request (interrupts disabled, or no core held): the function would stop here again + // and again. Before, it went on, and every call inside it asked again and returned at once; so it runs with + // the flag lowered and the request goes back up behind it (a request made meanwhile is not lost). + const uint8_t kept = __atomic_exchange_n(&c->preempt, 0, __ATOMIC_RELAXED); + self(c); + __atomic_fetch_or(&c->preempt, kept, __ATOMIC_RELAXED); + return; + } + self(c); +} +#endif + // scheduler tick: requests time-slice / starvation preemption on cores that need it namespace threads { void report_sched(); } diff --git a/tools/recomp/calleeregs.py b/tools/recomp/calleeregs.py new file mode 100644 index 0000000..a1ebd47 --- /dev/null +++ b/tools/recomp/calleeregs.py @@ -0,0 +1,286 @@ +"""Which volatile registers a game function reads and writes, and which a caller still needs after a call. + +leaflocal.transform_nonleaf keeps a function's guest registers in C locals and, before each call, stores every +register that changed since the last store ("dirty") into the Cpu struct, because the callee may read any of them; +after the call it reads all the volatile ones back. In the hot code those stores are 8% of the instructions +(7 per call), and most are not needed: temporaries nothing reads again, and arguments the callee does not take. + +This module computes, over the translated (struct form) statements of every function, + LI(f) the volatile registers (r0, r3-r12, f0-f13) f may read before it writes them, transitively; + MD(f) the volatile registers f may write, transitively; +and, for one function, which registers are still live after a statement. A call to f then needs a store of a +dirty register only if f reads it (LI), or f may overwrite it and the caller still needs its old value after the +call; and a register f never writes need not be read back (its local is still right). + +Why not the calling convention: the game was compiled with Green Hills, which allocates registers across calls (a +caller keeps a value in a volatile register across a call to a callee it knows does not touch it, +tools/verify/funcdb.py). MD is what makes that safe here: a register f never writes keeps its local. + +Everything unknown is "all volatile registers": indirect calls, imports, instruction hooks, hooked functions, +helpers that take the Cpu, unresolved names. Reads are over-approximated (every mention of a register is a read +unless it is the target of an unconditional assignment that comes first), writes too (every assignment is a +possible write; only unconditional ones kill, and a floating-point register only when both halves are written). +""" +import re + +V_GPR = [0] + list(range(3, 13)) +V_FPR = list(range(14)) +VMASK = sum(1 << n for n in V_GPR) | sum(1 << (32 + n) for n in V_FPR) +# registers a function's caller can read after it returns (EABI): r3, r4, f1, f2 +RET_MASK = (1 << 3) | (1 << 4) | (1 << 33) | (1 << 34) + +_OP = r"(\s*(?:=(?!=)|[-+|&^]=|<<=|>>=))?" +_TOK = re.compile(r"(&?)c->r\[(\d+)\]" + _OP + r"|(&?)c->f\[(\d+)\]\.ps([01])" + _OP + + r"|\bpsq_load\(c, (\d+),|\bpsq_store\(c, (\d+),") +_COMPLEX = re.compile(r"\bif\b|\?|\bswitch\b|\bwhile\b|\bfor\b|\bcase\b") +_TAIL_CALL = re.compile(r"MUSTTAIL return (f_[0-9A-F]{8}(?:_orig)?)\(c\)") +_CALL = re.compile(r"(?pc = c->ctr") +_LOOP = "PPC_LOOP(); " +_PSQ_BOTH = 3 + + +def _tokens(text): + """(start, kind, bit, half) for each mention of a volatile register. kind: u (read), d (plain assignment + target), ud (read and written: op=, or its address taken). half: 1 or 2 for a floating-point register's .ps0 / + .ps1, 3 where one mention covers both (psq_load / psq_store), 0 for an integer register.""" + out = [] + for m in _TOK.finditer(text): + if m.group(2) is not None: + amp, bit, op, half = m.group(1), 1 << int(m.group(2)), m.group(3), 0 + elif m.group(5) is not None: + amp, bit, op, half = m.group(4), 1 << (32 + int(m.group(5))), m.group(7), 1 + int(m.group(6)) + elif m.group(8) is not None: + bit = 1 << (32 + int(m.group(8))) + if bit & VMASK: + out.append((m.start(), "d", bit, _PSQ_BOTH)) + continue + else: + bit = 1 << (32 + int(m.group(9))) + if bit & VMASK: + out.append((m.start(), "u", bit, _PSQ_BOTH)) + continue + if not bit & VMASK: + continue + if amp: + kind = "ud" + elif op: + kind = "d" if op.strip() == "=" else "ud" + else: + kind = "u" + out.append((m.start(), kind, bit, half)) + return out + + +class Node: + __slots__ = ("use", "kill", "maydef", "calls", "unknown", "indirect", "tail", "term", "rets", "succ") + + +def _dead_node(): + nd = Node() + nd.use = nd.kill = nd.maydef = 0 + nd.calls, nd.unknown, nd.indirect, nd.tail, nd.term, nd.rets, nd.succ = [], False, False, None, True, False, [] + return nd + + +def build_nodes(stmts, addrs, next_name=None): + """The control-flow graph of one function from its translated statements (struct form) and their guest + addresses: per statement the volatile registers read before any unconditional write (use), unconditionally + written (kill) and possibly written (maydef), the direct calls (not tail calls), the tail call, whether the + statement leaves the function or jumps unconditionally (term), whether it may return, and the successors.""" + index = {a: i for i, a in enumerate(addrs)} + n = len(stmts) + nodes = [] + for i, s in enumerate(stmts): + nd = Node() + use = kill = maydef = 0 + halves = {} # floating-point register bit -> halves written unconditionally so far in this statement + if not _COMPLEX.search(s): + for piece in s.split(";"): + toks = _tokens(piece) + lead = len(piece) - len(piece.lstrip(" {}")) + first = toks[0] if toks and toks[0][1] in ("d", "ud") and toks[0][0] == lead else None + for k, (_, kind, b, half) in enumerate(toks): + if first is not None and k == 0: + continue + if kind in ("u", "ud"): + use |= b & ~kill + if kind in ("d", "ud"): + maydef |= b + if first is not None: + _, kind, b, half = first + if kind == "ud": + use |= b & ~kill + maydef |= b + if half == 0: + kill |= b + else: + halves[b] = halves.get(b, 0) | half + if halves[b] == 3 or half == _PSQ_BOTH: + kill |= b + else: + for _, kind, b, half in _tokens(s): + if kind in ("u", "ud"): + use |= b + if kind in ("d", "ud"): + maydef |= b + nd.use, nd.kill, nd.maydef = use, kill, maydef + nd.indirect = bool(_INDIRECT.search(s)) + nd.unknown = bool(_UNKNOWN.search(s)) or any( + m.group(1) not in _BENIGN and not _CLASSIFIED.match(m.group(1)) + for m in _C_USE.finditer(s)) + tm = _TAIL_CALL.search(s) + nd.tail = tm.group(1) if tm else None + nd.calls = [m.group(1) for m in _CALL.finditer(s)] + nd.rets = bool(_RETURN.search(s)) + t = s[len(_LOOP):] if s.startswith(_LOOP) else s + nd.term = t.startswith(_TERM) + succ = [index[int(x, 16)] for x in _GOTO.findall(s) if int(x, 16) in index] + if not nd.term and i + 1 < n: + succ.append(i + 1) + nd.succ = succ + nodes.append(nd) + # falling off the end of the function continues in the next one + if nodes and not nodes[-1].term and nodes[-1].tail is None: + nodes[-1].tail = next_name if next_name else None + if not next_name: + nodes[-1].unknown = True + return nodes + + +def reachable(nodes): + seen, st = set(), [0] if nodes else [] + while st: + k = st.pop() + if k in seen: + continue + seen.add(k) + st.extend(nodes[k].succ) + return seen + + +def liveness(nodes, lookup, ret_live=0, tail_live=0): + """live_in per node (volatile registers). lookup(name) -> (LI, MD), or None for unknown. ret_live: registers read + where the function returns; tail_live: registers a tail call passes on besides what its callee reads.""" + n = len(nodes) + use, kill = [0] * n, [0] * n + for k, nd in enumerate(nodes): + u = nd.use + if nd.unknown: + u |= VMASK + if nd.indirect: + u |= ARG_MASK + for c in nd.calls: + r = lookup(c) + u |= r[0] if r else VMASK + if nd.tail is not None: + r = lookup(nd.tail) + u |= (r[0] if r else VMASK) | tail_live + if nd.rets or (nd.term and not nd.succ and nd.tail is None): + u |= ret_live + use[k], kill[k] = u, nd.kill + live = [0] * n + changed = True + while changed: + changed = False + for k in range(n - 1, -1, -1): + out = 0 + for j in nodes[k].succ: + out |= live[j] + new = use[k] | (out & ~kill[k]) + if new != live[k]: + live[k] = new + changed = True + return live + + +def summarize(nodes, lookup): + """(LI, MD) of one function from its nodes, given the current summaries of the functions it calls""" + if not nodes: + return 0, 0 + reach = reachable(nodes) + dead = _dead_node() + live = liveness([nodes[k] if k in reach else dead for k in range(len(nodes))], lookup) + md = 0 + for k in reach: + nd = nodes[k] + md |= nd.maydef + if nd.unknown or nd.indirect: + md |= VMASK + for c in nd.calls: + r = lookup(c) + md |= r[1] if r else VMASK + if nd.tail is not None: + r = lookup(nd.tail) + md |= r[1] if r else VMASK + return live[0] & VMASK, md & VMASK + + +class Summaries: + """LI / MD of every function, solved together: the sets only grow, so iterating from empty sets until nothing + changes gives the least fixed point (recursion included). A name that was not added (an import, a name in + `unknown`: the hooked functions) is unknown, all volatile registers.""" + + def __init__(self, unknown=(), abi_hooked=()): + self.nodes = {} + self.unknown = set(unknown) + # hooked functions whose hook (runtime C++) reads only argument registers before it calls the original + # (fork_passes.runtime_abi_hooks checks that): they read those and what the original reads, and write anything + self.abi_hooked = set(abi_hooked) + self.li, self.md = {}, {} + self.rounds = 0 + + def add(self, name, stmts, addrs, next_name): + self.nodes[name] = build_nodes(stmts, addrs, next_name) + + def lookup(self, name): + if name in self.abi_hooked: + orig = name + "_orig" + if orig in self.li: + return (ARG_MASK | self.li[orig]) & VMASK, VMASK + return None + if name in self.unknown or name not in self.li: + return None + return self.li[name], self.md[name] + + def solve(self): + callers = {} + for name, nodes in self.nodes.items(): + for nd in nodes: + for c in nd.calls + ([nd.tail] if nd.tail else []): + callers.setdefault(c + "_orig" if c in self.abi_hooked else c, set()).add(name) + self.li[name] = self.md[name] = 0 + work = list(self.nodes) + queued = set(work) + while work: + name = work.pop() + queued.discard(name) + self.rounds += 1 + li, md = summarize(self.nodes[name], self.lookup) + if li != self.li[name] or md != self.md[name]: + self.li[name], self.md[name] = li, md + for c in callers.get(name, ()): + if c not in queued: + queued.add(c) + work.append(c) + return self + + +def names(mask): + """for messages""" + return [("r%d" % n) for n in range(32) if mask >> n & 1] + [("f%d" % n) for n in range(32) if mask >> (32 + n) & 1] diff --git a/tools/recomp/fork_passes.py b/tools/recomp/fork_passes.py index d201102..1d7c3ed 100644 --- a/tools/recomp/fork_passes.py +++ b/tools/recomp/fork_passes.py @@ -12,6 +12,11 @@ import sys import time +# a call that is not a tail call: a game function (direct or through the call-site cache), a runtime function, an import +CALL_TOKEN = re.compile(r"(?r\[(?:0|11|12)\]|c->f\[(?:0|9|1[0-3])\]") + func = re.compile(r"\b(\w+)\(Cpu\* c\)\s*\{") + for path, t in text.items(): + for m in mention.finditer(t): + owners = list(func.finditer(t, 0, m.start())) + owner = owners[-1].group(1) if owners else "" + if not (owner.startswith("site_") or owner == "store_aspect"): + sys.stderr.write("callee registers: %s reads %s in %s: hooks are assumed to read everything\n" + % (os.path.relpath(path, root), m.group(0), owner or "?")) + return set() + defined = set() + for t in text.values(): + defined.update("f_" + h for h in re.findall(r"\bhook_([0-9A-F]{8})\(", t)) + return defined + + def fork_record_summary(self, start, fname, body): + """emit_function in the summary pass: the function's statements (as leaflocal will see them)""" + stmts = [("site_%08X(c); " % self.sym(a) if a in self.sites else "") + s for a, _, s in body] + nxt = ("f_%08X_orig" if self.cur_end in self.hooks else "f_%08X") % self.sym(self.cur_end) + self.callee_summ_build.add(fname, stmts, [a for a, _, _ in body], nxt) + def fork_dead_tail(self): """emit_function: the body ends in an unconditional return or branch (no fall-through tail call).""" return getattr(self, "dead_tail", False) @@ -398,7 +509,9 @@ def fork_locals(self, start, hooked, body, out): elif NONLEAF and plain: # instruction hooks become part of their statement, so they get the call treatment with_sites = [("site_%08X(c); " % self.sym(a) if a in self.sites else "") + s for a, _, s in body] - done = leaflocal.transform_nonleaf(with_sites, start, [a for a, _, _ in body], self.saved_clobbers_sym) + done = leaflocal.transform_nonleaf(with_sites, start, [a for a, _, _ in body], self.saved_clobbers_sym, + self.plain_callees() if ELIDE_SAVED else None, + getattr(self, "callee_summ", None)) if done: prologue, stmts, tail_wb = done body = [(a, w, s) for (a, w, _), s in zip(body, stmts)] @@ -407,6 +520,15 @@ def fork_locals(self, start, hooked, body, out): sites_done = True return body, (tail_wb + " " if tail_wb else ""), sites_done + def fork_frame_free(self, body): + """emit_function: whether the function makes no call except (in the slow path of its entry check) the yield, so + that PPC_ENTER_FN (ppc.h, the slow path as a tail call) saves it a stack frame. A leaf made into locals is + one; any other function counts when its statements hold no call (a tail call, MUSTTAIL return f(c), makes no + frame). A function with calls gets the plain PPC_ENTER: the same hot path, and a smaller cold one.""" + if self.emitted_leaf: + return True + return not any(CALL_TOKEN.search(s) for _, _, s in body) + def fork_end_function(self, hooked, body): """emit_function, done: an inline copy is possible for a leaf that ends in an unconditional return and has no other tail jumps (the fall-through into the next function after it is unreachable).""" @@ -454,6 +576,8 @@ def split(items): for a in sorted(inline_src): text = inline_src[a].replace("void f_%08X(Cpu* __restrict c) {" % self.sym(a), "static inline __attribute__((always_inline)) void fi_%08X(Cpu* __restrict c) {" % self.sym(a), 1) + # (a tail call cannot be inlined: the copy keeps the plain check, ppc.h PPC_ENTER_FN) + text = re.sub(r"PPC_ENTER_FN\((0x[0-9A-Fa-f]+u), f_[0-9A-Fa-f_a-z]+\)", r"PPC_ENTER(\1)", text, count=1) lines = text.split("\n") for i in range(len(lines) - 1, -1, -1): # the unreachable fall-through if "MUSTTAIL return" in lines[i]: @@ -500,7 +624,7 @@ def fork_funcs_tail(self): def fork_table_head(self): """table.c, after the includes: which passes made this code (logged at boot)""" variant = ", ".join(n for n, on in (("cr liveness", CRLIVE), ("leaf locals", LEAF), ("nonleaf locals", NONLEAF), - ("hot layout", HOT), ("dead tails", DEAD_TAIL), ("helpers inlined", HELPER_INLINE), ("hooked locals", HOOKED_LOCALS), ("inline leaves", INLINE), ("lr elision", LR_ELIDE), + ("hot layout", HOT), ("dead tails", DEAD_TAIL), ("helpers inlined", HELPER_INLINE), ("hooked locals", HOOKED_LOCALS), ("callee-saved write-backs elided", ELIDE_SAVED), ("volatile write-backs by callee", CALLEE_REGS and ELIDE_SAVED), ("inline leaves", INLINE), ("lr elision", LR_ELIDE), ("single-precision tracking", ppc2c.SINGLE), ("whole-program single precision", ppc2c.SINGLE and singleflow.INTERPROC), ("static GQRs", GQR_STATIC), ("jump-site caches", IJUMP)) if on) diff --git a/tools/recomp/leaflocal.py b/tools/recomp/leaflocal.py index cf44531..ff8cef7 100644 --- a/tools/recomp/leaflocal.py +++ b/tools/recomp/leaflocal.py @@ -22,6 +22,8 @@ import os import re +import calleeregs + # WWHD_RECOMP_LEAF_POISON=1: a checking build; at each return the scratch registers the function # used get garbage, so a caller that relied on one would break visibly POISON = os.environ.get("WWHD_RECOMP_LEAF_POISON", "0") == "1" @@ -168,7 +170,25 @@ def _assigned(text): return regs -def transform_nonleaf(stmts, start, addrs, clobbers=frozenset()): +# (this fork) callee-saved registers (r14-r31, f14-f31; not r1) whose write-back before a direct call to an ordinary +# game function is emitted as PPC_WB_SAVED (ppc.h): by the EABI such a callee only saves and restores them, and the +# caller keeps its own copies, so the Switch leaves the stores out. They stay "dirty" in the analysis: the next call +# that may read any register (an import, an instruction hook, an indirect call, a hooked function, a save/restore +# helper) writes them back. tools/recomp/fork_passes.py ELIDE_SAVED. +ELIDE_SAVED_REGS = frozenset(set(range(14, 32)) | {32 + n for n in range(14, 32)}) +# (this fork) The volatile registers (r0, r3-r12, f0-f13; the ids are 0-31 and 32 + n as everywhere here) and the +# call statements the interprocedural write-back elision applies to: nothing but the return-address store and the call. +# tools/recomp/calleeregs.py says what each callee reads and writes. +VOLATILE_IDS = frozenset(VOLATILE_GPR | {32 + n for n in VOLATILE_FPR}) +_VOL_CALL = re.compile(r"(?:PPC_SET_LR\(c, 0x[0-9A-F]{8}u\); )?(f_[0-9A-F]{8}(?:_orig)?)\(c\);") +STATS = {"calls": 0, "dirty": 0, "stored": 0, "skipped": 0, "reloads": 0, "reloads_skipped": 0} + + +def _ids(mask): + return {r for r in range(64) if mask >> r & 1} + + +def transform_nonleaf(stmts, start, addrs, clobbers=frozenset(), plain=None, summ=None): """stmts: the statements (site calls already prefixed to theirs), addrs: their guest addresses. Returns (prologue, statements, write-back for falling off the end), or None to keep the struct form. A forward analysis over the function's jumps tracks which registers may differ from the @@ -191,6 +211,20 @@ def transform_nonleaf(stmts, start, addrs, clobbers=frozenset()): if not gprs and not fprs: return None used = gprs | {32 + n for n in fprs} + elide = ELIDE_SAVED_REGS & used if plain is not None else frozenset() + + def is_plain(m): # a direct call to an ordinary game function (not hooked, not a save/restore helper) + call = m.group(0) + return bool(elide) and call.startswith("f_") and call[:10] in plain + + vol = {} # statement -> (LI, MD, live after) of its callee: the volatile registers the call may leave alone + + def after_call(d, m, i=None): # what is still dirty after the call m (statement i) + keep = set(d) & elide if is_plain(m) else set() + if i in vol: # the callee neither reads nor writes these: the local is still right, the store was skipped + li, md, _ = vol[i] + keep |= {r for r in d if r in VOLATILE_IDS and not (li | md) >> r & 1} + return keep n = len(out) index = {a: i for i, a in enumerate(addrs)} # per statement: segments between calls (assignments), the calls, successors @@ -207,6 +241,24 @@ def transform_nonleaf(stmts, start, addrs, clobbers=frozenset()): nx = [i + 1] if i + 1 < n else [] # falling through (a superset after an unconditional jump: safe) nx += [index[int(t, 16)] for t in _GOTO.findall(s) if int(t, 16) in index] succ.append(nx) + if summ is not None and plain is not None: + nodes = calleeregs.build_nodes(stmts, addrs) + live_in = calleeregs.liveness(nodes, summ.lookup, ret_live=calleeregs.RET_MASK, tail_live=calleeregs.RET_MASK) + for i, s in enumerate(stmts): + if len(calls[i]) != 1: + continue + cm = _VOL_CALL.fullmatch(s.strip()) + if not cm or cm.group(1)[:10] not in plain or cm.group(1).endswith("_orig"): + continue + r = summ.lookup(cm.group(1)) + if r is None: + continue + lout = 0 + for j in nodes[i].succ: + lout |= live_in[j] + if i + 1 >= n or nodes[i].term: + lout = calleeregs.VMASK # falls into the next function, or leaves: everything may be read + vol[i] = (r[0], r[1], lout) # loop heads (targets of backward jumps): the edges entering them from before write back what is # dirty, so registers set up before a loop are not written back again at each call inside it heads = {j for i in range(n) for j in succ[i] if j <= i} @@ -219,7 +271,7 @@ def transform_nonleaf(stmts, start, addrs, clobbers=frozenset()): for k, part in enumerate(segs[i]): d |= part if k < len(calls[i]): - d = set() # written back, and the volatile ones read again + d = after_call(d, calls[i][k], i) # written back (but PPC_WB_SAVED ones), the volatile ones read again for j in succ[i]: arrive = set() if (j in heads and j > i) else d # a forward edge into a loop head syncs if d_in[j] is None or not arrive <= d_in[j]: @@ -235,6 +287,12 @@ def wb(regs): for r in sorted(r for r in regs if r >= 32)] return " ".join(parts) + def wb_saved(regs): # the callee-saved write-backs a plain call may leave out (PPC_WB_SAVED, ppc.h) + parts = ["c->r[%d] = r%d" % (r, r) for r in sorted(r for r in regs if r < 32)] + parts += ["c->f[%d].ps0 = f%d_0, c->f[%d].ps1 = f%d_1" % (r - 32, r - 32, r - 32, r - 32) + for r in sorted(r for r in regs if r >= 32)] + return "PPC_WB_SAVED((%s));" % ", ".join(parts) if parts else "" + def rl(gset, fset): parts = ["r%d = c->r[%d];" % (r, r) for r in sorted(gset)] parts += ["f%d_0 = c->f[%d].ps0; f%d_1 = c->f[%d].ps1;" % (r, r, r, r) for r in sorted(fset)] @@ -244,6 +302,9 @@ def rl(gset, fset): keep = " ".join(["PPC_KEEP_R(c, %d, r%d, 0x%08Xu);" % (r, r, start) for r in sorted(gprs & SAVED_GPR)] + ["PPC_KEEP_F(c, %d, f%d_0, f%d_1, 0x%08Xu);" % (r, r, r, start) for r in sorted(fprs & SAVED_FPR)]) reload_all = rl(gprs, fprs) + + def reload_volatile_of(i, md): # the volatile registers the callee of statement i may have written + return rl({r for r in gprs & VOLATILE_GPR if md >> r & 1}, {r for r in fprs & VOLATILE_FPR if md >> (32 + r) & 1}) ret_regs = RET_GPR | {32 + r for r in RET_FPR} final = [] for i, s in enumerate(out): @@ -259,9 +320,29 @@ def rl(gset, fset): guest = False # a save/restore helper: it changes callee-saved registers on purpose after = (reload_volatile + " " + keep) if guest else reload_all pieces.append(s[pos:m.start()]) - pieces.append("{ %s %s %s }" % (wb(d), call, after.strip())) + if i in vol: + li, md, lout = vol[i] + dv = {r for r in d if r in VOLATILE_IDS} + store = {r for r in dv if li >> r & 1 or (md >> r & 1 and lout >> r & 1)} + skip = dv - store + saved_e = d & elide + rest = {r for r in gprs | {32 + f for f in fprs} if r in VOLATILE_IDS and not md >> r & 1} + now = reload_volatile_of(i, md) + later = rl({r for r in rest if r < 32}, {r - 32 for r in rest if r >= 32}) + STATS["calls"] += 1 + STATS["dirty"] += len(dv) + STATS["stored"] += len(store) + STATS["skipped"] += len(skip) + STATS["reloads"] += len(rest) + len(now.split(";")) - 1 + STATS["reloads_skipped"] += len(rest) + after = (now + (" PPC_RELOAD_SKIPPED(%s)" % later if later else "") + " " + keep).strip() + pieces.append("{ %s %s %s %s }" % (wb(store | (d - dv - saved_e)), wb_saved(skip | saved_e), call, after)) + elif is_plain(m): + pieces.append("{ %s %s %s %s }" % (wb(d - elide), wb_saved(d & elide), call, after.strip())) + else: + pieces.append("{ %s %s %s }" % (wb(d), call, after.strip())) pos = m.end() - d = set() + d = after_call(d, m, i) pieces.append(s[pos:]) exit_dirty |= segs[i][-1] s = "".join(pieces) @@ -289,7 +370,7 @@ def rl(gset, fset): for k, part in enumerate(segs[i]): d_end |= part if k < len(calls[i]): - d_end = set() + d_end = after_call(d_end, calls[i][k], i) if d_end: s += " " + wb(d_end) final.append(s) @@ -298,7 +379,7 @@ def rl(gset, fset): for k, part in enumerate(segs[-1]): last |= part if k < len(calls[-1]): - last = set() + last = after_call(last, calls[-1][k], n - 1) prologue = [] if gprs: prologue.append("uint32_t %s;" % ", ".join("r%d = c->r[%d]" % (r, r) for r in sorted(gprs))) diff --git a/tools/recomp/recomp.py b/tools/recomp/recomp.py index 273fcc6..2643438 100644 --- a/tools/recomp/recomp.py +++ b/tools/recomp/recomp.py @@ -333,16 +333,22 @@ def emit_function(self, start): hooked = start in self.hooks name = self.sym(start) fname = "f_%08X_orig" % name if hooked else "f_%08X" % name + if getattr(self, "summary_pass", False): # (this fork) calleeregs.py: only what the function reads and writes + self.fork_record_summary(start, fname, body) + return "", 0 out = [] if hooked: # runtime hook: callers reach hook_X, which may call the original code (f_X_orig) out.append("void f_%08X(Cpu* __restrict c) { PPC_HOOK_CALL(c, hook_%08X); }\n" % (name, name)) - out += ["void %s(Cpu* __restrict c) {" % fname, " PPC_ENTER(0x%08Xu);" % start] + out += ["void %s(Cpu* __restrict c) {" % fname, " PPC_ENTER_FN(0x%08Xu, %s);" % (start, fname)] # (this fork: tail-call slow path) + enter_at = len(out) - 1 if self.fork_mod_check(start): # (this fork: or only the listed functions) # guest mods: the check sits in the game's code (f_X, or f_X_orig behind a port hook), so the # port's own hooks (interpolation, true 60) stay outermost and mods hook the game's code out.append(" PPC_MOD_HOOK(%d, 0x%08Xu);" % (self.ordinal[start], start)) body, tail_wb, sites_done = self.fork_locals(start, hooked, body, out) # (this fork) + if not self.fork_frame_free(body): # (this fork) a function that calls has its frame anyway: the plain check is smaller + out[enter_at] = " PPC_ENTER(0x%08Xu);" % start for a, w, s in body: if a in self.labels: out.append("L_%08X: ;" % a) @@ -372,7 +378,9 @@ def run(self, outdir, per_file): self.imm_override = self.imm_override self.fork_prepare() # (this fork) self.ordinal = {e: i for i, e in enumerate(self.sorted_entries)} + self.fork_summaries() # (this fork) files = self.fork_write_code(outdir, per_file) # (this fork: hot layout, inline leaves) + self.fork_stats() # (this fork) self.write_headers(outdir) self.write_report(outdir, len(files)) diff --git a/tools/recomp/test_calleeregs.py b/tools/recomp/test_calleeregs.py new file mode 100644 index 0000000..0792fec --- /dev/null +++ b/tools/recomp/test_calleeregs.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Tests for calleeregs.py (what each function reads and writes, and what stays live after a call). + python3 tools/recomp/test_calleeregs.py +Each case is a few translated statements (struct form) of invented functions; the expected sets are what the pass must +find. Over-approximation is allowed only where the comment says so: a missed read would drop a store the callee needs.""" +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import calleeregs as C # noqa: E402 + + +def solve(funcs, unknown=(), abi_hooked=()): + s = C.Summaries(unknown, abi_hooked) + for name, stmts in funcs.items(): + s.add(name, stmts, [0x1000 + 4 * i for i in range(len(stmts))], None) + return s.solve() + + +def li(s, name): + return set(C.names(s.li[name])) + + +def md(s, name): + return set(C.names(s.md[name])) + + +class Summaries(unittest.TestCase): + def test_write_before_read_is_not_an_input(self): + s = solve({"f_00000001": ["c->r[5] = c->r[3] + 1;", "c->r[3] = ld32(c->r[5]) + c->r[4];", "return;"]}) + self.assertEqual(li(s, "f_00000001"), {"r3", "r4"}) + self.assertEqual(md(s, "f_00000001"), {"r3", "r5"}) + + def test_conditional_write_does_not_kill(self): + s = solve({"f_00000003": ["if (c->r[3] == 0) c->r[7] = 1;", "c->r[8] = c->r[7];", "return;"]}) + self.assertEqual(li(s, "f_00000003"), {"r3", "r7"}) + self.assertEqual(md(s, "f_00000003"), {"r7", "r8"}) + + def test_one_half_of_a_float_register_does_not_kill_it(self): + s = solve({"f_00000004": ["c->f[1].ps0 = 1.0; c->f[1].ps1 = 2.0;", "c->f[2].ps0 = 3.0;", + "c->f[3].ps0 = c->f[1].ps0 + c->f[2].ps1;", "return;"]}) + self.assertEqual(li(s, "f_00000004"), {"f2"}) # f1 written whole, f2 only ps0 then ps1 read, f3 written + + def test_callee_reads_flow_to_the_caller(self): + s = solve({"f_00000001": ["c->r[5] = c->r[3] + 1;", "c->r[3] = ld32(c->r[5]) + c->r[4];", "return;"], + "f_00000002": ["c->r[4] = 7;", "PPC_SET_LR(c, 0x1u); f_00000001(c);", "c->r[9] = c->r[3] + c->r[6];", + "return;"]}) + self.assertEqual(li(s, "f_00000002"), {"r3", "r6"}) # r4 is set before the call; r3 and r6 are read + self.assertEqual(md(s, "f_00000002"), {"r3", "r4", "r5", "r9"}) + + def test_tail_call_passes_the_callee_reads_on(self): + s = solve({"f_00000001": ["c->r[3] = c->r[3] + c->r[4];", "return;"], + "f_00000006": ["c->r[10] = 1;", "MUSTTAIL return f_00000001(c);"]}) + self.assertEqual(li(s, "f_00000006"), {"r3", "r4"}) + self.assertEqual(md(s, "f_00000006"), {"r3", "r10"}) + + def test_recursion_terminates(self): + s = solve({"f_00000007": ["c->r[3] = c->r[3] - 1; f_00000007(c);", "c->r[12] = c->r[11];", "return;"]}) + self.assertEqual(li(s, "f_00000007"), {"r3", "r11"}) + + def test_indirect_call_reads_only_the_argument_registers(self): + s = solve({"f_00000005": ["c->r[5] = 1;", "PPC_ICALL(c, t);", "return;"]}) + self.assertEqual(li(s, "f_00000005"), {"r3", "r4", "r6", "r7", "r8", "r9", "r10", "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8"}) + self.assertEqual(len(md(s, "f_00000005")), 25) # and may write anything + + def test_instruction_hook_reads_everything(self): + s = solve({"f_00000006": ["site_00001000(c); c->r[3] = 1;", "return;"]}) + self.assertEqual(len(li(s, "f_00000006")), 25) + + def test_unknown_callee_reads_everything(self): + s = solve({"f_00000008": ["f_0000FFFF(c);", "return;"]}) + self.assertEqual(len(li(s, "f_00000008")), 25) + + def test_load_helper_taking_c_inside_an_expression_is_not_a_helper_call(self): + # ld32(c->r[5]) must not make the function "call a helper that reads everything" + s = solve({"f_00000009": ["c->r[3] = ld32(c->r[4]);", "return;"]}) + self.assertEqual(li(s, "f_00000009"), {"r4"}) + + def test_abi_hook_reads_arguments_and_what_the_original_reads(self): + s = solve({"f_0000000A_orig": ["c->r[3] = c->r[12];", "return;"], + "f_0000000B": ["f_0000000A(c);", "return;"]}, abi_hooked={"f_0000000A"}) + self.assertEqual(li(s, "f_0000000B"), set(C.names(C.ARG_MASK | (1 << 12)))) + self.assertEqual(len(md(s, "f_0000000B")), 25) + + def test_hooked_function_without_the_abi_assumption_reads_everything(self): + s = solve({"f_0000000B": ["f_0000000A(c);", "return;"]}, unknown={"f_0000000A"}) + self.assertEqual(len(li(s, "f_0000000B")), 25) + + +class Liveness(unittest.TestCase): + def test_a_register_the_caller_reads_after_the_call_is_live_there(self): + stmts = ["c->r[5] = 1;", "f_00000001(c);", "c->r[9] = c->r[5];", "return;"] + s = solve({"f_00000001": ["return;"]}) + nodes = C.build_nodes(stmts, [0x1000 + 4 * i for i in range(len(stmts))]) + live = C.liveness(nodes, s.lookup, ret_live=C.RET_MASK, tail_live=C.RET_MASK) + self.assertTrue(live[2] >> 5 & 1) # r5 is read at statement 2 + self.assertTrue(live[1] >> 5 & 1) # so it is live across the call + self.assertFalse(live[0] >> 5 & 1) # and not before it is written + + def test_a_return_reads_the_return_registers(self): + nodes = C.build_nodes(["if (c->r[3]) return;", "return;"], [0x1000, 0x1004]) + live = C.liveness(nodes, lambda n: None, ret_live=C.RET_MASK) + self.assertTrue(live[0] >> 4 & 1) # r4 may be returned by the conditional return + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/switch/build.sh b/tools/switch/build.sh index 39ae38c..0844cdc 100755 --- a/tools/switch/build.sh +++ b/tools/switch/build.sh @@ -72,7 +72,7 @@ for link in build/gen; do mounts+=(-v "$target":"$target":ro) fi done -dir=build/switch-dk +dir=${WWHD_BUILD_DIR:-build/switch-dk} # (this fork) another folder for a second build next to the usual one (PGO: docs/pgo.md) # deko3d's release library unless WWHD_DEKO3D_DEBUG_LIB=ON (checks every call, readable errors) debuglib=${WWHD_DEKO3D_DEBUG_LIB:-OFF} out=wwhd.nro @@ -83,6 +83,12 @@ options="-DWWHD_RENDERER=DEKO3D -DWWHD_DEKO3D_DEBUG_LIB=$debuglib -DWWHD_SWITCH_ # (this fork) WWHD_HEAP_CENSUS=ON: a diagnostics build that counts malloc by call site (the debug server's heap # command); passed every time, so the next build without it is a normal one again options="$options -DWWHD_HEAP_CENSUS=${WWHD_HEAP_CENSUS:-OFF}" +# (this fork) profile-guided optimisation, docs/pgo.md: WWHD_PGO=GEN (training build) or USE (with WWHD_PGO_DIR, the .gcda +# files); passed every time like the options above +options="$options -DWWHD_PGO=${WWHD_PGO:-OFF}" +[[ -n ${WWHD_PGO_SCOPE:-} ]] && options="$options -DWWHD_PGO_SCOPE=$WWHD_PGO_SCOPE" +[[ -n ${WWHD_PGO_DIR:-} ]] && options="$options -DWWHD_PGO_DIR=$WWHD_PGO_DIR" +[[ -n ${WWHD_PGO_USE_FLAGS:-} ]] && options="$options \"-DWWHD_PGO_USE_FLAGS=$WWHD_PGO_USE_FLAGS\"" # The NRO's icon is made here, on the host, as make_sd.py and the forwarder scripts do: the container has no # Pillow, so CMake gets a finished JPEG. Icon: --icon (any path), else made from the game's files (make_icon.py: # the title-screen logo, or meta/iconTex.tga) of WWHD_GAME_DIR (the extracted game, make_sd.py), else of build/sd, diff --git a/tools/switch/pgo_train.py b/tools/switch/pgo_train.py new file mode 100644 index 0000000..3d24577 --- /dev/null +++ b/tools/switch/pgo_train.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Train the profile-guided build on the console (docs/pgo.md). + +The console runs a WWHD_PGO=GEN build (instrumented hot game files and runtime frame-thread code). For each +scene this visits it, waits for the place to load, zeroes the counters ("pgo reset"), lets the game run for a +dwell time and writes the counters ("pgo dump": libgcov adds them to the .gcda files in sdmc:/switch/wwhd/pgo/), +then fetches the files to build/pgo/ for `cmake -DWWHD_PGO=USE`. + + pgo_train.py train [--nro build/switch-pgo-gen/wwhd.nro] [--dwell 30] [--settle 20] [--plan default|heldout|all] + deploys the training NRO (unless --no-deploy), boots to gameplay, visits the plan's scenes, fetches the files + pgo_train.py fetch [--out build/pgo] only fetches what is on the console + pgo_train.py clean removes the .gcda files from the console (a new training starts from zero) + +Scenes are the performance test's portable states (tools/switch/perf_states, played at two times of day) and the +Warp tab's places (the cheats' warps, which load places the states do not cover). A profile made on the scenes that +the performance test later judges would flatter the result: the plans differ in what they leave out. + default every state and warp except the held-out scenes below + heldout only the scenes that "default" leaves out (to check a profile on places it never saw) + all everything (the profile for a release build; judge it on a different console session or by play) +The held-out scenes: lava, windfall, dragonroost, outset_village (and warps 2, 3, 4). + +Training runs at a fraction of the normal speed (counters in every hot block), so the dwell is in wall-clock +seconds and the game does fewer frames than usual; the counts are per frame, not per second, and keep their +proportions. Never judge speed on a training build. +""" +import argparse +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import perf_test as pt # noqa: E402 +import wwhd_debug as dbg # noqa: E402 + +REPO = Path(__file__).resolve().parents[2] +OUT = REPO / "build" / "pgo" +REMOTE = "pgo" + +HELD_OUT_STATES = ["lava", "windfall", "dragonroost", "outset_village"] +HELD_OUT_WARPS = [2, 3, 4] +# (kind, name, time of day) ; the day states at the performance test's time and a night variant +STATES = ["outset", "foresthaven", "forbidden", "sea_totg", "hyrule", "outset_grass", "outset_lookout", "outset_cliff", + "lava", "windfall", "dragonroost", "outset_village"] +WARPS = [0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 13, 14, 15, 16] +DAY, NIGHT = 200.0, 20.0 +# The Outset states are made for the afternoon with no story event due: at night the game halts on its own assertion +# (d_event_data.cpp:1103 "demo_data", 2026-10-23). These survived the night. +NIGHT_OK = {"outset", "foresthaven", "forbidden", "sea_totg", "hyrule"} + + +def plan(which): + items = [] + for s in STATES: + held = s in HELD_OUT_STATES + for t in ((DAY, NIGHT) if s in NIGHT_OK else (DAY,)): + items.append(("state", s, t, held)) + for w in WARPS: + items.append(("warp", str(w), DAY, w in HELD_OUT_WARPS)) + if which == "default": + return [i for i in items if not i[3]] + if which == "heldout": + return [i for i in items if i[3]] + return items + + +def visit(con, item, settle, dwell): + kind, name, tod, _ = item + pt.log(f"{kind} {name} (time {tod:.0f}): load") + con.cmd("bench", "clock", "freeze", f"{tod:.2f}") + if kind == "state": + arrivals = int(con.status().get("arrivals", "0")) + con.cmd("bench", "load", name) + end = time.time() + 120 + while time.time() < end and int(con.status().get("arrivals", "0")) <= arrivals: + time.sleep(1.0) + if int(con.status().get("arrivals", "0")) <= arrivals: + pt.log(f" {name}: the state did not arrive; skipped") + return False + else: + con.cmd("warp", name) + time.sleep(settle) # the place's files, textures and shaders (the instrumented game is slow) + con.cmd("pgo", "reset") + time.sleep(dwell) + pt.log(" " + con.cmd("pgo", "dump").strip()) + return True + + +def fetch(con, out): + out.mkdir(parents=True, exist_ok=True) + text = con.try_cmd("ls", REMOTE) or "" + n = total = 0 + for line in text.splitlines(): + parts = line.split(None, 2) + if len(parts) == 3 and parts[0] == "f" and parts[2].endswith(".gcda"): + c = dbg.Conn(con.host, con.port, timeout=10.0) + try: + with open(out / parts[2], "wb") as f: + c.command("get", f"{REMOTE}/{parts[2]}", out=f, timeout=120.0) + finally: + c.close() + n += 1 + total += (out / parts[2]).stat().st_size + pt.log(f"fetched {n} .gcda files ({total / 1e6:.1f} MB) to {out}") + return n + + +def cmd_train(a): + con = pt.Console(a.host) + pt.QUEST_LOG = a.quest_log + if not a.no_deploy: + con.deploy(a.nro) + if con.try_cmd("pgo", "status") is None: + raise SystemExit("this build has no 'pgo' command: it must be a WWHD_PGO=GEN build") + items = plan(a.plan)[a.skip_first:] + states = sorted({i[1] for i in items if i[0] == "state"}) + pt.ensure_states(con, states) + pt.boot_to_gameplay(con, reload=False) + con.try_cmd("pgo", "reset") + done = 0 + for item in items: + try: + done += bool(visit(con, item, a.settle, a.dwell)) + except (RuntimeError, OSError) as e: + pt.log(f" {item[1]}: {e}") + pt.log(f"{done} of {len(items)} scenes trained") + fetch(con, Path(a.out)) + + +def cmd_fetch(a): + fetch(pt.Console(a.host), Path(a.out)) + + +def cmd_clean(a): + con = pt.Console(a.host) + text = con.try_cmd("ls", REMOTE) or "" + for line in text.splitlines(): + parts = line.split(None, 2) + if len(parts) == 3 and parts[0] == "f" and parts[2].endswith(".gcda"): + con.cmd("rm", f"{REMOTE}/{parts[2]}") + pt.log("removed the old .gcda files") + + +def host_from_files(): + import os + if os.environ.get("WWHD_SWITCH_HOST"): + return os.environ["WWHD_SWITCH_HOST"] + p = REPO / "build" / "switch_host.txt" + if p.exists(): + return p.read_text().splitlines()[0].strip() + return None + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + for name in ("train", "fetch", "clean"): + p = sub.add_parser(name) + p.add_argument("--host", default=host_from_files()) + if name != "clean": + p.add_argument("--out", default=str(OUT)) + t = sub.choices["train"] + t.add_argument("--nro", default=str(REPO / "build" / "switch-pgo-gen" / "wwhd.nro")) + t.add_argument("--no-deploy", action="store_true") + t.add_argument("--plan", choices=["default", "heldout", "all"], default="default") + t.add_argument("--dwell", type=float, default=30.0) + t.add_argument("--settle", type=float, default=20.0) + t.add_argument("--quest-log", type=int, default=1) + t.add_argument("--skip-first", type=int, default=0, help="resume: leave out the first N scenes of the plan (done in an earlier run)") + a = ap.parse_args() + if not a.host: + raise SystemExit("the console's address: build/switch_host.txt, WWHD_SWITCH_HOST or --host") + {"train": cmd_train, "fetch": cmd_fetch, "clean": cmd_clean}[a.cmd](a) + + +if __name__ == "__main__": + main()