perf(build): compile giant lifted functions in pieces cut at their labels, on by default (LONGSHOT_SPLIT_GIANTS) #385

Merged
HumanBean merged 7 commits from madebyjordan/Longshot:perf/split-giants into main 2026-11-14 16:58:45 +00:00
Contributor

Why

A player's first build spends most of its time compiling the lifted game code, and a few hundred huge lifted functions
(up to 6 MB of C each) cost far more than their size: clang's work on one function grows faster than linearly.
Compiling each of them as small pieces cuts the first build's compile by a fifth on a 6-core, 16 GB PC (22.4 -> 17.6
minutes), with the same game.

What changed

  • build: nx2/split_giants.py (new). For each lifted function of 256 KB of C or more, split_text() cuts the body at
    its top-level labels into pieces of about 8 KB (plan() keeps short loops inside one piece). Each piece is a
    static __attribute__((noinline)) uintptr_t s_<addr>_<n>(Ctx *c, uintptr_t k) that starts at the label numbered k
    and returns the next label's number, 0 for a guest return, or a tail-call number. f_<addr> keeps its name and ABI
    and becomes the dispatch loop; guest tail calls stay TAILCALL (musttail) from it, so the guest call depth never
    changes. This is exact because a lifted function keeps all guest state in the context and guest memory, and every C
    local lives inside one instruction's block (emit_function in third_party/a64-kit/lift/a64_lift.py): nothing is
    live across a top-level label. items_of() and _local_gotos() leave a function whole when its text doesn't show
    that: a top-level declaration (A64_LOCAL_REGS), a label inside a block, a goto to a label outside its own statement,
    another shape of return or tail call, a literal.
  • build: nx2/build.py. split_giants_flags() adds -DLONGSHOT_SPLIT_GIANTS=<KB>_<hash of split_giants.py> to the
    lifted chunks' flags (not the hot pieces: their profile was counted on whole functions), so the object keys follow
    the switch and the splitter. compile_one() then compiles a split copy beside the object (<obj>.split.c, removed
    after; split_giants.compile_copy()). The lifted files, the lift and its key, the game's patches and every tool that
    reads the lifted C are unchanged. mem_need() counts the pieces as functions, so the memory gate sees a split chunk's
    real need.
  • tests: tests/test_split_giants.py (new). It needs Clang (RUN_ALL_ARGS = ["--cc", "clang"]: run_all.py skips it
    without clang and fails it with --strict; by hand, a missing compiler or one that isn't Clang fails it), as the build
    compiles lifted code with clang on every OS. Text checks, then three kinds of function compiled whole and split into one
    program and run from the same 300 random states, compared: random functions in the lifter's shapes written by the test;
    functions emitted by the real a64_lift.Lifter.emit_function (with lift_fixes and poll installed) over a made-up
    image: an entry with code below it, loops, B.cond, TBZ, CBNZ, BL, BLR, a tail call into another entry, and a setjmp
    owner's call that sometimes longjmps back (host/hos_setjmp.c's assembly setjmp/longjmp), plus that function emitted with
    A64_LOCAL_REGS / A64_LOCAL_FLAGS, which must be left whole; and build.py's compile_one with the split flag.
  • build: on by default at 8 KB (split_giants_flags()): unset or any number turns it on, 0 off.
  • docs: docs/development.md, the switch in the build switches table.

Flags and settings

Flag What it does Default now Recommendation Helps on Doesn't help on Untested on
LONGSHOT_SPLIT_GIANTS=<KB> Compiles each lifted function of 256 KB of C or more as pieces of about <KB> KB cut at its labels; 0: as before on, at 8 Keep it on: -21% of the first build's compile on this PC, the same game; per call 2-3% faster on 320 recorded states of two giants, but an uncapped scene where split giants are hot was not measured (see Testing); 0 is the way back Every build that compiles the lifted chunks: Ryzen 5 5600, 16 GB, Windows 11: build.py's compile 1,347 -> 1,057 s Builds that reuse the lifted objects (a kept translation, an update that changes no lifted chunk) Linux (gcc or clang), a PC with more cores or memory

Measurements

main: cf605c85 (2026-11-14)
this PR: bold/split @ 012c615b
Rebased since onto main 0caf0618 (now 9f91cf6c), with the switch made on by default at 8: what the PR column measured with LONGSHOT_SPLIT_GIANTS=8 is now the default build. The numbers still hold: #357 and #358, merged since, change the lift and the button prompts, not build.py's compile.
Hardware: Ryzen 5 5600 (6 cores, 12 threads), 16 GB, RX 6700 XT, Crucial P3 NVMe (QLC), Windows 11 Home 26200
Scene: no game run: nx2/build.py --standalone --jobs 4 of one lift of the game's code (nightly-34's exefs, lifted by
main 514591a3's nx2/lift.py; 75 lifted chunks, 1,381 MB of C), from an empty object cache each time (a first build's
compile), the launcher's toolchain (llvm-mingw, clang 23), RX_RELEASE=1 A64_HOT_CHUNK=1, no hot list.
Method: whole builds one at a time on an otherwise quiet PC, interleaved: this PR with the switch unset, PR, PR, main,
PR, main. (The first, LONGSHOT_SPLIT_GIANTS unset, was off at the time; it is main's build.) Wall time of build.py; compiler-seconds summed from build.py's @@file <name> <seconds> lines; free RAM and
the compilers' summed working sets sampled every second. Medians.
How to reproduce: in a work folder with a lift (OOT_WORK), delete <work>/objcache and <work>/sa/build, then
python -I -B nx2/build.py --standalone --jobs 4, with and without LONGSHOT_SPLIT_GIANTS=8.

main @ cf605c85 this PR @ 012c615b, LONGSHOT_SPLIT_GIANTS=8
build.py wall time, s (runs) 1,347 (1,339, 1,355) 1,057 (1,057, 1,046, 1,070)
compiler-seconds, all files 5,322 (5,305, 5,339) 4,153 (4,153, 4,112, 4,184)
compiler-seconds, lifted chunks 4,594 3,436
the 15 chunks of giant functions (code_023-025) 1,388 518
37 other chunks with at least 1 MB of C in 256 KB+ functions 2,113 1,854
the other 23 lifted chunks 1,093 1,064
the runtime and renderer (not lifted) 727 715
the compilers' peak working set together, MB 5,005, 5,254 4,840, 4,836, 4,843
least free RAM during the build, MB 3,359, 3,381 3,734, 3,597, 2,837
links: longshot.exe, longshot_vk.dll yes, 440 MB + 12 MB yes, 443 MB + 12 MB

The switch unset gives main's build: the build log's object keys: line is the same (flags 4e8397c788, lifted code 410549c0cf) and split_giants_flags() adds nothing. That build ran once, first, at 1,634 s, and is left out of the
medians. What says it was the PC and not the build, from its own log against main's two runs: 74 of the 75 lifted chunks
were slower (median x1.26, range 1.00-1.40), and so were all 12 files of the runtime and renderer that take over 5 s
(median x1.03); the extra time follows each file's compile time (r = 0.79), not its size (r = -0.19), so it was not a
per-file cost such as a first-read scan; and it came and went over the build (by quarter of the lifted compiles x1.34,
1.33, 1.02, 1.21). Free RAM was the same as in main's runs (median 5,227 MB against 5,167 and 5,125), and no other
heavy job ran (the PC's shared job lock). There is no per-process CPU record of the time, so what else used the CPU is
not known.

Memory, by what was measured: across the whole build (4 compilers) the compilers' summed peak working set fell
5,129.5 -> 4,840 MB (medians, -5.6%), and the least free RAM did not change in a way one can tell from 2-3 runs. The giant
chunks compiled one at a time: 2.62x faster in all (1,462 -> 559 s), the largest peak 2,263 -> 586 MB (25.9%). A single
2-6 MB function's own peak falls to about a fifth (1,220 -> 247 MB, 2,263 -> 428 MB); that holds for single giants, not
for whole chunks or the build.

Single compiles, quiet, one compiler (CPU s of clang, peak working set), the same lift:

chunk main @ cf605c85 this PR @ 012c615b, =8
the 15 chunks of giant functions, summed 1,462 s, largest peak 2,263 MB 559 s, largest peak 586 MB
code_025_1 (one 6.2 MB function) 155.6 s, 2,263 MB 48.7 s, 428 MB
code_023_5 (one 2.0 MB function) 85.9 s, 1,220 MB 23.2 s, 247 MB

Piece size (three 2 MB functions at 4, 8 and 32 KB): all within 15% of each other, 8 KB best or tied. A split giant's
.text grows 0.5-0.6%.

main @ cf605c85 (2nd run):  compiled in 1332s, 0 failed
                            linked ...\sa\build\longshot.exe: 440 MB (2s)
this PR, =8 (2nd run):      compiled in 1039s, 0 failed
                            linked ...\sa\build\longshot.exe: 443 MB (2s)

Testing

Tested:

  • python -I -B tests/run_all.py --jobs 4, Windows 11: main @ 0caf0618 485 passed, 6 failed, 70 skipped; this PR @
    9f91cf6c 486 passed (test_split_giants added), the same 6 failed, 70 skipped. The 6 fail on main on this PC too:
    test_module_ids, test_pad_layout, test_oot_state_tap, test_portable_builder_home, test_portable_builder_startup,
    test_tested_configurations.
  • tests/test_split_giants.py with Clang 23 (llvm-mingw), each function run whole and split from 300 random states,
    comparing the context, guest memory, page marks, the calls out (each with a hash of the context), the setjmp resumes
    and the interrupt polls: 4 written lifted-shaped functions (20-47 pieces), holding #38's fast-float region shape;
    3 functions emitted by the real emit_function (13, 23 and 29 pieces; 233, 361 and 351 of their states resumed
    through setjmp/longjmp); and a 406 KB chunk through build.py's compile_one with the split flag (its object holds
    the pieces, the copy is removed): 0 differences in all 2,400 states. A64_LOCAL_REGS / A64_LOCAL_FLAGS functions are
    left whole. Through run_all.py: skipped without clang, failed with --strict; by hand, --cc naming a missing
    compiler or one that isn't Clang fails. #38's real fast-float emitter is not on main, so its regions are covered
    by the written shape only.
  • Every lifted function of 64 KB or more in a lift of the game (2,561): 2,511 pass the splitter's checks, the other 50
    have no label to cut at; none is refused for a safety reason.
  • Real game states: nightly-34 relinked with a recording build of two of its giants (f_81b8c000, #14 in the sampled hot
    list, and f_81c1a240): 160 calls of each recorded in play (the context, and every guest page as the call first
    touched it), then the lifted function and the split one run from each state: 320 states, 0 differences in the
    context, the memory, the page marks, the calls out and the polls. Timed on those states (12 alternating calls each, 60 states per function, memory put back before each call): split /
    whole, sum of mean times 0.979 (f_81b8c000) and 0.976 (f_81c1a240), of best times 0.975 and 0.969; a call takes
    ~2.5 us. Each crossing between pieces adds a call, a return and a dispatch, and can cost reloads; on these states
    that was less than what the smaller functions gained.
  • In the game, the builds above: main's exe and this PR's (=8, all 292 functions of 256 KB or more split), each in a
    test folder of nightly-34's game files, a scripted 300 s headless route (title, file select, the first area,
    walking, menus), alternated twice: all 4 runs went the full 300 s into play (up to ~1,800 draws a frame), no crash.
    Frame rates sat at the 60 fps cap: no speed claim from them.
  • Earlier, nightly-34 relinked with its two giant chunks split, against the same build unsplit, 3 runs each with 2 ms
    all-thread samples: the same frame rates within noise, and the giants in no sample on either side. One split run
    stopped at 43 s inside the development sampler's own stack walk (HOS_SAMPLE_ALL, #383), at the title,
    before the route reaches the split functions.

Not tested: a game workload that spends real time in split functions, uncapped: the route's 2 ms samples never
landed in the two recorded giants, and its frame rate sat at the cap, so the in-game cost of the crossings is known only
from the replay timing above (two functions, recorded states); a scene where f_81b8c000 (#14 in the old sampled hot
list) is hot was not found. A build with a hot list (code_hot.c, -O2 + PGO, left unsplit on purpose; a function hot
in another scene but not on the list is split). Real states for the other 290 split functions (the same mechanism and
text checks, no recording). Linux builds (clang there too; the pieces use the GNU case a ... b range and musttail,
which the lifted code already uses). PR #38's real emitter together with this (not on main).

Needs other testers:

  • Linux (AppImage build): a first build from this PR against one with LONGSHOT_SPLIT_GIANTS=0, expecting a shorter compile
    and the game unchanged (T-Q2FR)

Screenshots

Nothing on screen.

Risk and rollback

A lifted function compiled in pieces that behaved differently would show as a wrong game state; the checks above found
none, and any function whose text doesn't show the conditions stays whole. LONGSHOT_SPLIT_GIANTS=0 builds as before.
On by default, the first build after this lands compiles every lifted chunk again (their flags change), as a
translator change does; a player whose lifted C was let go gets the "does not fit" path and one re-lift.

Reviewer notes

  • Read items_of(), _local_gotos() and split_function() in nx2/split_giants.py first: the refusals are the safety.
  • #38 (fast float regions) writes a region as one statement with its own label ({ if (A64_FF_ON(c)) { ... goto L_ff_<pc>; ... } <exact copy> L_ff_<pc>:; }); _local_gotos() keeps such a statement whole in one piece, so the two
    combine at the source level (the test holds that shape, written by hand: #38's emitter is not on main).
  • #31 (4 MB chunks) cuts between functions, this cuts inside them: they compose at the source level, but their
    speed-ups are not necessarily additive.
  • #30's compile database would record the .split.c copy's path for a chunk with a giant.
  • The copy costs ~0.3 s of Python per chunk (22 s for a lift, before the memory gate) and ~1.4 GB of temporary writes
    over a build.

Issues

See #362 (the build-speed findings) and #383 (the sampler crash seen while measuring)

Checklist

For everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description).
  • Rebased on current main.
  • python -I -B tests/run_all.py run and passing (any failure that also fails on main is named above).
  • Tested in the game, or what wasn't is named under Testing.
  • Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests).
  • Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported.
  • No game files, dumps, build output or generated files.
  • third_party/ untouched (a new folder with a PROVENANCE.md instead).
  • The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled.

If applicable

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • Flags noted, and the switch reference updated (docs/development.md, the README).
  • Docs updated.
  • A check under tests/ that reads changed source updated in this PR, not weakened.
## Why A player's first build spends most of its time compiling the lifted game code, and a few hundred huge lifted functions (up to 6 MB of C each) cost far more than their size: clang's work on one function grows faster than linearly. Compiling each of them as small pieces cuts the first build's compile by a fifth on a 6-core, 16 GB PC (22.4 -> 17.6 minutes), with the same game. ## What changed - build: `nx2/split_giants.py` (new). For each lifted function of 256 KB of C or more, `split_text()` cuts the body at its top-level labels into pieces of about 8 KB (`plan()` keeps short loops inside one piece). Each piece is a `static __attribute__((noinline)) uintptr_t s_<addr>_<n>(Ctx *c, uintptr_t k)` that starts at the label numbered `k` and returns the next label's number, 0 for a guest return, or a tail-call number. `f_<addr>` keeps its name and ABI and becomes the dispatch loop; guest tail calls stay `TAILCALL` (musttail) from it, so the guest call depth never changes. This is exact because a lifted function keeps all guest state in the context and guest memory, and every C local lives inside one instruction's block (`emit_function` in `third_party/a64-kit/lift/a64_lift.py`): nothing is live across a top-level label. `items_of()` and `_local_gotos()` leave a function whole when its text doesn't show that: a top-level declaration (`A64_LOCAL_REGS`), a label inside a block, a goto to a label outside its own statement, another shape of return or tail call, a literal. - build: `nx2/build.py`. `split_giants_flags()` adds `-DLONGSHOT_SPLIT_GIANTS=<KB>_<hash of split_giants.py>` to the lifted chunks' flags (not the hot pieces: their profile was counted on whole functions), so the object keys follow the switch and the splitter. `compile_one()` then compiles a split copy beside the object (`<obj>.split.c`, removed after; `split_giants.compile_copy()`). The lifted files, the lift and its key, the game's patches and every tool that reads the lifted C are unchanged. `mem_need()` counts the pieces as functions, so the memory gate sees a split chunk's real need. - tests: `tests/test_split_giants.py` (new). It needs Clang (`RUN_ALL_ARGS = ["--cc", "clang"]`: run_all.py skips it without clang and fails it with `--strict`; by hand, a missing compiler or one that isn't Clang fails it), as the build compiles lifted code with clang on every OS. Text checks, then three kinds of function compiled whole and split into one program and run from the same 300 random states, compared: random functions in the lifter's shapes written by the test; functions emitted by the real `a64_lift.Lifter.emit_function` (with `lift_fixes` and `poll` installed) over a made-up image: an entry with code below it, loops, B.cond, TBZ, CBNZ, BL, BLR, a tail call into another entry, and a setjmp owner's call that sometimes longjmps back (host/hos_setjmp.c's assembly setjmp/longjmp), plus that function emitted with `A64_LOCAL_REGS` / `A64_LOCAL_FLAGS`, which must be left whole; and `build.py`'s `compile_one` with the split flag. - build: on by default at 8 KB (`split_giants_flags()`): unset or any number turns it on, `0` off. - docs: `docs/development.md`, the switch in the build switches table. ## Flags and settings | Flag | What it does | Default now | Recommendation | Helps on | Doesn't help on | Untested on | |---|---|---|---|---|---|---| | `LONGSHOT_SPLIT_GIANTS=<KB>` | Compiles each lifted function of 256 KB of C or more as pieces of about `<KB>` KB cut at its labels; `0`: as before | **on, at 8** | Keep it on: -21% of the first build's compile on this PC, the same game; per call 2-3% faster on 320 recorded states of two giants, but an uncapped scene where split giants are hot was not measured (see Testing); `0` is the way back | Every build that compiles the lifted chunks: Ryzen 5 5600, 16 GB, Windows 11: build.py's compile 1,347 -> 1,057 s | Builds that reuse the lifted objects (a kept translation, an update that changes no lifted chunk) | Linux (gcc or clang), a PC with more cores or memory | ## Measurements main: cf605c85 (2026-11-14) this PR: bold/split @ 012c615b Rebased since onto main 0caf0618 (now 9f91cf6c), with the switch made on by default at 8: what the PR column measured with `LONGSHOT_SPLIT_GIANTS=8` is now the default build. The numbers still hold: #357 and #358, merged since, change the lift and the button prompts, not `build.py`'s compile. Hardware: Ryzen 5 5600 (6 cores, 12 threads), 16 GB, RX 6700 XT, Crucial P3 NVMe (QLC), Windows 11 Home 26200 Scene: no game run: `nx2/build.py --standalone --jobs 4` of one lift of the game's code (nightly-34's exefs, lifted by main 514591a3's `nx2/lift.py`; 75 lifted chunks, 1,381 MB of C), from an empty object cache each time (a first build's compile), the launcher's toolchain (llvm-mingw, clang 23), `RX_RELEASE=1 A64_HOT_CHUNK=1`, no hot list. Method: whole builds one at a time on an otherwise quiet PC, interleaved: this PR with the switch unset, PR, PR, main, PR, main. (The first, `LONGSHOT_SPLIT_GIANTS` unset, was off at the time; it is main's build.) Wall time of build.py; compiler-seconds summed from build.py's `@@file <name> <seconds>` lines; free RAM and the compilers' summed working sets sampled every second. Medians. How to reproduce: in a work folder with a lift (`OOT_WORK`), delete `<work>/objcache` and `<work>/sa/build`, then `python -I -B nx2/build.py --standalone --jobs 4`, with and without `LONGSHOT_SPLIT_GIANTS=8`. | | main @ cf605c85 | this PR @ 012c615b, `LONGSHOT_SPLIT_GIANTS=8` | |---|---|---| | build.py wall time, s (runs) | **1,347** (1,339, 1,355) | **1,057** (1,057, 1,046, 1,070) | | compiler-seconds, all files | 5,322 (5,305, 5,339) | 4,153 (4,153, 4,112, 4,184) | | compiler-seconds, lifted chunks | 4,594 | 3,436 | | the 15 chunks of giant functions (code_023-025) | 1,388 | 518 | | 37 other chunks with at least 1 MB of C in 256 KB+ functions | 2,113 | 1,854 | | the other 23 lifted chunks | 1,093 | 1,064 | | the runtime and renderer (not lifted) | 727 | 715 | | the compilers' peak working set together, MB | 5,005, 5,254 | 4,840, 4,836, 4,843 | | least free RAM during the build, MB | 3,359, 3,381 | 3,734, 3,597, 2,837 | | links: `longshot.exe`, `longshot_vk.dll` | yes, 440 MB + 12 MB | yes, 443 MB + 12 MB | The switch unset gives main's build: the build log's `object keys:` line is the same (`flags 4e8397c788`, `lifted code 410549c0cf`) and `split_giants_flags()` adds nothing. That build ran once, first, at 1,634 s, and is left out of the medians. What says it was the PC and not the build, from its own log against main's two runs: 74 of the 75 lifted chunks were slower (median x1.26, range 1.00-1.40), and so were all 12 files of the runtime and renderer that take over 5 s (median x1.03); the extra time follows each file's compile time (r = 0.79), not its size (r = -0.19), so it was not a per-file cost such as a first-read scan; and it came and went over the build (by quarter of the lifted compiles x1.34, 1.33, 1.02, 1.21). Free RAM was the same as in main's runs (median 5,227 MB against 5,167 and 5,125), and no other heavy job ran (the PC's shared job lock). There is no per-process CPU record of the time, so what else used the CPU is not known. Memory, by what was measured: across the whole build (4 compilers) the compilers' summed peak working set fell 5,129.5 -> 4,840 MB (medians, -5.6%), and the least free RAM did not change in a way one can tell from 2-3 runs. The giant chunks compiled one at a time: 2.62x faster in all (1,462 -> 559 s), the largest peak 2,263 -> 586 MB (25.9%). A single 2-6 MB function's own peak falls to about a fifth (1,220 -> 247 MB, 2,263 -> 428 MB); that holds for single giants, not for whole chunks or the build. Single compiles, quiet, one compiler (CPU s of clang, peak working set), the same lift: | chunk | main @ cf605c85 | this PR @ 012c615b, `=8` | |---|---|---| | the 15 chunks of giant functions, summed | 1,462 s, largest peak 2,263 MB | 559 s, largest peak 586 MB | | code_025_1 (one 6.2 MB function) | 155.6 s, 2,263 MB | 48.7 s, 428 MB | | code_023_5 (one 2.0 MB function) | 85.9 s, 1,220 MB | 23.2 s, 247 MB | Piece size (three 2 MB functions at 4, 8 and 32 KB): all within 15% of each other, 8 KB best or tied. A split giant's `.text` grows 0.5-0.6%. ``` main @ cf605c85 (2nd run): compiled in 1332s, 0 failed linked ...\sa\build\longshot.exe: 440 MB (2s) this PR, =8 (2nd run): compiled in 1039s, 0 failed linked ...\sa\build\longshot.exe: 443 MB (2s) ``` ## Testing **Tested:** - `python -I -B tests/run_all.py --jobs 4`, Windows 11: main @ 0caf0618 485 passed, 6 failed, 70 skipped; this PR @ 9f91cf6c 486 passed (`test_split_giants` added), the same 6 failed, 70 skipped. The 6 fail on main on this PC too: test_module_ids, test_pad_layout, test_oot_state_tap, test_portable_builder_home, test_portable_builder_startup, test_tested_configurations. - `tests/test_split_giants.py` with Clang 23 (llvm-mingw), each function run whole and split from 300 random states, comparing the context, guest memory, page marks, the calls out (each with a hash of the context), the setjmp resumes and the interrupt polls: 4 written lifted-shaped functions (20-47 pieces), holding #38's fast-float region shape; 3 functions emitted by the real `emit_function` (13, 23 and 29 pieces; 233, 361 and 351 of their states resumed through setjmp/longjmp); and a 406 KB chunk through `build.py`'s `compile_one` with the split flag (its object holds the pieces, the copy is removed): 0 differences in all 2,400 states. `A64_LOCAL_REGS` / `A64_LOCAL_FLAGS` functions are left whole. Through run_all.py: skipped without clang, failed with `--strict`; by hand, `--cc` naming a missing compiler or one that isn't Clang fails. #38's real fast-float emitter is not on main, so its regions are covered by the written shape only. - Every lifted function of 64 KB or more in a lift of the game (2,561): 2,511 pass the splitter's checks, the other 50 have no label to cut at; none is refused for a safety reason. - Real game states: nightly-34 relinked with a recording build of two of its giants (f_81b8c000, #14 in the sampled hot list, and f_81c1a240): 160 calls of each recorded in play (the context, and every guest page as the call first touched it), then the lifted function and the split one run from each state: 320 states, 0 differences in the context, the memory, the page marks, the calls out and the polls. Timed on those states (12 alternating calls each, 60 states per function, memory put back before each call): split / whole, sum of mean times 0.979 (f_81b8c000) and 0.976 (f_81c1a240), of best times 0.975 and 0.969; a call takes ~2.5 us. Each crossing between pieces adds a call, a return and a dispatch, and can cost reloads; on these states that was less than what the smaller functions gained. - In the game, the builds above: main's exe and this PR's (`=8`, all 292 functions of 256 KB or more split), each in a test folder of nightly-34's game files, a scripted 300 s headless route (title, file select, the first area, walking, menus), alternated twice: all 4 runs went the full 300 s into play (up to ~1,800 draws a frame), no crash. Frame rates sat at the 60 fps cap: no speed claim from them. - Earlier, nightly-34 relinked with its two giant chunks split, against the same build unsplit, 3 runs each with 2 ms all-thread samples: the same frame rates within noise, and the giants in no sample on either side. One split run stopped at 43 s inside the development sampler's own stack walk (HOS_SAMPLE_ALL, #383), at the title, before the route reaches the split functions. **Not tested:** a game workload that spends real time in split functions, uncapped: the route's 2 ms samples never landed in the two recorded giants, and its frame rate sat at the cap, so the in-game cost of the crossings is known only from the replay timing above (two functions, recorded states); a scene where f_81b8c000 (#14 in the old sampled hot list) is hot was not found. A build with a hot list (code_hot.c, -O2 + PGO, left unsplit on purpose; a function hot in another scene but not on the list is split). Real states for the other 290 split functions (the same mechanism and text checks, no recording). Linux builds (clang there too; the pieces use the GNU `case a ... b` range and `musttail`, which the lifted code already uses). PR #38's real emitter together with this (not on main). **Needs other testers:** - Linux (AppImage build): a first build from this PR against one with `LONGSHOT_SPLIT_GIANTS=0`, expecting a shorter compile and the game unchanged (T-Q2FR) ## Screenshots Nothing on screen. ## Risk and rollback A lifted function compiled in pieces that behaved differently would show as a wrong game state; the checks above found none, and any function whose text doesn't show the conditions stays whole. `LONGSHOT_SPLIT_GIANTS=0` builds as before. On by default, the first build after this lands compiles every lifted chunk again (their flags change), as a translator change does; a player whose lifted C was let go gets the "does not fit" path and one re-lift. ## Reviewer notes - Read `items_of()`, `_local_gotos()` and `split_function()` in `nx2/split_giants.py` first: the refusals are the safety. - #38 (fast float regions) writes a region as one statement with its own label (`{ if (A64_FF_ON(c)) { ... goto L_ff_<pc>; ... } <exact copy> L_ff_<pc>:; }`); `_local_gotos()` keeps such a statement whole in one piece, so the two combine at the source level (the test holds that shape, written by hand: #38's emitter is not on main). - #31 (4 MB chunks) cuts between functions, this cuts inside them: they compose at the source level, but their speed-ups are not necessarily additive. - #30's compile database would record the `.split.c` copy's path for a chunk with a giant. - The copy costs ~0.3 s of Python per chunk (22 s for a lift, before the memory gate) and ~1.4 GB of temporary writes over a build. ## Issues See #362 (the build-speed findings) and #383 (the sampler crash seen while measuring) ## Checklist ### For everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`). - [x] Rebased on current `main`. - [x] `python -I -B tests/run_all.py` run and passing (any failure that also fails on `main` is named above). - [x] Tested in the game, or what wasn't is named under Testing. - [x] Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). - [x] Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. - [x] No game files, dumps, build output or generated files. - [x] `third_party/` untouched (a new folder with a `PROVENANCE.md` instead). - [x] The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled. ### If applicable - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [x] Flags noted, and the switch reference updated (docs/development.md, the README). - [x] Docs updated. - [x] A check under `tests/` that reads changed source updated in this PR, not weakened.
nx2/split_giants.py cuts each lifted function of 256 KB of C or more at its
top-level labels into static piece functions and a dispatch loop under the
function's own name. All guest state lives in the context and guest memory,
and every C local lives inside one instruction's block, so nothing is live
across a label. Gotos into another piece return the label's id (their poll
kept); guest returns and tail calls return ids the dispatcher turns into
return and TAILCALL. Functions whose text does not show this stay whole.

build.py compiles a copy beside the object when the chunk's flags carry the
switch (off by default here; LONGSHOT_SPLIT_GIANTS=<piece KB>), so the lifted
files, the patches and every tool that reads them are unchanged. mem_need
counts the pieces as functions.

tests/test_split_giants.py: text checks, and random lifted-shaped functions
compiled whole and split, run on 300 states each: context, memory, page marks,
calls out and polls equal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Function heads are found in one regex pass and only functions past the
size asked are parsed; a line with no comment or literal has its braces
counted by a regex. The same output on all 75 chunks of a lift of
nightly-34 (compared with the line-by-line version); 40 -> 22 s of Python
for a whole lift.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A goto to a label that is not one of the function's top-level labels is
allowed when the label is defined inside the same statement, which stays
whole in one piece. That is the shape of PR #38's fast float regions
({ if (...) { ... goto L_ff_<pc>; ... } <exact copy> L_ff_<pc>:; }), so
functions with them can still be split; any other goto refuses.
The test's random functions now hold such regions; a goto to another
statement's inner label is checked to refuse. Same output on all 75
chunks of a lift of nightly-34.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
perf(build): LONGSHOT_SPLIT_GIANTS on by default at 8 KB
Some checks failed
Website / Website on Cloudflare (pull_request_target) Has been skipped
PR description / Description follows the template (pull_request_target) Successful in 3s
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been cancelled
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been cancelled
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
Third-party parts / The same as upstream (pull_request) Has been cancelled
ab831f7673
A whole first build's lifted compile, build.py --standalone --jobs 4 from an empty object cache on a Ryzen 5 5600 with
16 GB under Windows, main and this alternated: 1,347 s (1,339 / 1,355) against 1,057 s (1,057 / 1,046 / 1,070), 5,322
against 4,153 compiler-seconds; all link, and the game ran its 300 s route on both. LONGSHOT_SPLIT_GIANTS=0 turns it off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
madebyjordan changed title from WIP: perf(build): compile giant lifted functions in pieces cut at their labels, on by default (LONGSHOT_SPLIT_GIANTS) to perf(build): compile giant lifted functions in pieces cut at their labels, on by default (LONGSHOT_SPLIT_GIANTS) 2026-11-14 07:26:19 +00:00
mooki requested changes 2026-11-14 07:52:28 +00:00
Dismissed
mooki left a comment

Read head ab831f76 against main 0caf06184: the complete four-file diff, description, empty comment and review lists, main's emitter and chunk writer, runtime dispatch and setjmp paths, and the related changes in #31 and #38; ran the new test with Clang 23.1.3 and GCC 16.2.1, plus nine existing compiler, memory-gate and cache checks. Clang reports 0 differences in all four sets of 300 states; GCC fails all four compiles on LLVM-only flags; the nine existing checks pass. No game, full build or timing run was made here.

What's good

  • The compile cost is real in main. a64_lift.py:1029-1067 can split only where no branch crosses, and nx2/chunks.py:394-424 cuts between functions, not inside a giant loop or switch. The new transform addresses that remaining case without changing the saved lift, its patches or its address table.
  • The piece size and splitter's hash reach the actual object key. Hot files retain their existing flags and are deliberately left whole. The temporary source reaches both the memory estimator and the compiler; the resulting object still occupies the original job's place in the link.
  • I found no guest-state mismatch or missing dispatch case in the supported emitted shapes. This is not a claim that the recorded states prove every function correct.

Must change

  1. Make the fixture's compiler selection match its compiler flags. tests/test_split_giants.py:233-235 unconditionally passes -mllvm -rotation-max-header-size=0, while find_cc, lines 200-212, accepts CC=gcc, --cc gcc, and a GCC-only installation. Reproduced with python3 -I -B tests/test_split_giants.py --cc gcc: all four compiles report unrecognized command-line option for both options, then split_giants: FAILED (4). No whole-versus-split comparison runs on that path. Either support the selected compiler throughout the fixture, or require Clang explicitly with the existing RUN_ALL_ARGS convention and remove the GCC fallback. That declaration also lets a strict suite detect a missing compiler instead of accepting the current success after all compiled comparisons were skipped. This is a test defect, not evidence that the split game misbehaves.

What crosses a cut

nx2/split_giants.py:175-239 passes the same context pointer to every piece, without copying it. Integer registers including SP and LR, vector registers, NZCV, FPCR/FPSR, exclusive-monitor state, thread pointers, PC, halt and poll state remain in that context. Guest memory remains shared. The normal emitter's C temporaries stay inside an instruction's balanced block, including instructions emitted over several lines. A64_LOCAL_REGS and A64_LOCAL_FLAGS instead create function-scope declarations, which items_of refuses. The dead-flag analysis has already run on the original guest control flow, so a new piece call does not introduce guest ABI flag clobbering.

The control paths are accounted for:

  • Entry calls piece 0 with token 0. Its original prefix still runs, including the entry goto added by nx2/lift_fixes.py when an entry follows lower-address code.
  • A branch within a piece stays a goto. A branch across pieces returns the destination label's token. The outer switch routes that token to its owning piece, whose switch enters the exact label. Sequential fallthrough returns the next piece's first label token. Every generated label token has a receiving case.
  • Return token 0 returns from the original wrapper. Tail-call tokens lie after all label tokens and become TAILCALL in that wrapper, preserving the original signature and musttail behavior. Pieces do not change guest LR or guest SP just to cross a cut.
  • Jump-table cases use the same transfers. Their default still executes A64_IJUMP before returning, including the existing longjmp hook. Ordinary calls resume at their original following statement.
  • The emitter's setjmp push, host setjmp, original call or resume, and pop form one statement and stay in one piece. That piece remains active while the protected call runs; the slot is popped before the piece returns. The separately generated exception-resume entry functions keep their original names and table entries. This path was traced, not executed here.
  • Back-edge polls remain before their transfers. Page-mark grouping already ends at labels, so a cut does not separate a store group from its mark.

Functions remain whole when too small or without a usable cut, or when the parser sees recognized top-level declarations, nested guest labels, missing targets, nonlocal private labels, computed gotos, unsupported setjmp/return/tail-call forms, literals, multiline comments or unbalanced braces. These checks describe this emitter's text, not a general C parser.

What the equivalence evidence establishes

No specific guest input that changes the result was established in this review. Ordinary floating-point edge values are not newly approximated by this splitter: it retains the same arithmetic statements and helpers. The reported 320 states are 160 calls each of two functions, not coverage of the other 290 split functions or every edge through those two. An unvisited switch destination, exception path, or different emission mode could escape that sample. The replay method and results are reported, but its comparison code and coverage record are not included, so I could not inspect how external calls, concurrent memory changes or timing-dependent reads were handled.

The committed fixture provides useful comparisons of the whole context, memory, marks, call events and poll counts, but generates its own C shapes rather than using emit_function. Its fast-float example is integer arithmetic with a private label; the real floating-point headers are empty stand-ins, and there is no setjmp case. Add made-up functions emitted through the actual emitter for non-first entry, setjmp resume, local-register/flag refusal, and the combined float-region path. Also exercise compile_one with the split flag: the current check calls compile_copy and the memory estimator directly, not that integration. These are coverage requests, not additional observed runtime defects.

Measurements and runtime cost

The reported compile comparison has named commits, one PC, the same lift and settings, and at least two interleaved runs of each in its stated order. The medians are correct: 1,347 s to 1,057 s is 21.53% less wall time. The retained ranges are 1,339-1,355 s and 1,046-1,070 s, with spreads of 1.19% and 2.27% of their medians. Those numbers support a compile-stage saving for this Windows, four-worker, no-hot-list configuration, not a measured reduction in the complete first build on every PC. Keep the excluded 1,634 s baseline visible; the assertion that the PC caused it needs the retained load record. The measured source 012c615b is not available from the fetched refs or its public commit endpoint, so the current-head carry cannot be independently checked from that source.

"A fifth of the memory" applies to the two individual examples, not the whole build. Their peaks are 2,263 to 428 MB and 1,220 to 247 MB. Across the build, the median summed compiler peak is 5,129.5 to 4,840 MB, about 5.64% less. The giant cluster's single-compiler totals give 2.62 times faster and its largest peak falls to 25.9%, so docs/development.md:326 should not generalize the best individual result to the entire cluster. Also reconcile the 75 chunks in the method with the table's 15 + 37 + 24 = 76.

There is runtime evidence, but not an uncapped game-speed measurement that establishes no cost. The two-function replays report ratios of 0.979 and 0.976; the four game runs stay at the 60 fps cap; the earlier sampling runs never sample the two changed giants. Each crossing adds a noinline call and return plus dispatch, and can force register reloads or prevent optimization across the cut. The short-loop rule minimizes crossings; it does not guarantee none. Eight KB of source does not mean eight KB of work executes on a branch-heavy path. With a hot list, the extracted hot files stay unsplit, but a function outside that list may be hot in another scene. Before recommending "no runtime cost found", measure a workload that actually spends time in the split functions, with the same hot-list/profile settings, named builds, two interleaved runs each, frame-time medians and spreads. Keep the reported sampler failure visible too.

  • #362 describes several separate experiments. This PR implements its giant-function splitting idea, not the clang-free cold tier, the proposed memory formula or the lighter compiler passes. Their gains cannot be attributed to this patch.
  • #31 changes file boundaries between functions. This change runs later, inside selected functions, on a temporary compile copy. They compose at the source level; their performance gains are not necessarily additive.
  • #38 emits the complete fast path, MXCSR save/restore, exact fallback and L_ff_<pc> label inside one outer block. _local_gotos recognizes its private jump and keeps the block whole. No float-region local or MXCSR window crosses a cut. That source-level compatibility does not settle the separate arithmetic and integration findings already posted on that PR.

This head merges cleanly with main and with each of those two PRs separately. No full suite, Windows execution, Linux game build, actual combined build with either PR, replay of the recorded states, or runtime speed measurement was made here. The current source-check jobs are blocked, not passed: https://git.magicbean.dev/MagicBeanDev/Longshot/actions/runs/4496.

Read head `ab831f76` against main `0caf06184`: the complete four-file diff, description, empty comment and review lists, main's emitter and chunk writer, runtime dispatch and setjmp paths, and the related changes in https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/31 and https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/38; ran the new test with Clang 23.1.3 and GCC 16.2.1, plus nine existing compiler, memory-gate and cache checks. Clang reports 0 differences in all four sets of 300 states; GCC fails all four compiles on LLVM-only flags; the nine existing checks pass. No game, full build or timing run was made here. ### What's good - The compile cost is real in main. `a64_lift.py:1029-1067` can split only where no branch crosses, and `nx2/chunks.py:394-424` cuts between functions, not inside a giant loop or switch. The new transform addresses that remaining case without changing the saved lift, its patches or its address table. - The piece size and splitter's hash reach the actual object key. Hot files retain their existing flags and are deliberately left whole. The temporary source reaches both the memory estimator and the compiler; the resulting object still occupies the original job's place in the link. - I found no guest-state mismatch or missing dispatch case in the supported emitted shapes. This is not a claim that the recorded states prove every function correct. ### Must change 1. **Make the fixture's compiler selection match its compiler flags.** `tests/test_split_giants.py:233-235` unconditionally passes `-mllvm -rotation-max-header-size=0`, while `find_cc`, lines 200-212, accepts `CC=gcc`, `--cc gcc`, and a GCC-only installation. Reproduced with `python3 -I -B tests/test_split_giants.py --cc gcc`: all four compiles report `unrecognized command-line option` for both options, then `split_giants: FAILED (4)`. No whole-versus-split comparison runs on that path. Either support the selected compiler throughout the fixture, or require Clang explicitly with the existing `RUN_ALL_ARGS` convention and remove the GCC fallback. That declaration also lets a strict suite detect a missing compiler instead of accepting the current success after all compiled comparisons were skipped. This is a test defect, not evidence that the split game misbehaves. ### What crosses a cut `nx2/split_giants.py:175-239` passes the same context pointer to every piece, without copying it. Integer registers including SP and LR, vector registers, NZCV, FPCR/FPSR, exclusive-monitor state, thread pointers, PC, halt and poll state remain in that context. Guest memory remains shared. The normal emitter's C temporaries stay inside an instruction's balanced block, including instructions emitted over several lines. `A64_LOCAL_REGS` and `A64_LOCAL_FLAGS` instead create function-scope declarations, which `items_of` refuses. The dead-flag analysis has already run on the original guest control flow, so a new piece call does not introduce guest ABI flag clobbering. The control paths are accounted for: - Entry calls piece 0 with token 0. Its original prefix still runs, including the entry `goto` added by `nx2/lift_fixes.py` when an entry follows lower-address code. - A branch within a piece stays a `goto`. A branch across pieces returns the destination label's token. The outer switch routes that token to its owning piece, whose switch enters the exact label. Sequential fallthrough returns the next piece's first label token. Every generated label token has a receiving case. - Return token 0 returns from the original wrapper. Tail-call tokens lie after all label tokens and become `TAILCALL` in that wrapper, preserving the original signature and musttail behavior. Pieces do not change guest LR or guest SP just to cross a cut. - Jump-table cases use the same transfers. Their default still executes `A64_IJUMP` before returning, including the existing longjmp hook. Ordinary calls resume at their original following statement. - The emitter's setjmp push, host setjmp, original call or resume, and pop form one statement and stay in one piece. That piece remains active while the protected call runs; the slot is popped before the piece returns. The separately generated exception-resume entry functions keep their original names and table entries. This path was traced, not executed here. - Back-edge polls remain before their transfers. Page-mark grouping already ends at labels, so a cut does not separate a store group from its mark. Functions remain whole when too small or without a usable cut, or when the parser sees recognized top-level declarations, nested guest labels, missing targets, nonlocal private labels, computed gotos, unsupported setjmp/return/tail-call forms, literals, multiline comments or unbalanced braces. These checks describe this emitter's text, not a general C parser. ### What the equivalence evidence establishes No specific guest input that changes the result was established in this review. Ordinary floating-point edge values are not newly approximated by this splitter: it retains the same arithmetic statements and helpers. The reported 320 states are 160 calls each of two functions, not coverage of the other 290 split functions or every edge through those two. An unvisited switch destination, exception path, or different emission mode could escape that sample. The replay method and results are reported, but its comparison code and coverage record are not included, so I could not inspect how external calls, concurrent memory changes or timing-dependent reads were handled. The committed fixture provides useful comparisons of the whole context, memory, marks, call events and poll counts, but generates its own C shapes rather than using `emit_function`. Its fast-float example is integer arithmetic with a private label; the real floating-point headers are empty stand-ins, and there is no setjmp case. Add made-up functions emitted through the actual emitter for non-first entry, setjmp resume, local-register/flag refusal, and the combined float-region path. Also exercise `compile_one` with the split flag: the current check calls `compile_copy` and the memory estimator directly, not that integration. These are coverage requests, not additional observed runtime defects. ### Measurements and runtime cost The reported compile comparison has named commits, one PC, the same lift and settings, and at least two interleaved runs of each in its stated order. The medians are correct: 1,347 s to 1,057 s is 21.53% less wall time. The retained ranges are 1,339-1,355 s and 1,046-1,070 s, with spreads of 1.19% and 2.27% of their medians. Those numbers support a compile-stage saving for this Windows, four-worker, no-hot-list configuration, not a measured reduction in the complete first build on every PC. Keep the excluded 1,634 s baseline visible; the assertion that the PC caused it needs the retained load record. The measured source `012c615b` is not available from the fetched refs or its public commit endpoint, so the current-head carry cannot be independently checked from that source. "A fifth of the memory" applies to the two individual examples, not the whole build. Their peaks are 2,263 to 428 MB and 1,220 to 247 MB. Across the build, the median summed compiler peak is 5,129.5 to 4,840 MB, about 5.64% less. The giant cluster's single-compiler totals give 2.62 times faster and its largest peak falls to 25.9%, so `docs/development.md:326` should not generalize the best individual result to the entire cluster. Also reconcile the 75 chunks in the method with the table's 15 + 37 + 24 = 76. There is runtime evidence, but not an uncapped game-speed measurement that establishes no cost. The two-function replays report ratios of 0.979 and 0.976; the four game runs stay at the 60 fps cap; the earlier sampling runs never sample the two changed giants. Each crossing adds a noinline call and return plus dispatch, and can force register reloads or prevent optimization across the cut. The short-loop rule minimizes crossings; it does not guarantee none. Eight KB of source does not mean eight KB of work executes on a branch-heavy path. With a hot list, the extracted hot files stay unsplit, but a function outside that list may be hot in another scene. Before recommending "no runtime cost found", measure a workload that actually spends time in the split functions, with the same hot-list/profile settings, named builds, two interleaved runs each, frame-time medians and spreads. Keep the reported sampler failure visible too. ### Related work and limits - https://git.magicbean.dev/MagicBeanDev/Longshot/issues/362 describes several separate experiments. This PR implements its giant-function splitting idea, not the clang-free cold tier, the proposed memory formula or the lighter compiler passes. Their gains cannot be attributed to this patch. - https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/31 changes file boundaries between functions. This change runs later, inside selected functions, on a temporary compile copy. They compose at the source level; their performance gains are not necessarily additive. - https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/38 emits the complete fast path, MXCSR save/restore, exact fallback and `L_ff_<pc>` label inside one outer block. `_local_gotos` recognizes its private jump and keeps the block whole. No float-region local or MXCSR window crosses a cut. That source-level compatibility does not settle the separate arithmetic and integration findings already posted on that PR. This head merges cleanly with main and with each of those two PRs separately. No full suite, Windows execution, Linux game build, actual combined build with either PR, replay of the recorded states, or runtime speed measurement was made here. The current source-check jobs are blocked, not passed: https://git.magicbean.dev/MagicBeanDev/Longshot/actions/runs/4496.
madebyjordan changed title from perf(build): compile giant lifted functions in pieces cut at their labels, on by default (LONGSHOT_SPLIT_GIANTS) to WIP: perf(build): compile giant lifted functions in pieces cut at their labels, on by default (LONGSHOT_SPLIT_GIANTS) 2026-11-14 07:56:44 +00:00
Author
Contributor

Thanks, mooki. Back to work in progress while I address it:

  • the fixture's compiler selection against its LLVM-only flags (the must-change);
  • functions emitted through the real emit_function (non-first entry, setjmp, A64_LOCAL_REGS/A64_LOCAL_FLAGS refused, a float region), and a check through compile_one with the split flag;
  • the description: memory claims scoped (single giants vs the whole build's -5.6%), 75 vs 76 chunks, the runtime wording to what was measured, and what supports leaving out the 1,634 s run.

The measured commit 012c615b is now on the fork as perf/split-giants-measured (https://git.magicbean.dev/madebyjordan/Longshot/src/branch/perf/split-giants-measured), so the carry to the current head can be checked: the head adds only the rebase onto 0caf0618 and the default.

Thanks, mooki. Back to work in progress while I address it: - the fixture's compiler selection against its LLVM-only flags (the must-change); - functions emitted through the real `emit_function` (non-first entry, setjmp, `A64_LOCAL_REGS`/`A64_LOCAL_FLAGS` refused, a float region), and a check through `compile_one` with the split flag; - the description: memory claims scoped (single giants vs the whole build's -5.6%), 75 vs 76 chunks, the runtime wording to what was measured, and what supports leaving out the 1,634 s run. The measured commit 012c615b is now on the fork as `perf/split-giants-measured` (https://git.magicbean.dev/madebyjordan/Longshot/src/branch/perf/split-giants-measured), so the carry to the current head can be checked: the head adds only the rebase onto 0caf0618 and the default.
Review of #385 (mooki):
- The compiled checks always pass Clang-only flags (-mllvm), so the test now
  requires Clang: RUN_ALL_ARGS = ["--cc", "clang"] (run_all.py skips it
  without clang, fails it with --strict), find_cc takes only a compiler whose
  --version says clang, and a missing or other compiler fails the test instead
  of passing with every comparison skipped. The build compiles lifted code with
  clang on every OS (nx2/build.py toolchain.find("clang")).
- Functions emitted by the real a64_lift.Lifter.emit_function, with lift_fixes
  and poll installed, over a made-up image: an entry with code below it (the
  entry goto), loops with polls, B.cond, TBZ, CBNZ, BL, BLR, a tail call into
  another entry, and a setjmp owner's call. The driver carries
  host/hos_setjmp.c's assembly setjmp/longjmp; the owner's stub longjmps back in
  some states, so the split caller resumes through recomp_sj_setjmp and
  a64_icall (233-361 resumes of 300 states per program, 0 differences). The
  same function emitted with A64_LOCAL_REGS or A64_LOCAL_FLAGS is left whole.
- nx2/build.py's compile_one with the split flag, with an object cache of its
  own: the object holds the pieces, the copy is removed, and the object runs
  as the whole function does.
PR #38's fast-float emitter is not on main: its region shape stays covered by
the written functions only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
docs: LONGSHOT_SPLIT_GIANTS's measured gains, by what was measured
Some checks are pending
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 3s
Website / Website on Cloudflare (pull_request_target) Has been skipped
9f91cf6cb0
The cluster's single compiles are 2.6x faster with its largest peak at 26%;
a single giant's peak falls to about a fifth; a whole first build's compile
takes ~21% less time with ~6% less summed compiler memory (#385 review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Author
Contributor

Thanks for the careful read, mooki. Pushed a4e125fd and 9f91cf6c; the description is updated to match.

Must change: the fixture's compiler. tests/test_split_giants.py now requires Clang, as build.py compiles lifted code with clang on every OS.

  • It declares RUN_ALL_ARGS = ["--cc", "clang"]: run_all.py skips it without clang and fails it with --strict.
  • By hand, a missing compiler, or one whose --version isn't Clang, fails the test, so it can no longer pass with every comparison skipped.
  • The GCC fallback is gone.

Coverage, all compiled whole and split and run from the same 300 states with Clang 23: 0 differences in all 2,400 states.

  • Functions emitted by the real Lifter.emit_function, with lift_fixes and poll installed, over a made-up image:
    • a non-first entry (code below it, the entry goto), loops with polls, B.cond, TBZ, CBNZ, BL, BLR, and a tail call into another entry;
    • a setjmp owner's call that sometimes longjmps back, through host/hos_setjmp.c's assembly setjmp/longjmp: 233, 361 and 351 of the three programs' states resumed that way;
    • the same function emitted with A64_LOCAL_REGS / A64_LOCAL_FLAGS is left whole.
  • A 406 KB chunk through build.py's compile_one with the split flag: the object holds the pieces, the copy is removed, and it runs the same as the whole function.
  • #38's real fast-float emitter isn't on main, so its regions are still covered by the written shape only. The description says so.

Description:

  • Memory is now scoped:

    • the whole build's summed compiler peak is 5,129.5 → 4,840 MB (-5.6%);
    • the giant chunks one at a time are 2.62x faster, with the largest peak at 25.9%;
    • "about a fifth" holds only for a single 2-6 MB function. docs/development.md's row says the same.
  • 75 vs 76: "the other 24" was 23 lifted chunks plus code_signs.c, a runtime source whose name starts with code_ (0.1 s; no total changes).

  • Runtime: "no runtime cost found" is gone. The description now gives what was measured: the replay ratios, best and mean, and the game runs at the 60 fps cap with no speed claim. It also says an uncapped scene where the split giants are hot wasn't found or measured.

  • The 1,634 s run is kept visible, with what supports leaving it out:

    • 74 of 75 lifted chunks and all 12 non-game files over 5 s were slower (median x1.26 and x1.03);
    • the extra time tracks each file's compile time (r = 0.79), not its size (r = -0.19);
    • it varied by quarter of the build (x1.34 / 1.33 / 1.02 / 1.21);
    • free RAM was unchanged, and no other heavy job held the lock.

    No per-process CPU record exists, so the cause isn't named.

  • #31: they compose at the source level, but the gains aren't necessarily additive.

  • The measured commit 012c615b is on the fork as perf/split-giants-measured.

Suite: run_all.py --jobs 4, main @ 0caf0618 485 / 6 / 70, this PR @ 9f91cf6c 486 / 6 / 70. The same six Windows-only failures; the extra pass is test_split_giants.

Thanks for the careful read, mooki. Pushed a4e125fd and 9f91cf6c; the description is updated to match. **Must change: the fixture's compiler.** `tests/test_split_giants.py` now requires Clang, as `build.py` compiles lifted code with clang on every OS. - It declares `RUN_ALL_ARGS = ["--cc", "clang"]`: run_all.py skips it without clang and fails it with `--strict`. - By hand, a missing compiler, or one whose `--version` isn't Clang, fails the test, so it can no longer pass with every comparison skipped. - The GCC fallback is gone. **Coverage**, all compiled whole and split and run from the same 300 states with Clang 23: 0 differences in all 2,400 states. - **Functions emitted by the real `Lifter.emit_function`**, with `lift_fixes` and `poll` installed, over a made-up image: - a non-first entry (code below it, the entry goto), loops with polls, B.cond, TBZ, CBNZ, BL, BLR, and a tail call into another entry; - a setjmp owner's call that sometimes longjmps back, through `host/hos_setjmp.c`'s assembly setjmp/longjmp: 233, 361 and 351 of the three programs' states resumed that way; - the same function emitted with `A64_LOCAL_REGS` / `A64_LOCAL_FLAGS` is left whole. - **A 406 KB chunk through `build.py`'s `compile_one` with the split flag:** the object holds the pieces, the copy is removed, and it runs the same as the whole function. - **#38's real fast-float emitter isn't on main,** so its regions are still covered by the written shape only. The description says so. **Description:** - **Memory is now scoped:** - the whole build's summed compiler peak is 5,129.5 → 4,840 MB (-5.6%); - the giant chunks one at a time are 2.62x faster, with the largest peak at 25.9%; - "about a fifth" holds only for a single 2-6 MB function. `docs/development.md`'s row says the same. - **75 vs 76:** "the other 24" was 23 lifted chunks plus `code_signs.c`, a runtime source whose name starts with `code_` (0.1 s; no total changes). - **Runtime:** "no runtime cost found" is gone. The description now gives what was measured: the replay ratios, best and mean, and the game runs at the 60 fps cap with no speed claim. It also says an uncapped scene where the split giants are hot wasn't found or measured. - **The 1,634 s run is kept visible, with what supports leaving it out:** - 74 of 75 lifted chunks and all 12 non-game files over 5 s were slower (median x1.26 and x1.03); - the extra time tracks each file's compile time (r = 0.79), not its size (r = -0.19); - it varied by quarter of the build (x1.34 / 1.33 / 1.02 / 1.21); - free RAM was unchanged, and no other heavy job held the lock. No per-process CPU record exists, so the cause isn't named. - **#31:** they compose at the source level, but the gains aren't necessarily additive. - **The measured commit** 012c615b is on the fork as `perf/split-giants-measured`. **Suite:** `run_all.py --jobs 4`, main @ 0caf0618 485 / 6 / 70, this PR @ 9f91cf6c 486 / 6 / 70. The same six Windows-only failures; the extra pass is `test_split_giants`.
madebyjordan changed title from WIP: perf(build): compile giant lifted functions in pieces cut at their labels, on by default (LONGSHOT_SPLIT_GIANTS) to perf(build): compile giant lifted functions in pieces cut at their labels, on by default (LONGSHOT_SPLIT_GIANTS) 2026-11-14 08:17:30 +00:00
mooki left a comment

Second look at head 9f91cf6c. The must-change point of the first review is settled, so the change request no longer applies. Nothing new was found in the two follow-up commits. What stays open is evidence, not a defect: the real float-region emitter of pull request 38 combined with this splitter has not been executed, and there is no uncapped game-speed measurement with the pieces.

Read head 9f91cf6c, both follow-up commits, the updated description and both replies, and compared the now-published measured commit 012c615b; ran the changed check with Clang, GCC and a missing compiler, plus the same nine existing checks. Clang reports 0 differences across all 2,400 comparisons; GCC and the missing compiler are rejected as intended; the nine existing checks pass. No game, full build or timing run was made here.

What's good

  • The compiler requirement is now explicit rather than silently dropping the compiled comparisons.
  • The new cases use the real function emitter for control flow and exercise a real native setjmp return, not just matching source strings. The compile-copy path is now compiled, linked and run too.
  • Neither nx2/build.py nor nx2/split_giants.py changed since the first review. The follow-up changes tests and the documented claims, not the runtime transformation.

Must change

The previous must-change point is settled. tests/test_split_giants.py:45 declares RUN_ALL_ARGS = ["--cc", "clang"]; lines 420-437 accept only a compiler whose version identifies Clang; lines 528-534 fail instead of skipping when none is available. --cc gcc now exits 1 with no Clang to compile with (gcc), before compiling any comparison. A nonexistent compiler also exits 1. Clang passes the full check. The missing-Clang skip versus strict-failure routing is explicit in tests/run_all.py:238-242,416-419; that runner policy was traced, not exercised with Clang removed.

No new functional defect found in these two commits. The previous change request can be cleared.

Coverage requests

Previous point Status What now establishes it
Real emitter and non-first entry Settled tests/test_split_giants.py:214-237 uses Lifter.emit_function with the real entry fix and polls. Lines 241-248 check the initial entry jump and splitting; lines 488-500 compile and compare three emitted functions. Instruction bodies are deliberately authored, as in the existing entry test, rather than translated from a dump.
Setjmp resume across a split caller Settled Lines 346-390 supply the native assembly save/restore and a caller-visible longjmp; lines 405-414 compare resume counts, context and effects. The three emitted cases produced 249, 367 and 332 resume events here, with no differences. The resume target's a64_icall remains a recording stand-in at line 340, so this establishes the split caller's wrapper behavior, not an end-to-end test of the game's exception routing.
Local-register and local-flag refusal Settled Lines 249-255 emit the same function with each mode, require changed emitted text, and require byte-identical unsplit output with the top-level-declaration refusal. These assertions passed.
Actual float-region emitter combined with this splitter Still open The explicit limitation at line 25 is accurate. Lines 78-86 still supply the handwritten integer imitation, and lines 448-449 use empty FP helper headers. Compatibility with the real emitter from #38 remains source-traced, not executed.
compile_one with the split flag Settled Lines 501-518 call the real compiler entry with the flag, require piece symbols in the object and removal of the temporary source, then link that object beside the whole function and compare 300 states. This passed. It does not claim cache-hit or failed-compile cleanup coverage for the new branch.

The larger fixture is useful evidence, not an exhaustive proof for all giants or guest inputs. The remaining float-combination request was a coverage improvement, not the reason for the change request.

Measurements and smaller points

  1. Memory wording: settled. docs/development.md:326 now distinguishes the cluster's 2.6-times single-compiler speed-up and largest peak of 2,263 to 586 MB, an individual giant's roughly one-fifth peak, and the whole build's roughly 6% summed-memory reduction. The description gives the corresponding medians, 5,129.5 to 4,840 MB. These match the arithmetic checked in the first review. The chunk count is also reconciled in the description as 15 + 37 + 23 lifted chunks; the extra name was a runtime source. The underlying per-file logs were not supplied for independent verification.

  2. Measured source availability and carry: settled. The published branch resolves to 012c615b557a0b680902e019517941c9fdbedc5e: https://git.magicbean.dev/madebyjordan/Longshot/src/branch/perf/split-giants-measured. Its nx2/split_giants.py is identical to this head. In nx2/build.py, the only difference is the default and its comments at lines 346-357: an unset switch now follows the same 8 KB path the measured build explicitly selected. This supports carrying the compile result for the same retained lift; it is not a newly measured complete build on the newer base.

  3. Excluded baseline: partly settled. Measurements now retains the 1,634 s run and supplies per-file slowdowns, variation across the build, free-memory readings and the absence of another lock-owning heavy job. That makes the exclusion more transparent. It still cannot identify the cause without the missing process/load record, and the text's “what says it was the PC” is stronger than that evidence warrants. Keep the exclusion labelled as an anomalous run rather than a proven diagnosis.

  4. Runtime wording: settled; game-speed measurement: still open. The unsupported “no runtime cost found” recommendation is gone. Testing now describes the two-function replay timing method and explicitly says the capped route did not establish crossing cost in a workload where these functions are hot. There is no new uncapped game-speed measurement. nx2/split_giants.py:194-239 still adds the piece call/return and dispatch boundaries reviewed before. The 0.979 and 0.976 replay ratios are narrow evidence, not a whole-game speed guarantee; a same-profile, named-build comparison with repeated uncapped runs remains the way to settle that question.

  5. Count resume events, not distinct states. tests/test_split_giants.py:412-414 sums every resume, including multiple resumes in one state. The description's “361 ... of their states” should therefore say “361 resume events”. There are only 300 initial states per program. This is a wording correction, not a failing check.

The description also correctly says that #31 composes at the source level without promising additive gains. Issue #362 still covers separate experiments, not additional results for this patch.

Not checked

No Windows execution here, full suite, real lift, game build, replay of the recorded game states, combined build with PR 38, or new performance measurement. The native comparisons and nine existing checks pass on Linux; the author's Windows results remain separately attributed. This head merges cleanly with main 0caf06184. Source-check jobs are still blocked, not green: https://git.magicbean.dev/MagicBeanDev/Longshot/actions/runs/4536.

Second look at head `9f91cf6c`. **The must-change point of the first review is settled, so the change request no longer applies.** Nothing new was found in the two follow-up commits. What stays open is evidence, not a defect: the real float-region emitter of pull request 38 combined with this splitter has not been executed, and there is no uncapped game-speed measurement with the pieces. Read head `9f91cf6c`, both follow-up commits, the updated description and both replies, and compared the now-published measured commit `012c615b`; ran the changed check with Clang, GCC and a missing compiler, plus the same nine existing checks. Clang reports 0 differences across all 2,400 comparisons; GCC and the missing compiler are rejected as intended; the nine existing checks pass. No game, full build or timing run was made here. ### What's good - The compiler requirement is now explicit rather than silently dropping the compiled comparisons. - The new cases use the real function emitter for control flow and exercise a real native setjmp return, not just matching source strings. The compile-copy path is now compiled, linked and run too. - Neither `nx2/build.py` nor `nx2/split_giants.py` changed since the first review. The follow-up changes tests and the documented claims, not the runtime transformation. ### Must change **The previous must-change point is settled.** `tests/test_split_giants.py:45` declares `RUN_ALL_ARGS = ["--cc", "clang"]`; lines 420-437 accept only a compiler whose version identifies Clang; lines 528-534 fail instead of skipping when none is available. `--cc gcc` now exits 1 with `no Clang to compile with (gcc)`, before compiling any comparison. A nonexistent compiler also exits 1. Clang passes the full check. The missing-Clang skip versus strict-failure routing is explicit in `tests/run_all.py:238-242,416-419`; that runner policy was traced, not exercised with Clang removed. No new functional defect found in these two commits. The previous change request can be cleared. ### Coverage requests | Previous point | Status | What now establishes it | |---|---|---| | Real emitter and non-first entry | **Settled** | `tests/test_split_giants.py:214-237` uses `Lifter.emit_function` with the real entry fix and polls. Lines 241-248 check the initial entry jump and splitting; lines 488-500 compile and compare three emitted functions. Instruction bodies are deliberately authored, as in the existing entry test, rather than translated from a dump. | | Setjmp resume across a split caller | **Settled** | Lines 346-390 supply the native assembly save/restore and a caller-visible longjmp; lines 405-414 compare resume counts, context and effects. The three emitted cases produced 249, 367 and 332 resume events here, with no differences. The resume target's `a64_icall` remains a recording stand-in at line 340, so this establishes the split caller's wrapper behavior, not an end-to-end test of the game's exception routing. | | Local-register and local-flag refusal | **Settled** | Lines 249-255 emit the same function with each mode, require changed emitted text, and require byte-identical unsplit output with the top-level-declaration refusal. These assertions passed. | | Actual float-region emitter combined with this splitter | **Still open** | The explicit limitation at line 25 is accurate. Lines 78-86 still supply the handwritten integer imitation, and lines 448-449 use empty FP helper headers. Compatibility with the real emitter from https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/38 remains source-traced, not executed. | | `compile_one` with the split flag | **Settled** | Lines 501-518 call the real compiler entry with the flag, require piece symbols in the object and removal of the temporary source, then link that object beside the whole function and compare 300 states. This passed. It does not claim cache-hit or failed-compile cleanup coverage for the new branch. | The larger fixture is useful evidence, not an exhaustive proof for all giants or guest inputs. The remaining float-combination request was a coverage improvement, not the reason for the change request. ### Measurements and smaller points 1. **Memory wording: settled.** `docs/development.md:326` now distinguishes the cluster's 2.6-times single-compiler speed-up and largest peak of 2,263 to 586 MB, an individual giant's roughly one-fifth peak, and the whole build's roughly 6% summed-memory reduction. The description gives the corresponding medians, 5,129.5 to 4,840 MB. These match the arithmetic checked in the first review. The chunk count is also reconciled in the description as 15 + 37 + 23 lifted chunks; the extra name was a runtime source. The underlying per-file logs were not supplied for independent verification. 2. **Measured source availability and carry: settled.** The published branch resolves to `012c615b557a0b680902e019517941c9fdbedc5e`: https://git.magicbean.dev/madebyjordan/Longshot/src/branch/perf/split-giants-measured. Its `nx2/split_giants.py` is identical to this head. In `nx2/build.py`, the only difference is the default and its comments at lines 346-357: an unset switch now follows the same 8 KB path the measured build explicitly selected. This supports carrying the compile result for the same retained lift; it is not a newly measured complete build on the newer base. 3. **Excluded baseline: partly settled.** Measurements now retains the 1,634 s run and supplies per-file slowdowns, variation across the build, free-memory readings and the absence of another lock-owning heavy job. That makes the exclusion more transparent. It still cannot identify the cause without the missing process/load record, and the text's “what says it was the PC” is stronger than that evidence warrants. Keep the exclusion labelled as an anomalous run rather than a proven diagnosis. 4. **Runtime wording: settled; game-speed measurement: still open.** The unsupported “no runtime cost found” recommendation is gone. Testing now describes the two-function replay timing method and explicitly says the capped route did not establish crossing cost in a workload where these functions are hot. There is no new uncapped game-speed measurement. `nx2/split_giants.py:194-239` still adds the piece call/return and dispatch boundaries reviewed before. The 0.979 and 0.976 replay ratios are narrow evidence, not a whole-game speed guarantee; a same-profile, named-build comparison with repeated uncapped runs remains the way to settle that question. 5. **Count resume events, not distinct states.** `tests/test_split_giants.py:412-414` sums every resume, including multiple resumes in one state. The description's “361 ... of their states” should therefore say “361 resume events”. There are only 300 initial states per program. This is a wording correction, not a failing check. The description also correctly says that https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/31 composes at the source level without promising additive gains. Issue https://git.magicbean.dev/MagicBeanDev/Longshot/issues/362 still covers separate experiments, not additional results for this patch. ### Not checked No Windows execution here, full suite, real lift, game build, replay of the recorded game states, combined build with PR 38, or new performance measurement. The native comparisons and nine existing checks pass on Linux; the author's Windows results remain separately attributed. This head merges cleanly with main `0caf06184`. Source-check jobs are still blocked, not green: https://git.magicbean.dev/MagicBeanDev/Longshot/actions/runs/4536.
mooki approved these changes 2026-11-14 12:45:19 +00:00
mooki left a comment

Approving at 9f91cf6c. The point of the first review is fixed (the new test requires Clang and fails without it), the follow-up added tests through the real emitter, a real setjmp return across a cut and the real compile entry, and the second look found nothing new: #385 (comment)

This replaces the earlier change request. Two things remain worth having, as evidence and not as conditions: a game-speed measurement with the pieces in a scene where the split functions are hot, and the float-region emitter of #38 run together with this splitter.

Approving at `9f91cf6c`. The point of the first review is fixed (the new test requires Clang and fails without it), the follow-up added tests through the real emitter, a real setjmp return across a cut and the real compile entry, and the second look found nothing new: https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/385#issuecomment-17779 This replaces the earlier change request. Two things remain worth having, as evidence and not as conditions: a game-speed measurement with the pieces in a scene where the split functions are hot, and the float-region emitter of https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/38 run together with this splitter.
HumanBean deleted branch perf/split-giants 2026-11-14 16:58:46 +00:00
Some checks are pending
CI / Conventional Commit title (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Blocked by required conditions
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Blocked by required conditions
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Blocked by required conditions
CI / Workflow lint (actionlint) (pull_request) Blocked by required conditions
Third-party parts / As their notes say (pull_request) Blocked by required conditions
Third-party parts / The same as upstream (pull_request) Blocked by required conditions
PR description / Description follows the template (pull_request_target) Successful in 3s
Website / Website on Cloudflare (pull_request_target) Has been skipped
Some workflows are waiting to be reviewed.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
MagicBeanDev/Longshot!385
No description provided.