dma: charge DMA2 linked-list walks what the validated model charged - #302
dma: charge DMA2 linked-list walks what the validated model charged#302TechnicallyComputers wants to merge 3 commits into
Conversation
Slicing the GPU linked-list DMA across guest cycles (8af48ae, PR #277) also changed what a walk costs: 8 clocks for every node header plus 5 setup clocks per non-empty node, where the pre-slicing path charged one clock per word via schedule_delayed_complete(2, actual_words, DMA_GPU_CYCLES_PER_WORD). An ordering table is mostly EMPTY nodes -- Final Fantasy VII's runs to ot_max 4094 -- and each empty slot still paid the full 8-cycle header for a packet that does not exist. Measured on FF7 (SCUS-94163): 4,807 nodes / 12,757 words 52,384 cycles vs 17,564 (3.0x) 44,010 nodes / 117,507 words 499,187 cycles vs 161,517 (3.1x) An NTSC frame is 564,480 cycles, so a single walk reached 88% of a frame. FF7's libgpu polls CHCR bit 24, finds the channel still busy after 790-890 reads, and force-clears the start bit at 0x800484F0 -- the documented stuck-channel path. cancel_async_transfer() then abandons the walk partway through, and the rest of the frame's geometry is never sent to GP0. In a battle 12.6% of ordering tables were aborted this way; models rendered with missing or shattered limbs. The guest is behaving correctly: it inspected the busy bit before clearing it. The slicing is also correct and worth keeping -- a guest really can change a packet after starting DMA and before DMA reaches it, and only a sliced walker models that. Only the cost was wrong. Header is one clock, setup is free, so a walk costs exactly (nodes + words). When setup is free the payload boundary is the same service as the header, so the header phase falls through instead of returning; cycles_to_event() never reports zero, and returning would spend an unaccounted scheduler cycle per non-empty node. The packet is still read at its own boundary, so #277's mid-walk mutation behaviour is unchanged and its test still passes. (nodes + words) is not a new assertion -- it is the cost this runtime shipped and was validated against. A non-zero per-node overhead may well be real on hardware, but it needs a measurement behind it, not a substituted constant. Observability, because none of the above was visible: dma_state gains a gpu_ot block -- starts, completes, cancels, starts_dropped, nodes/words/cycles of the last walk and the max, plus guest reads of CHCR(2) and a ring of the last 8 aborts carrying the guest PC, CHCR value, how far the walk got, and how many times the guest polled before giving up. The silent early return in start_async_gpu_linked_list() is now counted as starts_dropped rather than losing an ordering table with nothing said. PSX_GPU_LL_SYNC=1 drains a walk at start. Diagnostic A/B lever only: it completes immediately as well, so it is not a faithful restoration of the pre-slicing timing and must not be used as a fix. The timing test now asserts the cost model rather than describing it: a table of empty nodes must cost exactly one cycle per node. That is the check that would have caught this. Verified on FF7 SCUS-94163 from a save state: cycles_max 58,933 -> 16,972, a 144-node/490-word walk costs exactly 490 cycles, and battle geometry renders correctly with the walk still sliced. Not fixed here: the same commit also broke gpu_frame_dump's func attribution. GP0 writes now issue from dma_advance() on the cycle scheduler, so every primitive is stamped with whatever the scheduler interrupted -- all 1,803 primitives in a captured battle frame attributed to BIOS 0x1FC02B50 instead of the issuing game function. That disables gpu_frame_diff's "a function stopped drawing" verdict and gpu_frame_layers entirely. Stamping the DMA initiator was tried and does not work: g_debug_current_func_addr reads 0 at kick time. The ra column still carries real game addresses as a fallback. Left as a separate issue rather than shipped as a fix that does not fix it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9v7e6AVZe3eRP8VXey9Pz
|
I had Claude review and rig up a a/b test for Breath of Fire 3. Here's the summary -> Review notes for upstream (what the PR is missing)
I attached the .md file if you want to run a agent to see if yours agrees. |
|
Would like to get a consensus before merging this one, as I believe it's been superseded by #304 . Would like to get affirmation from @kerokline and @TechnicallyComputers on whether 304 has solved the problem already. If not, can we get a follow-up PR or a refactor of this one that's rebased onto the current main? |
|
I can test this myself to validate it fixes ff7 on master branch soon |
|
Ran the same A/B comparison with the 304 build. Here is the AI summary -> Yes, #304 addresses the same root issue, and it supersedes #302's fix. The comparison is written up in pr302-dma2-ot-cost-review.md Where they are identical. Both set the walk cost to header 1, setup 0, so a walk costs nodes plus words. The two #define lines are the same. #304 even carries #302's empty-ordering-table bound test verbatim, comment included. Both chase the same symptom: FF7's libgpu timing out on a long sliced walk and aborting the transfer. Where #304 goes further on timing: Each payload word is read from live RAM at its own one-clock event, instead of the whole packet on the header service. That also covers the Vampire Hunter D and Spot mutation cases #277 was written for. What #304 does not have, and #302 still uniquely offers: the gpu_ot observability block. The counters, the cancel ring with guest PC and poll count, the CHCR-poll stats, and starts_dropped. That is what my census ran on, and it is the only way to see an FF7-class abort without a screenshot. Worth salvaging as a small counters-only PR rebased on master. I would not keep the walker change. Neither fixes the hardware issue: a guest RAM or MMIO read during DMA stalls the CPU, so the poll loop can never reach its timeout. Both PRs shorten the walk instead of modelling the stall. |
|
Went ahead and made #317 as a follow up to this to merge out the odds and ends. Attributed you as co-author on those. Should be solved now. Will go ahead and close this out. If for any reason I missed something, do go ahead and open a new issue or file a new PR 👍 |
Add passive DMA2 ordering-table counters to dma_state so long or aborted linked-list walks are visible without relying on screenshots. The counters track starts, duplicate starts dropped while a walk is active, completions, cancels, last/max walk size and cycle cost, CHCR poll counts, the initiator PC, and the last eight cancel records. This intentionally does not port PR RetroPortingToolKit#302's superseded walker changes or the PSX_GPU_LL_SYNC diagnostic lever; PR RetroPortingToolKit#304 remains the timing implementation. Co-authored-by: Kevin Kline <9866117+kerokline@users.noreply.github.com>
|
Yes this is superseded now and the fix is already on master. |
Resubmission of #299, which was merged prematurely and reverted in #300. Same change, re-applied on top of the revert. Opened as a draft: this needs cross-title validation and peer review before it is merged — see Review gate at the bottom.
Fixes a rendering regression from #277 (
8af48ae9, "runtime: slice GPU linked-list DMA"). Slicing the walk is right and stays; only its per-node cost changes.What broke
#277 also changed what a walk costs — 8 clocks per node header plus 5 setup clocks per non-empty node, where the pre-slicing path charged one clock per word via
schedule_delayed_complete(2, actual_words, DMA_GPU_CYCLES_PER_WORD).An ordering table is mostly empty nodes — Final Fantasy VII's runs to
ot_max 4094— and every empty slot still paid the full 8-cycle header for a packet that does not exist. Measured on FF7 (SCUS-94163):At 88% of a frame, FF7's libgpu polls
CHCRbit 24, finds the channel still busy after 790–890 reads, and force-clears the start bit at0x800484F0— its documented stuck-channel path:cancel_async_transfer()then abandons the walk partway through and the rest of the frame's geometry never reaches GP0. 12.6% of ordering tables in a battle were aborted this way; character models rendered with missing or shattered limbs.The guest is behaving correctly — it inspected the busy bit before clearing it. Only the cost was wrong.
The change
Header costs one clock, setup is free, so a walk costs exactly
(nodes + words). When setup is free the payload boundary is the same service as the header, so the header phase falls through instead of returning —cycles_to_event()never reports zero, so returning would spend an unaccounted scheduler cycle per non-empty node. The packet is still read at its own boundary, so #277's mid-walk mutation behaviour is unchanged and its existing assertion still passes.(nodes + words)is not a new assertion: it is the cost this runtime shipped and was validated against. A non-zero per-node overhead may well be real on hardware, but it needs a measurement behind it rather than a substituted constant.Verification
FF7 SCUS-94163, from a save state, walk still sliced:
cycles_maxTest re-run and runtime rebuilt clean on top of current
masterafter the revert.The timing test now asserts the cost model instead of describing it: a table of empty nodes must cost exactly one cycle per node. That is the check that would have caught this.
Observability
None of the above was visible, so
dma_stategains agpu_otblock:starts,completes,cancels,starts_dropped, the last walk's nodes/words/cycles and the max, guest reads ofCHCR(2)total and during a walk, and a ring of the last 8 aborts carrying the guest PC,CHCRvalue, how far the walk got, and how many times the guest polled before giving up. The silent early return instart_async_gpu_linked_list()is now counted asstarts_droppedrather than losing an ordering table with nothing said.PSX_GPU_LL_SYNC=1drains a walk at start — diagnostic A/B lever only. It completes immediately as well, so it is not a faithful restoration of the pre-slicing timing and must not be used as a fix.Known issue, deliberately not fixed here
The same commit broke
gpu_frame_dump'sfuncattribution. GP0 writes now issue fromdma_advance()on the cycle scheduler, so every primitive is stamped with whatever the scheduler interrupted — all 1,803 primitives in a captured battle frame attributed to BIOS0x1FC02B50instead of the issuing game function. That disablesgpu_frame_diff's "a function stopped drawing" verdict andgpu_frame_layersentirely.Stamping the DMA initiator was tried and does not work —
g_debug_current_func_addrreads 0 at kick time. Theracolumn still carries real game addresses as a fallback. Filed as a separate issue rather than shipped as a fix that does not fix it.Review gate — do not merge until these are done
nodes_maxcounter would confirm whether a walk that cannot be pre-empted ever reaches that size.🤖 Generated with Claude Code
https://claude.ai/code/session_01L9v7e6AVZe3eRP8VXey9Pz