Skip to content

perf(build): updates skip the lift, a faster lift, and hot pieces that compile sooner and stay put - #2324

Merged
hckrman101 merged 17 commits into
NotRexed:mainfrom
hckrman101:perf/faster-build
Nov 11, 2026
Merged

hckrman101 merged 17 commits into
NotRexed:mainfrom
hckrman101:perf/faster-build

Conversation

@hckrman101

@hckrman101 hckrman101 commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

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

  1. A player's update skips the lift when the tools did not change (make_game.keep_translation / kept_before, build.py KEPT).
    • Before, the clean-up after a player's build deleted all of 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.
    • Now the clean-up deletes only the lifted code_*.c files and the build outputs. It keeps:
      • the rest of the lift (~124 MB: headers, runtime tables, image);
      • a list that build.py writes after the compile: each lifted file's object key, its size, and a hash of its flags and headers (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.
    • The lift key in .lift_key gets a third line, kept.
    • The next build with the same lift key, and a list naming that key, skips the lift. make_game passes the list as 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.
    • If an object is missing, or the flags or headers changed, build.py stops before compiling anything (exit status 75, KEPT_STALE; not 3, which is abort()'s on Windows). make_game then lifts again and compiles as usual.
    • If the clean-up cannot remove a lifted file (Windows: one still held open), the build still succeeds: it lets sa/ go as far as it can, and the next update lifts again.
    • The two builder apps' after-build clean-up (Storage.Free(afterBuild) / core.free(after_build=True)) also deleted sa/. It now leaves a kept lift in place. "Free up space" still deletes it.
  2. The big chunks are split in one process per file (chunks.split_big, using the same worker pool as the edits). Before, it read all 1.5 GB of chunks on one core.
  3. The runtime tables and standalone files are written beside the edits. These are a64_rtgen, hos_image.c, the import stubs and nvn2_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.
  4. The shader list is made again only when its inputs changed (make_game.shader_list_current).
    • Before, every build ran shader_census.py (about 4 s) and rewrote the 514 MB hookshot_shaders.bin.
    • Now a note in the work folder records what the list was made from: the script, the Python, the names, sizes and times of the files the census reads, and the list file itself. If none of these changed, the census is skipped.
  5. The Windows setup parts are zipped at the same time (scripts/make_setup_parts.py), one thread each, as the Linux make_parts.py has 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.
  6. (Dropped: compiling the hot pieces without loop unrolling. It was in this PR and is reverted by a later commit; see "Not in this PR".)
  7. The hot pieces of the last lift are kept (split_hot.py).
    • Before, split_hot dealt the hot functions out into eight pieces from scratch 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. That is the longest third of a cold compile.
    • Now the assignment is kept in <work>/image/hot_pieces.txt, next to the chunk boundaries:
      • functions from the last lift stay in their piece;
      • a new function goes to the piece of the function it shares the most calls with;
      • a fresh split is made when the kept split's longest piece would cost more than 15% over a fresh split's.
    • Each piece now declares only the functions and host calls it uses. A new declaration therefore no longer changes every piece.
    • The first split after this PR puts the same functions in each piece as before.
    • hot_pieces.txt is part of the lift key, as chunks.txt is: the pieces' C follows it.
  8. On Windows, the lift's per-file passes run in several processes (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=1 takes this path on Linux, for testing.
  9. Earlier in this PR (first pass):
    • the lift's edits run in one process per file;
    • the lift prints a timing line for each step;
    • the lift key covers only the modules the lift imports;
    • a compile that failed before the link keeps the objects it already made.

Measured

Setup: Linux, Ryzen 9 7900X (24 threads), 30 GB RAM, other jobs running on the PC.

  • Timed runs used oot-status --lock --timing, which gives the run its own core complex (6 cores / 12 threads).
  • Old and new were alternated.
  • The bundled clang 23 and Python were used.

Player update, same tools (make_game.py --nup, no HOOKSHOT_KEEP_BUILD_FILES, objects cached, shader prepare skipped). Times are from start to @@done:

update 1 update 2
before (lifts each time) 100 s (lift 83 s) 193 s (lift 175 s, PC busier)
after (lift kept) 9 s 7 s

Caveat: these four runs went through make_game.py under oot-status --timing. A queue bug that night moved make_game.py / build.py processes 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).

  • After a full build at the same commit, a kept update produced the same hookshot, hookshot_vk.so and image.bin (sha1).
  • Fallback test: I deleted one kept object (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 same hookshot sha1 as the full build.
  • The kept files take 124 MB in the work folder, on top of the objects.

Lift steps (lift.py + split_hot.py run alone, two rounds each). All 95 output files and chunks.txt were sha1-identical to the old lift:

step old (round 1 / 2) new (round 1 / 2)
big chunks split 6.0 / 5.6 s 2.3 / 2.1 s
patches 2.7 / 2.6 s 3.5 / 3.1 s
output size + page-mark groups 5.7 / 5.7 s 6.0 / 6.2 s
runtime tables + standalone files 4.3 / 4.0 s 0.0 s (beside the edits)
these four steps 18.7 / 17.9 s 11.8 / 11.4 s

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.txt were 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:

  • A faster discovery walk in the lifter (reading the image through a 32-bit view). The output was identical, but discovery ended at 21 vs 23 s in one round and 26 vs 25 s in the other: no clear gain.
  • The coverage report in a side process during the emit. The output was identical, but over four rounds the lift took 92.5 / 61.7 / 82.0 / 68.4 s against 71.6 / 72.4 / 88.4 / 69.8 s without it, a mean change of +0.6 s. The emit slowed by about what the report saved.
  • Writing the lifter's small constant-count loops (NEON permutes and lane loops) out as straight-line C in the emitter. The instruction test gave the same results as before (Unicorn compare, 41,552 cases, 744 of 749 forms pass, and the same 5 forms fail without the change). But one hot piece then compiled for over 16 minutes against about 3, so the idea was 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 to oot_patches.apply (the text hooks of #2366, Camera speed of #1945). They are in the per-file patch_file / report split 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.py at 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 -Werror warning that also fails without this PR). New tests: test_kept_translation.py, test_shader_list_skip.py and test_sticky_hot_pieces.py. Updated tests that cut functions out of build.py and make_game.py: test_lift_cleanup_error.py and test_compile_retry_jobs.py.

Not tested

  • Windows, for all of it (CI runs the Windows test suite; two of its tests failed on this branch at first, line endings and a missing lz4, and were fixed):
    • the kept lift on Windows, and the C# builder change (Storage.cs): no .NET SDK here, only the Python builder's twin was run;
    • the Windows setup zips with a real package;
    • the lift's per-file passes in spawned processes on Windows (item 8: only the Linux spawn path was run), and the side process for the tables there (it falls back to one process).
  • While checking this I noticed that the play profile is out of date for this lift: clang ignored the profile for 58 of the 134 functions in code_hot_1.c (-Wprofile-instr-out-of-date). This happens without this PR too.
  • A kept lift across a real release-to-release update. It was tested with two builds of the same tools, and with a deleted object for the fallback.
  • Only one round of the re-timed update (above); the first pair is in the table.
  • A full build of the last head, with the review fixes (unrolling back, the kept list's header, the hot pieces in the lift key): the merge just before them was built and run, the fixes only by the game-free tests. With unrolling back, the hot pieces compile with main's flags again.

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.c 261 → 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 of code_hot_5.c the 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_big cuts) 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.

hckrman101 and others added 12 commits November 11, 2026 05:09
…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>
@Arkex-ai

Copy link
Copy Markdown
Contributor

There is a cleanup integration point with #2367 (portable Builder): both touch Storage.cs, and your kept-translation marker changes what after-build cleanup may remove. When combining them, Storage.Free(afterBuild) should keep the marked lift in the selected portable work home; explicit Free up space should retain your current semantics. My #2250 separately scopes stale-process cleanup to proven work/output paths, so retaining a translation must not broaden which processes cleanup may terminate. These look complementary rather than duplicate work; could we preserve those two contracts when rebasing whichever lands second?

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>
hckrman101 and others added 4 commits November 11, 2026 15:24
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
hckrman101 marked this pull request as ready for review November 11, 2026 21:40
@hckrman101
hckrman101 merged commit 30c2ebd into NotRexed:main Nov 11, 2026
12 checks passed
@hckrman101
hckrman101 deleted the perf/faster-build branch November 11, 2026 21:50
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants