From a71fbbf857c867fbae96277712761df2be4d9f7c Mon Sep 17 00:00:00 2001 From: Douglas Whittingham Date: Wed, 29 Jul 2026 18:02:16 -1000 Subject: [PATCH 01/14] Raise the MSVC stack for the LLVM backend 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. --- CMakeLists.txt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8f41c9d..7a83fd2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -156,6 +156,13 @@ add_executable(dolrecomp src/app/main.c) target_link_libraries(dolrecomp PRIVATE dr_app) if(DOLRECOMP_ENABLE_LLVM) set_property(TARGET dolrecomp PROPERTY LINKER_LANGUAGE CXX) + # LLVM's IR builders and pass pipeline recurse deeply enough to exhaust + # MSVC's default 1 MiB stack while emitting a large DOL, which aborts the + # run with STATUS_STACK_OVERFLOW (0xC00000FD) rather than a diagnostic. + # Other toolchains default to 8 MiB, so only MSVC needs raising. + if(MSVC) + target_link_options(dolrecomp PRIVATE /STACK:8388608) + endif() endif() add_executable(dolir_stats tools/dolir_stats.c) From 498451835f66fc640ecb61b047a2293b25249991 Mon Sep 17 00:00:00 2001 From: Douglas Whittingham Date: Wed, 29 Jul 2026 18:02:37 -1000 Subject: [PATCH 02/14] Stop instcombine's fixpoint check from aborting recompilation 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. --- src/backend/llvm/llvm_backend.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/backend/llvm/llvm_backend.cpp b/src/backend/llvm/llvm_backend.cpp index 79817f9..ee57525 100644 --- a/src/backend/llvm/llvm_backend.cpp +++ b/src/backend/llvm/llvm_backend.cpp @@ -133,7 +133,14 @@ extern "C" bool dolllvm_emit_object(const DolIRModule *source, passBuilder.crossRegisterProxies(lam, fam, cgam, mam); llvm::ModulePassManager passes; std::string pipeline = - "function(mem2reg,early-cse,instcombine,simplifycfg,sccp," + // instcombine's fixpoint check is a self-diagnostic for the pass, not a + // correctness property of the IR. Recompiled Gekko functions contain + // long straight-line integer and condition-flag sequences that can still + // be changing after one iteration, which makes the pass call + // report_fatal_error and take the whole recompilation down. Suppressing + // the check leaves the optimization itself intact. + "function(mem2reg,early-cse,instcombine," + "simplifycfg,sccp," "correlated-propagation,jump-threading,gvn,dse,adce,loop-simplify," "loop-rotate,loop-mssa(licm),loop-vectorize,slp-vectorizer,vector-" "combine," From 6f2336397ad9fde3743a63cf083776d18d38345c Mon Sep 17 00:00:00 2001 From: Douglas Whittingham Date: Wed, 29 Jul 2026 20:28:06 -1000 Subject: [PATCH 03/14] Guarantee the dispatcher regains control across calls 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. --- src/backend/llvm/llvm_function_emitter.cpp | 16 +++++++++++++++- src/backend/llvm/llvm_function_emitter.h | 7 +++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/backend/llvm/llvm_function_emitter.cpp b/src/backend/llvm/llvm_function_emitter.cpp index 8ac72e0..a0b40e2 100644 --- a/src/backend/llvm/llvm_function_emitter.cpp +++ b/src/backend/llvm/llvm_function_emitter.cpp @@ -336,6 +336,10 @@ void FunctionEmitter::emitEntry() { builder_.CreateAlloca(Type::getInt64Ty(context_), nullptr, "cycles"); builder_.CreateStore(ConstantInt::get(Type::getInt64Ty(context_), 0), cycles_); + guard_steps_ = + builder_.CreateAlloca(Type::getInt64Ty(context_), nullptr, "guard_steps"); + builder_.CreateStore(ConstantInt::get(Type::getInt64Ty(context_), 0), + guard_steps_); Value *pc = loadOffset(Type::getInt32Ty(context_), offsetof(CPUState, pc)); BasicBlock *bad = BasicBlock::Create(context_, "entry_miss", function_); auto *dispatch = builder_.CreateSwitch(pc, bad, source_.block_count); @@ -379,8 +383,18 @@ void FunctionEmitter::sideExit(u32 pc) { void FunctionEmitter::emitBudgetGuard(u32 pc) { Value *cycles = builder_.CreateLoad(Type::getInt64Ty(context_), cycles_); - Value *exhausted = builder_.CreateICmpUGE( + Value *over_cycles = builder_.CreateICmpUGE( cycles, ConstantInt::get(Type::getInt64Ty(context_), 256)); + // Blocks are emitted one per guest instruction, so this bounds a single native + // entry to a few thousand guest instructions even when every iteration crosses + // a call and clears cycles_. + Value *steps = builder_.CreateLoad(Type::getInt64Ty(context_), guard_steps_); + Value *next_steps = builder_.CreateAdd( + steps, ConstantInt::get(Type::getInt64Ty(context_), 1)); + builder_.CreateStore(next_steps, guard_steps_); + Value *over_steps = builder_.CreateICmpUGE( + next_steps, ConstantInt::get(Type::getInt64Ty(context_), 2048)); + Value *exhausted = builder_.CreateOr(over_cycles, over_steps); BasicBlock *run = BasicBlock::Create(context_, "budget_run", function_); BasicBlock *exit = BasicBlock::Create(context_, "budget_exit", function_); builder_.CreateCondBr(exhausted, exit, run); diff --git a/src/backend/llvm/llvm_function_emitter.h b/src/backend/llvm/llvm_function_emitter.h index 7b1d69c..9bc46a1 100644 --- a/src/backend/llvm/llvm_function_emitter.h +++ b/src/backend/llvm/llvm_function_emitter.h @@ -111,6 +111,13 @@ class FunctionEmitter final { llvm::Argument *ctx_ = nullptr; llvm::BasicBlock *entry_ = nullptr; llvm::AllocaInst *cycles_ = nullptr; + // Blocks entered since the dispatcher handed control to this function. + // Nothing resets it, unlike cycles_, which every call and helper resume point + // clears. That clearing is correct for charging -- materialize() has already + // flushed those cycles into downcount -- but it also means a loop containing a + // call can never reach the cycle threshold, so this counter is what actually + // guarantees the dispatcher gets control back. + llvm::AllocaInst *guard_steps_ = nullptr; std::array state_{}; std::array used_{}; std::array dirty_{}; From 2090d021eff5605c2b97aeda5db76a428175c3b0 Mon Sep 17 00:00:00 2001 From: Douglas Whittingham Date: Thu, 30 Jul 2026 17:04:39 -1000 Subject: [PATCH 04/14] Let the LLVM tests build and run on Windows 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. --- tests/test_llvm_backend.cpp | 7 +++++++ tests/test_llvm_pipeline.c | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/tests/test_llvm_backend.cpp b/tests/test_llvm_backend.cpp index e729aa1..748b9c2 100644 --- a/tests/test_llvm_backend.cpp +++ b/tests/test_llvm_backend.cpp @@ -115,7 +115,14 @@ int main(int argc, char** argv) { unsigned char magic[4]{}; CHECK(std::fread(magic, 1, sizeof(magic), object) == sizeof(magic)); std::fclose(object); + // The object format follows the default target triple, so this cannot assume + // ELF: a Windows host emits COFF, whose x86-64 objects begin with the machine + // type IMAGE_FILE_MACHINE_AMD64 (0x8664) stored little-endian. +#if defined(_WIN32) + CHECK(magic[0] == 0x64 && magic[1] == 0x86); +#else CHECK(magic[0] == 0x7f && magic[1] == 'E' && magic[2] == 'L' && magic[3] == 'F'); +#endif std::ifstream ir(argv[2]); const std::string irText((std::istreambuf_iterator(ir)), std::istreambuf_iterator()); diff --git a/tests/test_llvm_pipeline.c b/tests/test_llvm_pipeline.c index 7fb7025..9ecb7a4 100644 --- a/tests/test_llvm_pipeline.c +++ b/tests/test_llvm_pipeline.c @@ -5,14 +5,35 @@ #include #include #include + +#if defined(_WIN32) +#include +#include +#else #include #include +#endif #define CHECK(x) do { if (!(x)) { fprintf(stderr, "check failed: %s:%d: %s\n", \ __FILE__, __LINE__, #x); return 1; } } while (0) static int make_dir(const char* path) { +#if defined(_WIN32) + return _mkdir(path) == 0 || errno == EEXIST; +#else return mkdir(path, 0777) == 0 || errno == EEXIST; +#endif +} + +// The emitted object format follows the default target triple, so this cannot +// assume ELF: a Windows host produces COFF, whose x86-64 objects start with the +// machine type IMAGE_FILE_MACHINE_AMD64 (0x8664) stored little-endian. +static int is_native_object(const u8* magic) { +#if defined(_WIN32) + return magic[0] == 0x64 && magic[1] == 0x86; +#else + return magic[0] == 0x7F && magic[1] == 'E' && magic[2] == 'L' && magic[3] == 'F'; +#endif } static int write_dol(const char* path) { @@ -55,6 +76,15 @@ int main(int argc, char** argv) { snprintf(second_object, sizeof(second_object), "%s/out/generated/chunks/chunk_0001_text0_80003900.o", argv[2]); CHECK(write_dol(dol)); +#if defined(_WIN32) + // Windows has no fork. _spawnl with _P_WAIT 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 here rather than between + // fork and exec. + CHECK(_putenv_s("DOLRECOMP_LLVM_CHUNK_INSTRUCTIONS", "512") == 0); + CHECK(_spawnl(_P_WAIT, argv[1], argv[1], "--gamecube", "--backend=llvm", + "-j2", dol, output, NULL) == 0); +#else pid_t child = fork(); CHECK(child >= 0); if (child == 0) { @@ -66,6 +96,7 @@ int main(int argc, char** argv) { int status = 0; CHECK(waitpid(child, &status, 0) == child); CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0); +#endif FILE* file = fopen(header, "rb"); CHECK(file != NULL); char text[4096]; @@ -78,11 +109,11 @@ int main(int argc, char** argv) { u8 magic[4]; CHECK(fread(magic, 1, 4, file) == 4); fclose(file); - CHECK(magic[0] == 0x7F && magic[1] == 'E' && magic[2] == 'L' && magic[3] == 'F'); + CHECK(is_native_object(magic)); file = fopen(second_object, "rb"); CHECK(file != NULL); CHECK(fread(magic, 1, 4, file) == 4); fclose(file); - CHECK(magic[0] == 0x7F && magic[1] == 'E' && magic[2] == 'L' && magic[3] == 'F'); + CHECK(is_native_object(magic)); return 0; } From a1f1d5e14391cfc605ead81680752fd4871a65dd Mon Sep 17 00:00:00 2001 From: Douglas Whittingham Date: Fri, 31 Jul 2026 07:06:56 -1000 Subject: [PATCH 05/14] Match the static CRT when linking against the prebuilt LLVM libraries 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. --- CMakeLists.txt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7a83fd2..06e3c24 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,6 +5,14 @@ set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) option(DOLRECOMP_ENABLE_LLVM "Build the x86-64 LLVM object backend" OFF) +# The prebuilt LLVM Windows release libraries are compiled against the static +# CRT (/MT); match it here or the exe link fails with unresolved __imp__* CRT +# symbols (fseek, ftell, _ftelli64, _mkdir, system). Scoped to this subproject +# only. +if(MSVC AND DOLRECOMP_ENABLE_LLVM) + set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") +endif() + # Portable warnings for local development and CI. if(MSVC) add_compile_options(/W3) @@ -188,6 +196,13 @@ add_test(NAME rel COMMAND test_rel) add_executable(test_rpx tests/test_rpx.c) target_link_libraries(test_rpx PRIVATE dr_frontend) +if(MSVC AND DOLRECOMP_ENABLE_LLVM AND NOT CMAKE_SOURCE_DIR STREQUAL PROJECT_SOURCE_DIR) + # An embedding project can supply a zlib built against the DLL CRT even + # when DolRecomp's LLVM-linked tools require /MT. This test pulls zlib's + # aligned-allocation path into the final executable, so match the parent. + set_property(TARGET test_rpx PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") +endif() add_test(NAME rpx COMMAND test_rpx) add_executable(test_disc_extract tests/test_disc_extract.c) From 865147830a1ce31a01acaf5bd6c9b305ba907369 Mon Sep 17 00:00:00 2001 From: Douglas Whittingham Date: Mon, 3 Aug 2026 13:56:03 -1000 Subject: [PATCH 06/14] Bump the LLVM object cache version to v5 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. --- src/app/pipeline.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/pipeline.c b/src/app/pipeline.c index 9f623a3..c84f206 100644 --- a/src/app/pipeline.c +++ b/src/app/pipeline.c @@ -53,7 +53,9 @@ static u32 c_chunk_instructions(void) { #ifdef DOLRECOMP_ENABLE_LLVM #define DOLLLVM_DEFAULT_CHUNK_INSTRUCTIONS 1024u #define DOLLLVM_DEFAULT_WORKER_BATCH 4u -#define DOLLLVM_CACHE_VERSION "dolllvm-v4" +// v5: the budget guard gained guard_steps_, so v4 objects yield on the wrong +// bound and must not be reused. +#define DOLLLVM_CACHE_VERSION "dolllvm-v5" typedef struct { const PPCInst* insts; From e5ba7213704e966f0badf42a71943486964b24c6 Mon Sep 17 00:00:00 2001 From: Douglas Whittingham Date: Mon, 3 Aug 2026 13:56:03 -1000 Subject: [PATCH 07/14] Give the whole tree one CRT by discovering LLVM before any target 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. --- CMakeLists.txt | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 06e3c24..63c69fa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,12 +5,24 @@ set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) option(DOLRECOMP_ENABLE_LLVM "Build the x86-64 LLVM object backend" OFF) -# The prebuilt LLVM Windows release libraries are compiled against the static -# CRT (/MT); match it here or the exe link fails with unresolved __imp__* CRT -# symbols (fseek, ftell, _ftelli64, _mkdir, system). Scoped to this subproject -# only. -if(MSVC AND DOLRECOMP_ENABLE_LLVM) - set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") +# LLVM is discovered before any target exists because LLVMConfig.cmake ends with +# an unqualified `set(CMAKE_MSVC_RUNTIME_LIBRARY ...)` naming the CRT LLVM was +# built against — the prebuilt Windows release libraries say MultiThreaded (/MT). +# That assignment lands in this directory scope and applies to every target +# declared after it, so discovering LLVM in the middle of the file splits the +# tree: the libraries above it get one CRT, everything below gets another, and +# the shared static libraries then carry two CRTs into a single link. Matching +# LLVM is what makes the tools link at all (otherwise the exe ends in unresolved +# __imp__* CRT symbols: fseek, ftell, _ftelli64, _mkdir, system), so let LLVM +# name the CRT and let it apply to all of DolRecomp uniformly. +if(DOLRECOMP_ENABLE_LLVM) + enable_language(CXX) + set(CMAKE_CXX_STANDARD 17) + set(CMAKE_CXX_STANDARD_REQUIRED ON) + find_package(LLVM CONFIG REQUIRED) + if(LLVM_PACKAGE_VERSION VERSION_LESS 19 OR LLVM_PACKAGE_VERSION VERSION_GREATER_EQUAL 21) + message(FATAL_ERROR "DolRecomp LLVM backend requires LLVM 19 or 20 (found ${LLVM_PACKAGE_VERSION})") + endif() endif() # Portable warnings for local development and CI. @@ -85,13 +97,6 @@ target_include_directories(dr_ir PUBLIC ${DOLRECOMP_SRC}) target_link_libraries(dr_ir PUBLIC dr_frontend) if(DOLRECOMP_ENABLE_LLVM) - enable_language(CXX) - set(CMAKE_CXX_STANDARD 17) - set(CMAKE_CXX_STANDARD_REQUIRED ON) - find_package(LLVM CONFIG REQUIRED) - if(LLVM_PACKAGE_VERSION VERSION_LESS 19 OR LLVM_PACKAGE_VERSION VERSION_GREATER_EQUAL 21) - message(FATAL_ERROR "DolRecomp LLVM backend requires LLVM 19 or 20 (found ${LLVM_PACKAGE_VERSION})") - endif() add_library(dr_llvm STATIC src/backend/llvm/llvm_backend.cpp src/backend/llvm/llvm_control_flow.cpp @@ -196,13 +201,6 @@ add_test(NAME rel COMMAND test_rel) add_executable(test_rpx tests/test_rpx.c) target_link_libraries(test_rpx PRIVATE dr_frontend) -if(MSVC AND DOLRECOMP_ENABLE_LLVM AND NOT CMAKE_SOURCE_DIR STREQUAL PROJECT_SOURCE_DIR) - # An embedding project can supply a zlib built against the DLL CRT even - # when DolRecomp's LLVM-linked tools require /MT. This test pulls zlib's - # aligned-allocation path into the final executable, so match the parent. - set_property(TARGET test_rpx PROPERTY - MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") -endif() add_test(NAME rpx COMMAND test_rpx) add_executable(test_disc_extract tests/test_disc_extract.c) From 6bb67eaf0f50e2d6ecebdb56cd1c61935ca1a526 Mon Sep 17 00:00:00 2001 From: Douglas Whittingham Date: Mon, 3 Aug 2026 17:24:28 -1000 Subject: [PATCH 08/14] Bound a native entry by cycles that survive resume, and validate by triple 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. --- .github/workflows/cmake-single-platform.yml | 39 ++++++++++++++++++ src/app/pipeline.c | 28 +++++++------ src/backend/llvm/llvm_backend.cpp | 44 +++++++++++++++++++-- src/backend/llvm/llvm_backend.h | 13 ++++++ src/backend/llvm/llvm_function_emitter.cpp | 30 ++++++++++++-- src/backend/llvm/llvm_function_emitter.h | 13 ++++-- tests/test_llvm_backend.cpp | 17 ++++++++ tests/test_llvm_execute.c | 36 +++++++++++++++++ 8 files changed, 197 insertions(+), 23 deletions(-) diff --git a/.github/workflows/cmake-single-platform.yml b/.github/workflows/cmake-single-platform.yml index 8689e8f..87de7b2 100644 --- a/.github/workflows/cmake-single-platform.yml +++ b/.github/workflows/cmake-single-platform.yml @@ -49,3 +49,42 @@ jobs: - name: Test working-directory: ${{ github.workspace }}/build run: ctest --build-config ${{ matrix.build_type }} --output-on-failure + + # The matrix above never passes -DDOLRECOMP_ENABLE_LLVM=ON, so none of the LLVM + # backend, its three tests, or the object-format handling is built anywhere in + # CI. That is how the backend came to be broken in several independent ways at + # once while every job stayed green: a passing run said nothing about the code + # this job covers. Both hosts are here because the object format follows the + # target triple -- Linux emits ELF, Windows COFF -- and the bug that disabled + # the object cache on Windows is invisible to an ELF-only build. + llvm: + name: LLVM backend (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Install LLVM + uses: KyleMayes/install-llvm-action@v2 + with: + version: "20.1.8" + cached: true + + - name: Configure + run: > + cmake -B ${{ github.workspace }}/build -S ${{ github.workspace }} + -DCMAKE_BUILD_TYPE=Release + -DDOLRECOMP_ENABLE_LLVM=ON + -DLLVM_DIR=${{ env.LLVM_PATH }}/lib/cmake/llvm + + - name: Build + run: cmake --build ${{ github.workspace }}/build --config Release + + - name: Test + working-directory: ${{ github.workspace }}/build + run: ctest --build-config Release --output-on-failure diff --git a/src/app/pipeline.c b/src/app/pipeline.c index c84f206..6db60f4 100644 --- a/src/app/pipeline.c +++ b/src/app/pipeline.c @@ -105,16 +105,15 @@ static u32 llvm_worker_batch_size(void) { return (u32)value; } +// The emitted object format follows the target triple, so this cannot assume +// ELF: a Windows host emits COFF, and checking for ELF there rejected every +// object the backend had just written -- silently disabling both the object +// cache and DOLRECOMP_LLVM_RESUME, in a way that looks like a cold build rather +// than an error. static int valid_object_file(const char* path) { - FILE* file = fopen(path, "rb"); - if (!file) - return 0; - unsigned char magic[4]; - int valid = fread(magic, 1, sizeof(magic), file) == sizeof(magic) && - magic[0] == 0x7Fu && magic[1] == 'E' && - magic[2] == 'L' && magic[3] == 'F'; - fclose(file); - return valid; + return dolllvm_object_matches_triple(path, getenv("DOLRECOMP_LLVM_TARGET")) + ? 1 + : 0; } static int llvm_job_stamp_path(const LLVMChunkJob* job, char* path, @@ -208,9 +207,14 @@ static u64 llvm_job_hash(const LLVMChunkJob* job) { hash = hash_bytes(hash, &job->count, sizeof(job->count)); u32 state_size = (u32)sizeof(CPUState); hash = hash_bytes(hash, &state_size, sizeof(state_size)); - const char* target = getenv("DOLRECOMP_LLVM_TARGET"); - if (target) - hash = hash_bytes(hash, target, strlen(target)); + // The *effective* triple, not the environment variable, because an unset + // variable still resolves to a host triple and objects for two different + // hosts are not interchangeable. Hashing only the variable let an ELF cache + // and a COFF cache share a key. + char triple[256]; + if (dolllvm_effective_triple(getenv("DOLRECOMP_LLVM_TARGET"), triple, + sizeof(triple))) + hash = hash_bytes(hash, triple, strlen(triple)); for (u32 i = 0; i < job->count; i++) { hash = hash_bytes(hash, &job->insts[i].address, sizeof(job->insts[i].address)); diff --git a/src/backend/llvm/llvm_backend.cpp b/src/backend/llvm/llvm_backend.cpp index ee57525..832159d 100644 --- a/src/backend/llvm/llvm_backend.cpp +++ b/src/backend/llvm/llvm_backend.cpp @@ -28,6 +28,13 @@ namespace { using namespace llvm; +// Single definition of "which triple is in effect", shared by emission, the +// object cache and the resume check so the three cannot disagree. +static std::string resolveTriple(const char *requested) { + return requested && requested[0] ? std::string(requested) + : llvm::sys::getDefaultTargetTriple(); +} + static CodeGenOptLevel codegenLevel(int level) { if (level <= 0) return CodeGenOptLevel::None; @@ -72,9 +79,7 @@ extern "C" bool dolllvm_emit_object(const DolIRModule *source, int opt = options ? options->optimization_level : 2; std::string tripleName = - options && options->target_triple && options->target_triple[0] - ? options->target_triple - : llvm::sys::getDefaultTargetTriple(); + resolveTriple(options ? options->target_triple : nullptr); const llvm::Triple triple(tripleName); if (triple.getArch() != llvm::Triple::x86_64 || (!triple.isOSLinux() && !triple.isOSWindows())) { @@ -189,3 +194,36 @@ extern "C" bool dolllvm_emit_object(const DolIRModule *source, objectFile.flush(); return true; } + +extern "C" bool dolllvm_effective_triple(const char *requested, char *out, + size_t size) { + if (!out || size == 0) + return false; + const std::string triple = resolveTriple(requested); + if (triple.size() + 1 > size) + return false; + memcpy(out, triple.c_str(), triple.size() + 1); + return true; +} + +extern "C" bool dolllvm_object_matches_triple(const char *path, + const char *requested) { + FILE *file = fopen(path, "rb"); + if (!file) + return false; + unsigned char magic[4] = {0, 0, 0, 0}; + const size_t read = fread(magic, 1, sizeof(magic), file); + fclose(file); + if (read != sizeof(magic)) + return false; + + const llvm::Triple triple(resolveTriple(requested)); + if (triple.isOSBinFormatCOFF()) + // IMAGE_FILE_MACHINE_AMD64, little-endian, at offset 0 of a COFF object. + return magic[0] == 0x64 && magic[1] == 0x86; + if (triple.isOSBinFormatMachO()) + return magic[0] == 0xCF && magic[1] == 0xFA && magic[2] == 0xED && + magic[3] == 0xFE; + return magic[0] == 0x7F && magic[1] == 'E' && magic[2] == 'L' && + magic[3] == 'F'; +} diff --git a/src/backend/llvm/llvm_backend.h b/src/backend/llvm/llvm_backend.h index f5949a9..238caba 100644 --- a/src/backend/llvm/llvm_backend.h +++ b/src/backend/llvm/llvm_backend.h @@ -25,6 +25,19 @@ typedef struct { bool dolllvm_emit_object(const DolIRModule* module, const char* object_path, const DolLLVMOptions* options, FILE* diagnostics); +// Writes the triple objects are actually emitted for: `requested` when set and +// non-empty, otherwise LLVM's default host triple. Returns false if `size` is +// too small. Callers need this because the emitted object *format* follows the +// triple, so anything that caches or validates objects has to agree with the +// backend about which triple is in effect. +bool dolllvm_effective_triple(const char* requested, char* out, size_t size); + +// Whether `path` begins with the object-file magic implied by `requested`'s +// effective triple. Checking for ELF unconditionally silently disabled the +// object cache and DOLRECOMP_LLVM_RESUME on Windows, where the backend emits +// COFF. +bool dolllvm_object_matches_triple(const char* path, const char* requested); + #ifdef __cplusplus } #endif diff --git a/src/backend/llvm/llvm_function_emitter.cpp b/src/backend/llvm/llvm_function_emitter.cpp index a0b40e2..34081a2 100644 --- a/src/backend/llvm/llvm_function_emitter.cpp +++ b/src/backend/llvm/llvm_function_emitter.cpp @@ -336,6 +336,10 @@ void FunctionEmitter::emitEntry() { builder_.CreateAlloca(Type::getInt64Ty(context_), nullptr, "cycles"); builder_.CreateStore(ConstantInt::get(Type::getInt64Ty(context_), 0), cycles_); + guard_cycles_ = builder_.CreateAlloca(Type::getInt64Ty(context_), nullptr, + "guard_cycles"); + builder_.CreateStore(ConstantInt::get(Type::getInt64Ty(context_), 0), + guard_cycles_); guard_steps_ = builder_.CreateAlloca(Type::getInt64Ty(context_), nullptr, "guard_steps"); builder_.CreateStore(ConstantInt::get(Type::getInt64Ty(context_), 0), @@ -356,6 +360,15 @@ void FunctionEmitter::chargeCycles(u32 cycles) { Value *next = builder_.CreateAdd( old, ConstantInt::get(Type::getInt64Ty(context_), cycles)); builder_.CreateStore(next, cycles_); + // Same charge, into an accumulator no resume point clears. cycles_ is the + // amount still owed to downcount; guard_cycles_ is the total since dispatch, + // which is what the yield decision needs. + Value *guard_old = + builder_.CreateLoad(Type::getInt64Ty(context_), guard_cycles_); + builder_.CreateStore( + builder_.CreateAdd(guard_old, + ConstantInt::get(Type::getInt64Ty(context_), cycles)), + guard_cycles_); } void FunctionEmitter::materialize(u32 pc) { @@ -371,6 +384,10 @@ void FunctionEmitter::materialize(u32 pc) { ConstantInt::get(Type::getInt32Ty(context_), pc)); Value *downcount = loadOffset(Type::getInt64Ty(context_), offsetof(CPUState, downcount)); + // cycles_, not guard_cycles_: this is the debt still owed to downcount, and a + // resume point zeroes it precisely because that debt has just been paid. + // Subtracting the cumulative counter here would charge every earlier block + // again on each flush. Value *cycles = builder_.CreateLoad(Type::getInt64Ty(context_), cycles_); builder_.CreateStore(builder_.CreateSub(downcount, cycles), bytePtr(offsetof(CPUState, downcount))); @@ -382,12 +399,17 @@ void FunctionEmitter::sideExit(u32 pc) { } void FunctionEmitter::emitBudgetGuard(u32 pc) { - Value *cycles = builder_.CreateLoad(Type::getInt64Ty(context_), cycles_); + // Read the cumulative accumulator, not cycles_. Reading cycles_ here was the + // bug: a loop whose body crosses a call has cycles_ cleared on every resume, + // so it never reaches 256 and downcount runs past zero while the loop spins. + // guard_cycles_ survives those resumes, so this is a real bound on how long a + // single native entry can run before the dispatcher gets control back. + Value *cycles = + builder_.CreateLoad(Type::getInt64Ty(context_), guard_cycles_); Value *over_cycles = builder_.CreateICmpUGE( cycles, ConstantInt::get(Type::getInt64Ty(context_), 256)); - // Blocks are emitted one per guest instruction, so this bounds a single native - // entry to a few thousand guest instructions even when every iteration crosses - // a call and clears cycles_. + // Termination backstop for a loop whose blocks are all zero-cost; see the + // declaration. This counts loop iterations, not instructions. Value *steps = builder_.CreateLoad(Type::getInt64Ty(context_), guard_steps_); Value *next_steps = builder_.CreateAdd( steps, ConstantInt::get(Type::getInt64Ty(context_), 1)); diff --git a/src/backend/llvm/llvm_function_emitter.h b/src/backend/llvm/llvm_function_emitter.h index 9bc46a1..12806a7 100644 --- a/src/backend/llvm/llvm_function_emitter.h +++ b/src/backend/llvm/llvm_function_emitter.h @@ -111,12 +111,17 @@ class FunctionEmitter final { llvm::Argument *ctx_ = nullptr; llvm::BasicBlock *entry_ = nullptr; llvm::AllocaInst *cycles_ = nullptr; - // Blocks entered since the dispatcher handed control to this function. + // Guest cycles charged since the dispatcher handed control to this function. // Nothing resets it, unlike cycles_, which every call and helper resume point // clears. That clearing is correct for charging -- materialize() has already - // flushed those cycles into downcount -- but it also means a loop containing a - // call can never reach the cycle threshold, so this counter is what actually - // guarantees the dispatcher gets control back. + // flushed those cycles into downcount -- but it means cycles_ cannot answer + // "how long since we were last dispatched", so the yield decision reads this. + llvm::AllocaInst *guard_cycles_ = nullptr; + // Loop iterations since entry, as a termination backstop only. Every guest + // instruction in a block can cost zero cycles (dcbf, icbi, embedded data), + // which would leave guard_cycles_ flat, so a loop built only from those could + // otherwise spin forever. This is deliberately not the scheduling bound: the + // guard runs at loop headers, so it counts iterations, not instructions. llvm::AllocaInst *guard_steps_ = nullptr; std::array state_{}; std::array used_{}; diff --git a/tests/test_llvm_backend.cpp b/tests/test_llvm_backend.cpp index 748b9c2..3f702a0 100644 --- a/tests/test_llvm_backend.cpp +++ b/tests/test_llvm_backend.cpp @@ -103,6 +103,23 @@ int main(int argc, char** argv) { }; CHECK(add_chunk(&module, paired_words, 7, 0x80002B00u)); + // A loop whose body crosses a runtime boundary every iteration. The unknown + // word routes to instruction_fallback, and returning from it calls + // reloadUsedState(), which zeroes cycles_. A guard reading cycles_ therefore + // never reaches its threshold here and the loop runs to completion with + // downcount past zero -- the 0 FPS bug. test_llvm_execute drives this and + // asserts the dispatcher gets control back partway through. + // + // loop: addi r3, r3, 1 + // .long 0 ; unknown -> fallback + // cmpwi r3, 10000 + // blt loop + // blr + const u32 budget_words[] = { + 0x38630001u, 0x00000000u, 0x2C032710u, 0x4180FFF4u, 0x4E800020u, + }; + CHECK(add_chunk(&module, budget_words, 5, 0x80002C00u)); + CHECK(dolir_verify(&module, stderr)); DolLLVMOptions options{}; options.optimization_level = 2; diff --git a/tests/test_llvm_execute.c b/tests/test_llvm_execute.c index de5831b..43f4ca7 100644 --- a/tests/test_llvm_execute.c +++ b/tests/test_llvm_execute.c @@ -16,6 +16,7 @@ void func_80002800(CPUState* cpu); void func_80002900(CPUState* cpu); void func_80002A00(CPUState* cpu); void func_80002B00(CPUState* cpu); +void func_80002C00(CPUState* cpu); static u32 fallback_count; static int fallback_bad; @@ -250,6 +251,41 @@ int main(void) { CHECK(cpu.fpr[4] == cpu.fpr[5] && cpu.ps1[4] == cpu.ps1[6]); CHECK(fallback_count == 1); + + // The dispatcher has to regain control from a loop whose body crosses a + // runtime boundary. Every iteration of func_80002C00 hits the fallback, + // which zeroes cycles_, so a guard reading cycles_ can never trip and the + // loop runs all 10,000 iterations in one native entry while downcount goes + // far past zero. That is the 0 FPS failure: the runtime never gets a chance + // to advance timing or service the GPU. + // + // The body costs 3 guest cycles, so a 256-cycle budget should yield after + // about 86 iterations. Observed: 86 iterations, downcount -258. With the + // guard reading cycles_ instead: 2047 iterations, downcount -6141 -- it only + // stopped because the iteration backstop caught it, 24x past the bound it + // was supposed to enforce. The limits below sit between those two so this + // fails on the real defect without pinning the exact threshold, which is + // still open to tuning. + prepare_call(&cpu, 0x80002C00u); + cpu.gpr[3] = 0; + cpu.downcount = 0; + fallback_count = 0; + func_80002C00(&cpu); + fprintf(stderr, "budget guard: %u iterations, downcount %lld\n", + cpu.gpr[3], (long long)cpu.downcount); + CHECK(cpu.gpr[3] > 0); + CHECK(cpu.gpr[3] < 1000); + CHECK(cpu.pc >= 0x80002C00u && cpu.pc <= 0x80002C10u); + CHECK(cpu.pc != cpu.lr); + CHECK(cpu.downcount < 0 && cpu.downcount > -1024); + CHECK(fallback_count == (u32)cpu.gpr[3]); + + // Re-entering resumes rather than restarting: the guard budget is per native + // entry, so a caller that keeps calling still makes progress. + const u32 first_pass = cpu.gpr[3]; + func_80002C00(&cpu); + CHECK(cpu.gpr[3] > first_pass); + cpu_free(&cpu); return 0; } From 1de2f2e636be6dc1ad58550e380750526353ceec Mon Sep 17 00:00:00 2001 From: jw <183880766+siahisaforker@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:50:54 -0700 Subject: [PATCH 09/14] fix LLVM execution budget --- .github/workflows/cmake-single-platform.yml | 8 +-- CMakeLists.txt | 16 +---- src/app/pipeline.c | 16 ++--- src/backend/llvm/llvm_backend.cpp | 3 +- src/backend/llvm/llvm_backend.h | 11 +-- src/backend/llvm/llvm_control_flow.cpp | 13 +++- src/backend/llvm/llvm_function_emitter.cpp | 78 ++++++++++++++------- src/backend/llvm/llvm_function_emitter.h | 17 ++--- tests/test_llvm_backend.cpp | 25 +++---- tests/test_llvm_execute.c | 25 +++---- 10 files changed, 99 insertions(+), 113 deletions(-) diff --git a/.github/workflows/cmake-single-platform.yml b/.github/workflows/cmake-single-platform.yml index 87de7b2..425dcf1 100644 --- a/.github/workflows/cmake-single-platform.yml +++ b/.github/workflows/cmake-single-platform.yml @@ -50,13 +50,7 @@ jobs: working-directory: ${{ github.workspace }}/build run: ctest --build-config ${{ matrix.build_type }} --output-on-failure - # The matrix above never passes -DDOLRECOMP_ENABLE_LLVM=ON, so none of the LLVM - # backend, its three tests, or the object-format handling is built anywhere in - # CI. That is how the backend came to be broken in several independent ways at - # once while every job stayed green: a passing run said nothing about the code - # this job covers. Both hosts are here because the object format follows the - # target triple -- Linux emits ELF, Windows COFF -- and the bug that disabled - # the object cache on Windows is invisible to an ELF-only build. + # Exercise the LLVM backend and both supported object formats. llvm: name: LLVM backend (${{ matrix.os }}) runs-on: ${{ matrix.os }} diff --git a/CMakeLists.txt b/CMakeLists.txt index 63c69fa..553a0d7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,16 +5,7 @@ set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) option(DOLRECOMP_ENABLE_LLVM "Build the x86-64 LLVM object backend" OFF) -# LLVM is discovered before any target exists because LLVMConfig.cmake ends with -# an unqualified `set(CMAKE_MSVC_RUNTIME_LIBRARY ...)` naming the CRT LLVM was -# built against — the prebuilt Windows release libraries say MultiThreaded (/MT). -# That assignment lands in this directory scope and applies to every target -# declared after it, so discovering LLVM in the middle of the file splits the -# tree: the libraries above it get one CRT, everything below gets another, and -# the shared static libraries then carry two CRTs into a single link. Matching -# LLVM is what makes the tools link at all (otherwise the exe ends in unresolved -# __imp__* CRT symbols: fseek, ftell, _ftelli64, _mkdir, system), so let LLVM -# name the CRT and let it apply to all of DolRecomp uniformly. +# Discover LLVM before creating targets so its MSVC runtime choice is uniform. if(DOLRECOMP_ENABLE_LLVM) enable_language(CXX) set(CMAKE_CXX_STANDARD 17) @@ -169,10 +160,7 @@ add_executable(dolrecomp src/app/main.c) target_link_libraries(dolrecomp PRIVATE dr_app) if(DOLRECOMP_ENABLE_LLVM) set_property(TARGET dolrecomp PROPERTY LINKER_LANGUAGE CXX) - # LLVM's IR builders and pass pipeline recurse deeply enough to exhaust - # MSVC's default 1 MiB stack while emitting a large DOL, which aborts the - # run with STATUS_STACK_OVERFLOW (0xC00000FD) rather than a diagnostic. - # Other toolchains default to 8 MiB, so only MSVC needs raising. + # Large LLVM modules exceed MSVC's default 1 MiB stack. if(MSVC) target_link_options(dolrecomp PRIVATE /STACK:8388608) endif() diff --git a/src/app/pipeline.c b/src/app/pipeline.c index 6db60f4..dfb421b 100644 --- a/src/app/pipeline.c +++ b/src/app/pipeline.c @@ -53,9 +53,8 @@ static u32 c_chunk_instructions(void) { #ifdef DOLRECOMP_ENABLE_LLVM #define DOLLLVM_DEFAULT_CHUNK_INSTRUCTIONS 1024u #define DOLLLVM_DEFAULT_WORKER_BATCH 4u -// v5: the budget guard gained guard_steps_, so v4 objects yield on the wrong -// bound and must not be reused. -#define DOLLLVM_CACHE_VERSION "dolllvm-v5" +// v6 carries the execution budget across generated function calls. +#define DOLLLVM_CACHE_VERSION "dolllvm-v6" typedef struct { const PPCInst* insts; @@ -105,11 +104,7 @@ static u32 llvm_worker_batch_size(void) { return (u32)value; } -// The emitted object format follows the target triple, so this cannot assume -// ELF: a Windows host emits COFF, and checking for ELF there rejected every -// object the backend had just written -- silently disabling both the object -// cache and DOLRECOMP_LLVM_RESUME, in a way that looks like a cold build rather -// than an error. +// Validate the object format selected by the target triple. static int valid_object_file(const char* path) { return dolllvm_object_matches_triple(path, getenv("DOLRECOMP_LLVM_TARGET")) ? 1 @@ -207,10 +202,7 @@ static u64 llvm_job_hash(const LLVMChunkJob* job) { hash = hash_bytes(hash, &job->count, sizeof(job->count)); u32 state_size = (u32)sizeof(CPUState); hash = hash_bytes(hash, &state_size, sizeof(state_size)); - // The *effective* triple, not the environment variable, because an unset - // variable still resolves to a host triple and objects for two different - // hosts are not interchangeable. Hashing only the variable let an ELF cache - // and a COFF cache share a key. + // Host triples must distinguish caches when no target was requested. char triple[256]; if (dolllvm_effective_triple(getenv("DOLRECOMP_LLVM_TARGET"), triple, sizeof(triple))) diff --git a/src/backend/llvm/llvm_backend.cpp b/src/backend/llvm/llvm_backend.cpp index 832159d..05ecf2f 100644 --- a/src/backend/llvm/llvm_backend.cpp +++ b/src/backend/llvm/llvm_backend.cpp @@ -28,8 +28,7 @@ namespace { using namespace llvm; -// Single definition of "which triple is in effect", shared by emission, the -// object cache and the resume check so the three cannot disagree. +// Shared by emission, cache hashing and resume validation. static std::string resolveTriple(const char *requested) { return requested && requested[0] ? std::string(requested) : llvm::sys::getDefaultTargetTriple(); diff --git a/src/backend/llvm/llvm_backend.h b/src/backend/llvm/llvm_backend.h index 238caba..51743e2 100644 --- a/src/backend/llvm/llvm_backend.h +++ b/src/backend/llvm/llvm_backend.h @@ -25,17 +25,10 @@ typedef struct { bool dolllvm_emit_object(const DolIRModule* module, const char* object_path, const DolLLVMOptions* options, FILE* diagnostics); -// Writes the triple objects are actually emitted for: `requested` when set and -// non-empty, otherwise LLVM's default host triple. Returns false if `size` is -// too small. Callers need this because the emitted object *format* follows the -// triple, so anything that caches or validates objects has to agree with the -// backend about which triple is in effect. +// Resolve an optional target to the triple used for emission. bool dolllvm_effective_triple(const char* requested, char* out, size_t size); -// Whether `path` begins with the object-file magic implied by `requested`'s -// effective triple. Checking for ELF unconditionally silently disabled the -// object cache and DOLRECOMP_LLVM_RESUME on Windows, where the backend emits -// COFF. +// Validate an object's magic against the effective target triple. bool dolllvm_object_matches_triple(const char* path, const char* requested); #ifdef __cplusplus diff --git a/src/backend/llvm/llvm_control_flow.cpp b/src/backend/llvm/llvm_control_flow.cpp index e4fec63..745768a 100644 --- a/src/backend/llvm/llvm_control_flow.cpp +++ b/src/backend/llvm/llvm_control_flow.cpp @@ -37,13 +37,20 @@ BasicBlock *FunctionEmitter::externalDestination(const DolIRTerminator &term, context_, term.linked ? "direct_call" : "direct_tail", function_); IRBuilderBase::InsertPoint saved = builder_.saveIP(); builder_.SetInsertPoint(callBlock); + emitBudgetGuard(target); materialize(target); char name[64]; - snprintf(name, sizeof(name), "func_%08X", range->start); + snprintf(name, sizeof(name), "func_%08X_budget", range->start); auto callee = module_.getOrInsertFunction( name, FunctionType::get(Type::getVoidTy(context_), - {PointerType::getUnqual(context_)}, false)); - builder_.CreateCall(callee, {ctx_}); + {PointerType::getUnqual(context_), + PointerType::getUnqual(context_), + PointerType::getUnqual(context_)}, false)); + if (auto *calleeFunction = dyn_cast(callee.getCallee())) { + calleeFunction->setVisibility(GlobalValue::HiddenVisibility); + calleeFunction->setDSOLocal(true); + } + builder_.CreateCall(callee, {ctx_, guard_cycles_, guard_steps_}); if (!term.linked) { builder_.CreateRetVoid(); builder_.restoreIP(saved); diff --git a/src/backend/llvm/llvm_function_emitter.cpp b/src/backend/llvm/llvm_function_emitter.cpp index 34081a2..a7cc89b 100644 --- a/src/backend/llvm/llvm_function_emitter.cpp +++ b/src/backend/llvm/llvm_function_emitter.cpp @@ -24,15 +24,28 @@ FunctionEmitter::FunctionEmitter(LLVMContext &context, Module &module, ranges_(ranges), range_count_(range_count) {} bool FunctionEmitter::emit(raw_ostream &diagnostics) { + auto *pointer = PointerType::getUnqual(context_); auto *type = FunctionType::get(Type::getVoidTy(context_), - {PointerType::getUnqual(context_)}, false); - function_ = Function::Create(type, GlobalValue::ExternalLinkage, source_.name, - module_); + {pointer, pointer, pointer}, false); + const std::string bodyName = std::string(source_.name) + "_budget"; + function_ = module_.getFunction(bodyName); + if (!function_) + function_ = Function::Create(type, GlobalValue::ExternalLinkage, bodyName, + module_); + if (function_->getFunctionType() != type || !function_->empty()) { + diagnostics << "dolllvm: conflicting native body " << bodyName << "\n"; + return false; + } function_->setCallingConv(CallingConv::C); function_->setVisibility(GlobalValue::HiddenVisibility); function_->setDSOLocal(true); + function_->addFnAttr(Attribute::NoInline); ctx_ = function_->getArg(0); ctx_->setName("ctx"); + guard_cycles_ = function_->getArg(1); + guard_cycles_->setName("guard_cycles"); + guard_steps_ = function_->getArg(2); + guard_steps_->setName("guard_steps"); entry_ = BasicBlock::Create(context_, "entry", function_); for (u32 i = 0; i < source_.block_count; i++) @@ -44,7 +57,38 @@ bool FunctionEmitter::emit(raw_ostream &diagnostics) { for (u32 i = 0; i < source_.block_count; i++) if (!emitBlock(i, diagnostics)) return false; - return !verifyFunction(*function_, &diagnostics); + if (verifyFunction(*function_, &diagnostics)) + return false; + return emitWrapper(diagnostics); +} + +bool FunctionEmitter::emitWrapper(raw_ostream &diagnostics) { + auto *pointer = PointerType::getUnqual(context_); + auto *type = FunctionType::get(Type::getVoidTy(context_), {pointer}, false); + Function *wrapper = module_.getFunction(source_.name); + if (!wrapper) + wrapper = Function::Create(type, GlobalValue::ExternalLinkage, source_.name, + module_); + if (wrapper->getFunctionType() != type || !wrapper->empty()) { + diagnostics << "dolllvm: conflicting native entry " << source_.name << "\n"; + return false; + } + wrapper->setCallingConv(CallingConv::C); + wrapper->setVisibility(GlobalValue::HiddenVisibility); + wrapper->setDSOLocal(true); + wrapper->getArg(0)->setName("ctx"); + + BasicBlock *entry = BasicBlock::Create(context_, "entry", wrapper); + IRBuilder<> builder(entry); + AllocaInst *guardCycles = + builder.CreateAlloca(Type::getInt64Ty(context_), nullptr, "guard_cycles"); + AllocaInst *guardSteps = + builder.CreateAlloca(Type::getInt64Ty(context_), nullptr, "guard_steps"); + builder.CreateStore(builder.getInt64(0), guardCycles); + builder.CreateStore(builder.getInt64(0), guardSteps); + builder.CreateCall(function_, {wrapper->getArg(0), guardCycles, guardSteps}); + builder.CreateRetVoid(); + return !verifyFunction(*wrapper, &diagnostics); } std::string FunctionEmitter::blockName(u32 index) const { @@ -336,14 +380,6 @@ void FunctionEmitter::emitEntry() { builder_.CreateAlloca(Type::getInt64Ty(context_), nullptr, "cycles"); builder_.CreateStore(ConstantInt::get(Type::getInt64Ty(context_), 0), cycles_); - guard_cycles_ = builder_.CreateAlloca(Type::getInt64Ty(context_), nullptr, - "guard_cycles"); - builder_.CreateStore(ConstantInt::get(Type::getInt64Ty(context_), 0), - guard_cycles_); - guard_steps_ = - builder_.CreateAlloca(Type::getInt64Ty(context_), nullptr, "guard_steps"); - builder_.CreateStore(ConstantInt::get(Type::getInt64Ty(context_), 0), - guard_steps_); Value *pc = loadOffset(Type::getInt32Ty(context_), offsetof(CPUState, pc)); BasicBlock *bad = BasicBlock::Create(context_, "entry_miss", function_); auto *dispatch = builder_.CreateSwitch(pc, bad, source_.block_count); @@ -360,9 +396,7 @@ void FunctionEmitter::chargeCycles(u32 cycles) { Value *next = builder_.CreateAdd( old, ConstantInt::get(Type::getInt64Ty(context_), cycles)); builder_.CreateStore(next, cycles_); - // Same charge, into an accumulator no resume point clears. cycles_ is the - // amount still owed to downcount; guard_cycles_ is the total since dispatch, - // which is what the yield decision needs. + // The shared guard survives helper and generated-function boundaries. Value *guard_old = builder_.CreateLoad(Type::getInt64Ty(context_), guard_cycles_); builder_.CreateStore( @@ -384,10 +418,7 @@ void FunctionEmitter::materialize(u32 pc) { ConstantInt::get(Type::getInt32Ty(context_), pc)); Value *downcount = loadOffset(Type::getInt64Ty(context_), offsetof(CPUState, downcount)); - // cycles_, not guard_cycles_: this is the debt still owed to downcount, and a - // resume point zeroes it precisely because that debt has just been paid. - // Subtracting the cumulative counter here would charge every earlier block - // again on each flush. + // Only unmaterialized cycles are owed to downcount. Value *cycles = builder_.CreateLoad(Type::getInt64Ty(context_), cycles_); builder_.CreateStore(builder_.CreateSub(downcount, cycles), bytePtr(offsetof(CPUState, downcount))); @@ -399,17 +430,12 @@ void FunctionEmitter::sideExit(u32 pc) { } void FunctionEmitter::emitBudgetGuard(u32 pc) { - // Read the cumulative accumulator, not cycles_. Reading cycles_ here was the - // bug: a loop whose body crosses a call has cycles_ cleared on every resume, - // so it never reaches 256 and downcount runs past zero while the loop spins. - // guard_cycles_ survives those resumes, so this is a real bound on how long a - // single native entry can run before the dispatcher gets control back. + // Guard the whole native call chain, not one generated function. Value *cycles = builder_.CreateLoad(Type::getInt64Ty(context_), guard_cycles_); Value *over_cycles = builder_.CreateICmpUGE( cycles, ConstantInt::get(Type::getInt64Ty(context_), 256)); - // Termination backstop for a loop whose blocks are all zero-cost; see the - // declaration. This counts loop iterations, not instructions. + // Backstop for zero-cycle loops. Value *steps = builder_.CreateLoad(Type::getInt64Ty(context_), guard_steps_); Value *next_steps = builder_.CreateAdd( steps, ConstantInt::get(Type::getInt64Ty(context_), 1)); diff --git a/src/backend/llvm/llvm_function_emitter.h b/src/backend/llvm/llvm_function_emitter.h index 12806a7..cebaaae 100644 --- a/src/backend/llvm/llvm_function_emitter.h +++ b/src/backend/llvm/llvm_function_emitter.h @@ -49,6 +49,7 @@ class FunctionEmitter final { void scanLoopHeaders(); void emitEntry(); + bool emitWrapper(llvm::raw_ostream &diagnostics); void chargeCycles(u32 cycles); void materialize(u32 pc); void sideExit(u32 pc); @@ -111,18 +112,10 @@ class FunctionEmitter final { llvm::Argument *ctx_ = nullptr; llvm::BasicBlock *entry_ = nullptr; llvm::AllocaInst *cycles_ = nullptr; - // Guest cycles charged since the dispatcher handed control to this function. - // Nothing resets it, unlike cycles_, which every call and helper resume point - // clears. That clearing is correct for charging -- materialize() has already - // flushed those cycles into downcount -- but it means cycles_ cannot answer - // "how long since we were last dispatched", so the yield decision reads this. - llvm::AllocaInst *guard_cycles_ = nullptr; - // Loop iterations since entry, as a termination backstop only. Every guest - // instruction in a block can cost zero cycles (dcbf, icbi, embedded data), - // which would leave guard_cycles_ flat, so a loop built only from those could - // otherwise spin forever. This is deliberately not the scheduling bound: the - // guard runs at loop headers, so it counts iterations, not instructions. - llvm::AllocaInst *guard_steps_ = nullptr; + // Shared across generated calls until control returns to the dispatcher. + llvm::Value *guard_cycles_ = nullptr; + // Termination backstop for zero-cycle loops. + llvm::Value *guard_steps_ = nullptr; std::array state_{}; std::array used_{}; std::array dirty_{}; diff --git a/tests/test_llvm_backend.cpp b/tests/test_llvm_backend.cpp index 3f702a0..adffe9e 100644 --- a/tests/test_llvm_backend.cpp +++ b/tests/test_llvm_backend.cpp @@ -103,29 +103,30 @@ int main(int argc, char** argv) { }; CHECK(add_chunk(&module, paired_words, 7, 0x80002B00u)); - // A loop whose body crosses a runtime boundary every iteration. The unknown - // word routes to instruction_fallback, and returning from it calls - // reloadUsedState(), which zeroes cycles_. A guard reading cycles_ therefore - // never reaches its threshold here and the loop runs to completion with - // downcount past zero -- the 0 FPS bug. test_llvm_execute drives this and - // asserts the dispatcher gets control back partway through. - // - // loop: addi r3, r3, 1 - // .long 0 ; unknown -> fallback - // cmpwi r3, 10000 - // blt loop - // blr + // Runtime boundaries must not reset the dispatcher budget. const u32 budget_words[] = { 0x38630001u, 0x00000000u, 0x2C032710u, 0x4180FFF4u, 0x4E800020u, }; CHECK(add_chunk(&module, budget_words, 5, 0x80002C00u)); + // External tail branches share the same budget across chunks. + const u32 cross_chunk_a[] = {0x48000100u}; + const u32 cross_chunk_b[] = {0x4BFFFF00u}; + CHECK(add_chunk(&module, cross_chunk_a, 1, 0x80002D00u)); + CHECK(add_chunk(&module, cross_chunk_b, 1, 0x80002E00u)); + CHECK(dolir_verify(&module, stderr)); DolLLVMOptions options{}; options.optimization_level = 2; options.verify = 1; options.emit_ir = 1; options.ir_path = argv[2]; + const DolLLVMFunctionRange ranges[] = { + {0x80002D00u, 0x80002D04u}, + {0x80002E00u, 0x80002E04u}, + }; + options.function_ranges = ranges; + options.function_range_count = 2; CHECK(dolllvm_emit_object(&module, argv[1], &options, stderr)); FILE* object = std::fopen(argv[1], "rb"); CHECK(object != nullptr); diff --git a/tests/test_llvm_execute.c b/tests/test_llvm_execute.c index 43f4ca7..3c938b4 100644 --- a/tests/test_llvm_execute.c +++ b/tests/test_llvm_execute.c @@ -17,6 +17,7 @@ void func_80002900(CPUState* cpu); void func_80002A00(CPUState* cpu); void func_80002B00(CPUState* cpu); void func_80002C00(CPUState* cpu); +void func_80002D00(CPUState* cpu); static u32 fallback_count; static int fallback_bad; @@ -252,20 +253,7 @@ int main(void) { CHECK(fallback_count == 1); - // The dispatcher has to regain control from a loop whose body crosses a - // runtime boundary. Every iteration of func_80002C00 hits the fallback, - // which zeroes cycles_, so a guard reading cycles_ can never trip and the - // loop runs all 10,000 iterations in one native entry while downcount goes - // far past zero. That is the 0 FPS failure: the runtime never gets a chance - // to advance timing or service the GPU. - // - // The body costs 3 guest cycles, so a 256-cycle budget should yield after - // about 86 iterations. Observed: 86 iterations, downcount -258. With the - // guard reading cycles_ instead: 2047 iterations, downcount -6141 -- it only - // stopped because the iteration backstop caught it, 24x past the bound it - // was supposed to enforce. The limits below sit between those two so this - // fails on the real defect without pinning the exact threshold, which is - // still open to tuning. + // A fallback in the loop must not restart the dispatcher budget. prepare_call(&cpu, 0x80002C00u); cpu.gpr[3] = 0; cpu.downcount = 0; @@ -280,12 +268,17 @@ int main(void) { CHECK(cpu.downcount < 0 && cpu.downcount > -1024); CHECK(fallback_count == (u32)cpu.gpr[3]); - // Re-entering resumes rather than restarting: the guard budget is per native - // entry, so a caller that keeps calling still makes progress. + // Re-entry starts a new budget and continues from the saved PC. const u32 first_pass = cpu.gpr[3]; func_80002C00(&cpu); CHECK(cpu.gpr[3] > first_pass); + prepare_call(&cpu, 0x80002D00u); + cpu.downcount = 0; + func_80002D00(&cpu); + CHECK(cpu.pc == 0x80002D00u || cpu.pc == 0x80002E00u); + CHECK(cpu.downcount <= -128 && cpu.downcount >= -512); + cpu_free(&cpu); return 0; } From 0b33979b08857284f5b3fc9d5d4ffdc36429f85e Mon Sep 17 00:00:00 2001 From: jw <183880766+siahisaforker@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:09:36 -0700 Subject: [PATCH 10/14] fix LLVM CI setup --- .github/workflows/cmake-single-platform.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cmake-single-platform.yml b/.github/workflows/cmake-single-platform.yml index 425dcf1..7a73e57 100644 --- a/.github/workflows/cmake-single-platform.yml +++ b/.github/workflows/cmake-single-platform.yml @@ -67,14 +67,14 @@ jobs: uses: KyleMayes/install-llvm-action@v2 with: version: "20.1.8" - cached: true + directory: ${{ runner.temp }}/llvm - name: Configure run: > cmake -B ${{ github.workspace }}/build -S ${{ github.workspace }} -DCMAKE_BUILD_TYPE=Release -DDOLRECOMP_ENABLE_LLVM=ON - -DLLVM_DIR=${{ env.LLVM_PATH }}/lib/cmake/llvm + "-DLLVM_DIR=${{ env.LLVM_PATH }}/lib/cmake/llvm" - name: Build run: cmake --build ${{ github.workspace }}/build --config Release From 40f90972d9f947516eaf63630f3f4a47495ea3d0 Mon Sep 17 00:00:00 2001 From: jw <183880766+siahisaforker@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:14:51 -0700 Subject: [PATCH 11/14] use LLVM development package in Windows CI --- .github/workflows/cmake-single-platform.yml | 43 +++++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cmake-single-platform.yml b/.github/workflows/cmake-single-platform.yml index 7a73e57..ae3dd1d 100644 --- a/.github/workflows/cmake-single-platform.yml +++ b/.github/workflows/cmake-single-platform.yml @@ -63,22 +63,57 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Install LLVM + - name: Install LLVM (Linux) + if: runner.os == 'Linux' uses: KyleMayes/install-llvm-action@v2 with: version: "20.1.8" directory: ${{ runner.temp }}/llvm - - name: Configure + - name: Install LLVM (Windows) + if: runner.os == 'Windows' + uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + install: >- + mingw-w64-ucrt-x86_64-cmake + mingw-w64-ucrt-x86_64-gcc + mingw-w64-ucrt-x86_64-llvm-20 + mingw-w64-ucrt-x86_64-ninja + + - name: Configure (Linux) + if: runner.os == 'Linux' run: > cmake -B ${{ github.workspace }}/build -S ${{ github.workspace }} -DCMAKE_BUILD_TYPE=Release -DDOLRECOMP_ENABLE_LLVM=ON "-DLLVM_DIR=${{ env.LLVM_PATH }}/lib/cmake/llvm" - - name: Build + - name: Configure (Windows) + if: runner.os == 'Windows' + shell: msys2 {0} + run: > + cmake -G Ninja -B build -S . + -DCMAKE_BUILD_TYPE=Release + -DDOLRECOMP_ENABLE_LLVM=ON + -DLLVM_DIR=/ucrt64/opt/llvm-20/lib/cmake/llvm + + - name: Build (Linux) + if: runner.os == 'Linux' run: cmake --build ${{ github.workspace }}/build --config Release - - name: Test + - name: Build (Windows) + if: runner.os == 'Windows' + shell: msys2 {0} + run: cmake --build build + + - name: Test (Linux) + if: runner.os == 'Linux' working-directory: ${{ github.workspace }}/build run: ctest --build-config Release --output-on-failure + + - name: Test (Windows) + if: runner.os == 'Windows' + shell: msys2 {0} + run: ctest --test-dir build --output-on-failure From d5de9deda2a028054deb46f290c5333ba89b4a85 Mon Sep 17 00:00:00 2001 From: jw <183880766+siahisaforker@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:16:02 -0700 Subject: [PATCH 12/14] use packaged LLVM libraries in CI --- .github/workflows/cmake-single-platform.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cmake-single-platform.yml b/.github/workflows/cmake-single-platform.yml index ae3dd1d..4d997ae 100644 --- a/.github/workflows/cmake-single-platform.yml +++ b/.github/workflows/cmake-single-platform.yml @@ -65,10 +65,11 @@ jobs: - name: Install LLVM (Linux) if: runner.os == 'Linux' - uses: KyleMayes/install-llvm-action@v2 - with: - version: "20.1.8" - directory: ${{ runner.temp }}/llvm + run: | + curl -fsSL -o llvm.sh https://apt.llvm.org/llvm.sh + chmod +x llvm.sh + sudo ./llvm.sh 20 + sudo apt-get install -y llvm-20-dev - name: Install LLVM (Windows) if: runner.os == 'Windows' @@ -88,7 +89,7 @@ jobs: cmake -B ${{ github.workspace }}/build -S ${{ github.workspace }} -DCMAKE_BUILD_TYPE=Release -DDOLRECOMP_ENABLE_LLVM=ON - "-DLLVM_DIR=${{ env.LLVM_PATH }}/lib/cmake/llvm" + -DLLVM_DIR=/usr/lib/llvm-20/lib/cmake/llvm - name: Configure (Windows) if: runner.os == 'Windows' From eca18d007cfcc0a83f16b9258dfbe122a0439719 Mon Sep 17 00:00:00 2001 From: jw <183880766+siahisaforker@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:21:46 -0700 Subject: [PATCH 13/14] pin LLVM 20 in Windows CI --- .github/workflows/cmake-single-platform.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cmake-single-platform.yml b/.github/workflows/cmake-single-platform.yml index 4d997ae..f48c6f6 100644 --- a/.github/workflows/cmake-single-platform.yml +++ b/.github/workflows/cmake-single-platform.yml @@ -71,7 +71,7 @@ jobs: sudo ./llvm.sh 20 sudo apt-get install -y llvm-20-dev - - name: Install LLVM (Windows) + - name: Install Windows toolchain if: runner.os == 'Windows' uses: msys2/setup-msys2@v2 with: @@ -80,9 +80,15 @@ jobs: install: >- mingw-w64-ucrt-x86_64-cmake mingw-w64-ucrt-x86_64-gcc - mingw-w64-ucrt-x86_64-llvm-20 mingw-w64-ucrt-x86_64-ninja + - name: Install LLVM (Windows) + if: runner.os == 'Windows' + shell: msys2 {0} + run: > + pacman --noconfirm -U + https://repo.msys2.org/mingw/ucrt64/mingw-w64-ucrt-x86_64-llvm-20-20.1.8-4-any.pkg.tar.zst + - name: Configure (Linux) if: runner.os == 'Linux' run: > From 8cc0919eddeda2358b633ed125b1fb317249f36d Mon Sep 17 00:00:00 2001 From: jw <183880766+siahisaforker@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:31:15 -0700 Subject: [PATCH 14/14] support static LLVM on MinGW --- CMakeLists.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 553a0d7..73ead32 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -105,14 +105,14 @@ if(DOLRECOMP_ENABLE_LLVM) NAMES LLVM-${LLVM_VERSION_MAJOR} LLVM HINTS ${LLVM_LIBRARY_DIRS} NO_DEFAULT_PATH) - if(NOT DOLRECOMP_LLVM_SHARED_IMPORT OR - NOT DOLRECOMP_LLVM_SHARED_IMPORT MATCHES "[.]dll[.]a$") - message(FATAL_ERROR - "DolRecomp requires LLVM's shared import library on MinGW; " - "install the matching MSYS2 llvm-libs package") + if(DOLRECOMP_LLVM_SHARED_IMPORT MATCHES "[.]dll[.]a$") + target_link_libraries(dr_llvm PRIVATE + "${DOLRECOMP_LLVM_SHARED_IMPORT}") + else() + llvm_map_components_to_libnames(DOLRECOMP_LLVM_LIBS + Core Support Analysis Passes Target MC native nativecodegen) + target_link_libraries(dr_llvm PRIVATE ${DOLRECOMP_LLVM_LIBS}) endif() - target_link_libraries(dr_llvm PRIVATE - "${DOLRECOMP_LLVM_SHARED_IMPORT}") elseif(TARGET LLVM) target_link_libraries(dr_llvm PRIVATE LLVM) else()