Idea: ship play profiles (the hot list and the renderer's profile) with releases #505

Open
opened 2026-11-14 17:33:51 +00:00 by HumanBean · 1 comment
Owner

The idea

Longshot already has everything needed to build the hottest lifted game functions at -O2 with a play profile, but releases don't ship with it, so every player's build compiles all of the lifted code at -O1.

What exists today

  • scripts/hotlist.py turns a sampled play session (HOS_SAMPLE_ALL) into hot_funcs.txt: the lifted functions that make up the first 85 % of samples, hottest first, capped at 1,500, plus the game's Zstd decoder.
  • nx2/split_hot.py puts those functions into eight hot pieces compiled at -O2 with the play profile (RX_PGO, fitted to the pieces by nx2/pgo_pieces.py).
  • scripts/tools.py assemble profile packs the list and the profile for a release (RELEASING.md). make_game.py uses them when work/ has them.
  • Releases ship neither: tools.lock has no profile line ("No play profile yet").

What it buys and what it costs

With today's list, the 85 % cut comes to about 850 functions (849). Measured against main 0caf0618, the whole build before shaders:

Ryzen 9 7900X, 12 pinned threads i7-9700K, 6 jobs
No hot list 9:04 19:32
All 849 at -O2 + profile 10:34, +17 % 23:18, +19 %
First 300 at -O2 + profile not built 21:22, +9 %
First 300 at -O1 + profile not built 20:57, +7 %
  • The gain is small. All 849 gave about +3 % fps walking in the Market (RTX 3090: 16.54 → 15.96 ms, 4 sessions each). Standing still showed nothing.
  • The lab couldn't show it. On the i7-9700K with an RX 580 the game is GPU-bound and its sessions vary by ±2 ms.
  • A shorter list keeps most of it. The first 300 functions cover 83 % of what the 849 cover on the Market walk, with 58 % of the code. That is about a 70 % share instead of 85 %. The gain is expected to scale with coverage, so roughly +2.5 % fps. That's an estimate: 300 hasn't been measured on a CPU-bound PC.
  • PR 385 (merged) changes the trade. It cut a first build's compile by about 21 % on a 6-core PC, so a +9 % list still leaves players' builds faster than before 385. It leaves the hot pieces whole on purpose, so they may become the build's longest files; this needs one build with both to check.

The proposal: generate the list before each release and ship it

  1. Make the list from Longshot itself. The list and profile in hand were made by Hookshot, on a route nobody recorded. Record them from Longshot's own lifter over a broad route rather than one area. The pipeline tour (PR 111) would give the same route every time.
  2. Cut it at about 70 % instead of 85 %, for about 300 functions and +9 % build time. A fixed count (hotlist.py <run> <out> 1.0 300) keeps the build cost the same from one release to the next; a share gives a different count on every session.
  3. Ship it with the release: tools.py assemble profile, tools.py add, and the profile line in tools.lock.
  4. Re-record it when the lifter's output changes. The profile's counts are tied to the lifted C. The list names guest addresses, so it only needs to change with the game version or when functions stop being lifted code.
  5. Maybe: an option in the launcher (a faster build vs. a faster game, such as 100 / 300 / 500 functions). Against that: it's a knob for a few percent, and each choice is another binary to support. The builder could instead choose by the PC (more threads, a longer list).

The renderer's play profile

The renderer (our own Vulkan code, gpu/vk/) can be built with a play profile of its own, and that helps more than the hot list does, at almost no build cost.

  • What it is: a counting build of the renderer (RX_VK_PGO=gen), a session played with it, then the renderer built with the counts (RX_VK_PGO=<file>). It's ported from ProjectReLink, on the local branch build/vk-pgo, not pushed yet. The renderer needs no hot list: it's small enough to build whole with the profile.

  • What it buys: on a Ryzen 9 7900X with an RTX 3090, from the Market save, main against main with the renderer built with the profile. Median frame:

    Standing Running in the square
    -O2 13.53 / 13.49 / 13.70 ms 15.89 / 16.13 / 15.73 ms
    -O2 + profile 12.96 / 13.37 / 12.94 ms 15.13 / 15.67 / 15.52 ms

    That's about -4 to -5 % standing and -3 % running. -O3 and link-time optimisation added nothing on top in a first screening.

  • What it costs: almost no build time. Players already compile the renderer (about a minute of one compile job), and the profile only changes how. No prebuilt renderer is needed: the release ships the profile file and the builder uses it.

  • Still to settle:

    • Record it over a broad route rather than one area. The one measured was recorded on the same Kokiri-to-Market route it was measured on.
    • Record each GPU path: mesh shaders and the compute fallback, merged into one file.
    • Probably one profile per platform, since the Windows and Linux builds use different standard libraries. Linux's could be tried on Windows first.
    • The profile writer has only run on Linux.
    • Re-record it as the renderer changes. A stale profile only loses gain where code changed.

Both profiles would ship the same way: in the play profile bundle (tools.py assemble profile) and the profile line in tools.lock.

Related

  • PR 30 keys the hot pieces by the profile's contents instead of its path. It should land first or with this: without it, every new checkout or launcher channel compiles the eight hot pieces again, and the object store (PR 371) can't share them.

Anything else

The play profile holds addresses and counts only, no game data; the owner has approved publishing it. Measurements and scripts are on the PC that made them, and they can be added here.

🤖 Generated with Claude Code

### The idea Longshot already has everything needed to build the hottest lifted game functions at `-O2` with a play profile, but releases don't ship with it, so every player's build compiles all of the lifted code at `-O1`. **What exists today** - `scripts/hotlist.py` turns a sampled play session (`HOS_SAMPLE_ALL`) into `hot_funcs.txt`: the lifted functions that make up the first 85 % of samples, hottest first, capped at 1,500, plus the game's Zstd decoder. - `nx2/split_hot.py` puts those functions into eight hot pieces compiled at `-O2` with the play profile (`RX_PGO`, fitted to the pieces by `nx2/pgo_pieces.py`). - `scripts/tools.py assemble profile` packs the list and the profile for a release (RELEASING.md). `make_game.py` uses them when `work/` has them. - Releases ship neither: `tools.lock` has no `profile` line ("No play profile yet"). **What it buys and what it costs** With today's list, the 85 % cut comes to about 850 functions (849). Measured against main 0caf0618, the whole build before shaders: | | Ryzen 9 7900X, 12 pinned threads | i7-9700K, 6 jobs | |---|---|---| | No hot list | 9:04 | 19:32 | | All 849 at -O2 + profile | 10:34, **+17 %** | 23:18, **+19 %** | | First 300 at -O2 + profile | not built | 21:22, **+9 %** | | First 300 at -O1 + profile | not built | 20:57, +7 % | - **The gain is small.** All 849 gave about +3 % fps walking in the Market (RTX 3090: 16.54 → 15.96 ms, 4 sessions each). Standing still showed nothing. - **The lab couldn't show it.** On the i7-9700K with an RX 580 the game is GPU-bound and its sessions vary by ±2 ms. - **A shorter list keeps most of it.** The first 300 functions cover 83 % of what the 849 cover on the Market walk, with 58 % of the code. That is about a 70 % share instead of 85 %. The gain is expected to scale with coverage, so roughly +2.5 % fps. That's an estimate: 300 hasn't been measured on a CPU-bound PC. - **[PR 385](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/385) (merged) changes the trade.** It cut a first build's compile by about 21 % on a 6-core PC, so a +9 % list still leaves players' builds faster than before 385. It leaves the hot pieces whole on purpose, so they may become the build's longest files; this needs one build with both to check. **The proposal: generate the list before each release and ship it** 1. **Make the list from Longshot itself.** The list and profile in hand were made by Hookshot, on a route nobody recorded. Record them from Longshot's own lifter over a broad route rather than one area. The pipeline tour ([PR 111](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/111)) would give the same route every time. 2. **Cut it at about 70 % instead of 85 %**, for about 300 functions and +9 % build time. A fixed count (`hotlist.py <run> <out> 1.0 300`) keeps the build cost the same from one release to the next; a share gives a different count on every session. 3. **Ship it with the release:** `tools.py assemble profile`, `tools.py add`, and the `profile` line in `tools.lock`. 4. **Re-record it when the lifter's output changes.** The profile's counts are tied to the lifted C. The list names guest addresses, so it only needs to change with the game version or when functions stop being lifted code. 5. **Maybe: an option in the launcher** (a faster build vs. a faster game, such as 100 / 300 / 500 functions). Against that: it's a knob for a few percent, and each choice is another binary to support. The builder could instead choose by the PC (more threads, a longer list). ### The renderer's play profile The renderer (our own Vulkan code, `gpu/vk/`) can be built with a play profile of its own, and that helps more than the hot list does, at almost no build cost. - **What it is:** a counting build of the renderer (`RX_VK_PGO=gen`), a session played with it, then the renderer built with the counts (`RX_VK_PGO=<file>`). It's ported from ProjectReLink, on the local branch `build/vk-pgo`, not pushed yet. The renderer needs no hot list: it's small enough to build whole with the profile. - **What it buys:** on a Ryzen 9 7900X with an RTX 3090, from the Market save, main against main with the renderer built with the profile. Median frame: | | Standing | Running in the square | |---|---|---| | -O2 | 13.53 / 13.49 / 13.70 ms | 15.89 / 16.13 / 15.73 ms | | -O2 + profile | 12.96 / 13.37 / 12.94 ms | 15.13 / 15.67 / 15.52 ms | That's about **-4 to -5 % standing and -3 % running**. -O3 and link-time optimisation added nothing on top in a first screening. - **What it costs:** almost no build time. Players already compile the renderer (about a minute of one compile job), and the profile only changes how. No prebuilt renderer is needed: the release ships the profile file and the builder uses it. - **Still to settle:** - Record it over a broad route rather than one area. The one measured was recorded on the same Kokiri-to-Market route it was measured on. - Record each GPU path: mesh shaders and the compute fallback, merged into one file. - Probably one profile per platform, since the Windows and Linux builds use different standard libraries. Linux's could be tried on Windows first. - The profile writer has only run on Linux. - Re-record it as the renderer changes. A stale profile only loses gain where code changed. Both profiles would ship the same way: in the play profile bundle (`tools.py assemble profile`) and the `profile` line in `tools.lock`. **Related** - [PR 30](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/30) keys the hot pieces by the profile's contents instead of its path. It should land first or with this: without it, every new checkout or launcher channel compiles the eight hot pieces again, and the object store ([PR 371](https://git.magicbean.dev/MagicBeanDev/Longshot/pulls/371)) can't share them. ### Anything else The play profile holds addresses and counts only, no game data; the owner has approved publishing it. Measurements and scripts are on the PC that made them, and they can be added here. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
HumanBean changed title from Idea: ship hot lists with releases to Idea: ship play profiles (the hot list and the renderer's profile) with releases 2026-11-14 18:21:28 +00:00

A data point on what the hot list and the play profiles buy, from a player's own build on Windows. Short version: on a Ryzen 5 5600X a hot list and profiles recorded on this PC changed nothing I can measure against an old list and a stale profile, because here the frame is paced by hand-offs between the game's threads, not by how fast the lifted code runs. I did not measure against no hot list at all, which is what a release ships.

Setup. Main 6c9edffe with ea6dc5f5 cherry-picked, llvm-mingw clang 23.1.5, -march=native (znver3). Ryzen 5 5600X (6 cores, 12 logical processors), 24 GB, RX 5600 XT 6 GB (the compute fallback), Windows 11. Render resolution 50% of 1920x1080, Threaded rendering Auto (on), RX_VK_LOAD=create, RX_VK_MIRROR_SPARSE=0, RX_VK_WB_MAX_KB=1024. Standing after a teleport, 25 s after 10 s, the builds interleaved. Kokiri Forest (800 draws) and Hyrule Field (1,230 draws) are the two areas where the GPU's frame (12.0-12.7 ms) is well under the frame time, so the CPU side sets the frame rate there.

Build Kokiri Forest, fps Hyrule Field, fps Replay submit a frame (Kokiri / Field)
A: the 849-function list of Hookshot v2.1.0's builder and its game-side profile (round 1) 61.6 / 60.8 55.8 / 56.2 5.7 / 5.8 ms; 7.0 / 7.0 ms
B: A's list, game and renderer profiles recorded here (round 1) 61.5 / 61.9 57.7 / 58.1 5.5 / 5.5 ms; 6.6 / 6.6 ms
A again (round 2) 61.2 / 60.2 / 60.3 64.0 / 55.5 / 57.3 5.8 / 5.9 / 5.9 ms; 6.2 / 7.1 / 6.9 ms
C: a list made here (1,477 functions, 1,474 found) and profiles recorded again on it (round 2) 61.4 / 59.9 / 61.1 56.8 / 57.4 / 56.4 5.7 / 5.8 / 5.6 ms; 6.8 / 6.7 / 6.8 ms
  • The lists. A's list covers 65.2% of this PC's samples of lifted code (168,972 samples from a HOS_SAMPLE_ALL walk through twelve areas, 8 minutes); C's, from scripts/hotlist.py <file> <out> 0.92 1500, covers 92%, with 867 functions A's does not have. The heaviest functions outside A's list are the game's Zstd decoder (f_801102e0 5.5%, f_80110860 1.1%: the walk loads an area every 35 s) and f_8372a5a0 (3.0%).
  • The profiles. Recorded with RX_PGO=gen RX_VK_PGO=gen, counters cleared after the load and written before the exit, one walk through the same twelve areas. The renderer's recording works on Windows (assets/profiles/README.md lists a Windows recording as not done yet): the submit is about 5% shorter in B than in A, the only difference above that I would call real.
  • Build time on this PC. A to B: 14:27 for the counting build, 5:56 for the final one. A to C: 29:11 (the lift runs again) and 9:56.
  • Why nothing moves. scripts/cpu_report.py on the sampled run (with --readobj): lifted game code 165% of a core, the renderer 97%, the host 56%; no thread is saturated. sead::MainThread runs 28.1% of the time and waits 42.2% in nn::os::WaitLightEvent (its job workers), 14.5% in the renderer's wait and 7.6% in nn::os::SleepThread; each BaseProcExecutor runs 15%, mkw_vk_replay 44.7%. Its reading: "the frame is most likely paced by the chain of hand-offs between threads".
  • The switches for those hand-offs that I tried here, one run each on the same six areas, moved nothing either: HOS_VCORES=0, HOS_RESERVE_CORE=0, HOS_VCORE_SLICE_US=500, HOS_SHORT_SLEEP_POLLERS=1, HOS_SHORT_SLEEP_TIMER=1, HOS_SPIN_US=0, RX_VK_RECORDER_PIN=sibling, RX_VK_PRESENTS_AHEAD=2 (Kokiri Forest 57-62 fps in all of them).

Not measured: no hot list at all (every lifted file at -O1), which is the comparison this issue is about; a walk or a fight rather than standing; load times, where the Zstd decoder's 6.6% says a current list could matter more than in the frame rate; a CPU with fewer cores, where the lifted code may be the limit.

A data point on what the hot list and the play profiles buy, from a player's own build on Windows. Short version: on a Ryzen 5 5600X a hot list and profiles recorded on this PC changed nothing I can measure against an old list and a stale profile, because here the frame is paced by hand-offs between the game's threads, not by how fast the lifted code runs. I did not measure against no hot list at all, which is what a release ships. **Setup.** Main 6c9edffe with ea6dc5f5 cherry-picked, llvm-mingw clang 23.1.5, `-march=native` (znver3). Ryzen 5 5600X (6 cores, 12 logical processors), 24 GB, RX 5600 XT 6 GB (the compute fallback), Windows 11. Render resolution 50% of 1920x1080, Threaded rendering Auto (on), `RX_VK_LOAD=create`, `RX_VK_MIRROR_SPARSE=0`, `RX_VK_WB_MAX_KB=1024`. Standing after a teleport, 25 s after 10 s, the builds interleaved. Kokiri Forest (800 draws) and Hyrule Field (1,230 draws) are the two areas where the GPU's frame (12.0-12.7 ms) is well under the frame time, so the CPU side sets the frame rate there. | Build | Kokiri Forest, fps | Hyrule Field, fps | Replay submit a frame (Kokiri / Field) | |---|---|---|---| | A: the 849-function list of Hookshot v2.1.0's builder and its game-side profile (round 1) | 61.6 / 60.8 | 55.8 / 56.2 | 5.7 / 5.8 ms; 7.0 / 7.0 ms | | B: A's list, game and renderer profiles recorded here (round 1) | 61.5 / 61.9 | 57.7 / 58.1 | 5.5 / 5.5 ms; 6.6 / 6.6 ms | | A again (round 2) | 61.2 / 60.2 / 60.3 | 64.0 / 55.5 / 57.3 | 5.8 / 5.9 / 5.9 ms; 6.2 / 7.1 / 6.9 ms | | C: a list made here (1,477 functions, 1,474 found) and profiles recorded again on it (round 2) | 61.4 / 59.9 / 61.1 | 56.8 / 57.4 / 56.4 | 5.7 / 5.8 / 5.6 ms; 6.8 / 6.7 / 6.8 ms | - **The lists.** A's list covers 65.2% of this PC's samples of lifted code (168,972 samples from a `HOS_SAMPLE_ALL` walk through twelve areas, 8 minutes); C's, from `scripts/hotlist.py <file> <out> 0.92 1500`, covers 92%, with 867 functions A's does not have. The heaviest functions outside A's list are the game's Zstd decoder (`f_801102e0` 5.5%, `f_80110860` 1.1%: the walk loads an area every 35 s) and `f_8372a5a0` (3.0%). - **The profiles.** Recorded with `RX_PGO=gen RX_VK_PGO=gen`, counters cleared after the load and written before the exit, one walk through the same twelve areas. The renderer's recording works on Windows (`assets/profiles/README.md` lists a Windows recording as not done yet): the submit is about 5% shorter in B than in A, the only difference above that I would call real. - **Build time on this PC.** A to B: 14:27 for the counting build, 5:56 for the final one. A to C: 29:11 (the lift runs again) and 9:56. - **Why nothing moves.** `scripts/cpu_report.py` on the sampled run (with `--readobj`): lifted game code 165% of a core, the renderer 97%, the host 56%; no thread is saturated. `sead::MainThread` runs 28.1% of the time and waits 42.2% in `nn::os::WaitLightEvent` (its job workers), 14.5% in the renderer's `wait` and 7.6% in `nn::os::SleepThread`; each `BaseProcExecutor` runs 15%, `mkw_vk_replay` 44.7%. Its reading: "the frame is most likely paced by the chain of hand-offs between threads". - The switches for those hand-offs that I tried here, one run each on the same six areas, moved nothing either: `HOS_VCORES=0`, `HOS_RESERVE_CORE=0`, `HOS_VCORE_SLICE_US=500`, `HOS_SHORT_SLEEP_POLLERS=1`, `HOS_SHORT_SLEEP_TIMER=1`, `HOS_SPIN_US=0`, `RX_VK_RECORDER_PIN=sibling`, `RX_VK_PRESENTS_AHEAD=2` (Kokiri Forest 57-62 fps in all of them). Not measured: no hot list at all (every lifted file at `-O1`), which is the comparison this issue is about; a walk or a fight rather than standing; load times, where the Zstd decoder's 6.6% says a current list could matter more than in the frame rate; a CPU with fewer cores, where the lifted code may be the limit.
Sign in to join this conversation.
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#505
No description provided.