openlang

Who owns a Vulkan buffer after submission?

A C++ RAII wrapper around a Vulkan buffer records work that uses the buffer, submits it, and goes out of scope. Its destructor calls vkDestroyBuffer right there. The submitted commands that refer to the buffer are still executing.

That call is invalid. Vulkan requires every submitted command referring to the buffer to have completed execution before the buffer is destroyed. Scope exit only shows that the host is finished with the wrapper. It shows nothing about the GPU.

My first idea is to move the wrapper into a frame retirement list at submission and destroy it only after completion of that submission is known. What I keep going back and forth on is where the move belongs. Inside the submit call? In whatever owns the frame? And calling it a frame list doesn't answer when an entry is actually allowed to go.

后续讨论 7

顺着彼此的依据读下去
  1. 固定链接

    One release owner, named. Someone removes the entry once completion is known, and someone keeps it if the host operation exits early. Those may be the same component, but the design should say which. Setting the wrapper's local pointer to null changes that variable and nothing else. It frees nothing and says nothing about whether the submitted commands have finished.

  2. 固定链接

    Check where the retirement list itself lives. If it's allocated from per-frame scratch memory, it gets wiped at frame end while its buffers still wait on the GPU, and those buffers never get destroyed at all.

  3. 固定链接

    If the transfer is a move into the retirement list, the wrapper's move constructor has to leave the source owning nothing, so that its destructor does nothing. Usually that means writing VK_NULL_HANDLE into the moved-from object and checking for it in the destructor. Skip that and the local still destroys the buffer at scope exit, now with a second owner sitting in the list.

    Moved-from objects are still destroyed. The standard's "valid but unspecified state" covers library types only. For your own wrapper, moved-from means whatever your move constructor writes, and nothing else.

  4. 固定链接

    darning was right about the moved-from object. My move constructor copied the handle and left the source holding it. With validation layers on, the moved-from local destroyed the buffer at scope exit and the layer reported VUID-vkDestroyBuffer-buffer-00922 on that call. Now it reads like this, and the report is gone.

    Buffer(Buffer&& other) noexcept
        : device_(other.device_), buffer_(other.buffer_) {
      other.buffer_ = VK_NULL_HANDLE;
    }
    
    ~Buffer() {
      if (buffer_ != VK_NULL_HANDLE)
        vkDestroyBuffer(device_, buffer_, nullptr);
    }

    The move happens in the frame owner, after the submit call returns success. The frame owner holds the fence for that submission. Each frame it calls vkGetFenceStatus on the oldest pending fence. VK_SUCCESS destroys that frame's entries. VK_NOT_READY keeps them. A failed submit never moves anything, so the local stays the single owner, which covers the early-exit case too.

  5. 固定链接

    Three things to check against that loop.

    First, polling only the oldest fence is valid within one queue, because a fence signal from vkQueueSubmit covers every command earlier in submission order on that queue. If uploads go through a separate transfer queue, its fences say nothing about the graphics queue, and its entries need their own list.

    Second, vkGetFenceStatus has a third outcome, VK_ERROR_DEVICE_LOST. With only VK_SUCCESS and VK_NOT_READY handled, a lost device either keeps the entries forever or lands in whichever branch the code treats as the default.

    Third, at teardown nothing will be polled again. Call vkDeviceWaitIdle, drain the whole list, then destroy the device. Assert that the list is empty after the drain. It is one line and it catches the first entry that slips past the retirement path.

  6. 固定链接

    Uploads go through the graphics queue for now, so one list holds. A transfer queue would get its own list keyed by its own fences.

    Device loss was the real gap. VK_ERROR_DEVICE_LOST from vkGetFenceStatus now leaves the poll loop and goes to teardown. A lost device never returns to a non-lost state, so polling again gains nothing. Teardown calls vkDeviceWaitIdle. On a lost device that call still returns in finite time, with VK_SUCCESS or VK_ERROR_DEVICE_LOST. The drain runs after it whatever it returns. Child object handles stay valid after the loss and must still be destroyed before the device, so both cases empty the list by the same path. The empty-list assertion sits right after the drain, where kiln put it.

  7. 固定链接

    The assertion needs one more decision. assert from <assert.h>, or <cassert> in C++, expands to nothing when NDEBUG is defined, and release configurations usually define it. In that build the check after the drain does not exist. If its purpose is to catch the first entry that escapes the retirement path, it should be an ordinary branch that reports the remaining count and is compiled in every configuration. An assert remains fine for the development build, provided nobody reads its silence in a release build as a passing check.

同一个问题的其他角度

  1. A status region that reads Saved twice

    The screen reader said "Saved" twice after one save. The state behind it has a single transition into saved, and the reducer…

    回复 3最近活动 typescriptjavascript
  2. One health bar went red and took the rest with it

    Got bitten by shared resources in Godot UI this time. Every unit in my little prototype has a health bar scene, a ProgressBar…

    回复 10最近活动 gdscript
  3. The label changed, but did the screen show it?

    The data arrives and the reaction switches the label from Loading to Calculating. A long synchronous calculation runs. The la…

    回复 4最近活动 javascripttypescript