Skip to content

DK Bananza: GPU hangs come from stale command list reads on GPU-written pages #392

Closed

Description

@jojo-chaechae

Tracking issue for the Donkey Kong Bananza GPU hangs, the stream corruption behind them, and the work still to do. It is written so the investigation can be resumed cold.

Symptoms

Bananza freezes during gameplay; fast, repeated inputs reproduce it most reliably (usually within 1 to 3 minutes). The log shows one of two signatures, and both turn out to be the same bug:

  • Stream corruption: a channel suddenly parses garbage. You see Dropping method ... on unbound subchannel, Unimplemented engine ..., Invalid KeplerMemory register, Native compute method out of range, Native QMD failed: unsupported QMD version or queue, and unknown host method with a count such as 2047.
  • Semaphore hang: Channel 2 waits on semaphore 0x2161?1b000 (op 1, 0x2, now 0xffffffff) on device address 0x5cd1c000. Channel 1 is usually also stuck: waits on semaphore 0x210?4e0000 (op 4, N, now M) on device 0xe1000, with N far ahead of M.

Root cause (current understanding)

  1. The semaphore hang is a consequence of the stream corruption. Device 0x5cd1c000 is the first page of a 0x40000 block the game maps with svc 0x80 at about 1.86 s, right before it creates its channels. Its GPU VA changes per run; its device address does not. Each frame, channels 1 and 2 run a fixed handshake on it:

    • Channel 1 resets 5 words to 0xffffffff.
    • Channel 1 releases +0 = 0. Channel 2 acquires +0 == 0.
    • Channel 2 releases +4 = 1, through a Native QMD release. Channel 1 acquires +4 == 1.
    • Channel 1 releases +0 = 2. Channel 2 acquires +0 == 2.
    • Channel 2 releases +4 = 3. Channel 1 acquires +4 == 3.

    Exactly one reset happens per cycle; about 12k resets happen during loading, before the cycles start. In the hangs, channel 1's stream got corrupted mid-cycle. It skipped its own releases and acquires, then waited on a garbage counter far ahead of the real value. Channel 2 then waits forever for a value that never comes.

  2. The corruption comes from reading command memory the game had already refilled for a later frame. The game rewrites command memory after submitting it: present returns frames ahead, which Mario Kart World also does. nvhost_gpu.cpp handles this by snapshotting each entry's words at submit time and executing the snapshot. However, any entry on a 4 KiB page that the GPU ever wrote skips the snapshot: the !IsGpuWrittenCommands(...) term in the submitted condition in DmaPusher::Step. That bit is sticky (g_nx2_gpu_written_pages) and page-granular, so CPU-written command chunks that share a page with GPU-written data are read live. By parse time, the game has already overwritten them.

    • Evidence: in the trace run, the corruption at 152.23 s was unknown host method at get=0x210bf59c44.
      • Parsed entry -0 (0x210bf59c44+0x6) was read on the unsafe path; all 6 of its words differ from the submit snapshot.
      • Parsed entry -8 (0x210bf59c14+0xc) was also read unsafe; 11 of its 12 words differ.
      • The EDEN_NX2_CMD_WRITES recorder logged no emulator-side writes (DeviceMemoryManager::WriteBlock*) into them, so the guest CPU rewrote them.
    • The earlier observation of chunks in the 0x210bxxxxx/0x210cxxxxx rings being a fragment at parse time and a proper chunk later fits this cause too.

Fix under test (not yet verified)

Commit d3b4fb5274, behind the env var EDEN_NX2_EPOCH_SNAPSHOT=1:

  • g_nx2_submit_epoch is incremented per submission.
  • Each submission records CommandList::submit_epoch.
  • Nx2MarkGpuWritten stores the current epoch per 4 KiB page in g_nx2_gpu_written_epoch (64 MiB of bss).
  • IsGpuWrittenCommands(addr, size, since_epoch) returns true, so the entry is read live, only if the GPU wrote the page at or after the entry's submission. Otherwise the snapshot is used.

Risk: a GPU write recorded before the submit but not yet in guest memory when the snapshot was taken would make the snapshot stale. Watch for missing terrain, trees or culled objects, the case the GPU-written path was added for.

A test run was launched but has no result yet. Next steps:

  1. Run with EDEN_NX2_EPOCH_SNAPSHOT=1 and fast inputs. Check for hangs or corruption, and for missing geometry.
  2. If it holds, make it the default and remove the env gate. Consider a finer granularity, or drop the sticky bit in favour of the epoch.
  3. If corruption persists, use the PB_TRACE dump: the parsed -N lines give each entry's read path and how many words differ from the snapshot.
  4. Check the case of an entry submitted after a pending GPU write. One option is to compare the snapshot with live memory when the epoch says snapshot, and log the mismatches.

Branch and builds

  • Branch: fix/bananza-terrain-full on jojo-chaechae/Eglantine. Local worktree: ~/edenpack-test/bananza-pr.
  • Builds use local-build.yml on the self-hosted runner:
    gh workflow run local-build.yml -R jojo-chaechae/Eglantine --ref fix/bananza-terrain-full -f linux_pgo=true -f windows=false -f android=false -f release=false
    docker cp eglantine-runner:/home/runner/runner/_work/Eglantine/Eglantine/out/eglantine-jojo-<sha>-linux-x86_64-pgo.AppImage <dir>
    
  • Run:
    : > ~/.local/share/eden/log/eden_log.txt
    ENV... <AppImage> -g ".../DKBananza-458752.nup"
    
    Copy the log out after each run, because the next launch truncates it.

Commits after 5c28733071, in order:

Commit Kind What
53fce4d58c revert Reverts the "split method waits for the entry that resumes its buffer" park heuristic. It froze, and the lookahead is invalid.
8db3e04f1a diag EDEN_NX2_PB_DUMP=<file> dumps the submission at the first split method resumed in a later entry. Binary format: u64 count, u64 current, then per entry u64 addr, u32 size, words. Analyser: ~/edenpack-test/tools/pbsplit.py.
070f1772b9 diag EDEN_NX2_SNAPSHOT_CHECK logs live reads that differ from the snapshot. Skips size-5 entries.
51ac02623a diag Stuck-acquire second-page report (superseded).
2cfdf53510 fix Semaphore mirror regions use an unbounded set instead of a 32-slot table. Games register well over 32; 140 were dropped by 4.4 s. Real fix, keep it.
72c79f0e02 diag The trace dump shows each parsed entry's read path (snapshot/safe/list copy/unsafe) and its diff against the snapshot.
cc52b2b271, be02ddbea4 diag EDEN_NX2_CMD_WRITES: a 4096-entry ring of emulator-side writes to submitted command pages, printed in the trace dump.
f179d08a2d diag Logs nx2:gpu semaphore region add/remove, and on a stuck acquire logs the second page and mirror state.
f3e53802b3, b2407f2a20 diag EDEN_NX2_SEM_WATCH_DEV=<device addr> logs releases, acquires, SEM_EXECUTE and device writes on the 2 pages there, tagged with the query-cache channel.
7138100c58 diag fix DumpCurrentEntry reads CPU memory with ReadBlockUnsafe. Read32 could flush a GPU-written page from the GPU thread and deadlock: a black screen right after the main menu.
d3b4fb5274 experiment EDEN_NX2_EPOCH_SNAPSHOT (above).

The diagnostic commits need squashing or dropping before anything goes upstream.

Useful env vars:

  • EDEN_NX2_PB_TRACE=1: cheap ring of the last 64 parsed entries, dumped at the first unbound-subchannel method or unknown host method.
  • EDEN_NX2_PB_CHECK
  • EDEN_NX2_CMD_WRITES=1
  • EDEN_NX2_SEM_WATCH_DEV=5cd1c000
  • EDEN_NX2_SEM_WATCH=<gpu va>
  • EDEN_NX2_PB_DUMP=<file>: the value is the output path. =1 writes a file literally named 1.

Logs (local)

Logs are in ~/edenpack-test/logs/b167/:

Log Shows
bananza-semlog.log The stuck region was never registered or mirrored.
bananza-devwatch.log The handshake protocol and the early-reset hang.
bananza-devwatch2.log The dump deadlock, plus channel attribution.
bananza-devwatch3.log Corruption leading into the semaphore hang.
bananza-trace.log The corruption dump showing snapshot mismatches.
bananza-epoch.log The pending experiment.

Notes are in ~/edenpack-test/SCRATCHPAD.md.

Other findings, not fixed

  • KeplerMemory::ConsumeSinkImpl writes regs.reg_array[method] without a bounds check. Garbage streams hit it: Invalid KeplerMemory register asserts.
  • Most split methods are legitimate and not part of this bug. Hardware carries method state across GP entries (open-gpu-doc dev_pbdma). The usual pattern is a macro 0xe50/0xe51 header with 5 parameters left, followed by a 5-word GPU-written record.
  • Entry 0x210f6c41f0+0x146, word 176, is a per-frame pointer cycling 0bfeb000/0d233000/0e47b000. It was read live with a different frame's value. It may relate to jitter; it is probably the same snapshot-vs-live issue.
  • Rubberbanding is deliberately parked.

Pending after this

The human-written contributing guideline was not asserted for this issue.

Activity

  1. self-assigned this
    on Nov 3, 2026
  2. jojo-chaechae commented on Nov 3, 2026

    @jojo-chaechae
    CollaboratorAuthor

    do not f' ing close this or delete this issue istg

  3. jojo-chaechae commented on Nov 3, 2026

    @jojo-chaechae
    CollaboratorAuthor

    documenting this here as ill pick up on this work later.. erm hopefully

  4. ymatrix1 commented on Nov 4, 2026

    @ymatrix1
    Contributor

    Data point from a different setup, in case it helps narrow this down. I could not confirm the stale-rewrite explanation on current nx2, and EDEN_NX2_EPOCH_SNAPSHOT=1 did not prevent the corruption in my runs.

    Setup. Windows 11, RX 5700, MSVC, loose dump (base/exefs/main, base/romfs has the full data, 0 unmapped writes at boot), Bananza v3.2.0. Build: nx2 d3a659982f plus my local #306 change (Ampere lowering only) and a test-only scripted-input patch. I ported d3b4fb5274 onto it by hand: the epoch is passed to both IsGpuWrittenCommands calls that feed gpu_written in DmaPusher::Step, which nx2 now computes before the submitted condition. I did not port 2cfdf53510 (it conflicts in nx2.h). Everything ran in a portable user folder.

    Two otherwise identical headless runs, scripted +/A presses, EDEN_NX2_PB_CHECK=1, GPU split not active:

    without EPOCH_SNAPSHOT with EPOCH_SNAPSHOT=1
    desync starts 65.1 s, host crash (139) 69.5 s, process ends
    first signature unknown host method: method=0x1 count=8063 Invalid GPU address for shader 0x0000200205f200
    "entries changed after submit" 3 of 2,080,000 54 of 4,600,000

    Same corruption afterwards in both: Dropping method ... on unbound subchannel, the Special puller engine method 0x00..0x3F not implemented flood, a bogus render target format and CreateImage ... layers ... failed. A manual run of mine with the variable set (I could not verify it was exported) ended the same way at 61.9 s.

    What doesn't fit the stale-read explanation. In the run without the variable, all 64 entries in the PB_CHECK ring before the failure, including the failing 0x217efd7cd0+0x31, are reported unchanged: the words read at parse time hash the same as at submit. So for this reproduction the game had not rewritten those entries. The few that did change (0x210f0c71e8+0x143, 3 times around 50 s) are not the failing ones.

    Two more observations, no conclusion drawn from them:

    • DumpCurrentEntry printed entry 2232 of 8054: addr=0x2238bb6c08 size=0x96 right after reporting the failure at get=0x217efd7cd0. Those are different entries, so the dump may not be showing the one that failed.
    • The failure time is 61 to 72 s after launch in four runs on three builds (also 127 s on the 1.0 build-189), with scripted and manual input alike, so the trigger may be a fixed point in the loading sequence rather than input speed.

    If useful I can rerun with other env vars from your branch (PB_TRACE, CMD_WRITES) once they are on current nx2.

  5. ymatrix1 commented on Nov 4, 2026

    @ymatrix1
    Contributor

    Follow-up to my earlier comment here, which was measured on a base that is now out of date.

    Current nx2 (9279b24e4c) does not reproduce this for me. Same machine as before (Windows 11, RTX 4070 SUPER, loose dump, Bananza v3.2.0, portable user folder), plain nx2 plus a test-only scripted-input patch, no env vars, played by hand through the door and into the lava cave:

    • 100 s into the session: present #3317, zero rejected graphics or compute shaders, zero skipped draws, zero unbound subchannel / Native QMD failed / Invalid GPU address / unknown host method, zero fence stalls, zero semaphore waits, and the new frame-wait counter reports 0 ran a frame ahead.
    • The terrain wall that was missing on my older base (6 draws skipped on the 1920x1080 target, all from rejected F2I forms) renders now, and the session felt faster and with fewer stutters (subjective). Frame times in the log still grow in the cave, 17 ms in the menu to 33-48 ms later, with the GPU fence wait growing with them, so the GPU looks like the limit there.

    What I measured on the older base (d3a659982f), in case it helps the history. Three configurations of EDEN_NX2_FRAMES_AHEAD, same scripted run:

    • 0 (always wait for this frame's fence): clean for 130 s, zero desync, zero entries changed after submit.
    • 1 (always wait for the previous frame): the stream corrupted at present Alternate NS2 patch set with keyboard and macro performance support #2, 10.5 s in.
    • unset (the default, which waits for the previous frame while a channel is suspended on a semaphore): desync between 53 and 77 s.

    EDEN_NX2_EPOCH_SNAPSHOT=1 did not help on that base, nor did turning the snapshot or the GPU-written list copy off, nor the usage-tracker fix from #462. So on that base the corruption followed how far ahead the game was allowed to run, which fits the frame-wait change in #506 and the snapshot fixes that landed afterwards. This is one machine and a handful of runs, not a proof, and I did not bisect which of those commits is the one that matters.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions