Repository navigation
perf(build): updates skip the lift, a faster lift, and hot pieces that compile sooner and stay put - #2324
Merged
Conversation
…of the lift The lift's edits of its C (oot_native_resolution, oot_patches, the output size, the page-mark groups) were five passes over the 1.5 GB of code_*.c on one core, after an emit that already runs in several processes. Each file's edits are its own and only the counts are over all of them, so chunks.edit_c now runs two forked passes (patches; then, after the cross-file rules, the output size and the groups), one process per file. Files and log lines are the one-process ones, byte for byte. No fork (Windows): one process, as before. lift.py prints each step's seconds (lift: <step> in Ns (Ms)), so a lift's minutes can be placed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it made Any build.py failure deleted objcache and sa/build/obj, for issue 1197's link of a stale cached runtime object. A compiler stopped twice for memory, or the build closed or killed mid-compile, took the same path and threw away every finished object: on a small PC the retry compiled for up to an hour again. Objects are stamped and cached only once their compiler succeeded, so make_game now wipes them only when build.py got past "compiled in Ns, 0 failed" (the link, or a step after it, failed). The lift is forgotten either way, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lift_key hashed every source file under nx2/ and third_party/a64-kit: the builder, the compile, the bake, the prompts, the shader census and their tests (some 60 modules the lift never loads). A change to any of them translated the game again, minutes on every build. The key now follows the import statements of lift.py and split_hot.py (in functions too) to the modules they load, found as kit.py puts them on the path, and keeps every non-Python source file there (a64emit.cpp is compiled when the lift starts). Over main's last 1200 commits 119 changed the old key and 41 change the new one. A test imports the lift's scripts and fails when a module they load is not in the key. The key changes once with this commit (one lift for everyone). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a player's build, make_game deleted all of sa/ (the lifted C, 1.5 GB), so every update translated the game again even when nothing the lift reads had changed. Now the clean-up deletes only the lifted code_*.c files and the build outputs, and keeps the rest of the lift (~130 MB) with build.py's list of each lifted file's object key (gen/lifted_objects.txt), marking the lift key "kept". The next build with the same lift key skips the lift: build.py names the lifted files' objects by that list and links them from the object cache. When an object is gone or the flags or headers changed, build.py stops before compiling anything (exit status 3) and make_game translates the game again, then compiles. The two builder apps' after-build clean-up (which also deleted sa/) leaves a kept translation in place; Free up space still deletes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
split_big read every lifted chunk (1.5 GB) on one core to find the few whose giant functions need cutting into parts. Each chunk is now read and cut in a forked process (the edits' _each, sized by emit_jobs; one process where there is no fork). The parts, costs and lines are the same, in order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…edits The runtime tables, hos_image.c, the import stubs and nvn2_exports.c read the image, not the lifted C, but were written after the edits, on one core. They are now made in a forked process started before the edits and joined after them; where there is no fork (or HOOKSHOT_LIFT_JOBS=1), or the process fails, they are made after the edits as before. Files and printed lines are the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scripts/make_setup_parts.py zipped the parts one after the other. They are now zipped at once, one thread each, as scripts/linux/make_parts.py has done since NotRexed#2079 (zlib lets go of the GIL while it compresses). Each zip and hookshot-setup.txt are the same bytes as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every build ran nx2/shader_census.py over the game's shader files (4 s, and 514 MB of hookshot_shaders.bin written) although an update seldom changes those files or the census. make_game now notes in the work folder what the list was made from (the census script, the Python, the names, sizes and times of the files it reads, and the list file itself) and skips the census when all are the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The eight hot pieces (code_hot_N.c: -O2 with the play profile) are the longest files of a cold compile. With the profile, clang unrolled the guest's loops inside their giant functions, and the passes after it (SROA, machine LICM, register allocation) ran over the larger code. With -fno-unroll-loops: code_hot_4.c alone 261 -> 216 s, code_hot_0.c 216 -> 216 s (timed, own cores); on a loaded 6-core build all eight took 2807 s of CPU instead of 4547 s, the longest 425 s instead of 912 s. Game speed in Kokiri Forest (headless, uncapped, alternating runs): 62.8/64.8 fps before, 65.1/65.2 fps after; the main game thread 6.2/5.9 ms a frame before, 5.9/5.8 after. The flag changes the hot pieces' object keys: every build compiles them once more. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
split_hot.py dealt the hot functions out into eight pieces afresh at every lift, so a few bytes more in one hot function (a patch) moved functions between most of the pieces and every player compiled all eight again, the longest third of a cold compile. Now the pieces are kept in <work>/image/hot_pieces.txt beside the chunk boundaries: the functions of the last lift stay in their piece, new ones go to the piece of the function they share the most calls with, and a fresh split is made when the kept one's longest piece would cost over 15% more than a fresh split's. Each piece now declares only the functions and host calls it names, so a new declaration no longer changes every piece. On the game's hot code, a one-function edit changed 5.4 of the 8 pieces on average before (12 trials) and 1 after. A first split gives the same functions in each piece as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Windows the lift's per-file passes (big chunk split, patches, output size and page-mark groups) ran in one process, because they used forked workers only. Their workers take a file's path and read the file themselves, so where fork is missing they now start as new processes (spawn), sized as the emit's (file_jobs). The emit and the side process still need fork and stay as they were. HOOKSHOT_LIFT_NO_FORK=1 takes the Windows path elsewhere, for testing. Linux with HOOKSHOT_LIFT_NO_FORK=1 against the one-process lift (two rounds): the three passes took 9.9 / 16.1 s instead of 40.6 / 35.6 s; the lifted files were the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_lift_cache imported the lift with only capstone stood in, but lz4 is not installed on CI either; stand in for both and print the import's error if it fails. test_kept_translation wrote its sources in text mode, so CRLF made them 19 bytes on Windows instead of 17. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
There is a cleanup integration point with #2367 (portable Builder): both touch |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hckrman101
pushed a commit
that referenced
this pull request
Nov 11, 2026
…#2383) On x86-64-v3 builds the single-precision FMADD/FMLA, FRECPS and FRSQRTS fast paths still compute in binary64 and round twice, although the CPU has a hardware FMA. One fused instruction gives the same results and makes FMA-bound guest code 22 to 45% faster. ## What changed suyu's runtime header uses `__builtin_fmaf` in `recomp_fpx_fma32`, `recomp_fpx_recps32` and `recomp_fpx_rsqrts32` only when `RECOMP_FPX_HOST == 1` (an AArch64 host). On x86-64-v3, where `RECOMP_FPX_FMA64` is already set from `__FMA__`, they took the binary64 form with a midpoint test, and fell back to the exact body on every binary32 midpoint. New `nx2/suyu_rt_fixes.py` wraps `a64_rtgen.runtime_h`, as `lift_fixes` does for the lifter, and changes those three guards to `RECOMP_FPX_HOST == 1 || RECOMP_FPX_FMA64`. Nothing under `third_party/` is edited. Each substitution must match exactly once, so a refreshed a64-kit that moves these lines fails loudly. `nx2/lift.py` and `nx2/test_leaf.py` install it next to `lift_fixes`. Builds at x86-64-v2, baseline x86-64, MSVC and wasm are unchanged: `RECOMP_FPX_FMA64` is 0 there. An existing game picks the change up on its next lift (a new file under `nx2/` changes the lift key). ## Measurements llvm-mingw clang 23 from the v2.0.14 builder, at the build's lifted-code flags (`-march=x86-64-v3 -ffp-contract=off -fno-strict-aliasing -O2`), headers from the real generators. Synthetic kernels shaped like the lifted call forms; thread CPU cycles per element (`QueryThreadCycleTime`), median of 31 samples, 7 interleaved before/after rounds: | Kernel | FZ off | FZ on | |---|---|---| | FMA throughput | 4.05 -> 2.79 (-31%) | -31% | | FMA dependent chain | 19.43 -> 11.07 (-43%) | -46% | | 4x4 mat*vec FMLA | 30.78 -> 18.00 (-42%) | -12% (noisy) | | FRECPS + FMUL Newton x2 | 14.33 -> 10.16 (-29%) | -25% | | FRSQRTS Newton x2 | 21.90 -> 16.88 (-23%) | -21% | The new header emits `vfmadd`/`vfnmadd` and no `fmaf` call. The game-wide gain depends on how much of a frame runs these instructions, which needs an in-game frame-time comparison. I have not run one. ## How it was tested - `tests/fpx_sticky_test.c` gets a `fast` mode: the inline fast paths against `recomp_fpe_*`, value and FPSR, under random FPCR/FPSR with `a*b` near 1, 2 or 3 as in the Newton steps, plus three binary32 midpoints that must be kept when the build has FMA. `tests/test_fpx_sticky.py` runs it with 8M cases in both the v3 and v2 builds: 0 mismatches. Without `suyu_rt_fixes` installed the v3 run fails (the midpoints fall back). - A separate fuzz of 200M cases per op (specials, subnormals, FZ on and off, cancellations, midpoints, Newton-like pairs): 0 mismatches. It catches sabotage: dropping the FZ check on `z` gave 85k mismatches in 2M. - `python -I -B tests/test_fpx_sticky.py` with `HOOKSHOT_MINGW` at the builder's llvm-mingw: passes. - `python -I -B tests/run_all.py` on Windows: the same failing set as untouched `main` at 32693f0, none new (the failures here come from tools missing on this PC). `nx2/test_leaf.py` needs the dump and was not run. ## Notes The only behaviour change is that binary32 midpoints now stay on the fast path, with the same result. #2324 moves the `a64_rtgen.write` call in `nx2/lift.py`; this change patches at import time, so it still applies, with at most a small conflict in the import block. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This reverts the -fno-unroll-loops flag on the hot pieces. Besides the loops that made the giant functions slow to compile, it also stopped the full unrolling of the lifter's fixed-count lane loops (for (_i < 4) and the like, about 25,000 in the hot code): on lane-heavy functions of code_hot_5.c far more loops and stack accesses were left in the code. The Kokiri Forest check barely ran those functions, so it could not show the cost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… keep it is no failed build - build.py's list of the lifted files' objects (lifted_objects.txt) now starts with the lift key it was written for and a format number. make_game.py (kept_before, keep_translation) and build.py use it only when both fit; a new lift deletes the list of the one before. - keep_translation runs after the game folder is in place: a lifted file it cannot remove (Windows, still held open) made the builder report a failed build. It now lets all of sa go as far as it can and returns None: the next update translates again. - make_game passes the list to build.py as --kept-lift, not through an environment variable any caller could have set. - KEPT_STALE is 75, not 3 (abort()'s exit status on Windows). - gen_print leaves out the list's .new file too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
split_hot keeps each hot function in the piece of the last lift (image/hot_pieces.txt), so the pieces' C follows that file: two work folders with other histories could lift the same game into other pieces under one key. The file is in the key now, as chunks.txt is. The lift-key test also checks that no module of the lift imports by a name made at run time, which the key's import walk could not see. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hckrman101
pushed a commit
that referenced
this pull request
Nov 11, 2026
## Summary Starting a Windows build could stop Python or compiler processes belonging to another work folder. Cleanup now requires positive work-folder or output ownership and preserves processes whose ownership cannot be established. Fixes #2142. ## Technical changes - Parse complete Windows arguments and compare normalized absolute paths at component boundaries. Unrelated orphans, sibling-prefix outputs and include/input paths no longer qualify. - Protect the full ancestor chain, including non-Python launchers. Recognize this installation's default work and a proven make_game parent's matching build.py child; preserve unknown orphans and unrelated descendants. - Stop eligible processes individually, without `taskkill /T`; report successful stops only and retain best-effort cleanup. ## Validation Current tested head **`40321e87`**, rebased onto main **`355453de`**. Default-work cleanup now recognizes previous packaged Builder versions under the same home, including their proven build child. Other homes, sibling prefixes, unexpected layouts and custom-work mismatches remain excluded. Eleven tests pass on Windows and Linux, including 500 quoted-argument controls; the previous production source fails the two new cross-version/query-diagnostic regressions on both systems. All process queries and termination calls are intercepted. No real process was stopped. Fresh [hosted checks](https://github.com/NotRexed/Hookshot/pull/2250/checks) are separate exact-head evidence. <details> <summary>Earlier validation evidence and limits</summary> Earlier tested head **`e14e3a8c`**, rebased onto main **`6df80a8d`**. The selector, test and documentation patch is unchanged by range-diff; the worklist conflict preserves both main's existing row and this PR's row. **Ten focused tests pass on Windows and Linux**, including 500 deterministic quoted-argument round trips. All process queries and termination calls are intercepted; no real process was stopped. Fresh [CI checks](https://github.com/NotRexed/Hookshot/pull/2250/checks) run on this head. At `6a3af365` on base `ebbba2ea`, the unmodified helper selected unrelated synthetic processes as well as the intended control. Four causal mutations removing work ownership, path boundaries, ancestor protection or individual termination were rejected. Full Windows: **342 passed / 3 failed / 30 skipped**, versus exact base **341 / 3 / 30**, with the same failure names. Full Linux: **352 / 0 / 23**; a separate release-script run with jq supplied its missing internal coverage (**24 cases passed**). These full/causal results refer to that earlier head, not the current rebase. This is a synthetic selector regression, not a real termination or PID-reuse guarantee. Unknown orphan build.py processes and outputs known only through relative paths or response files remain untouched. Linux cleanup behavior is unchanged; gameplay does not exercise this Windows process selector. [Dev-work claim](https://discord.com/channels/1568810484818378804/1569019786766188665/1570037297234051113). Coordination with #2324 and #2373 preserves this engine fix independently of retained-lift and launcher UI changes. </details> <!-- pr-ci-status --> | Current-head workflow | Result | |---|---| | [Game-free source regressions](https://github.com/NotRexed/Hookshot/actions/runs/43095862413) | Queued | | [Linux AppImage](https://github.com/NotRexed/Hookshot/actions/runs/43095862343) | Queued | | [PR title](https://github.com/NotRexed/Hookshot/actions/runs/43095862104) | Queued | | [Vulkan back end with NVIDIA DLSS](https://github.com/NotRexed/Hookshot/actions/runs/43095862318) | Queued | | [Windows builder](https://github.com/NotRexed/Hookshot/actions/runs/43095862349) | Queued | <!-- /pr-ci-status -->
hckrman101
marked this pull request as ready for review
November 11, 2026 21:40
hckrman101
added a commit
that referenced
this pull request
Nov 11, 2026
…tching it (#2518) A development build that only checks whether something works (the game starts, a menu, a screenshot, a log) still waits for the eight hot pieces, which are compiled at `-O2` with the play profile and take about a third of the game's compile time. Their level can already be lowered with `RX_HOT_OPT=O1`, but that is not written down anywhere, and through `make_game.py` it also makes the next build translate the whole game again. ## What changed - `docs/development.md`, *Building*: an `RX_HOT_OPT` row in the switches table and a short section, *A faster build for development*: what to set (`RX_HOT_OPT=O1` with `nx2/build.py`, which then uses no profile; through `make_game.py` the shipped profile is still passed), when switching keeps the lift and the objects (the checkout's own `work/` or `HOOKSHOT_KEEP_BUILD_FILES=1`; elsewhere `make_game.py`'s clean-up keeps only the last build's, so a switch lifts again and recompiles the hot pieces), what it saves, and a clear warning that it is never for fps, frame times, build timings, hitch checks, profiles, or a release or player build. - `CONTRIBUTING.md`, *Building*: a one-line pointer to that section. - `nx2/make_game.py`: `lift_key` leaves `RX_HOT_OPT` and `RX_PGO` out (`COMPILE_ONLY_ENV`). Only `nx2/build.py` reads them, after the lift, so they never change the lifted C. Before this, setting `RX_HOT_OPT` for one build, or unsetting it again, changed the key and translated the game again (minutes). Any other `A64_`/`OOT_`/`RX_` variable still changes the key. - `tests/test_lift_cache.py`: both variables, given to `lift_key` or in the environment, leave the key as it was; and no file of `make_game.lift_sources()` (the translator's files, which the key hashes) names them, so a translator change that starts reading one fails the check instead of reusing a stale lift. The objects need no change: their keys include the flags, so `-O1` and `-O2` hot objects are cached side by side. `make_game.py` is not one of `lift_sources()`, so this edit costs no new lift. The direct `build.py --standalone` example runs without `make_game.py`'s environment (`RX_RELEASE`, `A64_HOT_CHUNK`), so its runtime objects differ from a `make_game.py` build's; the docs say so. ## Measured These numbers come from existing builds, not from a new `-O1` build: the per-file compile times in the `build_times.json` of six earlier full builds on one Linux desktop (Ryzen 9 7900X, 24 threads, 30 GB, other work running), plus peak memory measured during earlier build work. | | Hot pieces (`-O2`, profile) | The 72 other lifted files (`-O1`) | |---|---|---| | Compile time, all of them | 1,270-1,810 s | 2,820-3,750 s | | Per MB of C | 16-22 s | 2.2-2.9 s | | Longest single file | 300-800 s | ~360 s (`code_021_1.c`) | | Peak memory per compiler | 0.8-1.7 GB | 1.4-1.6 GB (24 MB file) | The hot pieces are 81 MB of the 1.4 GB of lifted C. At the other files' rate they would take about 200 s instead of about 1,500 s, so the game's compile needs about a quarter less time in total, and the longest single file becomes an ordinary one, so a build after a new lift can finish several minutes sooner on a CPU with many threads. Peak memory barely moves, because the ordinary files already set it. The docs give these as estimates and say where they come from. The game still plays with the hot pieces at `-O1`: the profile-counting build (`RX_PGO=gen`) already compiles them at `-O1` and is played through to record the profile. Its frame rate was not measured for this PR, and the docs ask readers to check that it is fast enough for their test. ## Tested - `python -I -B tests/test_lift_cache.py` passes after merging `main` (with #2324's `check_lift_sources`), and fails without the `make_game.py` change. - `python -I -B tests/run_all.py` on Linux, before merging `main`: 411 passed, 12 skipped (four checks that write to `/tmp` were run again with a writable `/tmp`, and pass). --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
hckrman101
pushed a commit
that referenced
this pull request
Nov 12, 2026
… game again (#2674) In a new work folder (a fresh worktree, or a player's new install), the second build translates the whole game again even though nothing changed: 319 s for a no-op rebuild on Windows, 290 s of it the lift. For players, the first update after a new install doesn't get to keep the translation (#2324). ## Cause `lift_key` hashes `image/hot_pieces.txt`, split_hot's kept pieces, and the first lift in a work folder is what writes that file. The key was taken *before* that lift and stored after it. So the next build, which now finds `hot_pieces.txt`, computes another key and translates again. After that the key is stable, which is why the problem only shows once per folder. ## Change - `translate()` takes the key again after the lift and split_hot, from the files the next build will read, and stores that one. `keep_translation` uses it too (`nonlocal key`). - A lift run from those inputs (the pieces this lift wrote, the boundaries it kept) writes the same C. If they ever differ, the build relifts, as it does today. ## Testing - **`tests/test_lift_cache.py`:** - A new check fails without the change and passes with it: the key is taken again after split_hot, and a fresh folder's key from before its first lift isn't the next build's. - The test also read the lift's module list by whitespace, so it failed in any checkout whose path has a space in it (here `E:\Hobby Development\...`). It now reads by lines. CI's paths have no spaces, so CI was not affected. - **`tests/test_lift_cleanup_error.py`** runs the fresh-lift branch with stubs; it now stands in for `lift_key` and expects lift → key taken again → key written. Against `main`'s `make_game.py` it fails, with the change it passes. - **End to end on Windows** (Ryzen 9 3900X, NVMe, `main` at 0ec31ff plus this change): in a work folder with no `hot_pieces.txt` and no key, the first build took 315 s (the lift is needed) and the second 14 s, skipping the lift. Without the change, the second took 319 s. - **Full `run_all.py` on Windows:** every check that reads `make_game.py` (52) passes, apart from `test_cpp_preflight`, `test_linux_update` and `test_prepare_stall`, which fail the same way on a clean `main` here. ## Not tested A player's update through the Builder end to end. It takes the same path in `make_game.py` (`keep_translation`, then `kept_before` with this key), but I haven't run it through the Builder. Part of the dev-loop proposal's Phase 0 (#2636). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
hckrman101
pushed a commit
that referenced
this pull request
Nov 12, 2026
) On Windows the lift writes the game's C in one process, because there is no `fork`: about 65 s of the lift on a 32-thread PC here, and 145–170 s on the Ryzen 9 3900X of #2636's Phase 0. This is that proposal's item A3: the emit now runs in several new processes on Windows and writes the same files, byte for byte. ## Who this reaches Builds that translate the game again. Since #2324 an update with an unchanged lift key skips the lift. `nx2/chunks.py` is one of the key's inputs, so the first build after this lands translates once more. ## Change `nx2/chunks.py`: - **New processes where there is no fork** (`spawn_emit`). Each gets the lifter's small tables once (`EMIT_TABLES`) and, with each run of 2,000 functions, their bodies; it translates that run's words itself. A3 sketched workers that map `image.bin`; sending the bodies needed no second discovery. - **Only when the workers' emit is the lift's:** a plain `a64_lift.Lifter` (no subclass, no `emit_function` of its own), with `nx2/poll.py` installed exactly when its counter is passed. Otherwise one process, as before. - **Each worker checks itself:** it compares where its translator and emit passes come from with the lift's, and stops if they differ. The lift then emits the rest itself and says so. - **The log line says what ran:** `lift: emit and write (16 processes)` or `(1 process)`. #2693's pattern reads both. - **Linux and macOS are unchanged:** forked workers, as before. **One more change, for everyone on Windows:** the available-memory cap (one process per 2 GB free, from the Steam Deck reports) now applies on Windows too, where free memory was not read before. It is `file_jobs()`' cap, so the per-file passes of #2324 are limited by it as well on a PC with little free memory. That cost is not measured, and I can move it to its own PR if you prefer. ## Measured i9-13900K (24 cores, 32 threads), 64 GB, NVMe, Windows 11 Home 10.0.26300. The standalone lift (`nx2/lift.py --standalone --write`) in its default configuration (polls on, `A64_LOCAL_REGS` unset, no hot list), `main` (ba005ca) and this branch alternated, three runs each: | | `emit and write` | whole standalone lift | |---|---|---| | `main`, 1 process | 66.5 s, 64.9 s, 66.0 s | 119 s, 117 s, 120 s | | this branch, 16 processes | 20.9 s, 21.2 s, 21.2 s | 73 s, 73 s, 74 s | - **Same output:** the 85 files of the gen folder are SHA-256 identical between the two. - **Fewer processes** (an earlier revision of this branch): 38.1 s with 4, 26.1 s with 8. - **Memory:** about 170 MB per worker. Peak working set summed over the lift's processes: 5.1–5.9 GB with 1, 7.9 GB with 16. ## In the game A full build with the Builder's steps (`nx2/make_game.py --nup`, French, shaders not prepared) from this branch plus one unrelated commit (the damaged-texture check opening files several at a time, its own PR): the log says `lift: emit and write (16 processes) in 23.2s`. Build `v2.1.0-rc.3-256-gf79aa054`, RTX 4090, NVIDIA 621.01, Vulkan 1.4, Windows 11, 5120x1440, upscaler off, frame generation off, 60 fps cap. Kokiri Forest from a save: 52–60 fps, no device lost, no stutter seen, nothing out of place. ## Testing - **`tests/test_spawn_emit.py` (new, game-free):** a real `a64_lift.Lifter` over a made-up image of 4,500 functions, written by `chunks.write` with 1 process and with 3 spawned ones: the same files and counters. It covers: - the plain configuration; - the lift's default (`poll.install()`, `A64_LOCAL_REGS=4`, comments on); - a translator that differs in the workers (the lift falls back and says so); - a `Lifter` subclass (kept in one process). A fallback cannot pass for the spawn: the "worker process stopped" line fails the test. - **Teeth:** each entry of `EMIT_TABLES`, dropped or sent empty, makes it fail (checked by hand). - **Where it runs:** it builds the translator in a temporary folder, so it needs a C++ compiler, as `tests/test_fpx_sticky.py` does. On the game-free CI (windows-2022 and ubuntu-24.04, Python 3.12) of a private copy of this branch, it passed on both (15.7 s on Windows; 18.2 s and 8.3 s in two Linux runs). Those two Linux runs each had one other failure, a different one each time and outside this change: `tests/test_bake_skeleton.py` stopped at 600 s on a runner where every check took twice its usual time, then `tests/test_linux_saves_move.py` (host/hos_main.c) failed once after passing in the first run. - **`python -I -B tests/run_all.py` on Windows:** 448 passed, 5 failed. `nx2/test_nup_paths.py` needs the symlink privilege. `tests/test_bug_report.py` and `tests/test_input_background.py` fail the same way here without this change. `nx2/test_nup_verify.py` and `tests/test_prepare_at_build.py` failed once with 32 checks at once and pass alone. ## Not measured - `--no-polls`, `A64_LOCAL_REGS` or a hot list on real data (the test covers the first two on the made-up image). - A PC with 16 GB or less, where the cap leaves fewer processes. - The per-file passes under the new Windows cap. See #2636 See #2324 Assisted-by: Claude Opus 5.5 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Nov 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebuilding the game spends most of a warm build in the lift (translating the game to C), and most of a cold build in the eight hot pieces. This PR makes both shorter. The game's code comes out the same.
What changes
make_game.keep_translation/kept_before,build.pyKEPT).sa/, including the 1.5 GB of lifted C. So every update lifted again (1-3 minutes), even when nothing the lift reads had changed.code_*.cfiles and the build outputs. It keeps:gen/lifted_objects.txt). Its first line names the lift key it was written for and a format number; a new lift deletes the list of the one before..lift_keygets a third line,kept.build.py --kept-lift. build.py then takes the lifted files' objects from the object cache by the keys in that list. These are the same objects as before, so nothing is compiled again.KEPT_STALE; not 3, which isabort()'s on Windows). make_game then lifts again and compiles as usual.sa/go as far as it can, and the next update lifts again.Storage.Free(afterBuild)/core.free(after_build=True)) also deletedsa/. It now leaves a kept lift in place. "Free up space" still deletes it.chunks.split_big, using the same worker pool as the edits). Before, it read all 1.5 GB of chunks on one core.a64_rtgen,hos_image.c, the import stubs andnvn2_exports.c. They read the image, not the lifted C, so a forked process now makes them while the C is being edited. Without fork (Windows), or if that process fails, they are made after the edits, as before.make_game.shader_list_current).shader_census.py(about 4 s) and rewrote the 514 MBhookshot_shaders.bin.scripts/make_setup_parts.py), one thread each, as the Linuxmake_parts.pyhas done since ci: cache the AppImage's back-end objects for pull requests, and a lighter deps cache #2079. The zips come out byte for byte the same.split_hot.py).<work>/image/hot_pieces.txt, next to the chunk boundaries:hot_pieces.txtis part of the lift key, aschunks.txtis: the pieces' C follows it.chunks.file_jobs,_each). Before, they used forked workers only, so without fork (Windows) they ran in one process. Their workers take a file's path and read the file themselves, so without fork they now start as new processes (spawn). The emit still runs in one process there.HOOKSHOT_LIFT_NO_FORK=1takes this path on Linux, for testing.Measured
Setup: Linux, Ryzen 9 7900X (24 threads), 30 GB RAM, other jobs running on the PC.
oot-status --lock --timing, which gives the run its own core complex (6 cores / 12 threads).Player update, same tools (
make_game.py --nup, noHOOKSHOT_KEEP_BUILD_FILES, objects cached, shader prepare skipped). Times are from start to@@done:Caveat: these four runs went through
make_game.pyunderoot-status --timing. A queue bug that night movedmake_game.py/build.pyprocesses onto the busy build CPUs during timed runs, so the absolute seconds of both rows are inflated by load. The gap does not come from load, though: the "before" runs spent 83 / 175 s in a lift that the "after" runs skip entirely. The lift-step, hot-piece and game-speed numbers below ran lift.py, clang or the game directly, and the bug did not affect them.A second pair after the queue bug was fixed (
--timing --cpu-only, one round, the PC heavily loaded and the builds at idle priority, so both absolute times are high again): make_game took 640 s before (lift 614 s: discovery alone 162 s, against 26 s on a quiet PC) and 42 s after (lift skipped; the rest is the finish stage's file copies).hookshot,hookshot_vk.soandimage.bin(sha1).code_010) and ran make_game again. It printed "the translation kept from the last build does not fit this one (1 of 80 files: code_010.c): translating the game again", lifted, compiled that one file, and produced the samehookshotsha1 as the full build.Lift steps (lift.py + split_hot.py run alone, two rounds each). All 95 output files and
chunks.txtwere sha1-identical to the old lift:Shader list: the finish stage took 1 s when the list was kept, against 4-7 s when it was made.
Windows setup parts: I could not run a real package here. On a 104 MB stand-in package (three parts), three rounds took 1.81 / 1.50 / 1.50 s before and 1.05 / 1.15 / 1.04 s after. The zips and
hookshot-setup.txtwere identical.Windows path for the per-file passes: measured on Linux with
HOOKSHOT_LIFT_NO_FORK=1, against the old one-process lift (HOOKSHOT_LIFT_JOBS=1), two rounds. The three per-file passes (big chunk split, patches, output size + page-mark groups) took 9.9 / 16.1 s, against 40.6 / 35.6 s. The non-hot lifted files were byte-identical. The hot pieces differ only because of item 7. The whole lift's time varied more than that, because the one-process emit took 56-84 s from run to run.Hot pieces kept: this was measured without a game run, on the game's real
code_hot.c(849 functions). In 12 trials, each adding 1-60 lines to one random hot function, the next split changed 5.42 of the 8 pieces on average before (1-6 per trial) and exactly 1 after. A fresh split put the same functions in each piece as the old code.Tried and dropped:
Full build of the branch (before merging main, still with the dropped item 6): lift 94 s, 9 of 264 files compiled (the eight hot pieces, 165-363 s each, and one more), then a kept update from it as above.
Merge with main: main removed
oot_native_resolution.py(#2042) and added patches tooot_patches.apply(the text hooks of #2366, Camera speed of #1945). They are in the per-filepatch_file/reportsplit now; the ripple fix of #2409 goes through the frame-time table as before. A full build of the merge booted from the test save into Kokiri Forest and played 20 s of scripted running and sword swings (headless) without an error. It lifted with no patch warnings: every patch found once, the camera speed at its 4 functions, the three text hooks, both step divides and both frame counters (the ripple one included). 3 files compiled.Tests:
tests/run_all.pyat the head (the merge and the review fixes): 402 passed, 0 failed, 12 skipped. New or changed for the fixes: a kept list of another lift or format is not used, a clean-up that cannot remove a file is no failed build, the kept hot pieces change the lift key, and no module of the lift imports by a name made at run time. Before the merge: 364 passed, 1 failed (test_wayland_libdecor_hint, a harness-Werrorwarning that also fails without this PR). New tests:test_kept_translation.py,test_shader_list_skip.pyandtest_sticky_hot_pieces.py. Updated tests that cut functions out of build.py and make_game.py:test_lift_cleanup_error.pyandtest_compile_retry_jobs.py.Not tested
lz4, and were fixed):Storage.cs): no .NET SDK here, only the Python builder's twin was run;code_hot_1.c(-Wprofile-instr-out-of-date). This happens without this PR too.Not in this PR (measured or written, for later)
Compiling the hot pieces without loop unrolling (
-fno-unroll-loops). It cut the hot pieces' compile a lot (code_hot_4.c261 → 216 s alone; all eight 4547 → 2807 s of CPU in a loaded build), and Kokiri Forest ran as fast (62.8/64.8 fps before, 65.1/65.2 after). But a review found that it also stops clang from fully unrolling the lifter's ~25,000 fixed-count lane loops (for (_i < 4)and the like): on six lane-heavy functions ofcode_hot_5.cthe loops left in the code went from 65 to 263 and the stack accesses from 744 to 1165. The scenes measured barely run those functions, so they could not show it. Reverted. A narrower set (-mllvm -unroll-runtime=false -mllvm -unroll-allow-partial=false, perhaps-mllvm -unroll-max-upperbound=0) gave code byte-identical to today's on that sample; its compile-time gain with the play profile is not measured yet.Smaller chunks of ordinary code: three 24 MB chunks compiled whole took 145 s of CPU and 1.4-1.6 GB each; the same code cut in thirds (at function starts, as
split_bigcuts) took 117 s (-19%) and 0.52-0.70 GB each. More compilers would fit on a 16 GB PC, and less CPU on any PC. It changes every ordinary chunk's object (one full compile for everyone) and needs a game-speed check, since fewer callees share a file.Unpacking the romfs while the game is translated and compiled (about as long as the unpack, ~1-2 minutes, off a first build): written and tested game-free, not measured end to end.