Repository navigation
fix(render): bound bitmap upload staging and share samplers - #622
1 commit merged into
Conversation
|
pls fix the merge conflicts <3 |
59fc136 to
6e1cf91
Compare
|
Rebased onto current
Build + |
Use three persistent fenced staging buffers and four device-owned bitmap samplers instead of per-image allocations. Preserve whole-image uploads and existing decoder limits, check submission failures, and keep borrowed sampler ownership explicit. Add decoded-loader, failure/cleanup, truncated OZT and non-presenting GPU regression coverage, including intermediate readbacks and opt-in Vulkan validation. Refs sven-n#620
6e1cf91 to
12a7e60
Compare
|
Rebased onto current
|
Mosch0512
left a comment
There was a problem hiding this comment.
Verdict: changes requested (item 1 below).
Bounding the staging buffers and sharing the four samplers is the right fix for #620. Before this PR, every bitmap also kept its own sampler, which on large loads can run into Vulkan's maxSamplerAllocationCount (as low as 4000 on some drivers). Moving registration and accounting behind a successful upload also fixes the leaked m_dwUsedTextureMemory on failure. What needs changing is the failure model. One GPU error that leaves no staging slot in doubt now disables bitmap uploads for the rest of the session.
Required
- A single failed texture creation, map, acquire or begin-copy faults the whole uploader until device shutdown. The next checked
LoadBitmap(the default; about 700 of the 738 calls) then exits the client (inline,BitmapGpuResources.cppline 150).
Non-blocking
- Diagnostics: with no initialized uploader, the error reads "upload: wrong thread" (line 106). After a fault, each rejection overwrites the root-cause error (line 113).
std::exitreplacesExitProcesson Windows, so static destructors now run on the fatal path (TextureFailure.cppline 28).- A failed replacement still destroys the image it replaces. This predates the PR, but the new test locks it in (
GlobalBitmapLoad.cppline 221). - Tests: the GPU regression prints the FD peaks but never checks them (
test_bitmap_gpu_upload_integration.cppline 307).bitmap_loader_testsredefines symbols thatMuClientalso defines (tests/render/CMakeLists.txtline 258). - Efficiency follow-ups: one slot, submit and fence per image (line 133); the JPEG RGBA temporary (
GlobalBitmapLoad.cppline 451); 12 MiB of staging stays resident (line 49). - Cleanups: an unreachable capacity check (line 127), duplicated pitch math (line 124), and CODING_RULES rules 1 and 3 in
Uploadand the sampler table (line 103,BitmapGpuResources.hline 111).
I reviewed this by reading and tracing the code. I didn't build or run it.
| SDL_GPUTexture* texture = m_calls.createTexture(m_device, &textureInfo); | ||
| if (!texture) | ||
| { | ||
| Fail("create bitmap texture", true); |
There was a problem hiding this comment.
Required: Fail(..., true) sets m_faulted. After that, every Upload returns "upload: uninitialized or faulted" until Shutdown, and production code never reads IsFaulted(). Every GPU stage sets it: texture creation here, map (157), acquire (172), begin-copy (179), submit (202) and the fence wait (95).
The comment at line 197 explains why a slot must not be reused after a failed submit. That reasoning only covers submit and the fence wait. In the other four cases, Retire has just waited for this slot, so it is idle and nothing reads it:
- texture creation and map fail before anything is recorded;
- acquire fails before a command buffer exists;
- begin-copy fails and the command buffer is cancelled.
The resulting failure mode is much broader than before. Before this PR, a failed SDL_CreateGPUTexture (for example running out of device memory on a 1024×1024 texture) failed only that bitmap. Now:
- One unchecked load fails, for example an RmlUi or editor image through
LoadImageExclusive, or one of theLoadBitmap(..., false)calls. - The next checked
LoadBitmapalso fails. That covers almost all of them, sincebCheckdefaults to true.PopUpErrorCheckMsgBox(..., true)then shows a dialog the player cannot continue past, and the client exits. - In a
FOR_WORKbuild, where the player can continue, every map change force-unloads the IDs it replaces (GlobalBitmapLoad.cpp:221) before decoding. The new map then renders without textures.
Suggested fix: return a plain failure without faulting for texture creation, map, acquire and begin-copy. Only a failed submit or fence wait needs to take its slot out of service, and that could be done per slot: mark that slot as unusable and skip it, and fault only when no slot is left. The test "bitmap owner faults on GPU errors" would then expect !IsFaulted() for stages 0-3.
| BitmapGpuResources::Result BitmapGpuResources::Upload(std::span<const std::uint8_t> pixels, int width, int height, | ||
| SDL_GPUFilter filter, SDL_GPUSamplerAddressMode wrap) | ||
| { | ||
| if (!OnOwnerThread()) |
There was a problem hiding this comment.
Non-blocking: m_thread is a default std::thread::id until Initialize and again after Shutdown, and it never equals a running thread's id. Before init, after shutdown, or after a failed init, every upload therefore reports "upload: wrong thread: upload must run on the owner thread". The real cause is an uninitialized uploader, which the !m_device branch below would report. With m_device null that branch can't be reached. Checking !m_device first would give the right message. Reading m_device is no more racy than reading m_thread, given the external serialization documented in the header.
| } | ||
| if (!m_device || m_faulted) | ||
| { | ||
| Fail("upload: uninitialized or faulted", false); |
There was a problem hiding this comment.
Non-blocking: after a fault, every rejected upload calls Fail("upload: uninitialized or faulted", false). That overwrites m_stage and m_error, so the original SDL error is gone after the next load. The renderer's Shutdown log ("bitmap uploader shutdown failed at {}: {}") and every following g_ErrorReport line show the generic text. The root cause appears only in the first failure's line, among hundreds of follow-on failures. Returning {nullptr, nullptr, "upload: faulted", m_error} without calling Fail would keep it.
|
|
||
| [[noreturn]] void ExitAfterTextureError() | ||
| { | ||
| std::exit(EXIT_FAILURE); |
There was a problem hiding this comment.
Non-blocking: on Windows this path used to call ExitProcess(0), which skips C++ static destructors and atexit handlers. std::exit runs them. They now run from whatever call stack hit the texture error, after DestroyWindow/DestroySound have torn down part of the client while the renderer, the SDL GPU device and the window are still alive. Destruction order across files isn't defined. That covers the AnimationTaskPool singleton (joins its workers), g_MuHelper, the renderer's registry maps and s_bitmapGpuResources.
I haven't hit a crash from this. If the only goal was a failing exit code, ExitProcess(EXIT_FAILURE) keeps the old Windows behavior; on Linux and macOS the shim in WinApiShims.h already calls exit().
| { | ||
| mu::log::Get("render")->debug("SDL_gpu -- bitmap id {} replaced: {} -> {}", uiBitmapIndex, | ||
| mu_wchar_to_utf8(pBitmap->FileName), mu_wchar_to_utf8(filename.c_str())); | ||
| UnloadImage(uiBitmapIndex, true); |
There was a problem hiding this comment.
Non-blocking (predates the PR): the replacement path force-unloads the current bitmap before the new file is even opened. If the new one fails to decode or upload, the ID is left empty. Any holders of the old image (it had Ref == 2 in the test) then draw the GetTexture error sentinel or the white fallback. The new test "bitmap replacement keeps existing ID and refcount semantics on rejected upload" asserts exactly this (GetNumberOfTexture() == 0), so it now locks the behavior in.
This PR already moved registration and accounting behind a successful upload. Decoding and uploading into a fresh BITMAP_t first, then unloading the old one and inserting the new one, would make a failed replacement keep the original. Otherwise, a test name that says the original is dropped would be clearer.
|
|
||
| SDL_GPUTransferBufferCreateInfo transferInfo{}; | ||
| transferInfo.usage = SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD; | ||
| transferInfo.size = static_cast<Uint32>(kSlotCapacity); |
There was a problem hiding this comment.
Non-blocking: the three slots, 12 MiB of host-visible upload memory, stay allocated from renderer Init until device shutdown, although nearly all bitmap uploads happen on loading screens. The docs section already mentions the size. Was releasing them after load phases, or creating them on the first upload, considered? Either keeps the FD bound without the permanent footprint.
| const auto pitch = (rowBytes + kRowAlignment - 1) / kRowAlignment * kRowAlignment; | ||
| const auto sourceBytes = rowBytes * static_cast<std::uint64_t>(height); | ||
| const auto stagingBytes = pitch * static_cast<std::uint64_t>(height); | ||
| if (pixels.size() < sourceBytes || stagingBytes > kSlotCapacity) |
There was a problem hiding this comment.
Non-blocking: stagingBytes > kSlotCapacity can't be true. Width and height are bounded just above, and kSlotCapacity is derived from those same limits with static_asserts in the header. The dead check shares its message with the real pixels.size() check, which suggests a runtime overflow is possible. It could be dropped, or turned into an assert.
| return Failure(); | ||
| } | ||
| const auto rowBytes = static_cast<std::uint64_t>(width) * kBytesPerPixel; | ||
| const auto pitch = (rowBytes + kRowAlignment - 1) / kRowAlignment * kRowAlignment; |
There was a problem hiding this comment.
Non-blocking: the 256-byte pitch rounding appears in kMaximumPitch, here, in the integration test, and as FrameReadbackRowAlignment in MuRendererSDLGpu.cpp. CheckDownload hard-codes + 255) / 256 * 256 (line 172 of the test), so changing kRowAlignment would leave the readback using the old pitch. A public static constexpr std::uint64_t PaddedPitch(int width) on BitmapGpuResources could serve kMaximumPitch, Upload and the test.
| return true; | ||
| } | ||
|
|
||
| BitmapGpuResources::Result BitmapGpuResources::Upload(std::span<const std::uint8_t> pixels, int width, int height, |
There was a problem hiding this comment.
Non-blocking: Upload is about 110 lines (103-213). It validates the request, retires the slot, creates the texture, copies rows, records the pass, submits and picks the sampler. CODING_RULES rule 1: "A function does one thing" and "Aim for functions that fit on one screen (~40 lines)". releaseTexture cleanup is repeated on four paths in between. Extracting the validation, the staging copy and the record/submit step would keep the ownership rules easy to check. It would also make the fault-model change above easier to apply.
| SDL_GPUDevice* m_device = nullptr; | ||
| std::thread::id m_thread; | ||
| std::array<Slot, kSlotCount> m_slots{}; | ||
| std::array<SDL_GPUSampler*, 4> m_samplers{}; |
There was a problem hiding this comment.
Non-blocking: the filter × wrap table is encoded with unnamed numbers in three places. The 4 here; i / 2 == 0 and i % 2 == 0, plus the stage-name string, in Initialize; and (LINEAR ? 2u : 0u) + (CLAMP ? 1u : 0u) in Upload. CODING_RULES rule 3: "if it's not self-evident, give it a name." A named kSamplerCount and one SamplerIndex(filter, wrap) helper used by both sides would keep them from drifting apart.
|
This PR is merged unchanged into the integration PR #764, together with #758 and #716, with the conflicts resolved there. When #764 merges with a merge commit, GitHub marks this PR as merged too. Please don't push to this branch until #764 merges. A new commit here would leave this PR open, and it would have to be merged into #764 again. What #764 adds on top of this PR:
The opt-in GPU test passes on Direct3D 12 and Vulkan, including a new 2048×1030 banded upload. |
Resolved against the upscaled textures on main (#718) and #758: - The loader moved to GlobalBitmapLoad.cpp; it now carries #718/#758's marker scale, original-size Width/Height, premultiplied RmlUi images, canvases that keep their pixels, the released CPU copy and the base-level texture memory count. - The uploader keeps its three 4 MiB slots and stages an image larger than one slot (marked files up to 4x the 1024 px limit) in bands of rows, one submission each. - Mipmapped uploads: the GPU builds the chain after the last band, and two more shared samplers (trilinear, anisotropic, repeat/clamp) serve them, so upscaled textures no longer create a sampler each. - Tests: six shared samplers, the wider size limit, banded staging with mips, and a marked file in the loader (mipmapped, original size, released pixels; a canvas shrunk and kept).
Texture creation, mapping, acquiring and the copy pass fail before the slot is in use, so only that upload fails now. A failed submit or fence wait leaves the GPU's use of its slot unknown: that slot is taken out of service and uploads go on through the others; the uploader faults only when no slot is left. A faulted uploader keeps the error that faulted it instead of overwriting it with every rejection, and an uninitialized one says so rather than "wrong thread". From the review of #622.
Summary
Bound bitmap-upload resources during bulk loading instead of allocating a transfer buffer and sampler for every image. On the Linux/NVIDIA reproduction in #620, upload-related resource retention exhausted a 1024-FD limit before startup completed.
Related issues
Closes #620.
Verification
The Windows preflight commit
2b941fe8contains this exact contribution (45cb60fb) plus only a fork-specific workflow. Quality CI ran at8799e5d7, adding only a fork/temporary-branch-specific comparison-base correction because the fork's main branch is older than upstream. Both CI scaffolding changes are excluded from this PR; application and test sources match.GPU regression
With a claimed window and no swapchain acquisition/presentation, each device lifetime performs 2400 changing uploads with three staging buffers, four samplers, three preallocated real destination textures and a reusable readback buffer. Intermediate checkpoints verify all three destination sizes, including padded rows and maximum-size images; the test repeats after device recreation. Only destination allocation is substituted through the existing test seam to isolate staging pressure; a separate default-allocation smoke test covers ordinary texture ownership.
To opt in when running CTest on a GPU-capable host, set
MU_RUN_GPU_UPLOAD_TESTS=1; additionally setMU_GPU_UPLOAD_VALIDATION=1for validation-mode coverage. Without opt-in, the GPU case explicitly skips.Limits
No native D3D12/Metal runtime or TSan result is claimed. Decoder tests use real decoders/cache/accounting but stub external renderer/registry effects; fatal-dispatch tests stub popup and OS cleanup. GPU readbacks are periodic, not every upload. The original baseline lacked startup timings, so historical performance parity is not established. The sampler-allocation-limit failure itself was not reproduced; the measured regression is FD exhaustion.
Written with AI assistance and tested by hand.
Checklist
docs/CODING_RULES.md.