Summary
Adds DXMT_D9_LATE_READBACK=1 (default off). For small GetRenderTargetData readbacks (≤ 4096 bytes, ≤ 64 pixels) into mirror-backed SYSTEMMEM surfaces, the copy is posted to a staging slot without waiting on the GPU, and the previous completed result is returned (about one frame stale). The first 3 calls per destination stay synchronous. Additive (152 lines, no deletions) in src/d3d9/d3d9_device.cpp; no behavior change unless the env var is set.
Motivation
Prototype 2 (D3D9, 32-bit) reads back a 1x1 A16B16G16R16F render target about once per frame (0.96 per present unpatched). Each call flushes the frame, commits, then blocks in WaitCPUFence + waitForGpuOrDeviceError, so CPU and GPU take turns instead of overlapping. Queries are not the cause (query=0), and DXMT_D9_BATCH_READBACK doesn't cover this path (kind 2).
Test setup
- iPhone, 1320x2868 @3x, 120 Hz; both runs
thermal=nominalat launch. Game at 1568x720. - Same build both runs (v0.1.3 (4) built via GitHub Actions on a fork with this patch). Only
DXMT_D9_LATE_READBACKdiffers (0 vs 1). Other env:FEX_TSOENABLED=0,FEX_SMCCHECKS=0,MADEIRA_LIQUID_METAL=1,MADEIRA_PROMOTE=1, stats flags on. - Pacing: "Hold the display at its maximum rate" ON, frame cap 60 (my best-performing setup, so both runs use it).
- Scene: Prototype 2. Linden Park, Yellow Zone. Both runs start from cold and complete the same "Orion Two" mission. Starts in a very light edge of map scenario, fly through the open city toward another very light corner of the map, but to have a large battle so there are clear differences between the two runs even in these runs, but in city the gap is even larger. With the frame pacing setup I've been using, an absolute lock at 40 no matter what's on screen or really even time spent gaming/thermal state is expected with the patch enabled.
Pacing note. In this configuration light scenes lock at 40 fps even with an idle GPU (frames with GPU < 5 ms are 99.9% off / 99.5% on exactly 25.0 ms). With the 120 Hz hold, a frame that misses its 16.7 ms deadline presents on the next tick at 25 ms. This is existing behavior, not this patch, and 40 fps acts as a ceiling here, so how often the ceiling is held is the more telling number. (Frame intervals from [metal-HUD] lines.)
Results
| Patch off | Patch on | |
|---|---|---|
| Duration / frames | 560 s / 18,000 | 630 s / 25,288 |
| Average fps | 32.1 | 40.1 |
| Frame time p50 / p90 / p99 | 25.0 / 37.5 / 50.0 ms | 25.0 / 25.0 / 25.0 ms |
| Frames at the 25.0 ms ceiling | 55.5% | 97.9% |
| Heavy part* | 30.3 fps, p99 50 ms, 42.8% at ceiling | 39.9 fps, p99 25 ms, 99.4% at ceiling |
*off: 10:13–10:20 (GPU 16.7 ms avg); on: 09:34–09:39 (GPU 17.2 ms avg).
| GPU ms | Off fps (% at 25 ms) | On fps (% at 25 ms) |
|---|---|---|
| < 5 | 39.9 (99.9%) | 40.0 (99.5%) |
| 5–12 | 38.8 (94.1%) | 40.4 (94.0%) |
| 12–16 | 37.4 (85.9%) | 40.0 (99.6%) |
| 16–20 | 29.4 (31.6%) | 40.0 (100%) |
| ≥ 20 | 24.9 (5.5%) | 39.9 (99.5%) |
Readback counters
| Patch off | Patch on | |
|---|---|---|
Presents ([submit-causes]) | 17,870 | 25,018 |
Target readbacks ([readback-detail], kind=2) | 17,207 (0.96/present) | 23,033 (0.92/present) |
| Readback-caused submits | 17,969 | 24,133 |
| Late calls (waits avoided) | n/a | 23,024 (target minus 9 warm-up) |
| Skipped (both slots busy) | n/a | 0 |
Each of the 3 destination surfaces printed [late-readback] calls=7200 skipped=0 bytes=8 format=113 last (printed every 600 calls). calls = waits avoided; skipped = times both staging slots were busy so no new copy was queued (0 means the value was at most one frame old).
Also observed (process CPU only, [xp]): average CPU power 2.56 W off vs 1.81 W on; 79.7 vs 45.1 mJ/frame; more cores (2.19 vs 1.48) at lower P-core clocks (2.38 vs 3.28 GHz). Not isolated.
Visual check: No exposure flicker or lag observed whatsoever, completely normal gameplay
How to enable (in-app)
Settings → "Add an entry" (or this game's config → Advanced). Add, then close Madeira from the app switcher and relaunch:
env.DXMT_D9_LATE_READBACK=1
env.DXMT_D9_READBACK_STATS=1
env.DXMT_D9_SUBMIT_STATS=1Verify: [madeira-env] ... DXMT_D9_LATE_READBACK=1 and [late-readback] calls=1 skipped=0 ... in the log.
Design notes and limitations
commitCurrentChunkTimed(3)is kept so the completion signal stays at a chunk boundary (ring-recycling invariant in the code comments). The frame's GPU work is still split in two, so[submit-causes] readbackstays about equal to present. Only the CPU and GPU waits are removed. Moving the copy to end-of-frame could be a follow-up.- Dedicated staging buffers (one 64 KB buffer, two slots), not the upload ring, since ring blocks can be recycled before they're read. Capped at 8 destination surfaces.
- Buffer-backed destinations aren't covered; a one-time warning is logged.
- A stale value is fine for a brightness average, wrong for readbacks used in game logic, hence opt-in. Tested on one title.
- Settings UI:
ConfigCatalog_generated.swiftis generated from thegetEnvVarreads, so the comment above the read in this PR becomes the row's description. No app change is needed; the row appears when the app's dxmt submodule is bumped and the catalog regenerated.
Licensing and disclosure
I license this contribution under GPL-3.0-or-later, consistent with the repo's statement that Madeira modifications are GPL-3.0-or-later (COPYING.GPL-3.0, LICENSE-MADEIRA.md). No new files or third-party code.
AI disclosure: initial patch drafted with Claude (Anthropic); built via GitHub Actions and tested on device by me.