graphics: cut native-RHI churn stalls (batched view retirement, bounded drains, buffer bind classes, rebind skip)

This commit is contained in:
mchughalex 2026-07-21 05:24:35 +01:00
parent 7466d3c75e
commit aa0f046ecf
3 changed files with 197 additions and 61 deletions

View file

@ -235,9 +235,20 @@ class Pipeline {
// Descriptors.
// ---------------------------------------------------------------------------
// Binding classes a buffer will be used with. kFull is the historical
// default (constant buffer / buffer SRV / vertex / index / copy source).
// Narrower classes let backends drop the descriptor-window padding that only
// dynamic-offset constant/SRV binds need, and request narrower usage.
enum class BufferBindClass : uint32_t {
kFull,
kVertexIndex, // vertex/index binds and copy source only
kCopySrc, // copy source only (texture upload staging)
};
struct BufferDesc {
uint64_t size = 0;
HeapKind heap = HeapKind::kDefault;
BufferBindClass bind_class = BufferBindClass::kFull;
};
struct TextureDesc {

View file

@ -15,6 +15,7 @@
#include <map>
#include <mutex>
#include <string>
#include <unordered_set>
#include <utility>
#include <vector>
@ -395,7 +396,26 @@ class NrCmdD3D12 : public nrhi::Cmd {
ResourceState after) override;
void FlushBarriers() override;
// Called at frame begin and on root-signature changes: the fresh deferred
// command list / new root signature carries no bindings, so the latched
// tuples must not suppress the first re-bind.
void ResetFrameState() {
std::memset(last_table_views_, 0, sizeof(last_table_views_));
std::memset(last_table_counts_, 0, sizeof(last_table_counts_));
}
NrDeviceD3D12* device = nullptr;
private:
void BindTextureTable(uint32_t param, NrTextureViewD3D12* const* views, uint32_t count);
// Last tuple bound per root param: consecutive draws mostly rebind the
// same views (shadow atlas, cube map, white fallback), skipping both the
// binding-cache lookup and the root-table re-record. A zero count is the
// empty/invalid state; real bindings always have count >= 1.
NrTextureViewD3D12* last_table_views_[nrhi::kMaxBindingParams]
[nrhi::kMaxTextureTableSize] = {};
uint32_t last_table_counts_[nrhi::kMaxBindingParams] = {};
};
class NrDeviceD3D12 : public nrhi::Device {
@ -432,6 +452,7 @@ class NrDeviceD3D12 : public nrhi::Device {
~NrDeviceD3D12() override {
// Callers guarantee GPU idle; release everything immediately.
FlushDissolvedViews();
DrainRetired(~0ull);
for (auto& entry : guest_outputs_) {
entry.second->resource->Release();
@ -585,22 +606,37 @@ class NrDeviceD3D12 : public nrhi::Device {
void DestroyDeferred(nrhi::TextureView* view) override {
if (view == nullptr) return;
auto* v = static_cast<NrTextureViewD3D12*>(view);
// Destruction is batched: the view object stays allocated (so its
// address cannot be reused while stale binding-cache keys still hold it)
// and FlushDissolvedViews sweeps the binding cache ONCE per frame for
// the whole batch instead of once per destroyed view.
dissolved_views_.push_back(static_cast<NrTextureViewD3D12*>(view));
}
// Render thread, once per frame (and at device destruction): retire every
// shader-visible binding referencing a view destroyed since the last
// flush, then retire the views themselves.
void FlushDissolvedViews() {
if (dissolved_views_.empty()) return;
std::unordered_set<const NrTextureViewD3D12*> dissolved(dissolved_views_.begin(),
dissolved_views_.end());
std::lock_guard<std::mutex> lock(mutex_);
const uint64_t submission = cp_->GetCurrentSubmission();
staging_slots_.Retire(v->staging_slot, 1, submission);
// Retire every shader-visible binding this view participates in.
std::erase_if(bindings_, [&](auto& entry) {
for (uint32_t i = 0; i < entry.first.count; ++i) {
if (entry.first.views[i] == v) {
if (dissolved.count(entry.first.views[i]) != 0) {
srv_slots_.Retire(entry.second.first_slot, entry.second.count, submission);
return true;
}
}
return false;
});
for (NrTextureViewD3D12* v : dissolved_views_) {
staging_slots_.Retire(v->staging_slot, 1, submission);
delete v;
}
dissolved_views_.clear();
}
void DestroyDeferred(nrhi::Pipeline* pipeline) override {
if (pipeline == nullptr) return;
@ -867,9 +903,14 @@ class NrDeviceD3D12 : public nrhi::Device {
D3D12_RESOURCE_STATES guest_output_internal_state, uint32_t width,
uint32_t height, nrhi::Texture** guest_output_out) {
guest_output_state_ = guest_output_internal_state;
cmd_.ResetFrameState();
FlushDissolvedViews();
{
std::lock_guard<std::mutex> lock(mutex_);
DrainRetired(cp_->GetCompletedSubmission());
// Bounded per frame: the eviction sweeps retire thousands of objects
// at once, and releasing them all in one frame is its own hitch (the
// remainder drains over the following frames).
DrainRetired(cp_->GetCompletedSubmission(), 512);
}
NrTextureD3D12*& wrapper = guest_outputs_[guest_output_resource];
if (wrapper != nullptr &&
@ -1006,9 +1047,11 @@ class NrDeviceD3D12 : public nrhi::Device {
uint32_t count;
};
void DrainRetired(uint64_t completed) {
void DrainRetired(uint64_t completed, size_t max_objects = SIZE_MAX) {
size_t released = 0;
std::erase_if(retired_, [&](const RetiredObject& r) {
if (r.submission < completed) {
if (r.submission < completed && released < max_objects) {
++released;
if (r.resource) r.resource->Release();
if (r.pso) r.pso->Release();
return true;
@ -1053,6 +1096,10 @@ class NrDeviceD3D12 : public nrhi::Device {
SlotAllocator dsv_slots_;
std::vector<RetiredObject> retired_;
std::map<BindingKey, Binding> bindings_;
// Views destroyed since the last FlushDissolvedViews (render thread only).
// The objects stay allocated until the flush so stale binding-cache keys
// can never collide with a newly created view at the same address.
std::vector<NrTextureViewD3D12*> dissolved_views_;
std::map<ID3D12Resource*, NrTextureD3D12*> guest_outputs_;
D3D12_RESOURCE_STATES guest_output_state_ = D3D12_RESOURCE_STATE_COMMON;
};
@ -1062,6 +1109,8 @@ void NrCmdD3D12::SetBindingLayout(nrhi::BindingLayout* layout) {
DeferredCommandList& list = device->cp()->GetDeferredCommandList();
list.SetDescriptorHeaps(device->srv_heap(), nullptr);
list.D3DSetGraphicsRootSignature(l->root_signature);
// Root-signature semantics: all bindings reset.
ResetFrameState();
}
void NrCmdD3D12::SetPipeline(nrhi::Pipeline* pipeline) {
@ -1085,22 +1134,31 @@ void NrCmdD3D12::SetBufferSrv(uint32_t param, nrhi::Buffer* buffer, uint64_t off
param, static_cast<NrBufferD3D12*>(buffer)->gpu_va + offset);
}
void NrCmdD3D12::BindTextureTable(uint32_t param, NrTextureViewD3D12* const* views,
uint32_t count) {
if (param >= nrhi::kMaxBindingParams) return;
if (count == last_table_counts_[param] &&
std::memcmp(last_table_views_[param], views, count * sizeof(views[0])) == 0) {
return; // identical tuple already bound on this root param
}
D3D12_GPU_DESCRIPTOR_HANDLE handle;
if (device->GetBinding(views, count, &handle)) {
device->cp()->GetDeferredCommandList().D3DSetGraphicsRootDescriptorTable(param, handle);
std::memcpy(last_table_views_[param], views, count * sizeof(views[0]));
last_table_counts_[param] = count;
}
}
void NrCmdD3D12::SetTexture(uint32_t param, nrhi::TextureView* view) {
NrTextureViewD3D12* views[1] = {static_cast<NrTextureViewD3D12*>(view)};
D3D12_GPU_DESCRIPTOR_HANDLE handle;
if (device->GetBinding(views, 1, &handle)) {
device->cp()->GetDeferredCommandList().D3DSetGraphicsRootDescriptorTable(param, handle);
}
BindTextureTable(param, views, 1);
}
void NrCmdD3D12::SetTexturePair(uint32_t param, nrhi::TextureView* first,
nrhi::TextureView* second) {
NrTextureViewD3D12* views[2] = {static_cast<NrTextureViewD3D12*>(first),
static_cast<NrTextureViewD3D12*>(second)};
D3D12_GPU_DESCRIPTOR_HANDLE handle;
if (device->GetBinding(views, 2, &handle)) {
device->cp()->GetDeferredCommandList().D3DSetGraphicsRootDescriptorTable(param, handle);
}
BindTextureTable(param, views, 2);
}
void NrCmdD3D12::SetTextures(uint32_t param, nrhi::TextureView* const* views, uint32_t count) {
@ -1109,10 +1167,7 @@ void NrCmdD3D12::SetTextures(uint32_t param, nrhi::TextureView* const* views, ui
for (uint32_t i = 0; i < count; ++i) {
typed[i] = static_cast<NrTextureViewD3D12*>(views[i]);
}
D3D12_GPU_DESCRIPTOR_HANDLE handle;
if (device->GetBinding(typed, count, &handle)) {
device->cp()->GetDeferredCommandList().D3DSetGraphicsRootDescriptorTable(param, handle);
}
BindTextureTable(param, typed, count);
}
void NrCmdD3D12::SetRenderTargets(nrhi::Texture* color, nrhi::Texture* depth) {

View file

@ -248,8 +248,9 @@ class NrBufferVulkan : public nrhi::Buffer {
VkBuffer buffer = VK_NULL_HANDLE;
VmaAllocation allocation = VK_NULL_HANDLE;
void* mapping = nullptr;
uint64_t size_ = 0; // the app-requested size (allocation is padded)
uint64_t size_ = 0; // the app-requested size (kFull allocations are padded)
HeapKind heap = HeapKind::kDefault;
nrhi::BufferBindClass bind_class = nrhi::BufferBindClass::kFull;
bool device_local = false; // memory-type DEVICE_LOCAL (diagnostics)
};
@ -384,10 +385,11 @@ struct NrFrameProf {
Bucket view_create;
Bucket const_slice;
Bucket pipeline_build;
Bucket drain;
void Reset() { *this = NrFrameProf{}; }
uint64_t Total() const {
return pass_open.us + flush_barriers.us + copies.us + table_miss.us + set0_miss.us +
view_destroy.us + view_create.us + const_slice.us + pipeline_build.us;
view_destroy.us + view_create.us + const_slice.us + pipeline_build.us + drain.us;
}
};
@ -535,6 +537,7 @@ class NrDeviceVulkan : public nrhi::Device {
// Callers guarantee GPU idle; release everything immediately.
const ui::vulkan::VulkanDevice::Functions& dfn = vulkan_device_->functions();
const VkDevice device = vulkan_device_->device();
FlushDissolvedViews();
DrainRetired(~0ull);
for (auto& entry : set0_sets_) {
dfn.vkFreeDescriptorSets(device, entry.second.pool, 1, &entry.second.set);
@ -649,12 +652,31 @@ class NrDeviceVulkan : public nrhi::Device {
info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
info.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
VmaAllocationCreateInfo alloc_info = {};
switch (desc.heap) {
case HeapKind::kUpload:
info.size = desc.size + kBufferPadding;
info.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT |
// The descriptor-window padding only matters for buffers that can be
// bound through dynamic-offset UBO/SSBO descriptors; narrower bind
// classes skip it (and the descriptor usage bits).
const bool descriptor_bindable = desc.bind_class == nrhi::BufferBindClass::kFull;
const uint64_t padding = descriptor_bindable ? kBufferPadding : 0;
VkBufferUsageFlags app_usage;
switch (desc.bind_class) {
case nrhi::BufferBindClass::kCopySrc:
app_usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
break;
case nrhi::BufferBindClass::kVertexIndex:
app_usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT |
VK_BUFFER_USAGE_INDEX_BUFFER_BIT;
break;
case nrhi::BufferBindClass::kFull:
default:
app_usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT |
VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT |
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
break;
}
switch (desc.heap) {
case HeapKind::kUpload:
info.size = desc.size + padding;
info.usage = app_usage;
alloc_info.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT |
VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT;
alloc_info.usage = VMA_MEMORY_USAGE_AUTO;
@ -676,13 +698,14 @@ class NrDeviceVulkan : public nrhi::Device {
break;
case HeapKind::kDefault:
default:
info.size = desc.size + kBufferPadding;
info.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT |
VK_BUFFER_USAGE_INDEX_BUFFER_BIT | VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT |
VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
info.size = desc.size + padding;
info.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT | app_usage;
alloc_info.usage = VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE;
break;
}
// Unpadded classes could otherwise reach vkCreateBuffer with size 0
// (invalid); the padded classes never could.
info.size = std::max<VkDeviceSize>(info.size, 4);
VkBuffer buffer = VK_NULL_HANDLE;
VmaAllocation allocation = VK_NULL_HANDLE;
VmaAllocationInfo result_info = {};
@ -712,6 +735,7 @@ class NrDeviceVulkan : public nrhi::Device {
b->mapping = result_info.pMappedData; // persistent map for host heaps
b->size_ = desc.size;
b->heap = desc.heap;
b->bind_class = desc.bind_class;
b->device_local = mem_dl;
{
std::lock_guard<std::mutex> lock(mutex_);
@ -806,8 +830,10 @@ class NrDeviceVulkan : public nrhi::Device {
// Retire cached set-0 descriptor sets whose buffer tuple references this
// buffer. set0_sets_ is render-thread-only, like the D3D12 backend's
// bindings_: buffer destruction of bound buffers happens on the render
// thread.
// thread. Non-kFull buffers can never appear in a descriptor tuple, so
// their destruction (the mesh/staging churn path) skips the scan.
const uint64_t submission = cp_->GetCurrentSubmission();
if (b->bind_class == nrhi::BufferBindClass::kFull) {
for (auto it = set0_sets_.begin(); it != set0_sets_.end();) {
bool references = false;
for (uint32_t i = 0; i < it->first.count; ++i) {
@ -824,6 +850,7 @@ class NrDeviceVulkan : public nrhi::Device {
++it;
}
}
}
if (b->heap == HeapKind::kUpload) {
(b->device_local ? g_buf_bytes_upload_dl : g_buf_bytes_upload_host)
.fetch_sub(b->size_, std::memory_order_relaxed);
@ -864,15 +891,33 @@ class NrDeviceVulkan : public nrhi::Device {
void DestroyDeferred(nrhi::TextureView* view) override {
if (view == nullptr) return;
NrProfScope prof_scope(prof_.view_destroy);
// Destruction is batched: the view object stays allocated (so its
// address cannot be reused by a new view while stale cache keys still
// hold it) and FlushDissolvedViews sweeps the descriptor-set cache ONCE
// per frame for the whole batch. The eviction sweeps retire ~1500 views
// in a single frame; a per-view cache scan was a measured 10-15 ms
// recurring hitch.
auto* v = static_cast<NrTextureViewVulkan*>(view);
{
std::lock_guard<std::mutex> lock(mutex_);
live_views_.erase(v);
}
dissolved_views_.push_back(v);
}
// Render thread, once per frame (and at device destruction): retire every
// cached texture descriptor set referencing a view destroyed since the
// last flush, then retire the views themselves.
void FlushDissolvedViews() {
if (dissolved_views_.empty()) return;
NrProfScope prof_scope(prof_.view_destroy);
const uint64_t submission = cp_->GetCurrentSubmission();
// Retire every cached texture descriptor set this view participates in
// (render-thread-only cache, mirroring the D3D12 backend's bindings_).
std::unordered_set<const NrTextureViewVulkan*> dissolved(dissolved_views_.begin(),
dissolved_views_.end());
for (auto it = table_sets_.begin(); it != table_sets_.end();) {
bool references = false;
for (uint32_t i = 0; i < it->first.count; ++i) {
if (it->first.views[i] == v) {
if (dissolved.count(it->first.views[i]) != 0) {
references = true;
break;
}
@ -886,13 +931,15 @@ class NrDeviceVulkan : public nrhi::Device {
}
}
std::lock_guard<std::mutex> lock(mutex_);
live_views_.erase(v);
for (NrTextureViewVulkan* v : dissolved_views_) {
RetiredObject r;
r.submission = submission;
r.view = v->view;
retired_.push_back(r);
delete v;
}
dissolved_views_.clear();
}
void DestroyDeferred(nrhi::Pipeline* pipeline) override {
if (pipeline == nullptr) return;
@ -1210,9 +1257,13 @@ class NrDeviceVulkan : public nrhi::Device {
bool guest_output_ever_written, uint32_t width, uint32_t height,
nrhi::Texture** guest_output_out) {
prof_.Reset();
FlushDissolvedViews();
{
std::lock_guard<std::mutex> lock(mutex_);
DrainRetired(cp_->GetCompletedSubmission());
// Bounded per frame: the eviction sweeps retire thousands of objects
// at once, and destroying them all in one frame is its own hitch (the
// remainder drains over the following frames).
DrainRetired(cp_->GetCompletedSubmission(), 512);
}
++frame_index_;
if ((frame_index_ % 600) == 0) {
@ -1307,13 +1358,14 @@ class NrDeviceVulkan : public nrhi::Device {
REXLOG_INFO(
"nrhi-vulkan: SLOW frame {}us: pass_open={}us/{} flush={}us/{} copies={}us/{} "
"table_miss={}us/{} set0_miss={}us/{} view_destroy={}us/{} view_create={}us/{} "
"const={}us/{} pso={}us/{}",
"const={}us/{} pso={}us/{} drain={}us/{}",
total_us, prof_.pass_open.us, prof_.pass_open.count, prof_.flush_barriers.us,
prof_.flush_barriers.count, prof_.copies.us, prof_.copies.count, prof_.table_miss.us,
prof_.table_miss.count, prof_.set0_miss.us, prof_.set0_miss.count,
prof_.view_destroy.us, prof_.view_destroy.count, prof_.view_create.us,
prof_.view_create.count, prof_.const_slice.us, prof_.const_slice.count,
prof_.pipeline_build.us, prof_.pipeline_build.count);
prof_.pipeline_build.us, prof_.pipeline_build.count, prof_.drain.us,
prof_.drain.count);
}
}
}
@ -1742,11 +1794,14 @@ class NrDeviceVulkan : public nrhi::Device {
retired_.push_back(r);
}
void DrainRetired(uint64_t completed) {
void DrainRetired(uint64_t completed, size_t max_objects = SIZE_MAX) {
NrProfScope prof_scope(prof_.drain);
const ui::vulkan::VulkanDevice::Functions& dfn = vulkan_device_->functions();
const VkDevice device = vulkan_device_->device();
size_t destroyed = 0;
std::erase_if(retired_, [&](const RetiredObject& r) {
if (r.submission >= completed) return false;
if (r.submission >= completed || destroyed >= max_objects) return false;
++destroyed;
if (r.framebuffer != VK_NULL_HANDLE) dfn.vkDestroyFramebuffer(device, r.framebuffer, nullptr);
if (r.view != VK_NULL_HANDLE) dfn.vkDestroyImageView(device, r.view, nullptr);
for (VkPipeline pipeline : r.pipelines) {
@ -2027,6 +2082,11 @@ class NrDeviceVulkan : public nrhi::Device {
std::unordered_set<NrPipelineVulkan*> live_pipelines_;
std::unordered_set<NrShaderVulkan*> live_shaders_;
// Views destroyed since the last FlushDissolvedViews (render thread only).
// The objects stay allocated until the flush so stale descriptor-set cache
// keys can never collide with a newly created view at the same address.
std::vector<NrTextureViewVulkan*> dissolved_views_;
std::map<RenderPassKey, VkRenderPass> render_passes_;
std::map<FramebufferKey, VkFramebuffer> framebuffers_;
std::vector<VkDescriptorPool> descriptor_pools_;
@ -2312,14 +2372,24 @@ void NrCmdVulkan::SetTextures(uint32_t param, nrhi::TextureView* const* views, u
if (layout_ == nullptr || param >= layout_->param_count) return;
const NrBindingLayoutVulkan::ParamInfo& p = layout_->params[param];
if (p.kind != nrhi::BindingParamKind::kTextureTable) return;
// Consecutive draws mostly rebind the same tuple (shadow atlas, cube map,
// white fallback); leaving the table clean skips the per-draw descriptor
// set lookup and rebind entirely.
bool changed = false;
for (uint32_t i = 0; i < p.table_size; ++i) {
// Missing tail entries (table declares N, bound M<N) and null entries
// fall back to the backend's 1x1 white.
table_views_[p.table_index][i] =
NrTextureViewVulkan* view =
i < count ? static_cast<NrTextureViewVulkan*>(views[i]) : nullptr;
if (table_views_[p.table_index][i] != view) {
table_views_[p.table_index][i] = view;
changed = true;
}
}
if (changed) {
table_dirty_[p.table_index] = true;
}
}
void NrCmdVulkan::SetRenderTargets(nrhi::Texture* color, nrhi::Texture* depth) {
EndRenderPassIfOpen();