Skip to content

Make the LLVM backend produce a module that runs - #10

Merged
siahisaforker merged 14 commits into
ExpansionPak:mainfrom
dougchansan:llvm-backend-windows-fixes
Aug 4, 2026
Merged

Make the LLVM backend produce a module that runs#10
siahisaforker merged 14 commits into
ExpansionPak:mainfrom
dougchansan:llvm-backend-windows-fixes

Conversation

@dougchansan

@dougchansan dougchansan commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Three separate faults stop the LLVM backend getting a retail DOL to a running
module. Two abort recompilation; the third produces a module that loads, pegs a
core, and never presents a frame. One commit each (1–3).

Two further commits (4–5) are about Windows rather than the backend itself: the
LLVM tests could not compile or pass on a Windows host, and the tree does not
link there at all with -DDOLRECOMP_ENABLE_LLVM=ON. Those are plausibly why the
backend was broken in three independent ways at once — nothing was exercising it
on this platform.

Two later commits answer @siahisaforker's review and are written up in the reply
below rather than here: the object cache version, and a replacement for the CRT
fix in section 5.

Tested on Luigi's Mansion (NTSC-U, GLME01) — 532,936 instructions, 0 unknown
opcodes. Before: recompilation aborted. After: boots into gameplay and renders,
and ctest runs 17/17 on Windows.

This is about the backend working at all. It is not faster than the C backend
— numbers below.


1. a71fbbf — Raise the MSVC stack for the LLVM backend

Symptom: emits all 522 objects, then dies with STATUS_STACK_OVERFLOW
(0xC00000FD) before writing the manifest. Looks like a run that succeeded and
then vanished.

Cause: LLVM's IR builders and pass pipeline recurse with function size. MSVC
links executables with a 1 MiB stack; the toolchains this backend was developed
against use 8 MiB.

Change: target_link_options(dolrecomp PRIVATE /STACK:8388608), under MSVC
only and only when the LLVM backend is enabled. CMakeLists.txt, 7 lines.


2. 4984518 — Stop instcombine's fixpoint check from aborting recompilation

Symptom: with enough stack to finish emission, the run dies on
LLVM ERROR: Instruction Combining on func_80064760 did not reach a fixpoint after 1 iterations.

Cause: that check is a self-diagnostic for the pass, not a property of the IR.
Recompiled Gekko functions have long straight-line integer and condition-flag
sequences where the pass can still be making progress when the check runs. It
reports via report_fatal_error, so one function kills the whole recompilation.

Change: pass pipeline asks for instcombine<no-verify-fixpoint>, as the
diagnostic itself suggests. llvm_backend.cpp, 1 line plus a comment.

Note: the DolRecomp vendored in RecompCore-ModernGekko already has this, so the
two trees have diverged on a known crash.


3. 6f23363 — Guarantee the dispatcher regains control across calls

Symptom: this is the 0 FPS one. Module loads, reports its entry point, then
pegs a core forever with frame_count=0 and present_count=0. The runtime never
regains control, so it can never advance timing or service the GPU.

Cause: emitBudgetGuard yields once cycles_ hits 256, making cycles_ both
the cycle-charging accumulator and the yield scheduler. Every call and helper
resume point clears it — eight sites. Clearing is correct for charging, since
materialize() already flushed those cycles into downcount, but it also
restarts the yield countdown. So a loop whose body crosses a call never reaches
the threshold. It nests: a helper called in a loop does not reach 256 in its own
frame either, so neither caller nor callee ever hands control back.

externalDestination already documents this for the unlinked case, where it
declines to emit a direct call for exactly this reason. The linked case has the
same problem.

Change: a guard_steps_ counter, incremented per block, that no resume path
clears; the guard yields on either bound.

Value *over_cycles = builder_.CreateICmpUGE(cycles, ...256);
Value *over_steps  = builder_.CreateICmpUGE(next_steps, ...2048);
Value *exhausted   = builder_.CreateOr(over_cycles, over_steps);

Cycle accounting untouched, so nothing is charged twice — only the yield decision
survives a call. llvm_function_emitter.{cpp,h}, 22 lines.


4. 2090d02 — Let the LLVM tests build and run on Windows

test_llvm_pipeline.c used fork/execl/waitpid via sys/wait.h, and both
it and test_llvm_backend.cpp asserted the emitted objects begin with the ELF
magic. Neither holds on Windows: there is no sys/wait.h, and the emitted object
format follows the default target triple, so a Windows host produces COFF.

_spawnl with _P_WAIT replaces the fork/exec pair — it runs the child to
completion and returns its exit status directly, and the child inherits this
process's environment, so the chunk-size override is set before the call rather
than between fork and exec. The magic check becomes an is_native_object()
helper that looks for IMAGE_FILE_MACHINE_AMD64 (0x64 0x86, little-endian) on
Windows and ELF elsewhere. POSIX paths are unchanged.

tests/test_llvm_pipeline.c and tests/test_llvm_backend.cpp, 42 lines.


5. a1f1d5e — Match the static CRT when linking against the prebuilt LLVM libraries

Configuring with -DDOLRECOMP_ENABLE_LLVM=ON under MSVC does not link. The
prebuilt LLVM Windows release libraries are built against the static CRT (/MT)
while CMake defaults these targets to the DLL CRT, so the link ends in five
unresolved __imp__* CRT symbols — fseek, ftell, _ftelli64, _mkdir,
system — reported against dr_frontend and dr_platform rather than against
anything the caller wrote.

This commit forced /MT at the top of the file and gave test_rpx the opposite
treatment when DolRecomp is built as a subdirectory. That was wrong, and
e5ba721 replaces it
— see the reply below. Kept in the history rather than
squashed because the review comment that found it is worth reading against the
code it describes.


Verification

Before After
cmake configure + link (MSVC) unresolved __imp__* links
--backend=llvm exit 0xC00000FD 0, manifest written
Objects on disk 3 of 522 522 of 522
Module loads, 0 frames ever boots to gameplay
frame_count at 4 samples 0, 0, 0, 0 177, 772, 1373, 1973
ctest 3 LLVM tests uncompilable 17/17

Renders correctly rather than merely presenting: mansion exterior, file-select
screen, and in-game Foyer with Luigi's flashlight lighting the floor. Loads
savestates. In the IR, guard_steps carries across call_resume while cycles
is still reset there, and surviving budget_exit blocks in chunk 0 go 431 → 549.

The "after" column was re-measured on this branch's actual base (93b881c)
rather than carried over from where the work started. That matters here: the
tree I originally developed against turned out to predate 93b881c, so every
number was re-taken rather than assumed to still hold. The build was also
configured without -DCMAKE_MSVC_RUNTIME_LIBRARY on the command line, so the
link result tests commit 5 instead of masking it.

Performance

Both backends built from this branch, same module flags (-O3 -march=native),
same savestate, frame limiter disabled so the numbers measure headroom rather
than saturating at the 100% cap:

Configuration Mean Range SD Module
C backend 169.4% 165.5 – 171.9% 48 MB
LLVM, -O3 73.6% 73.1 – 74.0% 0.3% 541 MB

LLVM runs at about 43% of the C backend's throughput, from a module 11x the
size. In practice that is the difference between holding the game's 30 FPS cap
with room to spare and running at roughly 22.

That gap is much wider than it used to be, and not because the LLVM backend
regressed — 93b881c roughly doubled the C backend. Measured against the
pre-93b881c tree the two were within a few points of each other, which is
what an earlier revision of this section reported. The C-side CFG work simply
left the LLVM path behind.

A measurement note that cost me some bad numbers, in case it saves you the same:
this module needs a long warmup. At 15 seconds three runs gave 70.2%, 73.8% and
165.2%; at 45 seconds five consecutive runs gave 73.1–74.0% with an SD of 0.3%.
The 165.2% was never reproduced and appears to have been an artifact. Short
warmups on a 541 MB module produce numbers that look plausible individually and
are not stable.

Raising codegen off the hardcoded createTargetMachine(triple, "generic", "") to
the host CPU made no measurable difference, so it is deliberately not in this PR.

None of this is an argument for the LLVM backend on speed. It is slower, larger,
and these commits do not change that — they only make it build and run at all on
Windows, which is the claim this PR is making.

One note, not fixed here

  • The module cache key covers the DOL and toolchain but not
    DOLRECOMP_LLVM_OPT_LEVEL, so builds differing only in opt level silently reuse
    each other's objects. That gave me a mixed-optimization module before I noticed.
    Separate from the version bump in commit 6, which only invalidates across
    emitter changes.

Environment

MSVC 14.50.35717 (VS 18), Ninja, Release, LLVM 20.1.8, Windows 11. No
-DCMAKE_MSVC_RUNTIME_LIBRARY on the command line — commit 5 makes that
unnecessary, and the tree was configured without it deliberately so the build
result tests that commit rather than masking it. Clean configure, full build,
ctest 17/17.

/STACK:8388608 matches other toolchains' default. The 2048-block bound sits
alongside the existing 256-cycle one and is untuned. Both open to your preference.

Emitting a large DOL through the LLVM backend aborts with
STATUS_STACK_OVERFLOW (0xC00000FD) on MSVC builds. The failure lands after
every object has already been emitted but before the object manifest is
written, so the run looks like it succeeded right up until it disappears and
leaves an unusable output directory behind.

LLVM's IR builders and pass pipeline recurse in proportion to function size,
and MSVC defaults an executable to a 1 MiB stack where the toolchains this
backend was developed against default to 8 MiB. Nothing in the backend was
wrong; it simply had an eighth of the stack it needed.

Raise it for the dolrecomp executable when the LLVM backend is enabled, and
only under MSVC, since no other toolchain needs it.
With enough stack to get past object emission, the LLVM backend then dies on:

  LLVM ERROR: Instruction Combining on func_80064760 did not reach a fixpoint
  after 1 iterations

instcombine's fixpoint verification is a self-diagnostic for the pass, not a
correctness property of the IR it produced. Recompiled Gekko functions contain
long straight-line integer and condition-flag sequences, and on those the pass
can still be making progress when the check runs. Because it reports the
mismatch through report_fatal_error, one such function takes down the entire
recompilation rather than degrading that function's optimization.

Suppress the check, as LLVM's own diagnostic suggests. The optimization itself
still runs; only the assertion about converging in a single iteration is
dropped.
A recompiled module that reaches a loop containing a call never returns to the
dispatcher. It pegs a core and the runtime is never able to advance timing or
service the GPU, so the game runs but presents no frames at all.

emitBudgetGuard yields once cycles_ reaches 256, which makes cycles_ both the
cycle-charging accumulator and the yield scheduler. Every call and helper resume
point clears it -- correct for charging, since materialize() has already flushed
those cycles into downcount, but it also restarts the yield countdown. A loop
whose body crosses a call therefore never accumulates to the threshold. It nests:
a small helper called in a loop does not reach 256 within its own frame either,
so neither the caller nor the callee ever hands control back.

externalDestination already documents this hazard for the unlinked case, where it
declines to emit a direct call for exactly this reason. The linked case has the
same problem.

Track blocks entered since entry in a counter that no resume path clears, and
yield when either bound is hit. Cycle accounting is untouched, so nothing is
charged twice; only the yield decision now survives a call.

Verified on Luigi's Mansion (NTSC-U, GLME01): before, the module loaded and span
with frame_count=0; after, it boots to gameplay and renders. Confirmed in the
emitted IR that guard_steps carries across call_resume while cycles is still
reset there, and that surviving budget_exit blocks rose from 431 to 549 in the
first chunk.
The three LLVM tests could not run on a Windows host, so the whole backend was
untested there. That is a plausible reason it was broken in three independent
ways at once.

test_llvm_pipeline included sys/wait.h and used fork, execl and waitpid, none of
which exist on Windows, so it did not compile. It now spawns the child with
_spawnl and _P_WAIT, which runs it to completion and returns its exit status
directly; the override that fork set between fork and exec is instead set in this
process, which the child inherits. mkdir(path, 0777) becomes _mkdir(path).

Both tests then asserted the emitted object began with the ELF magic. The object
format follows the default target triple, so a Windows host emits COFF and the
check failed on a perfectly good object. Both now compare against the format the
host actually produces, via a helper in the pipeline test since it checks two
objects.

With this, ctest runs 17/17 on Windows, including llvm_backend, llvm_execute and
llvm_pipeline. codegen_compile also passes once the MSVC environment is present.
Configuring with -DDOLRECOMP_ENABLE_LLVM=ON under MSVC fails to link. The
prebuilt LLVM Windows release libraries are built against the static CRT
(/MT), while CMake defaults these targets to the DLL CRT, so the link ends
with unresolved __imp__* CRT symbols -- fseek, ftell, _ftelli64, _mkdir and
system -- reported against dr_frontend and dr_platform rather than against
anything the caller wrote.

Set CMAKE_MSVC_RUNTIME_LIBRARY to match, scoped to this subproject so an
embedding build is unaffected.

test_rpx needs the opposite treatment when DolRecomp is built as a
subdirectory: an embedding project can supply a zlib linked against the DLL
CRT, and this test pulls zlib's aligned-allocation path into the executable,
so it follows the parent there instead.
@siahisaforker

siahisaforker commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

The LLVM emitter changed, but DOLLLVM_CACHE_VERSION remains dolllvm-v4. Bump the cache version.
(pipeline.c:56)

DolRecomp forces every target to /MT, then changes only test_rpx to /MD when embedded. That executable
still links the /MT dr_frontend, likely producing LNK2038 runtime-library mismatches. The CRT policy
needs to be consistent across each final link graph
(CMakeLists.txt:8)
(CMakeLists.txt:197)

The budget guard gained guard_steps_, so a v4 object yields on the old
bound alone. Without the bump a tree that had run the previous backend
reuses those objects and shows none of the fix.
LLVMConfig.cmake ends with an unqualified

    set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded)

naming the CRT LLVM itself was built against. Discovering LLVM halfway
down the file let that land after dr_cpu, dr_platform, dr_frontend and
dr_ir were already declared, so those four kept the caller's CRT and
everything below took LLVM's. The earlier fix papered over one symptom
by forcing /MT at the top and exempting test_rpx, which put /MT
dr_frontend and a /MD test_rpx in one link: LNK4098, both LIBCMT and
MSVCRT searched, and a binary with two CRTs in it.

Move find_package(LLVM) above every target so its choice covers all of
them, and drop both the forced set() and the test_rpx exemption.

Verified per-target from build.ninja: LLVM=OFF is 23/23 /MD, LLVM=ON is
27/27 /MT standalone and the same via add_subdirectory(). ctest 14/14
with LLVM off, 17/17 with it on.
@dougchansan

dougchansan commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Both fixed.

6. 8651478 — Bump the LLVM object cache version

DOLLLVM_CACHE_VERSION stayed at dolllvm-v4 while commit 3 changed what the emitter produces, so a tree that had already run the backend would reuse v4 objects and show none of the guard fix. Now dolllvm-v5. pipeline.c, 3 lines.

7. e5ba721 — Give the whole tree one CRT by discovering LLVM before any target

You are right, and the cause turned out to sit above the test_rpx line. LLVMConfig.cmake ends with an unqualified

set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded)

naming the CRT LLVM itself was built against. Discovering LLVM halfway down CMakeLists.txt let that land after dr_cpu, dr_platform, dr_frontend and dr_ir were already declared, so those four kept the caller's CRT and everything below took LLVM's. Commit 5 masked that standalone by forcing /MT at the top; embedded, its test_rpx exemption then put a /MT dr_frontend and a /MD test_rpx in one link.

One correction to your prediction: it does not reach LNK2038. A C-only link gets no detect_mismatch pragma, so /VERBOSE:LIB shows LNK4098 with both LIBCMT and MSVCRT searched and the mixed-CRT binary links — the worse of the two outcomes, since nothing stops it.

Moving find_package(LLVM) above every target makes its choice cover all of them. Both the forced set() and the exemption are gone. Checked per target out of build.ninja:

Configuration CRT
DOLRECOMP_ENABLE_LLVM=OFF 23/23 /MD
=ON, standalone 27/27 /MT
=ON, via add_subdirectory() 27/27 /MT

A parent target declared after add_subdirectory stays /MD, so this cannot reach an embedder's own targets. ctest 14/14 with the backend off, 17/17 with it on.

CMakeLists.txt, 34 lines across both commits.

One consequence worth your call

LLVM now names the CRT for the whole subproject, so an embedder passing -DCMAKE_MSVC_RUNTIME_LIBRARY is overridden rather than obeyed. That is the direction that links, since the LLVM libraries are the fixed side, but erroring on the conflict instead is a one-line change if you would rather it be loud.

@siahisaforker

Copy link
Copy Markdown
Contributor

I found a few remaining problems:

valid_object_file() still only recognizes ELF. Windows emits COFF, so object caching and
DOLRECOMP_LLVM_RESUME won’t reuse Windows objects. This should validate the object format for the effective
target triple, which should also be included in the cache hash.

The new budget guard doesn’t behave as described. guard_steps_ is incremented only at loop headers, not
once per guest instruction. Since calls and helper resumes reset cycles_, a loop can run another 2048
iterations while downcount continues past zero before the dispatcher regains control. A cumulative cycle
budget that survives resume points would preserve the intended scheduling bound.

Lastly, CI still builds with LLVM disabled, so none of the new LLVM or Windows COFF coverage actually runs
there. An LLVM-enabled job and an execution test for a loop crossing a native call/helper would catch both
of these regressions.

…riple

Three findings from review, and the reason none of them were caught.

The budget guard read cycles_, which every runtime boundary zeroes. A
loop whose body crosses one therefore never reached 256 and ran until
the iteration backstop caught it: 2047 iterations with downcount at
-6141, 24x past the bound it was meant to enforce. chargeCycles now also
feeds guard_cycles_, which no resume point clears, and the guard reads
that. Same loop: 86 iterations, downcount -258. guard_steps_ stays, but
only as a termination backstop for blocks that cost zero cycles, and its
comment no longer claims to count instructions -- the guard runs at loop
headers, so it counts iterations.

valid_object_file() accepted only ELF, so on Windows it rejected every
COFF object the backend had just written, silently disabling the object
cache and DOLRECOMP_LLVM_RESUME. It looks like a cold build, not an
error. The triple now has one definition, shared by emission, caching and
validation, and the effective triple is hashed unconditionally so an ELF
cache and a COFF cache cannot share a key -- previously only the
environment variable was hashed, and only when set.

None of this was covered because CI never passes -DDOLRECOMP_ENABLE_LLVM
=ON: the backend, its tests and the object-format handling are built
nowhere, so a green run said nothing about them. Adds an LLVM job on
Linux and Windows, since an ELF-only build cannot see the COFF bug, and
an execution test for a loop crossing a runtime boundary that asserts the
dispatcher gets control back. That test fails on the old guard.
@dougchansan

Copy link
Copy Markdown
Contributor Author

All three are real. 6bb67ea.

valid_object_file()

Right, and worse than a missed cache: it rejected every object the backend had just written on Windows, so the run looks like a cold build rather than an error. The triple now has one definition — resolveTriple() — shared by emission, the cache and the resume check, exposed as dolllvm_effective_triple() and dolllvm_object_matches_triple() so the format knowledge stays where llvm::Triple can answer it rather than being duplicated as magic bytes in pipeline.c.

The effective triple is now hashed unconditionally. Previously only DOLRECOMP_LLVM_TARGET was, and only when set, so an unset variable on two different hosts produced the same key — an ELF cache and a COFF cache could collide, which is the sharper form of your point.

The budget guard

You are right and my description was wrong, not just optimistic. The guard is emitted under if (loop_headers_[index]), so guard_steps_ counts loop iterations; the comment claiming blocks are one per guest instruction was simply false.

Taking your suggestion: chargeCycles now also feeds guard_cycles_, which no resume point clears, and the guard reads that instead of cycles_. materialize() still subtracts cycles_, since that is the debt actually owed to downcount and clearing it at a resume is correct.

Measured on the loop the new test drives — body crosses the fallback every iteration, 3 guest cycles per iteration:

iterations downcount
guard reading cycles_ 2047 -6141
guard reading guard_cycles_ 86 -258

2047 was the iteration backstop catching it, 24x past the bound it was meant to enforce, exactly as you described. guard_steps_ survives but only as a termination backstop for a loop whose blocks all cost zero cycles (dcbf, icbi, embedded data would leave guard_cycles_ flat), and its comment now says that instead of claiming to be the scheduling bound.

CI

This is the one that matters, and it is the reason the other two existed. The workflow never passed -DDOLRECOMP_ENABLE_LLVM=ON, so the backend, its three tests and all the object-format handling were compiled nowhere. I quoted "ctest 17/17" as evidence in the description above while the job producing that number never built the code under discussion.

Added an LLVM job on both Linux and Windows — both, because an ELF-only build cannot see the COFF bug — and tests/test_llvm_execute.c now drives a loop crossing a runtime boundary and asserts the dispatcher gets control back. I checked it fails on the old guard rather than assuming it would; that is where the 2047/-6141 figures come from.

The bounds it asserts sit between the two rows above rather than pinning 86 exactly, since you flagged the thresholds as untuned and I would rather the test not fight you over retuning them.

@siahisaforker
siahisaforker merged commit 5da1e72 into ExpansionPak:main Aug 4, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants