perf(ui): flush only the written descriptor range, not the whole heap (#61)

WriteBufferDescriptor / WriteSampledImageDescriptor / RegisterSampler /
WriteSwapchainDescriptors each FlushDevice()'d the entire multi-KB per-frame
descriptor heap after writing one few-byte descriptor. Add a ranged
VulkanBuffer::FlushDevice(offset, bytes) that flushes only the touched bytes,
rounding the range outward to nonCoherentAtomSize as vkFlushMappedMemoryRanges
requires (the WHOLE_SIZE path sidestepped that). The coherent-memory gate from
issue #60 is preserved — coherent memory still skips the flush entirely.

The descriptor writers now self-flush their written range, so the redundant
whole-heap flushes in UIRenderer::Initialize and the resize callback are gone.

Rounding lives in AlignMappedFlushRange (pure math) and is unit-tested without
a device in the new VulkanBufferRangedFlush test; the change was also verified
end-to-end by running HelloUI on a real GPU with validation layers (font-atlas
glyphs, sampler and storage-image descriptors all flush correctly, no VUIDs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-06-16 18:29:24 +00:00
commit 6d7ad87e38
6 changed files with 236 additions and 7 deletions

View file

@ -161,6 +161,14 @@ export namespace Crafter {
inline static VkPhysicalDeviceMemoryProperties memoryProperties;
// VkPhysicalDeviceLimits::nonCoherentAtomSize — the alignment (in bytes,
// a power of two) that vkFlushMappedMemoryRanges / vkInvalidateMappedMemoryRanges
// require for the offset and size of a sub-buffer range. Whole-buffer
// (VK_WHOLE_SIZE) flushes sidestep it; ranged flushes must round outward
// to it (see VulkanBuffer::FlushDevice(offset,bytes)). Populated at device
// creation; defaults to 1 so the rounding math is well-defined before then.
inline static VkDeviceSize nonCoherentAtomSize = 1;
inline static VkPhysicalDeviceDescriptorHeapPropertiesEXT descriptorHeapProperties = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DESCRIPTOR_HEAP_PROPERTIES_EXT
};

View file

@ -29,10 +29,35 @@ import std;
import :Device;
namespace Crafter {
// Round a host-write flush range outward to nonCoherentAtomSize boundaries —
// the alignment vkFlushMappedMemoryRanges demands for a sub-buffer range
// (whole-buffer VK_WHOLE_SIZE calls sidestep it). `atom` is
// VkPhysicalDeviceLimits::nonCoherentAtomSize (a power of two ≥ 1) and
// `mappingSize` is the byte size the memory was mapped with (the allocation
// size). The start rounds down and the end rounds up to atom multiples; the
// end is then clamped to `mappingSize`. Clamping is what keeps the range
// valid even when `mappingSize` itself is not atom-aligned: the spec permits
// a non-atom-multiple size only when offset+size equals the mapping size, and
// the clamp lands exactly on that exception. Pure math, so it is unit-tested
// without a device.
export struct MappedFlushRange { VkDeviceSize offset; VkDeviceSize size; };
export constexpr MappedFlushRange AlignMappedFlushRange(
VkDeviceSize offset, VkDeviceSize bytes,
VkDeviceSize atom, VkDeviceSize mappingSize) {
VkDeviceSize begin = (offset / atom) * atom;
VkDeviceSize end = ((offset + bytes + atom - 1) / atom) * atom;
if (end > mappingSize) end = mappingSize;
return { begin, end - begin };
}
export class VulkanBufferBase {
public:
VkDeviceAddress address;
std::uint32_t size;
// Byte size the memory was mapped with — equal to the allocation's
// VkMemoryRequirements::size, which is ≥ `size` and what ranged flushes
// clamp their upper bound to. Only meaningful for mapped buffers.
VkDeviceSize mappedSize = 0;
VkBuffer buffer = VK_NULL_HANDLE;
VkDeviceMemory memory;
// Property flags of the memory type actually chosen by GetMemoryType —
@ -108,6 +133,7 @@ namespace Crafter {
address = vkGetBufferDeviceAddress(Device::device, &addressInfo);
if constexpr(Mapped) {
mappedSize = memReqs.size;
Device::CheckVkResult(vkMapMemory(Device::device, memory, 0, memReqs.size, 0, reinterpret_cast<void**>(&(VulkanBufferMappedConditional<T, true>::value))));
}
}
@ -184,6 +210,27 @@ namespace Crafter {
vkFlushMappedMemoryRanges(Device::device, 1, &range);
}
// Flush only the host writes in [offset, offset+bytes) to the device,
// instead of the whole buffer. Use after touching a small sub-range
// (e.g. one descriptor in a multi-KB descriptor heap) so cache
// maintenance scales with the bytes actually written. The range is
// rounded outward to nonCoherentAtomSize as the Vulkan spec requires.
// No-op on coherent memory, same gate as the whole-buffer FlushDevice().
void FlushDevice(VkDeviceSize offset, VkDeviceSize bytes) requires(Mapped) {
if (memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) {
return;
}
MappedFlushRange r = AlignMappedFlushRange(
offset, bytes, Device::nonCoherentAtomSize, mappedSize);
VkMappedMemoryRange range = {
.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE,
.memory = memory,
.offset = r.offset,
.size = r.size
};
vkFlushMappedMemoryRanges(Device::device, 1, &range);
}
void FlushDevice(VkCommandBuffer cmd, VkAccessFlags dstAccessMask, VkPipelineStageFlags dstStageMask) requires(Mapped) {
FlushDevice();
VkBufferMemoryBarrier barrier = {
@ -227,6 +274,7 @@ namespace Crafter {
buffer = other.buffer;
memory = other.memory;
size = other.size;
mappedSize = other.mappedSize;
memoryPropertyFlagsChosen = other.memoryPropertyFlagsChosen;
other.buffer = VK_NULL_HANDLE;
address = other.address;