perf(build): compile the lifted code without lifetime markers #2775

Open
github-sync wants to merge 1 commit from github/pr/2775 into main
Owner

@corentinbonjour opened this pull request on GitHub · 2026-11-12 12:24 UTC · original

Building the game is limited by memory on most PCs: the memory gate starts a compiler only when its peak fits, and a 25 MB lifted chunk takes about 1.5 GB. Leaving out clang's lifetime markers cuts every lifted compile's peak by about 10%, with no measurable run-time cost on lifted third-party code.

What changed

  • nx2/build.py flags(): adds -Xclang -disable-lifetime-markers. The lifted chunks, the hot pieces (hot_fl is built from fl) and the files built with them (a64_table.c, a64_rt.c, the shim) all get it. The module docstring lists it with the other lifted-code flags.
  • tests/test_lifted_flags.py (new): checks that the pair reaches the compiler at -O1 and -O2 and that the level is unchanged. When --cxx is clang, it also compiles a scoped local with the lifted flags and checks that the IR has no llvm.lifetime call. A reference compile without the pair must have one, so the check is not empty. If a future clang drops the cc1 option, the test fails here before a build breaks.
  • mem_need() is unchanged. On the synthetic chunk it estimates 1736 MB against a measured 1476 MB before this change and 1324 MB after, so it is still an upper bound. Its coefficients come from game chunks, and I have not re-measured those.

Measurements

Synthetic input: third-party AArch64 code lifted with this branch's lifter into a 25.4 MB chunk (code_000.c) and a 6.0 MB chunk (code_001.c). No game files. Compiler: llvm-mingw clang 23 from the builder (v2.0.14), MinGW target, the exact build.flags() list with and without the pair. I ran 5 repetitions with the two variants side by side in each, so both saw the same load. Each compile ran in its own Windows job: CPU is the whole process tree, and peak is the largest working set of any process in it. Values are medians with [min..max].

case metric without the flag with the flag change
25 MB chunk, -O1 peak working set 1476 MB [1475..1476] 1324 MB [1324..1325] -10.3%
25 MB chunk, -O1 peak commit 1452 MB 1298 MB -10.6%
25 MB chunk, -O1 CPU 203.6 s [171.6..217.8] 200.4 s [167.2..214.7] -1.6% (every pair -3 to -4 s)
6 MB chunk, -O2 peak working set 367 MB [367..367] 330 MB [330..331] -10.1%
6 MB chunk, -O2 CPU 44.6 s [30.9..49.0] 42.8 s [30.9..48.0] -4.1%

An earlier run of the same comparison, on a hot piece of 8.7 MB compiled at -O2 with a real profile (from an instrumented build of the same synthetic code), gave the same -10% peak, and the profile still applied: 2066 branch weights and 118 entry counts with and without the flag. Lifted zstd decompression and Opus encode/decode timed in that harness (QueryThreadCycleTime, 12 to 30 interleaved rounds) did not change beyond the noise.

Code side effects on the synthetic chunks:

  • .text grows 1.1% at -O1 and 0.4% at -O2.
  • Without the markers LLVM can no longer share stack slots between scopes. At -O1, 237 of 1655 functions with a frame get a bigger one. The median stays at 40 B, the p99 goes from 328 B to 2.2 KB, and the largest from 440 B to 3.7 KB, still under a page, so no stack probe is added. At -O2, 10 of 779 grow.
  • Guest threads ask for 64 MB stacks, so this is far from the limit.

How it was tested

  • python -I -B tests/test_lifted_flags.py --cxx clang++ passes here (MSVC and MinGW targets). On an untouched main it fails on the first check.
  • python -I -B tests/run_all.py with llvm-mingw on PATH and RX_CPM set, here and on an untouched origin/main worktree: the same 13 checks fail on both, all from tools missing on this PC. Three more (test_pipeline_cache_save, test_direct_buffer_writes, test_oot_wide_save_picture) failed only on the branch's run, from compile timeouts while a lift was running alongside. All three pass when re-run alone.
  • test_mem_gate* and test_mem_limit pass.

Notes

  • The flags are part of every object key, so the first build after updating compiles the game code again, once.
  • -Xclang is clang-only. The lifted code is always built with clang (toolchain.find("clang")) on both platforms.
> [@corentinbonjour](https://github.com/corentinbonjour) opened this pull request on GitHub · 2026-11-12 12:24 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2775) Building the game is limited by memory on most PCs: the memory gate starts a compiler only when its peak fits, and a 25 MB lifted chunk takes about 1.5 GB. Leaving out clang's lifetime markers cuts every lifted compile's peak by about 10%, with no measurable run-time cost on lifted third-party code. ## What changed - `nx2/build.py` `flags()`: adds `-Xclang -disable-lifetime-markers`. The lifted chunks, the hot pieces (`hot_fl` is built from `fl`) and the files built with them (`a64_table.c`, `a64_rt.c`, the shim) all get it. The module docstring lists it with the other lifted-code flags. - `tests/test_lifted_flags.py` (new): checks that the pair reaches the compiler at -O1 and -O2 and that the level is unchanged. When `--cxx` is clang, it also compiles a scoped local with the lifted flags and checks that the IR has no `llvm.lifetime` call. A reference compile without the pair must have one, so the check is not empty. If a future clang drops the cc1 option, the test fails here before a build breaks. - `mem_need()` is unchanged. On the synthetic chunk it estimates 1736 MB against a measured 1476 MB before this change and 1324 MB after, so it is still an upper bound. Its coefficients come from game chunks, and I have not re-measured those. ## Measurements Synthetic input: third-party AArch64 code lifted with this branch's lifter into a 25.4 MB chunk (`code_000.c`) and a 6.0 MB chunk (`code_001.c`). No game files. Compiler: llvm-mingw clang 23 from the builder (v2.0.14), MinGW target, the exact `build.flags()` list with and without the pair. I ran 5 repetitions with the two variants side by side in each, so both saw the same load. Each compile ran in its own Windows job: CPU is the whole process tree, and peak is the largest working set of any process in it. Values are medians with [min..max]. | case | metric | without the flag | with the flag | change | |---|---|---|---|---| | 25 MB chunk, -O1 | peak working set | 1476 MB [1475..1476] | 1324 MB [1324..1325] | -10.3% | | 25 MB chunk, -O1 | peak commit | 1452 MB | 1298 MB | -10.6% | | 25 MB chunk, -O1 | CPU | 203.6 s [171.6..217.8] | 200.4 s [167.2..214.7] | -1.6% (every pair -3 to -4 s) | | 6 MB chunk, -O2 | peak working set | 367 MB [367..367] | 330 MB [330..331] | -10.1% | | 6 MB chunk, -O2 | CPU | 44.6 s [30.9..49.0] | 42.8 s [30.9..48.0] | -4.1% | An earlier run of the same comparison, on a hot piece of 8.7 MB compiled at -O2 with a real profile (from an instrumented build of the same synthetic code), gave the same -10% peak, and the profile still applied: 2066 branch weights and 118 entry counts with and without the flag. Lifted zstd decompression and Opus encode/decode timed in that harness (QueryThreadCycleTime, 12 to 30 interleaved rounds) did not change beyond the noise. Code side effects on the synthetic chunks: - `.text` grows 1.1% at -O1 and 0.4% at -O2. - Without the markers LLVM can no longer share stack slots between scopes. At -O1, 237 of 1655 functions with a frame get a bigger one. The median stays at 40 B, the p99 goes from 328 B to 2.2 KB, and the largest from 440 B to 3.7 KB, still under a page, so no stack probe is added. At -O2, 10 of 779 grow. - Guest threads ask for 64 MB stacks, so this is far from the limit. ## How it was tested - `python -I -B tests/test_lifted_flags.py --cxx clang++` passes here (MSVC and MinGW targets). On an untouched main it fails on the first check. - `python -I -B tests/run_all.py` with llvm-mingw on PATH and `RX_CPM` set, here and on an untouched `origin/main` worktree: the same 13 checks fail on both, all from tools missing on this PC. Three more (`test_pipeline_cache_save`, `test_direct_buffer_writes`, `test_oot_wide_save_picture`) failed only on the branch's run, from compile timeouts while a lift was running alongside. All three pass when re-run alone. - `test_mem_gate*` and `test_mem_limit` pass. ## Notes - The flags are part of every object key, so the first build after updating compiles the game code again, once. - `-Xclang` is clang-only. The lifted code is always built with clang (`toolchain.find("clang")`) on both platforms. <!-- github-sync:item:2775 -->
Pass -Xclang -disable-lifetime-markers with the lifted-code flags (chunks and
hot pieces). Each compiler peaks about 10% lower (1476 -> 1324 MB on a 25 MB
-O1 chunk, 367 -> 330 MB at -O2), so the memory gate fits more compiles at
once. Compile CPU drops 2-4%. mem_need stays as it is, an upper bound.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Author
Owner

@Joey-Magnus reviewed on GitHub · 2026-11-12 14:35 UTC · original

I merged this into current main and ran tests/test_lifted_flags.py --cxx clang++ with clang 21 and tests/test_build_times.py. Both pass, and the IR check finds llvm.lifetime in the reference compile and none with the flags, so the cc1 option works on an older clang too. The change itself is one flag at nx2/build.py:610. Two things for the maintainers to weigh rather than bugs:

  • Every object key includes the flags, so every player's first build after this lands recompiles all the lifted game code once. On low-RAM PCs that is the slow, memory-gated part. The benefit is about 10% lower peak per compiler from then on, which also lets the memory gate start more compilers.
  • The measurements are on synthetic third-party code lifted with the same lifter, not on Ocarina's chunks. That applies to both the peak memory and the "runs as fast" part (zstd and Opus timings). It is probably representative, but a peak-working-set comparison on one real game chunk, and a frame-time check in the game, would back the claim with the code it ships for. Bigger stack frames (p99 from 328 B to 2.2 KB) are fine on the 64 MB guest stacks.
> [@Joey-Magnus](https://github.com/Joey-Magnus) reviewed on GitHub · 2026-11-12 14:35 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2775#pullrequestreview-5876253018) I merged this into current main and ran tests/test_lifted_flags.py --cxx clang++ with clang 21 and tests/test_build_times.py. Both pass, and the IR check finds llvm.lifetime in the reference compile and none with the flags, so the cc1 option works on an older clang too. The change itself is one flag at nx2/build.py:610. Two things for the maintainers to weigh rather than bugs: - Every object key includes the flags, so every player's first build after this lands recompiles all the lifted game code once. On low-RAM PCs that is the slow, memory-gated part. The benefit is about 10% lower peak per compiler from then on, which also lets the memory gate start more compilers. - The measurements are on synthetic third-party code lifted with the same lifter, not on Ocarina's chunks. That applies to both the peak memory and the "runs as fast" part (zstd and Opus timings). It is probably representative, but a peak-working-set comparison on one real game chunk, and a frame-time check in the game, would back the claim with the code it ships for. Bigger stack frames (p99 from 328 B to 2.2 KB) are fine on the 64 MB guest stacks. <!-- github-sync:r:5876253018 -->
Commenting is not possible because the repository is archived.
No description provided.