RenderingElement3D::tlases was a plain static array, so its buffers were freed by its static destructor at process exit. The Vulkan driver and layers are loaded during Device::Initialize, after the array was constructed, so their own statics are torn down first, and destroying the buffers then called into freed driver and layer state: every ray-traced app run with CRAFTER_GRAPHICS_VALIDATION segfaulted on exit inside the validation layer. TLASHighWaterMark and TLASInstanceDirtyTracking worked around it by tearing frame 0's TLAS down by hand before returning. tlases now refers to an array that is never destroyed, the choice Device already makes for the device itself, and exit reclaims the memory. Indexing it is unchanged for callers. The hand teardown in the two tests goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
360 lines
17 KiB
C++
360 lines
17 KiB
C++
//SPDX-License-Identifier: LGPL-3.0-only
|
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
// Issue #64: TLAS host-visible input buffers (instanceBuffer / metadataBuffer)
|
|
// grow on a high-water mark instead of being reallocated to the exact instance
|
|
// count on every topology change. These buffers only ever need to hold *at
|
|
// least* primitiveCount entries — the AS build reads exactly primitiveCount of
|
|
// them — so an allocation left over from a larger earlier frame is reused.
|
|
//
|
|
// This drives the real hardware path: a headless Vulkan RT device (no swapchain
|
|
// needed — a TLAS build only touches the queue + command pool), a real cube
|
|
// BLAS, and RenderingElement3D::BuildTLAS recorded into one-time command
|
|
// buffers at a sequence of instance counts.
|
|
//
|
|
// What is asserted:
|
|
// - First build at a given count ALLOCATES the host inputs (non-null handle,
|
|
// non-zero device address, size == count·sizeof(entry)).
|
|
// - Growing PAST the current capacity REALLOCATES (new VkBuffer handle, new
|
|
// address, larger size) — the only case that still pays the realloc.
|
|
// - SHRINKING reuses the existing allocation unchanged (same handle, same
|
|
// address, same size) — the core win of this issue, and the case the
|
|
// pre-fix code reallocated on.
|
|
// - Growing back up but still WITHIN the high-water capacity also reuses it.
|
|
// - Growing to EXACTLY the capacity reuses it (boundary: count > capacity,
|
|
// not >=).
|
|
// - instanceBuffer and metadataBuffer grow in lockstep (one capacity check
|
|
// governs both).
|
|
// - A same-count rebuild takes the refit (UPDATE) path: builtInstanceCount
|
|
// is unchanged and the inputs are obviously not reallocated.
|
|
// - builtInstanceCount tracks the live count across every build (the AS
|
|
// itself is still rebuilt on a count change — the fix only spares the two
|
|
// host buffers, not the AS storage/scratch).
|
|
// - The Vulkan validation layer reports ZERO errors across all of the above
|
|
// — the strongest check that feeding an oversized instance buffer to the
|
|
// AS build (with tlasRangeInfo.primitiveCount < capacity) is spec-correct.
|
|
//
|
|
// Issue #65: this test also guards the instance buffer's memory-type request —
|
|
// HOST_VISIBLE (required) with a best-effort DEVICE_LOCAL preference and no
|
|
// mandatory HOST_COHERENT, so the per-frame GPU accesses stay in local VRAM
|
|
// (BAR) rather than crossing PCIe. See the assertion block after the first
|
|
// build. The zero-validation-errors check below also covers the dropped
|
|
// HOST_COHERENT: the FlushDevice(cmd, ...) in BuildTLAS gates on the chosen
|
|
// type's coherency flag, so a non-coherent BAR type still flushes correctly.
|
|
//
|
|
// Issue #75: the metadata buffer gets the identical upgrade — it is CPU-written
|
|
// every frame and read by the ray shaders as a STORAGE_BUFFER every frame, so
|
|
// the same HOST_VISIBLE | prefer-DEVICE_LOCAL, no-mandatory-HOST_COHERENT
|
|
// request keeps those per-frame shader reads in local VRAM. A second assertion
|
|
// block after the first build checks its chosen memory type the same way. The
|
|
// dropped HOST_COHERENT is covered by the zero-validation-errors check: the
|
|
// FlushDevice() added after the copy loop in BuildTLAS makes the host writes
|
|
// available on a non-coherent type and self-gates to a no-op on a coherent one.
|
|
//
|
|
// Validation layers are required for the last check to be meaningful; the build
|
|
// marks this test as needing the SDK layers.
|
|
|
|
#include "vulkan/vulkan.h"
|
|
#include <cstdlib>
|
|
|
|
import Crafter.Graphics;
|
|
import Crafter.Math;
|
|
import std;
|
|
|
|
using namespace Crafter;
|
|
|
|
namespace {
|
|
|
|
int failures = 0;
|
|
|
|
void Check(bool ok, std::string_view what) {
|
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
|
if (!ok) ++failures;
|
|
}
|
|
|
|
// One-time command buffer helpers — record a build, submit, block.
|
|
VkCommandBuffer BeginCmd() {
|
|
VkCommandBufferAllocateInfo allocInfo {
|
|
.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO,
|
|
.commandPool = Device::commandPool,
|
|
.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY,
|
|
.commandBufferCount = 1,
|
|
};
|
|
VkCommandBuffer cmd = VK_NULL_HANDLE;
|
|
Device::CheckVkResult(vkAllocateCommandBuffers(Device::device, &allocInfo, &cmd));
|
|
VkCommandBufferBeginInfo beginInfo {
|
|
.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
|
|
.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT,
|
|
};
|
|
Device::CheckVkResult(vkBeginCommandBuffer(cmd, &beginInfo));
|
|
return cmd;
|
|
}
|
|
|
|
void SubmitWait(VkCommandBuffer cmd) {
|
|
Device::CheckVkResult(vkEndCommandBuffer(cmd));
|
|
VkSubmitInfo submitInfo {
|
|
.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO,
|
|
.commandBufferCount = 1,
|
|
.pCommandBuffers = &cmd,
|
|
};
|
|
Device::CheckVkResult(vkQueueSubmit(Device::queue, 1, &submitInfo, VK_NULL_HANDLE));
|
|
Device::CheckVkResult(vkQueueWaitIdle(Device::queue));
|
|
vkFreeCommandBuffers(Device::device, Device::commandPool, 1, &cmd);
|
|
}
|
|
|
|
// A unit cube (8 verts, 12 triangles) — enough topology for a real BLAS.
|
|
std::vector<Vector<float, 3, 3>> CubeVerts(float s) {
|
|
return {
|
|
{-s,-s,-s}, { s,-s,-s}, { s, s,-s}, {-s, s,-s},
|
|
{-s,-s, s}, { s,-s, s}, { s, s, s}, {-s, s, s},
|
|
};
|
|
}
|
|
std::vector<std::uint32_t> CubeIndices() {
|
|
return {
|
|
0,1,2, 0,2,3, 4,6,5, 4,7,6,
|
|
0,4,5, 0,5,1, 3,2,6, 3,6,7,
|
|
1,5,6, 1,6,2, 0,3,7, 0,7,4,
|
|
};
|
|
}
|
|
|
|
// One TLAS instance referencing `blasAddr`, identity transform, visible mask.
|
|
VkAccelerationStructureInstanceKHR MakeInstance(VkDeviceAddress blasAddr) {
|
|
VkAccelerationStructureInstanceKHR inst{};
|
|
inst.transform = VkTransformMatrixKHR{{
|
|
{1.0f, 0.0f, 0.0f, 0.0f},
|
|
{0.0f, 1.0f, 0.0f, 0.0f},
|
|
{0.0f, 0.0f, 1.0f, 0.0f},
|
|
}};
|
|
inst.mask = 0xFF;
|
|
inst.accelerationStructureReference = blasAddr;
|
|
return inst;
|
|
}
|
|
|
|
// Register exactly `n` elements from a stable (reserved, never-reallocated)
|
|
// pool. Clears the current registration first so the live count is exactly n.
|
|
void SetCount(std::vector<RenderingElement3D>& pool, std::uint32_t n) {
|
|
while (!RenderingElement3D::elements.empty()) {
|
|
RenderingElement3D::Remove(RenderingElement3D::elements.back());
|
|
}
|
|
for (std::uint32_t i = 0; i < n; ++i) {
|
|
RenderingElement3D::Add(&pool[i]);
|
|
}
|
|
}
|
|
|
|
// Build the frame-0 TLAS for the currently-registered elements.
|
|
void BuildOnce() {
|
|
VkCommandBuffer cmd = BeginCmd();
|
|
RenderingElement3D::BuildTLAS(cmd, 0);
|
|
SubmitWait(cmd);
|
|
}
|
|
|
|
// Snapshot of the host-input buffer identity, for before/after comparison.
|
|
struct InputSnapshot {
|
|
VkBuffer instBuf;
|
|
VkDeviceAddress instAddr;
|
|
std::uint32_t instSize;
|
|
VkBuffer metaBuf;
|
|
VkDeviceAddress metaAddr;
|
|
std::uint32_t metaSize;
|
|
};
|
|
InputSnapshot Snapshot() {
|
|
auto& tlas = RenderingElement3D::tlases[0];
|
|
return {
|
|
tlas.instanceBuffer.buffer, tlas.instanceBuffer.address, tlas.instanceBuffer.size,
|
|
tlas.metadataBuffer.buffer, tlas.metadataBuffer.address, tlas.metadataBuffer.size,
|
|
};
|
|
}
|
|
|
|
// Both host inputs untouched (same VkBuffer handle, address and size).
|
|
bool Reused(const InputSnapshot& a, const InputSnapshot& b) {
|
|
return a.instBuf == b.instBuf && a.instAddr == b.instAddr && a.instSize == b.instSize
|
|
&& a.metaBuf == b.metaBuf && a.metaAddr == b.metaAddr && a.metaSize == b.metaSize;
|
|
}
|
|
|
|
constexpr std::uint32_t kEntrySize = sizeof(VkAccelerationStructureInstanceKHR);
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
Device::Initialize();
|
|
Device::validationErrorCount = 0;
|
|
|
|
// One real cube BLAS that every TLAS instance references.
|
|
Mesh cube;
|
|
{
|
|
auto verts = CubeVerts(1.0f);
|
|
auto idx = CubeIndices();
|
|
VkCommandBuffer cmd = BeginCmd();
|
|
cube.Build(verts, idx, cmd, RTBuildOptions{ .allowUpdate = true });
|
|
SubmitWait(cmd);
|
|
}
|
|
Check(cube.blasAddr != 0, "cube BLAS produced a non-zero blasAddr");
|
|
|
|
// Stable backing store for the elements. Reserve to the max count this
|
|
// test ever registers so the vector never reallocates (the static
|
|
// `elements` array holds raw pointers into it).
|
|
constexpr std::uint32_t kMaxElems = 32;
|
|
std::vector<RenderingElement3D> pool(kMaxElems);
|
|
for (auto& e : pool) e.instance = MakeInstance(cube.blasAddr);
|
|
|
|
// ── 1. First build at 4 instances → allocates the host inputs. ──────────
|
|
SetCount(pool, 4);
|
|
BuildOnce();
|
|
InputSnapshot s4 = Snapshot();
|
|
Check(s4.instBuf != VK_NULL_HANDLE, "first build allocated the instance buffer");
|
|
Check(s4.metaBuf != VK_NULL_HANDLE, "first build allocated the metadata buffer");
|
|
Check(s4.instAddr != 0, "instance buffer has a non-zero device address");
|
|
Check(s4.instSize == 4 * kEntrySize, "instance buffer sized for 4 entries");
|
|
Check(RenderingElement3D::tlases[0].builtInstanceCount == 4,
|
|
"builtInstanceCount == 4 after first build");
|
|
|
|
// ── Issue #65: the instance buffer is allocated HOST_VISIBLE (required,
|
|
// mappable) with a best-effort DEVICE_LOCAL preference (BAR/VRAM) and the
|
|
// mandatory HOST_COHERENT dropped — so the per-frame compute-write of the
|
|
// transform field and the AS build's read of the instances both stay in
|
|
// local VRAM instead of crossing PCIe. The chosen type must match exactly
|
|
// what GetMemoryType resolves for this buffer's memoryTypeBits with that
|
|
// prefer-but-don't-require request: DEVICE_LOCAL when a host-visible
|
|
// device-local type is reachable (resizable BAR), plain HOST_VISIBLE
|
|
// otherwise. Asserting equality with the recomputed selection makes the
|
|
// check device-independent — it tracks the call-site request, not the
|
|
// particular GPU this runs on. ────────────────────────────────────────────
|
|
{
|
|
auto& ib = RenderingElement3D::tlases[0].instanceBuffer;
|
|
VkMemoryRequirements req{};
|
|
vkGetBufferMemoryRequirements(Device::device, ib.buffer, &req);
|
|
std::uint32_t expectIdx = Device::GetMemoryType(
|
|
req.memoryTypeBits,
|
|
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT,
|
|
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
|
VkMemoryPropertyFlags expectFlags =
|
|
Device::memoryProperties.memoryTypes[expectIdx].propertyFlags;
|
|
Check((ib.memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) != 0,
|
|
"instance buffer memory is HOST_VISIBLE (mappable — the required flag)");
|
|
Check(ib.memoryPropertyFlagsChosen == expectFlags,
|
|
"instance buffer memory matches the HOST_VISIBLE | prefer-DEVICE_LOCAL request (#65)");
|
|
|
|
bool barReachable = false;
|
|
constexpr VkMemoryPropertyFlags kBar =
|
|
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT;
|
|
for (std::uint32_t i = 0; i < Device::memoryProperties.memoryTypeCount; ++i) {
|
|
if ((req.memoryTypeBits & (1u << i))
|
|
&& (Device::memoryProperties.memoryTypes[i].propertyFlags & kBar) == kBar) {
|
|
barReachable = true;
|
|
break;
|
|
}
|
|
}
|
|
if (barReachable) {
|
|
Check((ib.memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT) != 0,
|
|
"instance buffer landed on a DEVICE_LOCAL (BAR/VRAM) type where one is reachable");
|
|
} else {
|
|
std::println("INFO no host-visible device-local type reachable — "
|
|
"fell back to plain HOST_VISIBLE (expected on non-BAR GPUs)");
|
|
}
|
|
}
|
|
|
|
// ── Issue #75: the metadata buffer carries the same memory-type request as
|
|
// the instance buffer — HOST_VISIBLE (required, mappable) with a best-effort
|
|
// DEVICE_LOCAL preference (BAR/VRAM) and the mandatory HOST_COHERENT dropped.
|
|
// It is CPU-written every frame and read by the ray shaders every frame, so
|
|
// landing it in local VRAM keeps those per-frame storage-buffer reads off
|
|
// PCIe. Same device-independent check: assert the recorded chosen flags equal
|
|
// what GetMemoryType resolves for this buffer's memoryTypeBits with that
|
|
// request, and that DEVICE_LOCAL is chosen wherever a host-visible
|
|
// device-local type is reachable. ───────────────────────────────────────────
|
|
{
|
|
auto& mb = RenderingElement3D::tlases[0].metadataBuffer;
|
|
VkMemoryRequirements req{};
|
|
vkGetBufferMemoryRequirements(Device::device, mb.buffer, &req);
|
|
std::uint32_t expectIdx = Device::GetMemoryType(
|
|
req.memoryTypeBits,
|
|
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT,
|
|
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
|
VkMemoryPropertyFlags expectFlags =
|
|
Device::memoryProperties.memoryTypes[expectIdx].propertyFlags;
|
|
Check((mb.memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) != 0,
|
|
"metadata buffer memory is HOST_VISIBLE (mappable — the required flag)");
|
|
Check(mb.memoryPropertyFlagsChosen == expectFlags,
|
|
"metadata buffer memory matches the HOST_VISIBLE | prefer-DEVICE_LOCAL request (#75)");
|
|
|
|
bool barReachable = false;
|
|
constexpr VkMemoryPropertyFlags kBar =
|
|
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT;
|
|
for (std::uint32_t i = 0; i < Device::memoryProperties.memoryTypeCount; ++i) {
|
|
if ((req.memoryTypeBits & (1u << i))
|
|
&& (Device::memoryProperties.memoryTypes[i].propertyFlags & kBar) == kBar) {
|
|
barReachable = true;
|
|
break;
|
|
}
|
|
}
|
|
if (barReachable) {
|
|
Check((mb.memoryPropertyFlagsChosen & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT) != 0,
|
|
"metadata buffer landed on a DEVICE_LOCAL (BAR/VRAM) type where one is reachable");
|
|
} else {
|
|
std::println("INFO no host-visible device-local type reachable — "
|
|
"metadata fell back to plain HOST_VISIBLE (expected on non-BAR GPUs)");
|
|
}
|
|
}
|
|
|
|
// ── 2. Grow to 16 (past capacity) → REALLOCATES. ────────────────────────
|
|
SetCount(pool, 16);
|
|
BuildOnce();
|
|
InputSnapshot s16 = Snapshot();
|
|
Check(s16.instBuf != s4.instBuf, "growing past capacity reallocated the instance buffer");
|
|
Check(s16.metaBuf != s4.metaBuf, "growing past capacity reallocated the metadata buffer");
|
|
Check(s16.instSize == 16 * kEntrySize, "instance buffer grew to 16 entries");
|
|
Check(RenderingElement3D::tlases[0].builtInstanceCount == 16,
|
|
"builtInstanceCount == 16 after growth");
|
|
|
|
// ── 3. Shrink to 2 → REUSES the 16-entry allocation (the core win). ─────
|
|
SetCount(pool, 2);
|
|
BuildOnce();
|
|
InputSnapshot s2 = Snapshot();
|
|
Check(Reused(s16, s2),
|
|
"shrinking to 2 reused the existing buffers (no realloc — high-water mark)");
|
|
Check(s2.instSize == 16 * kEntrySize,
|
|
"instance buffer kept its 16-entry high-water size after shrink");
|
|
Check(RenderingElement3D::tlases[0].builtInstanceCount == 2,
|
|
"builtInstanceCount tracks the live count (2) even on the reuse path");
|
|
|
|
// ── 4. Grow to 10 (still within the high-water capacity) → REUSES. ──────
|
|
SetCount(pool, 10);
|
|
BuildOnce();
|
|
InputSnapshot s10 = Snapshot();
|
|
Check(Reused(s16, s10),
|
|
"growing to 10 (≤ capacity 16) reused the existing buffers");
|
|
|
|
// ── 5. Grow to exactly 16 (== capacity) → REUSES (boundary: > not >=). ──
|
|
SetCount(pool, 16);
|
|
BuildOnce();
|
|
InputSnapshot s16b = Snapshot();
|
|
Check(Reused(s16, s16b),
|
|
"growing to exactly the capacity (16) reused the existing buffers");
|
|
|
|
// ── 6. Same count again → refit (UPDATE) path, inputs untouched. ────────
|
|
BuildOnce();
|
|
InputSnapshot s16c = Snapshot();
|
|
Check(Reused(s16, s16c), "same-count rebuild left the inputs untouched");
|
|
Check(RenderingElement3D::tlases[0].builtInstanceCount == 16,
|
|
"same-count rebuild kept builtInstanceCount == 16 (took the refit path)");
|
|
|
|
// ── 7. Grow to 32 (past the high-water) → REALLOCATES again. ────────────
|
|
SetCount(pool, 32);
|
|
BuildOnce();
|
|
InputSnapshot s32 = Snapshot();
|
|
Check(s32.instBuf != s16.instBuf, "growing past the high-water (32) reallocated again");
|
|
Check(s32.instSize == 32 * kEntrySize, "instance buffer grew to 32 entries");
|
|
|
|
// Unregister everything so nothing dangles past the pool's lifetime.
|
|
SetCount(pool, 0);
|
|
|
|
Check(Device::validationErrorCount == 0,
|
|
std::format("no Vulkan validation errors ({} seen)", Device::validationErrorCount));
|
|
|
|
if (failures != 0) {
|
|
std::println("{} check(s) failed", failures);
|
|
return EXIT_FAILURE;
|
|
}
|
|
std::println("all checks passed");
|
|
return EXIT_SUCCESS;
|
|
}
|