Skip to content

moderngekko-port: add one-command PGO workflow - #26

Open
dougchansan wants to merge 5 commits into
ExpansionPak:masterfrom
dougchansan:feature/pgo-run
Open

moderngekko-port: add one-command PGO workflow#26
dougchansan wants to merge 5 commits into
ExpansionPak:masterfrom
dougchansan:feature/pgo-run

Conversation

@dougchansan

@dougchansan dougchansan commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds moderngekko-port pgo-run, which does the whole instrumentation-PGO cycle in one command: build an instrumented module, run it so a developer can play a representative workload, merge and validate the profiles that produces, and build the final module against them.
  • Supports both module backends, --backend c and --backend llvm.
  • Makes the PGO mode and the merged profile's contents part of the module cache identity, so a plain module can no longer answer a PGO build and two different profiles cannot collide.
  • Prevents an instrumented module from ever replacing the active module: the generation build writes to a private cache root, and the PGO module is published only after every stage has succeeded.

The profiled program is the generated per-game module, not moderngekko-port. The port is a build driver that runs for a few minutes; the module is the recompiled game, and it is where every frame is spent.

Usage

# LLVM backend, interactive training
moderngekko-port pgo-run extracted/GM4E01 --backend llvm

# Training from a savestate; everything after -- is forwarded to the runner
# exactly as `run` forwards it
moderngekko-port pgo-run extracted/GM4E01 \
    --backend llvm \
    --profile-dir build/pgo/GM4E01 \
    -- \
    --load-state states/race.sav \
    --graphics Vulkan \
    --no-mods

# C backend
moderngekko-port pgo-run extracted/GM4E01 --backend c --toolchain clang

Full option list:

moderngekko-port pgo-run <game-root>
    [--backend c|llvm] [--toolchain auto|clang] [--opt-level 0-3]
    [--output <module-cache>] [--profile-dir <path>] [--llvm-profdata <path>]
    [--keep-work] [-- <moderngekko-run arguments>]

inspect, build and run are unchanged.

Implementation

Workspace isolation. Everything a run produces lives under --profile-dir, or under <output>/<disc-id>/pgo when that is not given: raw/ for the .profraw, gen/ for the instrumented module's own private module cache, merged.profdata, and pgo-manifest.txt. The generation build is pointed at that private cache root specifically so it cannot write the real active-module.txt. raw/ is emptied at the start of every run, so a profile left by an earlier build cannot be merged into this one.

Compile and link instrumentation. The module template has no hook of its own for extra flags, so the generation build passes -DCMAKE_C_FLAGS=-fprofile-generate and -DCMAKE_SHARED_LINKER_FLAGS=-fprofile-generate. Both are needed: the profiling runtime that actually writes the .profraw comes from the link line, and an instrumented module that never linked it produces no profile at all. This was verified against real GM4E01 module builds on both backends rather than assumed — see Testing.

The use build passes -fprofile-use=<merged.profdata> plus -Werror=profile-instr-out-of-date. -Wprofile-instr-unprofiled is deliberately not promoted: a training run that never entered a function is normal, and there are thousands of them.

LLVM DolRecomp environment. The LLVM backend emits and code-generates IR in-process, so no compile flag ever reaches its chunks. Instrumentation comes from DolRecomp's own IR-level PGO passes, driven by DOLRECOMP_LLVM_PGO=gen / =use, DOLRECOMP_LLVM_PROFILE=<merged> and DOLRECOMP_LLVM_PGO_STALE=error. -fprofile-generate still reaches the link for the same reason as above. These are set through a scoped environment guard that restores in its destructor, so a failed stage cannot leak DOLRECOMP_LLVM_PGO=gen into later builds in the same shell.

llvm-profdata discovery and validation. Tried in order: --llvm-profdata, $LLVM_PROFDATA, beside the resolved clang, clang --print-prog-name=llvm-profdata, xcrun --find on macOS, then PATH. A candidate is only accepted if it prints an LLVM version banner — a nonexistent path still produces output, so nonempty is not proof. A known Clang/llvm-profdata major-version mismatch is a hard error; an unparseable banner skips the check rather than guessing.

There is a third LLVM this cannot check. With --backend llvm the merged profile is written by llvm-profdata and read back by the LLVM that DolRecomp is linked against, and those are separate installations. The pinned recompiler builds against LLVM 19 or 20, so a distribution whose default clang is newer merges a profile the recompiler cannot parse. DolRecomp reports no version of its own, so there is nothing to compare against and the check cannot be extended to cover it. It also fails late: DolRecomp opens the profile eagerly, so a wrong path is caught immediately, but only parses it inside PGOInstrumentationUse once emission has started. A failed LLVM-backend PGO build therefore prints the versions actually used, the LLVM 19/20 requirement, and the ldd invocation that shows which one the recompiler has — including the specific note that overriding --llvm-profdata alone does not fix it, since the C sources are compiled by whichever clang is on PATH and that clang supplies the profiling runtime.

After merging, llvm-profdata show is parsed and the profile is rejected if it has no function records or a maximum function count of zero. IR instrumentation creates a record for every function whether or not it ran, so records-without-counts is exactly what an early flush looks like — a successful merge of a useless profile. No title-specific minimum is imposed; a small program with one hot function is a legitimate profile.

Cache identity. The module cache key gains the PGO mode (off/generate/use) and, for use builds, the SHA-256 of the merged profile's contents plus the stale-profile policy. The profile path is deliberately excluded: the same profile copied elsewhere is the same build, and two different profiles written to the same scratch filename are not.

The same gap existed for DolRecomp's code-generation environment (DOLRECOMP_LLVM_CPU, DOLRECOMP_LLVM_FEATURES, DOLRECOMP_LLVM_TARGET, DOLRECOMP_LLVM_CHUNK_INSTRUCTIONS, DOLRECOMP_C_CHUNK_INSTRUCTIONS, DOLRECOMP_DISPATCH_LOOKUP, DOLRECOMP_UNSAFE_DIRECT_CALLS), none of which were in the key at all, so a module built with DOLRECOMP_LLVM_CPU=znver3 answered a later build with it unset. Those are folded in when set, and contribute nothing when unset so a default environment keeps the cache entries it already has.

Active-module transaction safety. The PGO module becomes active only after the instrumented build, a clean training exit, raw-profile discovery, the merge, validation, the PGO build and final module validation have all succeeded. Any failure exits nonzero, names the stage, leaves the previously active module untouched, and keeps the work files for diagnosis. A forced kill of the training process is treated as a failure, not a successful run — the profiling runtime flushes on normal shutdown and skips it on a kill.

Manifest. The module's manifest.txt and the workspace's pgo-manifest.txt record pgo_mode, pgo_backend, pgo_profile_sha256, pgo_profile_path, pgo_raw_profile_count, pgo_total_function_count, pgo_max_function_count, pgo_stale_policy, clang_version and llvm_profdata_version.

Incidental changes

Three things were in the way and are separated into their own commits or clearly scoped:

  • tools/port_command_line.hpp — the command line moved out of main(). It is the one path every command goes through and no test could reach it; run forwards unknown arguments while build rejects them, and pgo-run adds three options that must not leak into the others.
  • include/moderngekko/sha256.hpp — SHA-256 moved out of src/runtime/game.cpp so a profile can be hashed from a translation unit that does not link the runtime. A second copy of a hash function stays correct right up until one copy is fixed.
  • Windows process launching moved to the wide-character APIs. Quoting went through path::string(), which narrows via the active code page, so a module cache under a path that code page cannot represent reached the compiler as question marks.

Testing

Automated tests

Two new standalone test targets, neither of which needs a game or Dolphin linkage, so both run in the existing fast cross-platform CI job:

cmake --build build --target moderngekko_pgo_support_test
cmake --build build --target moderngekko_port_command_line_test
ctest --test-dir build -R "moderngekko\.(pgo_support|port_command_line)" --output-on-failure

moderngekko.pgo_support covers cache identity (off vs generate vs use all differ; two profile digests at one path differ; one digest at two paths does not; the stale policy is included), llvm-profdata show parsing (valid summary accepted; zero maximum count, zero records, malformed numbers, an error message and a missing field all rejected), raw-profile discovery (finds nested .profraw, ignores .profdata and .txt, handles a directory named raw profiles プレイヤー, returns empty for a missing directory), workspace derivation, manifest fields, llvm-profdata candidate ordering, path formatting, and the Windows path-length estimate.

moderngekko.port_command_line covers the parsing that every command goes through: build still rejects a stray --load-state, run still forwards it, -- still stops option parsing, the three pgo-run options do not exist for build and are forwarded by run, and backend/opt-level validation is unchanged.

Full project build and tests

cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build --output-on-failure --timeout 300 -E "fullbench|fuzzer|zstreamtest"

Result: build exit 0, 45/45 tests passed, 0 failed. (codegen_compile fails if ctest is run from a shell without the MSVC environment, because the nested CMake it spawns finds cl.exe with no INCLUDE/LIB; inside a developer prompt it passes. This is unrelated to the change.)

End-to-end, C backend, real game

Run against a locally extracted GM4E01 (Mario Kart: Double Dash). No game data is committed and none was downloaded.

moderngekko-port pgo-run <extracted>/GM4E01 --backend c --toolchain clang     --output C:\mgpgo\c\modules --profile-dir C:\mgpgo\c\pgo --keep-work

Command exit status 0. Reported:

Training run:          completed normally
Raw profiles:          1
Total raw-profile bytes:   40420032
Total functions:           1138
Maximum function count:    2233771529
Merged profile SHA256: 71839a7ed69f1d5281a543fd090425bde6fd0326c64bb4cf6e1d58260e8ab4a1
Final module SHA256:   4639573afee88dd3f2510293e7ee8d0672567bd7c4817ae3825284101c622ff6
Active module updated: yes

Checked against the artefacts on disk rather than only the summary:

Claim Evidence
-fprofile-generate reaches compilation FLAGS = -fprofile-generate -O3 ... -flto=thin -O2 -ffp-contract=off on all 191 translation units in the generated build.ninja
...and linking LINK_FLAGS = -shared -fprofile-generate -fuse-ld=lld-link
A .profraw is written 40,420,032 bytes, one file, from a normally-closed run
The merged profile has nonzero counts Total functions: 1138, Maximum function count: 2233771529, Total count: 93602657813
No stale-profile diagnostics the use build compiled with -Werror=profile-instr-out-of-date and produced one unrelated -Wcomment warning from GXRuntime and nothing else
Instrumented and use modules differ e60ab604... (140 MB) vs 4639573a... (79 MB)
Generate and use get different cache keys ...-7e2807c1130bafbe vs ...-d2fdc6f1b4437e35
active-module.txt names the PGO module points at the -d2fdc6f1b4437e35 module
A failed stage leaves the active module alone three runs failed at three different stages (instrumented build, training run, profile validation) and no active-module.txt was ever created; work files were kept each time

The training run was closed with WM_CLOSE — a normal window close, not a kill — because that distinction is the point: the profiling runtime flushes on the normal shutdown path.

Three defects were found by this end-to-end run and fixed in their own commits; none of them could have been caught by unit tests:

  1. The module build exceeded Windows' 260-character path limit at chunk 41/192 and died with ninja: error: ... Unable to create file. No such file or directory.
  2. llvm-profdata show returned 'C:\Program' is not recognized as an internal or external command, so the 1138-function profile above was rejected as empty. _wpopen runs cmd /c, which drops the quotes around a spaced program path once a second quoted argument follows.
  3. The training run's failure reported only "nonzero", which made a harness problem indistinguishable from a real one.

End-to-end, LLVM backend, real game

Same game, --backend llvm, with moderngekko-port built -DDOLRECOMP_ENABLE_LLVM=ON against LLVM 20.1.8, and Clang and llvm-profdata 20.1.8 on PATH so they match the LLVM DolRecomp links.

moderngekko-port pgo-run <extracted>/GM4E01 --backend llvm --toolchain clang     --output C:\mgpgo\llvm\modules --profile-dir C:\mgpgo\llvm\pgo --keep-work

Command exit status 0.

Claim Evidence
The DolRecomp PGO environment actually reaches the recompiler DolRecomp echoed dolllvm: PGO use, profile C:\mgpgo\llvm\pgo\merged.profdata (7b5595c7b89c0384/23640128)
IR-level instrumentation really happened the profile holds 11,710 functions against the C backend's 1,138 on the same game — the C half of this module is only a few dozen translation units, so the extra records are the recompiled PowerPC functions DolRecomp instrumented
No stale functions in use mode dolllvm: PGO profile match: 11606/11606 functions matched, 0 unmatched across 0 stale chunks, under DOLRECOMP_LLVM_PGO_STALE=error. This is DolRecomp's positive check — every function that matched its record carries entry-count metadata — so a full match is a direct statement that the profile and the emitted IR agree
-fprofile-generate reaches compilation and linking FLAGS = -fprofile-generate -O3 ... and LINK_FLAGS = -shared -fprofile-generate -fuse-ld=lld-link in the module build, alongside the 5,803 objects DolRecomp had already instrumented
A .profraw is written 23,635,408 bytes from a normally-closed run
Instrumented and use modules differ ef42ccdc... (413 MB) vs b296f53f... (327 MB)
Generate and use get different cache keys ...-9b8e223a7de7c7ff vs ...-bdbf0415b1d74eb9, and both differ from the C backend's keys for the same game
active-module.txt names the PGO module points at the -bdbf0415b1d74eb9 module

The resulting pgo-manifest.txt:

pgo_mode=use
pgo_backend=llvm
pgo_profile_sha256=80296e82d54cc92ab3068fddb22620c71bcea367bc392593661819d313e21d71
pgo_raw_profile_count=1
pgo_total_function_count=11710
pgo_max_function_count=1183949576
pgo_stale_policy=error
clang_version=clang version 20.1.8
llvm_profdata_version=LLVM version 20.1.8

Reported from the field

Thank you to @mooman for flagging this.

After the runs above, a TimeSplitters 2 (GTSE4F) --backend llvm run on Linux failed at stage 6 with unsupported instrumentation profile format version, repeated once per chunk worker. Stages 1-5 had all succeeded, including a merged, validated 10,836-function profile: Clang and llvm-profdata were both 22.1.8 and agreed with each other, and the LLVM inside DolRecomp — 19 or 20 — rejected what they produced. That is the third-LLVM case described under Implementation, and it is what the added diagnostic and README section are for.

Worth noting that the transaction safety behaved correctly under a real failure that nobody engineered: the run exited nonzero, named the stage, left the previously active module untouched, and kept the work files.

Platforms

  • Windows 11 / MSVC 18 — full build, full test suite, and both end-to-end runs above, locally. The C-backend run used Clang and llvm-profdata 22.1.5; the LLVM-backend run used 20.1.8 to match the LLVM that DolRecomp links.
  • Linux, macOS — CI only (standalone tests), plus the full build and test job on Linux and Windows.

Limitations

  • Training is interactive. There is no scripted workload, no timed exit, and no title-specific training plan in this PR.
  • A representative workload is the developer's responsibility. The profile is only as good as what was played.
  • Clang and a matching llvm-profdata are required. GCC and MSVC fail immediately with an explanation rather than quietly producing an unprofiled module.
  • --backend llvm additionally requires Clang and llvm-profdata to match the LLVM DolRecomp was built against (LLVM 19 or 20 for the pinned recompiler). This one is not checked, because DolRecomp does not report its LLVM version; it is documented in the README and explained on failure. The proper fix is a --version in DolRecomp that prints LLVM_PACKAGE_VERSION, which would let this check run in a second instead of failing ten minutes into a build — that belongs in a DolRecomp change, not here.
  • No universal speedup is claimed. PGO gains vary by title, backend, host architecture and training workload, and this PR deliberately reports none.

`pgo-run` builds an instrumented module, runs it so a developer can play a
representative workload, merges the profiles that produces, validates them, and
builds the final module against them.

The profiled program is the generated per-game module, not moderngekko-port. The
port is a build driver that runs for minutes; the module is the recompiled game,
and it is where the frames go.

Both backends are covered, and each needs its instrumentation in a different
place. The C backend's chunks are C, so clang's -fprofile-generate and
-fprofile-use= reach them through the module's compile flags. The LLVM backend
emits and codegens IR in-process, so no compile flag ever reaches those chunks;
instrumentation comes from DolRecomp's own IR-level PGO passes. In both cases
-fprofile-generate also has to reach the module *link* line, which is where the
profiling runtime comes from -- an instrumented module that never linked it
writes no profile at all.

PGO is modelled as a semantic build input rather than an ambient CFLAGS mutation
around two Build() calls, and it is part of the module cache identity: the mode
(off/generate/use) and, for use builds, the SHA-256 of the merged profile's
*contents*. A plain module can no longer answer a PGO build, and two different
profiles written to the same scratch filename no longer collide. The same
profile moved elsewhere still reuses its module, because the path is not part of
the identity.

The same key gap existed for DolRecomp's codegen environment variables, which
were not in the key at all, so those are folded in when set.

A failed run must never leave an instrumented module selected. The generation
build writes to a private cache root, and the PGO module becomes active only
after every stage has succeeded. Any failure exits nonzero, names the stage, and
leaves the previously active module untouched.

Incidental to the above:

- The command line moved out of main() into tools/port_command_line.hpp so the
  one path every command goes through is testable.
- SHA-256 moved from src/runtime/game.cpp to a header, so a profile can be
  hashed without linking the runtime.
- Process launching on Windows moved to the wide-character APIs. Quoting went
  through path::string(), which narrows via the active code page, so a cache
  under a path that code page cannot represent reached the compiler as question
  marks.
A real GM4E01 build died at 41/192 with

  ninja: error: WriteFile(...cpu_interpreter_integer.c.obj.rsp):
  Unable to create file. No such file or directory

which says nothing about what was wrong: the module build's object and response
files had gone past Windows' 260-character limit, and ninja does not use the
\?\ extended-length prefix. A plain build already sits close to that limit, and
pgo-run nests a second module cache under the workspace, which was enough to
push an ordinary cache location over.

Measure the longest path the module build would need before building anything
and stop with a message naming --profile-dir, rather than twenty minutes later
inside ninja. The workspace's generation cache is also renamed from
generate-modules to gen, since it prefixes every one of those paths.
`llvm-profdata show` came back as

  'C:\Program' is not recognized as an internal or external command

so a merged profile with 1138 functions and a maximum count of 2.2 billion was
reported as "no profile summary" and the run failed at validation.

ReadCommand goes through _wpopen, which runs `cmd /c <command>`, and cmd drops
the quotes around a program path containing spaces as soon as a second quoted
argument follows it. One quoted argument is fine, which is why probing
`llvm-profdata --version` worked and reading the summary did not. Wrapping the
whole command in one more pair of quotes makes cmd strip exactly that pair and
pass the rest through, which is what `cmd /c "..."` is documented to do.

RunCommand was never affected: it builds the argument vector for CreateProcessW
itself and no shell is involved, which is why the merge that produced the
profile succeeded in the same run that failed to read it back.

Also report the training run's exit status rather than only that it was
nonzero. A game closed normally exits 0, and every other value is a different
problem; RunCommand collapsed all of them to false.
llvm-profdata indents its version under the "LLVM (http://llvm.org/):"
heading, so the manifest recorded

  llvm_profdata_version=  LLVM version 22.1.5
@dougchansan
dougchansan marked this pull request as ready for review August 11, 2026 04:20
…build

Reported from a TimeSplitters 2 run: stages 1 to 5 all succeeded -- the module
was instrumented, the game trained, and llvm-profdata merged and validated a
10,836-function profile -- and then the PGO build failed with

  error: .../merged.profdata: unsupported instrumentation profile format version

repeated once per chunk worker, with nothing said about versions.

There are three LLVMs in an LLVM-backend PGO build, not two. clang compiles the
module's C sources and supplies the profiling runtime, llvm-profdata merges what
that runtime writes, and the LLVM DolRecomp is linked against reads the result
back inside PGOInstrumentationUse. The existing check compares the first two,
which is why clang 22 and llvm-profdata 22 passed it, and the recompiler -- built
against LLVM 19 or 20, as its CMakeLists requires -- rejected the profile they
agreed on.

The check cannot be extended to cover the third: DolRecomp does not report its
own LLVM version, so there is nothing to compare against. It also fails late,
because DolRecomp opens the profile eagerly but only parses it once emission has
started.

So say what happened instead. On a failed LLVM-backend PGO build, name the
versions actually used, state that the pinned recompiler wants LLVM 19 or 20,
and give the ldd invocation that shows which one it has. Note specifically that
overriding --llvm-profdata alone does not fix it, since the C sources are
compiled by whichever clang is on PATH and that clang provides the runtime --
the obvious first thing to try, and it does not work.

README documents the same constraint up front.

Thank you to @mooman for flagging this.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant