// ============================================================================
// GPU STAGE ORACLE — the whole pipeline, timed on the real device, with no engine.
//
// WHY THIS EXISTS.
//
// Every question about the GPU pipeline has, until now, only been answerable by
// launching the game and looking. That is why the one performance audit we have
// (docs/plans/2026-03-24-gpu-pipeline-optimization-clas-spec.md) states
//
// "Performance audit shows the GPU is 99% idle"
//
// and records no method. It may well be right. It is not checkable, it cannot be
// re-run after a change, and it cannot be broken down per stage — so it has sat
// there since March being quoted rather than tested. `nvidia-smi` cannot settle it
// either: utilization.gpu measures "was any kernel running", which cannot separate
// "idle, waiting on submits" from "running on 4 of 36 SMs". Those are precisely the
// two competing explanations in that document.
//
// A timestamp query around a dispatch can. That is all this is.
//
// THE RULE THIS ORACLE OBEYS — it compiles the SHIPPING shader string.
//
// DENSITY_GLSL_{HEADER,BODY,MAIN} come from dc_shader_source.h, the same header
// dc_compute.cpp includes, assembled by the same two functions the engine calls.
// There is no second copy to drift. An oracle carrying its own GLSL would prove
// only that it agrees with itself — the gated-oracle failure hyper-games #43 §6
// paid four rounds for, where a test passed because the thing it was measuring
// never ran.
//
// The same rule is why GpuParams comes from dc_gpu_params.h rather than being
// re-declared here: a 164-byte std140 block transcribed by hand is a silent
// garbage-in generator.
//
// WHAT IT MEASURES TODAY
//
// - GPU time for one density_fill dispatch over a 33^3 grid, via
// vkCmdWriteTimestamp either side, converted with limits.timestampPeriod.
// - The same across LOD (voxel_scale doubling) with octaves HELD FIXED. This is
// a control, not the LOD cost curve: spacing changes, instruction count does
// not. The real curve needs nyquist_octaves() crossed in — see the note there.
// - The same across octave count, which is what `pn_max_octaves = 99` and
// nyquist_octaves() are trading against. This one is the live question.
// - compute_normals on/off. MAIN calls evaluate_density four extra times for the
// gradient, so the call count predicts 5x. Measured 7.15x — the gradient
// samples also re-run apply_gpu_edits and apply_brick_delta, which the naive
// count misses. Note the shipping path sets compute_normals = 0
// (dc_compute.cpp:476), so the sweeps run with it OFF and this row is
// "what it would cost", not what it costs.
//
// - Every mesh stage: all 16 dispatches of dispatch_mesh_stages(), in one
// submit, each bracketed by its own timestamp pair. (It is called "nine
// stages" in the engine, but the two prefix scans are three passes each and
// two stages dispatch indirect.)
//
// WHAT IT DOES NOT MEASURE YET — and must not be read as if it did
//
// - Submit/sync overhead, which is the March audit's actual claim. Everything
// here runs in ONE submit, so these are the fused-best-case numbers; the
// claim is about the gaps BETWEEN submits and needs a mode that splits the
// command buffer the way the engine does.
// - Occupancy. Timestamps give elapsed GPU time, not SM utilisation. A stage
// that is fast because it is trivial and one that is fast because it is well
// occupied look identical here. Nsight is the instrument for that.
// - WHY edge_intersect is 86% of the mesh pipeline. It is, measurably. The
// cause is not established, and guessing at it here would put a story in a
// file whose whole purpose is to replace stories with numbers.
//
// Build (no engine, no godot-cpp, no scons):
// g++ -O2 -std=c++17 -I libdc/include -o /tmp/gpu_stages \
// tests/test_gpu_stages.cpp -lvulkan
//
// Run: /tmp/gpu_stages [--reps N]
// ============================================================================
#include <vulkan/vulkan.h>
#include "dc_shader_source.h" // the shipping GLSL + assemblers
#include "dc_gpu_params.h" // the shipping std140 block
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include <algorithm>
#include <array>
// ---------------------------------------------------------------- plumbing
static void die(const char* what, VkResult r) {
fprintf(stderr, "FATAL: %s -> VkResult %d\n", what, (int)r);
exit(1);
}
#define VK(x) do { VkResult _r = (x); if (_r != VK_SUCCESS) die(#x, _r); } while (0)
// Compile GLSL with glslc(1) rather than linking libshaderc: glslc is already a
// hard dependency of anyone building shaders here, and shelling out keeps the
// build line to one g++ invocation with no package hunting. If this fails, the
// error printed is glslc's own, against the assembled source — which is the
// message you actually want when a shader edit breaks.
static std::vector<uint32_t> compile_glsl(const std::string& src, const char* tag) {
char in_path[] = "/tmp/dc_gpu_oracle_XXXXXX";
int fd = mkstemp(in_path);
if (fd < 0) { perror("mkstemp"); exit(1); }
FILE* f = fdopen(fd, "w");
fwrite(src.data(), 1, src.size(), f);
fclose(f);
std::string spv_path = std::string(in_path) + ".spv";
std::string cmd = "glslc -fshader-stage=comp -O -o " + spv_path + " " + in_path;
int rc = system(cmd.c_str());
if (rc != 0) {
fprintf(stderr, "FATAL: glslc failed for %s (source left at %s)\n", tag, in_path);
exit(1);
}
FILE* sf = fopen(spv_path.c_str(), "rb");
if (!sf) { fprintf(stderr, "FATAL: no SPIR-V at %s\n", spv_path.c_str()); exit(1); }
fseek(sf, 0, SEEK_END);
long n = ftell(sf);
fseek(sf, 0, SEEK_SET);
std::vector<uint32_t> spv(n / 4);
if (fread(spv.data(), 1, n, sf) != (size_t)n) { fprintf(stderr, "FATAL: short read\n"); exit(1); }
fclose(sf);
remove(in_path);
remove(spv_path.c_str());
return spv;
}
struct Ctx {
VkInstance instance = VK_NULL_HANDLE;
VkPhysicalDevice phys = VK_NULL_HANDLE;
VkDevice dev = VK_NULL_HANDLE;
VkQueue queue = VK_NULL_HANDLE;
uint32_t qfam = 0;
VkCommandPool cmdpool = VK_NULL_HANDLE;
VkQueryPool qpool = VK_NULL_HANDLE;
float ts_period = 1.0f; // ns per timestamp tick
VkPhysicalDeviceMemoryProperties memprops{};
char devname[256] = {0};
};
static uint32_t find_mem(Ctx& c, uint32_t bits, VkMemoryPropertyFlags want) {
for (uint32_t i = 0; i < c.memprops.memoryTypeCount; i++) {
if ((bits & (1u << i)) &&
(c.memprops.memoryTypes[i].propertyFlags & want) == want)
return i;
}
fprintf(stderr, "FATAL: no memory type with flags 0x%x\n", want);
exit(1);
}
struct Buf {
VkBuffer buf = VK_NULL_HANDLE;
VkDeviceMemory mem = VK_NULL_HANDLE;
void* mapped = nullptr;
VkDeviceSize size = 0;
};
// DEVICE_LOCAL, with a staging buffer for host traffic.
//
// THIS WAS HOST_VISIBLE|HOST_COHERENT AND IT WRECKED THE MESH NUMBERS. The
// justification — "every buffer is either tiny or written once, and the
// timestamps bracket the dispatch" — held for the density stage and was badly
// wrong for the mesh stages: host-coherent memory lives in system RAM and every
// access crosses PCIe. edge_data is 3.1 MB, planeset 2 MB, and perm_tables is
// read several times per noise sample. The first run reported scan_propagate at
// 6502 us for ~98k additions and bake_planeset at 1923 us with an EMPTY edit
// list, which is what tipped it off: a prefix-sum propagate cannot be the most
// expensive stage in a dual-contouring pipeline.
//
// The engine allocates device-local storage buffers, so anything measured
// against host memory is not comparable to it — which is the whole point of the
// oracle. Device-local is not an optimisation here; it is the correct subject.
static Buf make_buf(Ctx& c, VkDeviceSize size, VkBufferUsageFlags usage) {
if (size == 0) size = 16; // Vulkan rejects zero-sized buffers
Buf b; b.size = size;
VkBufferCreateInfo bi{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
bi.size = size;
bi.usage = usage | VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
bi.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
VK(vkCreateBuffer(c.dev, &bi, nullptr, &b.buf));
VkMemoryRequirements mr;
vkGetBufferMemoryRequirements(c.dev, b.buf, &mr);
VkMemoryAllocateInfo ai{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
ai.allocationSize = mr.size;
ai.memoryTypeIndex = find_mem(c, mr.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
VK(vkAllocateMemory(c.dev, &ai, nullptr, &b.mem));
VK(vkBindBufferMemory(c.dev, b.buf, b.mem, 0));
return b; // no persistent map: device-local, reached via staging
}
// One host-visible scratch buffer, big enough for the largest thing we move.
static Buf g_stage;
static VkCommandBuffer g_xfer = VK_NULL_HANDLE;
static VkFence g_xfer_fence = VK_NULL_HANDLE;
static void init_staging(Ctx& c, VkDeviceSize size) {
Buf b; b.size = size;
VkBufferCreateInfo bi{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
bi.size = size;
bi.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT;
bi.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
VK(vkCreateBuffer(c.dev, &bi, nullptr, &b.buf));
VkMemoryRequirements mr;
vkGetBufferMemoryRequirements(c.dev, b.buf, &mr);
VkMemoryAllocateInfo ai{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
ai.allocationSize = mr.size;
ai.memoryTypeIndex = find_mem(c, mr.memoryTypeBits,
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
VK(vkAllocateMemory(c.dev, &ai, nullptr, &b.mem));
VK(vkBindBufferMemory(c.dev, b.buf, b.mem, 0));
VK(vkMapMemory(c.dev, b.mem, 0, VK_WHOLE_SIZE, 0, &b.mapped));
g_stage = b;
VkCommandBufferAllocateInfo cbi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
cbi.commandPool = c.cmdpool; cbi.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
cbi.commandBufferCount = 1;
VK(vkAllocateCommandBuffers(c.dev, &cbi, &g_xfer));
VkFenceCreateInfo fi{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
VK(vkCreateFence(c.dev, &fi, nullptr, &g_xfer_fence));
}
static void run_copy(Ctx& c, VkBuffer src, VkBuffer dst, VkDeviceSize n) {
VkCommandBufferBeginInfo bi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
VK(vkBeginCommandBuffer(g_xfer, &bi));
VkBufferCopy region{0, 0, n};
vkCmdCopyBuffer(g_xfer, src, dst, 1, ®ion);
VK(vkEndCommandBuffer(g_xfer));
VkSubmitInfo si{VK_STRUCTURE_TYPE_SUBMIT_INFO};
si.commandBufferCount = 1; si.pCommandBuffers = &g_xfer;
VK(vkResetFences(c.dev, 1, &g_xfer_fence));
VK(vkQueueSubmit(c.queue, 1, &si, g_xfer_fence));
VK(vkWaitForFences(c.dev, 1, &g_xfer_fence, VK_TRUE, UINT64_MAX));
}
static void upload(Ctx& c, const Buf& dst, const void* data, VkDeviceSize n) {
memcpy(g_stage.mapped, data, n);
run_copy(c, g_stage.buf, dst.buf, n);
}
static void download(Ctx& c, const Buf& src, void* data, VkDeviceSize n) {
run_copy(c, src.buf, g_stage.buf, n);
memcpy(data, g_stage.mapped, n);
}
static void zero_fill(Ctx& c, const Buf& b) {
VkDeviceSize n = std::min<VkDeviceSize>(b.size, g_stage.size);
memset(g_stage.mapped, 0, n);
run_copy(c, g_stage.buf, b.buf, n);
}
static void init_vulkan(Ctx& c) {
VkApplicationInfo app{VK_STRUCTURE_TYPE_APPLICATION_INFO};
app.pApplicationName = "dc-terrain gpu oracle";
app.apiVersion = VK_API_VERSION_1_1;
VkInstanceCreateInfo ii{VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO};
ii.pApplicationInfo = &app;
VK(vkCreateInstance(&ii, nullptr, &c.instance));
uint32_t n = 0;
VK(vkEnumeratePhysicalDevices(c.instance, &n, nullptr));
if (!n) { fprintf(stderr, "FATAL: no Vulkan device\n"); exit(1); }
std::vector<VkPhysicalDevice> devs(n);
VK(vkEnumeratePhysicalDevices(c.instance, &n, devs.data()));
// Prefer a discrete GPU — on a box with an iGPU the first device is often the
// wrong one, and timing the wrong GPU is worse than not timing at all.
c.phys = devs[0];
for (auto d : devs) {
VkPhysicalDeviceProperties p;
vkGetPhysicalDeviceProperties(d, &p);
if (p.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) { c.phys = d; break; }
}
VkPhysicalDeviceProperties props;
vkGetPhysicalDeviceProperties(c.phys, &props);
c.ts_period = props.limits.timestampPeriod;
snprintf(c.devname, sizeof(c.devname), "%s", props.deviceName);
if (c.ts_period == 0.0f) {
fprintf(stderr, "FATAL: device reports timestampPeriod 0 — no timing available\n");
exit(1);
}
vkGetPhysicalDeviceMemoryProperties(c.phys, &c.memprops);
uint32_t qn = 0;
vkGetPhysicalDeviceQueueFamilyProperties(c.phys, &qn, nullptr);
std::vector<VkQueueFamilyProperties> qs(qn);
vkGetPhysicalDeviceQueueFamilyProperties(c.phys, &qn, qs.data());
bool found = false;
for (uint32_t i = 0; i < qn; i++) {
if (qs[i].queueFlags & VK_QUEUE_COMPUTE_BIT) {
if (qs[i].timestampValidBits == 0) continue; // timestamps unusable here
c.qfam = i; found = true; break;
}
}
if (!found) { fprintf(stderr, "FATAL: no compute queue with timestamp support\n"); exit(1); }
float prio = 1.0f;
VkDeviceQueueCreateInfo qi{VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO};
qi.queueFamilyIndex = c.qfam; qi.queueCount = 1; qi.pQueuePriorities = &prio;
VkDeviceCreateInfo di{VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO};
di.queueCreateInfoCount = 1; di.pQueueCreateInfos = &qi;
VK(vkCreateDevice(c.phys, &di, nullptr, &c.dev));
vkGetDeviceQueue(c.dev, c.qfam, 0, &c.queue);
VkCommandPoolCreateInfo ci{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO};
ci.queueFamilyIndex = c.qfam;
ci.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
VK(vkCreateCommandPool(c.dev, &ci, nullptr, &c.cmdpool));
VkQueryPoolCreateInfo qpi{VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO};
qpi.queryType = VK_QUERY_TYPE_TIMESTAMP;
qpi.queryCount = 2;
VK(vkCreateQueryPool(c.dev, &qpi, nullptr, &c.qpool));
}
// ---------------------------------------------------------------- the rig
static const int STRIDE = 33; // density grid stride (32 voxels + 1)
static const int LOCAL = 8; // local_size_{x,y,z} in HEADER
static const int PERM_ENTRIES = 3072; // PERM_DETAIL (2560) + 512
struct Rig {
Ctx& c;
VkDescriptorSetLayout dsl = VK_NULL_HANDLE;
VkDescriptorPool dpool = VK_NULL_HANDLE;
VkDescriptorSet dset = VK_NULL_HANDLE;
VkPipelineLayout players = VK_NULL_HANDLE;
VkPipeline pipe = VK_NULL_HANDLE;
VkShaderModule mod = VK_NULL_HANDLE;
VkCommandBuffer cmd = VK_NULL_HANDLE;
VkFence fence = VK_NULL_HANDLE;
std::vector<Buf> bufs; // index == binding
explicit Rig(Ctx& ctx) : c(ctx) {}
};
static void build_rig(Rig& r) {
Ctx& c = r.c;
const int cells = STRIDE * STRIDE * STRIDE;
// Bindings 0..12, matching DENSITY_GLSL_HEADER exactly. Binding 4 is the
// std140 uniform block; every other binding is std430 storage.
r.bufs.resize(13);
auto SB = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
r.bufs[0] = make_buf(c, cells * sizeof(float), SB); // densities
r.bufs[1] = make_buf(c, cells * sizeof(float), SB); // normal_x
r.bufs[2] = make_buf(c, cells * sizeof(float), SB); // normal_y
r.bufs[3] = make_buf(c, cells * sizeof(float), SB); // normal_z
r.bufs[4] = make_buf(c, 256, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT); // Params (164B, padded)
r.bufs[5] = make_buf(c, 4 + 64 * sizeof(float), SB); // VoronoiBuf
r.bufs[6] = make_buf(c, PERM_ENTRIES * sizeof(int32_t), SB); // PermBuf
r.bufs[7] = make_buf(c, 16 + 64 * 48, SB); // EditBuf (hdr + GpuEdit[])
r.bufs[8] = make_buf(c, 16, SB); // MeshBVHBuf (count 0)
r.bufs[9] = make_buf(c, 16, SB); // MeshTriBuf
r.bufs[10] = make_buf(c, 16, SB); // MeshTriIdxBuf
r.bufs[11] = make_buf(c, cells * sizeof(float), SB); // BrickBuf
r.bufs[12] = make_buf(c, 16, SB); // ConvexPlaneBuf
// ZERO FIRST, THEN UPLOAD — in that order, and the order is load-bearing.
// Device-local memory comes back undefined, so it must be cleared; but doing
// it after the permutation upload wipes the table, perm() returns 0 for every
// index, grad3() degenerates, and the density field is quietly wrong. That
// shipped one run of mesh timings measured against a field the engine would
// never produce — caught only because the edge count moved (887 -> 961) when
// nothing that determines it had changed.
for (int i = 0; i < 13; i++) zero_fill(c, r.bufs[i]);
// Permutation tables: the classic Perlin shuffle, duplicated to 512 per table
// so perm() can index ii+perm(jj+perm(kk)) without bounds checks. The exact
// permutation does not matter for TIMING — only that indices are in range and
// the gradients are not degenerate, which would let the compiler fold work away.
{
std::vector<int32_t> perm_host(PERM_ENTRIES, 0);
int32_t* p = perm_host.data();
for (int t = 0; t < PERM_ENTRIES; t += 512) {
int perm[256];
for (int i = 0; i < 256; i++) perm[i] = i;
unsigned s = 1469598103u + (unsigned)t;
for (int i = 255; i > 0; i--) { // Fisher-Yates, fixed seed
s = s * 1664525u + 1013904223u;
int j = (int)(s % (unsigned)(i + 1));
int tmp = perm[i]; perm[i] = perm[j]; perm[j] = tmp;
}
for (int i = 0; i < 512 && t + i < PERM_ENTRIES; i++)
p[t + i] = perm[i & 255];
}
upload(c, r.bufs[6], perm_host.data(), PERM_ENTRIES * sizeof(int32_t));
}
std::vector<VkDescriptorSetLayoutBinding> lb(13);
for (int i = 0; i < 13; i++) {
lb[i].binding = i;
lb[i].descriptorType = (i == 4) ? VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER
: VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
lb[i].descriptorCount = 1;
lb[i].stageFlags = VK_SHADER_STAGE_COMPUTE_BIT;
}
VkDescriptorSetLayoutCreateInfo li{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO};
li.bindingCount = 13; li.pBindings = lb.data();
VK(vkCreateDescriptorSetLayout(c.dev, &li, nullptr, &r.dsl));
VkDescriptorPoolSize ps[2] = {
{ VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 12 },
{ VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 1 },
};
VkDescriptorPoolCreateInfo pi{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO};
pi.maxSets = 1; pi.poolSizeCount = 2; pi.pPoolSizes = ps;
VK(vkCreateDescriptorPool(c.dev, &pi, nullptr, &r.dpool));
VkDescriptorSetAllocateInfo dai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
dai.descriptorPool = r.dpool; dai.descriptorSetCount = 1; dai.pSetLayouts = &r.dsl;
VK(vkAllocateDescriptorSets(c.dev, &dai, &r.dset));
std::vector<VkDescriptorBufferInfo> dbi(13);
std::vector<VkWriteDescriptorSet> w(13);
for (int i = 0; i < 13; i++) {
dbi[i] = { r.bufs[i].buf, 0, VK_WHOLE_SIZE };
w[i] = VkWriteDescriptorSet{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
w[i].dstSet = r.dset; w[i].dstBinding = i; w[i].descriptorCount = 1;
w[i].descriptorType = lb[i].descriptorType;
w[i].pBufferInfo = &dbi[i];
}
vkUpdateDescriptorSets(c.dev, 13, w.data(), 0, nullptr);
// THE SHIPPING STRING. _build_default_shader() is the engine's own assembler,
// from dc_shader_source.h — not a reimplementation of it.
std::string src = _build_default_shader();
std::vector<uint32_t> spv = compile_glsl(src, "density_fill(default)");
VkShaderModuleCreateInfo smi{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
smi.codeSize = spv.size() * 4; smi.pCode = spv.data();
VK(vkCreateShaderModule(c.dev, &smi, nullptr, &r.mod));
VkPipelineLayoutCreateInfo pli{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO};
pli.setLayoutCount = 1; pli.pSetLayouts = &r.dsl;
VK(vkCreatePipelineLayout(c.dev, &pli, nullptr, &r.players));
VkComputePipelineCreateInfo cpi{VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO};
cpi.stage = VkPipelineShaderStageCreateInfo{VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO};
cpi.stage.stage = VK_SHADER_STAGE_COMPUTE_BIT;
cpi.stage.module = r.mod;
cpi.stage.pName = "main";
cpi.layout = r.players;
VK(vkCreateComputePipelines(c.dev, VK_NULL_HANDLE, 1, &cpi, nullptr, &r.pipe));
VkCommandBufferAllocateInfo cbi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
cbi.commandPool = c.cmdpool;
cbi.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
cbi.commandBufferCount = 1;
VK(vkAllocateCommandBuffers(c.dev, &cbi, &r.cmd));
VkFenceCreateInfo fi{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
VK(vkCreateFence(c.dev, &fi, nullptr, &r.fence));
}
// One dispatch, bracketed by timestamps. Returns GPU nanoseconds.
static double dispatch_ns(Rig& r) {
Ctx& c = r.c;
const uint32_t groups = (STRIDE + LOCAL - 1) / LOCAL; // 33 -> 5
VkCommandBufferBeginInfo bi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
VK(vkBeginCommandBuffer(r.cmd, &bi));
vkCmdResetQueryPool(r.cmd, c.qpool, 0, 2);
vkCmdWriteTimestamp(r.cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, c.qpool, 0);
vkCmdBindPipeline(r.cmd, VK_PIPELINE_BIND_POINT_COMPUTE, r.pipe);
vkCmdBindDescriptorSets(r.cmd, VK_PIPELINE_BIND_POINT_COMPUTE, r.players,
0, 1, &r.dset, 0, nullptr);
vkCmdDispatch(r.cmd, groups, groups, groups);
vkCmdWriteTimestamp(r.cmd, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, c.qpool, 1);
VK(vkEndCommandBuffer(r.cmd));
VkSubmitInfo si{VK_STRUCTURE_TYPE_SUBMIT_INFO};
si.commandBufferCount = 1; si.pCommandBuffers = &r.cmd;
VK(vkResetFences(c.dev, 1, &r.fence));
VK(vkQueueSubmit(c.queue, 1, &si, r.fence));
VK(vkWaitForFences(c.dev, 1, &r.fence, VK_TRUE, UINT64_MAX));
uint64_t ts[2] = {0, 0};
VK(vkGetQueryPoolResults(c.dev, c.qpool, 0, 2, sizeof(ts), ts, sizeof(uint64_t),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT));
return double(ts[1] - ts[0]) * double(c.ts_period);
}
// Defaults lifted from map_dc_world.tscn so the numbers describe the shipping
// world rather than an invented one. Anything not in that scene keeps the
// DensityConfig default from libdc/include/dc_density.h.
static void fill_params(GpuParams& p, float voxel_scale, int octaves, bool normals) {
memset(&p, 0, sizeof(p));
p.grid_stride = STRIDE;
p.voxel_scale = voxel_scale;
p.origin_x = 0.0f; p.origin_y = 0.0f; p.origin_z = 0.0f;
p.primary_frequency = 0.01f; // map_dc_world.tscn
p.primary_strength = 20.0f;
p.tunnel_frequency = 0.02f;
p.tunnel_strength = 1.5f;
p.tunnel_radius = 0.35f;
p.ridge_frequency = 0.015f;
p.ridge_strength = 8.0f;
p.warp_frequency = 0.008f;
p.warp_strength = 6.0f;
p.detail_frequency = 0.08f;
p.detail_strength = 2.0f;
p.ground_bias = 1.0f;
p.solid_floor_depth = 60.0f;
p.sky_height = 80.0f;
p.flat_center_radius = 26.0f; // map_dc_world.tscn
p.floor_strength = 2.13f; // map_dc_world.tscn
p.floor_height_min = -50.0f; // map_dc_world.tscn
p.floor_height_max = 50.0f; // map_dc_world.tscn
p.room_height = 15.0f; // map_dc_world.tscn
p.terrain_size = 443.0f; // map_dc_world.tscn
p.primary_octaves = octaves;
p.tunnel_octaves = octaves;
p.ridge_octaves = octaves;
p.warp_octaves = octaves;
p.detail_octaves = octaves;
p.compute_normals = normals ? 1 : 0;
p.seed = 44; // map_dc_world.tscn
p.voronoi_cell_count = 8;
p.use_noise = 1;
p.spherical = 0;
p.flags = 0;
}
static double bench(Rig& r, float voxel_scale, int octaves, bool normals, int reps) {
GpuParams p;
fill_params(p, voxel_scale, octaves, normals);
upload(r.c, r.bufs[4], &p, sizeof(p));
// WARM PROPERLY. One dispatch is not enough: the first measured configuration
// came out 10x every other one, and the octave sweep's identical (0.224 m, 8
// octaves, normals on) row disagreed with the LOD sweep's by that same factor.
// Two rows of the same rig contradicting each other is the only reason it was
// caught — a single sweep would have reported "LOD 0 is 7x LOD 1" as a finding.
// The cost is driver-side lazy work on first real use (pipeline residency,
// memory paging), which one dispatch does not exhaust.
for (int i = 0; i < 3; i++) dispatch_ns(r);
std::vector<double> v;
v.reserve(reps);
for (int i = 0; i < reps; i++) v.push_back(dispatch_ns(r));
std::sort(v.begin(), v.end());
return v[v.size() / 2]; // median
}
// ====================================================================== mesh
//
// The other fifteen dispatches. dispatch_mesh_stages() calls them "nine stages"
// but the scans are three passes each and two stages are indirect, so the command
// buffer holds 16 dispatches. Order, dispatch dims and bindings are transcribed
// from dc_gpu_mesh_pipeline.cpp:590-760 and _build_uniform_sets():473-570.
//
// The shaders are read from shaders/ on disk — the SAME FILES the extension loads
// via dladdr at runtime, including edge_intersect.glsl's // GENERATOR_SDF_BODY
// marker being filled with generators/noise_sdf.glsl. No copy, same rule as the
// density string.
static std::string shader_dir = "shaders";
static std::string read_shader(const std::string& name) {
std::string path = shader_dir + "/" + name;
FILE* f = fopen(path.c_str(), "rb");
if (!f) { fprintf(stderr, "FATAL: cannot read %s\n", path.c_str()); exit(1); }
fseek(f, 0, SEEK_END); long n = ftell(f); fseek(f, 0, SEEK_SET);
std::string s(n, '\0');
if (fread(&s[0], 1, n, f) != (size_t)n) { fprintf(stderr, "FATAL: short read %s\n", path.c_str()); exit(1); }
fclose(f);
return s;
}
// Mirrors DCGpuMeshPipeline::_build_edge_intersect_glsl. Same marker, same
// default body, same failure if the marker is missing.
static std::string build_edge_intersect() {
std::string tmpl = read_shader("edge_intersect.glsl");
std::string body = read_shader("generators/noise_sdf.glsl");
const std::string marker = "// GENERATOR_SDF_BODY";
size_t pos = tmpl.find(marker);
if (pos == std::string::npos) {
fprintf(stderr, "FATAL: GENERATOR_SDF_BODY marker not found in edge_intersect.glsl\n");
exit(1);
}
tmpl.replace(pos, marker.length(), body);
return tmpl;
}
struct Stage {
const char* name = "";
VkShaderModule mod = VK_NULL_HANDLE;
VkDescriptorSetLayout dsl = VK_NULL_HANDLE;
VkPipelineLayout plo = VK_NULL_HANDLE;
VkPipeline pipe = VK_NULL_HANDLE;
VkDescriptorSet dset = VK_NULL_HANDLE;
uint32_t pc_size = 0;
// Direct dispatch groups, or indirect when indirect_offset >= 0.
uint32_t gx = 1, gy = 1, gz = 1;
int indirect_offset = -1;
};
struct BindSpec { uint32_t binding; const Buf* buf; bool uniform; };
static Stage make_stage(Ctx& c, VkDescriptorPool pool, const char* name,
const std::string& glsl,
const std::vector<BindSpec>& binds, uint32_t pc_size) {
Stage s; s.name = name; s.pc_size = pc_size;
std::vector<VkDescriptorSetLayoutBinding> lb;
for (auto& b : binds) {
VkDescriptorSetLayoutBinding x{};
x.binding = b.binding;
x.descriptorType = b.uniform ? VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER
: VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
x.descriptorCount = 1;
x.stageFlags = VK_SHADER_STAGE_COMPUTE_BIT;
lb.push_back(x);
}
VkDescriptorSetLayoutCreateInfo li{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO};
li.bindingCount = (uint32_t)lb.size(); li.pBindings = lb.data();
VK(vkCreateDescriptorSetLayout(c.dev, &li, nullptr, &s.dsl));
VkDescriptorSetAllocateInfo dai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
dai.descriptorPool = pool; dai.descriptorSetCount = 1; dai.pSetLayouts = &s.dsl;
VK(vkAllocateDescriptorSets(c.dev, &dai, &s.dset));
std::vector<VkDescriptorBufferInfo> dbi(binds.size());
std::vector<VkWriteDescriptorSet> w(binds.size());
for (size_t i = 0; i < binds.size(); i++) {
dbi[i] = { binds[i].buf->buf, 0, VK_WHOLE_SIZE };
w[i] = VkWriteDescriptorSet{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
w[i].dstSet = s.dset; w[i].dstBinding = binds[i].binding;
w[i].descriptorCount = 1;
w[i].descriptorType = lb[i].descriptorType;
w[i].pBufferInfo = &dbi[i];
}
vkUpdateDescriptorSets(c.dev, (uint32_t)w.size(), w.data(), 0, nullptr);
std::vector<uint32_t> spv = compile_glsl(glsl, name);
VkShaderModuleCreateInfo smi{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
smi.codeSize = spv.size() * 4; smi.pCode = spv.data();
VK(vkCreateShaderModule(c.dev, &smi, nullptr, &s.mod));
VkPushConstantRange pcr{VK_SHADER_STAGE_COMPUTE_BIT, 0, pc_size};
VkPipelineLayoutCreateInfo pli{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO};
pli.setLayoutCount = 1; pli.pSetLayouts = &s.dsl;
if (pc_size) { pli.pushConstantRangeCount = 1; pli.pPushConstantRanges = &pcr; }
VK(vkCreatePipelineLayout(c.dev, &pli, nullptr, &s.plo));
VkComputePipelineCreateInfo cpi{VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO};
cpi.stage = VkPipelineShaderStageCreateInfo{VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO};
cpi.stage.stage = VK_SHADER_STAGE_COMPUTE_BIT;
cpi.stage.module = s.mod; cpi.stage.pName = "main";
cpi.layout = s.plo;
VK(vkCreateComputePipelines(c.dev, VK_NULL_HANDLE, 1, &cpi, nullptr, &s.pipe));
return s;
}
// A full-pipeline barrier between stages, matching compute_list_add_barrier().
static void barrier(VkCommandBuffer cmd) {
VkMemoryBarrier mb{VK_STRUCTURE_TYPE_MEMORY_BARRIER};
mb.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT;
mb.dstAccessMask = VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT |
VK_ACCESS_INDIRECT_COMMAND_READ_BIT;
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT,
0, 1, &mb, 0, nullptr, 0, nullptr);
}
int main(int argc, char** argv); // fwd, see below
// ---------------------------------------------------------------------------
static void run_mesh(Ctx& c, Rig& density, int reps) {
const int V = STRIDE - 1; // grid_dims = 32
const int V3 = V * V * V; // 32768
const int EC = V3 * 3; // 98304
const int SB = (EC + 1023) / 1024 + 1;
auto U = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
// Sizes verbatim from _allocate_buffers():289.
Buf edge_flags = make_buf(c, EC * 4, U);
Buf edge_scan = make_buf(c, EC * 4, U);
Buf active_edges = make_buf(c, EC * 4, U);
Buf edge_data = make_buf(c, EC * 8 * 4, U);
Buf voxel_flags = make_buf(c, V3 * 4, U);
Buf voxel_scan = make_buf(c, V3 * 4, U);
Buf active_vox = make_buf(c, V3 * 4, U);
Buf vertex = make_buf(c, V3 * 12 * 4, U);
Buf index = make_buf(c, EC * 6 * 4, U);
Buf counts = make_buf(c, 16, U);
Buf dispatch_args= make_buf(c, 32, U | VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT);
Buf blk_sums = make_buf(c, SB * 4, U);
Buf blk_scanned = make_buf(c, SB * 4, U);
Buf planeset = make_buf(c, V3 * 64, U);
Buf faceproj = make_buf(c, 16, U);
// Device-local memory comes back UNDEFINED. The engine's
// storage_buffer_create(bytes, zero) writes zeros, so anything that
// accumulates (counts) or is read before written (flags, args) must start
// zeroed here too, or the first dispatch reads garbage and the stage times
// describe work that would never happen in the engine.
for (const Buf* b : { &edge_flags, &edge_scan, &active_edges, &edge_data,
&voxel_flags, &voxel_scan, &active_vox, &vertex,
&index, &counts, &dispatch_args, &blk_sums,
&blk_scanned, &planeset, &faceproj })
zero_fill(c, *b);
VkDescriptorPoolSize ps[2] = {
{ VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 128 },
{ VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 8 },
};
VkDescriptorPoolCreateInfo pi{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO};
pi.maxSets = 32; pi.poolSizeCount = 2; pi.pPoolSizes = ps;
VkDescriptorPool pool;
VK(vkCreateDescriptorPool(c.dev, &pi, nullptr, &pool));
const Buf& dens = density.bufs[0];
const Buf& params = density.bufs[4];
const Buf& voro = density.bufs[5];
const Buf& perm = density.bufs[6];
const Buf& edit = density.bufs[7];
const Buf& brick = density.bufs[11];
const Buf& convex = density.bufs[12];
const uint32_t MPC = (uint32_t)sizeof(MeshPushConstants);
const uint32_t SPC = 16; // scan/compact/prepare push block: {N, a, b, pad}
std::string s_scan_blocks = read_shader("scan_blocks.glsl");
std::string s_scan_single = read_shader("scan_single.glsl");
std::string s_scan_prop = read_shader("scan_propagate.glsl");
std::string s_compact = read_shader("stream_compact_index.glsl");
std::string s_prepare = read_shader("prepare_dispatch.glsl");
std::vector<Stage> st;
// 1 edge_classify
st.push_back(make_stage(c, pool, "edge_classify", read_shader("edge_classify.glsl"),
{{0,&dens,false},{1,&edge_flags,false}}, MPC));
st.back().gx = (EC / 3 + 255) / 256; // dispatched over V3, per :628
st.back().gx = (V3 + 255) / 256;
// 2-4 edge scan
st.push_back(make_stage(c, pool, "scan_blocks(edge)", s_scan_blocks,
{{0,&edge_flags,false},{1,&edge_scan,false},{2,&blk_sums,false}}, SPC));
st.back().gx = (EC + 1023) / 1024;
st.push_back(make_stage(c, pool, "scan_single(edge)", s_scan_single,
{{0,&blk_sums,false},{1,&blk_scanned,false}}, SPC));
st.push_back(make_stage(c, pool, "scan_propagate(edge)", s_scan_prop,
{{0,&edge_scan,false},{1,&blk_scanned,false}}, SPC));
st.back().gx = (EC + 1023) / 1024;
// 5 compact edges
st.push_back(make_stage(c, pool, "compact(edge)", s_compact,
{{0,&edge_flags,false},{1,&edge_scan,false},{2,&active_edges,false},{3,&counts,false}}, SPC));
st.back().gx = (EC + 255) / 256;
// 6 prepare dispatch (edges)
st.push_back(make_stage(c, pool, "prepare_dispatch(edge)", s_prepare,
{{0,&edge_scan,false},{1,&edge_flags,false},{2,&dispatch_args,false},{3,&counts,false}}, SPC));
// 7 edge_intersect (INDIRECT, args offset 0)
st.push_back(make_stage(c, pool, "edge_intersect", build_edge_intersect(),
{{0,&dens,false},{1,&edge_flags,false},{2,&edge_scan,false},{3,&edge_data,false},
{4,¶ms,true},{5,&voro,false},{6,&perm,false},{7,&edit,false},
{8,&active_edges,false},{9,&counts,false},{10,&brick,false},{11,&convex,false}}, MPC));
st.back().indirect_offset = 0;
// 8 voxel_classify
st.push_back(make_stage(c, pool, "voxel_classify", read_shader("voxel_classify.glsl"),
{{1,&edge_flags,false},{4,&voxel_flags,false}}, MPC));
st.back().gx = (V3 + 255) / 256;
// 9-11 voxel scan
st.push_back(make_stage(c, pool, "scan_blocks(voxel)", s_scan_blocks,
{{0,&voxel_flags,false},{1,&voxel_scan,false},{2,&blk_sums,false}}, SPC));
st.back().gx = (V3 + 1023) / 1024;
st.push_back(make_stage(c, pool, "scan_single(voxel)", s_scan_single,
{{0,&blk_sums,false},{1,&blk_scanned,false}}, SPC));
st.push_back(make_stage(c, pool, "scan_propagate(voxel)", s_scan_prop,
{{0,&voxel_scan,false},{1,&blk_scanned,false}}, SPC));
st.back().gx = (V3 + 1023) / 1024;
// 12 compact voxels
st.push_back(make_stage(c, pool, "compact(voxel)", s_compact,
{{0,&voxel_flags,false},{1,&voxel_scan,false},{2,&active_vox,false},{3,&counts,false}}, SPC));
st.back().gx = (V3 + 255) / 256;
// 13 prepare dispatch (voxels)
st.push_back(make_stage(c, pool, "prepare_dispatch(voxel)", s_prepare,
{{0,&voxel_scan,false},{1,&voxel_flags,false},{2,&dispatch_args,false},{3,&counts,false}}, SPC));
// 14 bake_planeset (gated on has_edits in the engine; measured here so the
// cost of the gate is visible rather than assumed)
st.push_back(make_stage(c, pool, "bake_planeset", read_shader("bake_planeset.glsl"),
{{9,&edit,false},{10,&convex,false},{11,&planeset,false},{12,&faceproj,false}}, MPC));
st.back().gx = (V3 + 255) / 256;
// 15 qef_solve (INDIRECT, args offset 12)
st.push_back(make_stage(c, pool, "qef_solve", read_shader("qef_solve.glsl"),
{{0,&dens,false},{1,&edge_flags,false},{2,&edge_scan,false},{3,&edge_data,false},
{4,&voxel_flags,false},{5,&voxel_scan,false},{6,&vertex,false},{7,&active_vox,false},
{8,&counts,false},{9,&edit,false},{10,&convex,false},{11,&planeset,false}}, MPC));
st.back().indirect_offset = 12;
// 16 quad_emit
st.push_back(make_stage(c, pool, "quad_emit", read_shader("quad_emit.glsl"),
{{0,&dens,false},{1,&edge_flags,false},{4,&voxel_flags,false},{5,&voxel_scan,false},
{6,&vertex,false},{8,&index,false},{9,&counts,false}}, MPC));
st.back().gx = (V3 + 255) / 256;
// Push blocks.
MeshPushConstants mpc;
memset(&mpc, 0, sizeof(mpc));
mpc.cell_origin[0] = mpc.cell_origin[1] = mpc.cell_origin[2] = 0.0f;
mpc.voxel_size = 0.224f;
mpc.grid_dims[0] = mpc.grid_dims[1] = mpc.grid_dims[2] = V;
mpc.total_elements = (uint32_t)V3;
mpc.mesh_quality_flags = 0;
uint32_t spc_edge[4] = { (uint32_t)EC, 0, 0, 0 };
uint32_t spc_blocks[4] = { (uint32_t)((EC + 1023) / 1024), 0, 0, 0 };
uint32_t spc_v3[4] = { (uint32_t)V3, 0, 0, 0 };
uint32_t spc_vblk[4] = { (uint32_t)((V3 + 1023) / 1024), 0, 0, 0 };
uint32_t spc_prep_e[4] = { (uint32_t)EC, 0, 0, 0 };
uint32_t spc_prep_v[4] = { (uint32_t)V3, 3, 2, 0 }; // per :721
auto push_for = [&](size_t i) -> const void* {
switch (i) {
case 1: return spc_edge; case 2: return spc_blocks;
case 3: return spc_edge; case 4: return spc_edge;
case 5: return spc_prep_e;
case 8: return spc_v3; case 9: return spc_vblk;
case 10: return spc_v3; case 11: return spc_v3;
case 12: return spc_prep_v;
default: return &mpc;
}
};
// A query pool big enough for a timestamp either side of every stage.
VkQueryPool qp;
VkQueryPoolCreateInfo qpi{VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO};
qpi.queryType = VK_QUERY_TYPE_TIMESTAMP;
qpi.queryCount = (uint32_t)st.size() + 2;
VK(vkCreateQueryPool(c.dev, &qpi, nullptr, &qp));
VkCommandBufferAllocateInfo cbi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
cbi.commandPool = c.cmdpool; cbi.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
cbi.commandBufferCount = 1;
VkCommandBuffer cmd;
VK(vkAllocateCommandBuffers(c.dev, &cbi, &cmd));
VkFence fence;
VkFenceCreateInfo fi{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
VK(vkCreateFence(c.dev, &fi, nullptr, &fence));
// ONE SUBMIT for all 16 dispatches — the fully-fused arrangement.
auto record_and_run = [&](std::vector<double>& out_ns, double& total_wall_ns) {
// RESET THE ACCUMULATORS FIRST. compact() and quad_emit() append through
// atomics, so counts[] and index_buf grow every repetition — the engine
// hands each chunk freshly zeroed buffers, this harness was re-running on
// the previous run's leftovers. It showed up as indices=300420 where an
// earlier run of the same chunk read 59472, and it means every timing
// taken before this fix was measuring a progressively dirtier pipeline.
zero_fill(c, counts);
zero_fill(c, index);
VkCommandBufferBeginInfo bi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
VK(vkBeginCommandBuffer(cmd, &bi));
vkCmdResetQueryPool(cmd, qp, 0, (uint32_t)st.size() + 2);
vkCmdWriteTimestamp(cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, qp, 0);
for (size_t i = 0; i < st.size(); i++) {
Stage& s = st[i];
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, s.pipe);
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, s.plo, 0, 1, &s.dset, 0, nullptr);
if (s.pc_size) vkCmdPushConstants(cmd, s.plo, VK_SHADER_STAGE_COMPUTE_BIT, 0, s.pc_size, push_for(i));
if (s.indirect_offset >= 0)
vkCmdDispatchIndirect(cmd, dispatch_args.buf, (VkDeviceSize)s.indirect_offset);
else
vkCmdDispatch(cmd, s.gx, s.gy, s.gz);
barrier(cmd);
vkCmdWriteTimestamp(cmd, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, qp, (uint32_t)i + 1);
}
VK(vkEndCommandBuffer(cmd));
VkSubmitInfo si{VK_STRUCTURE_TYPE_SUBMIT_INFO};
si.commandBufferCount = 1; si.pCommandBuffers = &cmd;
VK(vkResetFences(c.dev, 1, &fence));
VK(vkQueueSubmit(c.queue, 1, &si, fence));
VK(vkWaitForFences(c.dev, 1, &fence, VK_TRUE, UINT64_MAX));
std::vector<uint64_t> ts(st.size() + 1);
VK(vkGetQueryPoolResults(c.dev, qp, 0, (uint32_t)ts.size(),
ts.size() * sizeof(uint64_t), ts.data(), sizeof(uint64_t),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT));
out_ns.resize(st.size());
for (size_t i = 0; i < st.size(); i++)
out_ns[i] = double(ts[i + 1] - ts[i]) * double(c.ts_period);
total_wall_ns = double(ts[st.size()] - ts[0]) * double(c.ts_period);
};
// The density grid must be filled first or every mesh stage sees zeros and
// classifies nothing — a pipeline measured on an empty chunk is measuring the
// early-out, not the work. This is the same trap as #43 §6: without it the
// numbers would be small, stable, and meaningless.
auto set_scale = [&](float vs) {
GpuParams p;
fill_params(p, vs, 8, false);
upload(c, density.bufs[4], &p, sizeof(p));
mpc.voxel_size = vs;
for (int i = 0; i < 3; i++) dispatch_ns(density);
};
set_scale(0.224f);
std::vector<double> ns;
double wall = 0;
for (int i = 0; i < 3; i++) record_and_run(ns, wall); // warm
std::vector<std::vector<double>> runs;
std::vector<double> walls;
for (int r = 0; r < reps; r++) {
record_and_run(ns, wall);
runs.push_back(ns);
walls.push_back(wall);
}
printf("\n== mesh stages, one submit, 32^3 grid (%d edges, %d voxels) ==\n", EC, V3);
printf("%-24s %12s\n", "stage", "GPU us");
double sum = 0;
for (size_t i = 0; i < st.size(); i++) {
std::vector<double> v;
for (auto& r : runs) v.push_back(r[i]);
std::sort(v.begin(), v.end());
double m = v[v.size() / 2];
sum += m;
printf("%-24s %12.1f\n", st[i].name, m / 1000.0);
}
std::sort(walls.begin(), walls.end());
printf("%-24s %12.1f\n", "-- sum of stages --", sum / 1000.0);
printf("%-24s %12.1f\n", "-- first..last span --", walls[walls.size() / 2] / 1000.0);
// The counts buffer tells us the chunk was not empty. A pipeline that
// classified zero edges would report tiny times that mean nothing.
// ---------------------------------------------------------------- occupancy
//
// IS edge_intersect SLOW PER SAMPLE, OR SLOW BECAUSE IT IS TINY?
//
// It dispatches ceil(active_edges/256) workgroups (prepare_dispatch.glsl), so
// at 887 active edges it gets FOUR, on a 36-SM GPU. density_fill gets 125.
// If occupancy is the cause, ns-per-edge must FALL as the active count rises.
// If the per-edge work is simply expensive, it must stay FLAT. One of those is
// true; this decides which, instead of the reasoning deciding it.
//
// Voxel scale is the knob: a coarser chunk spans more world, so more surface
// crosses it and more edges go active. Per-edge WORK is identical either way
// (8 binary-search iterations + 6 gradient samples, always).
printf("\n== edge_intersect vs active edge count ==\n");
printf("%-10s %10s %10s %12s %10s\n", "voxel(m)", "edges", "groups", "GPU us", "ns/edge");
for (int lod = 0; lod <= 8; lod += 2) {
float vs = 0.224f * float(1 << lod);
set_scale(vs);
std::vector<double> v; double w;
for (int i = 0; i < 2; i++) record_and_run(v, w);
std::vector<double> got;
for (int i = 0; i < reps; i++) { record_and_run(v, w); got.push_back(v[6]); }
std::sort(got.begin(), got.end());
double m = got[got.size() / 2];
uint32_t cn[4] = {0,0,0,0};
download(c, counts, cn, sizeof(cn));
uint32_t groups = (cn[0] + 255u) / 256u; if (!groups) groups = 1;
printf("%-10.3f %10u %10u %12.1f %10.1f\n",
vs, cn[0], groups, m / 1000.0, cn[0] ? m / cn[0] : 0.0);
}
set_scale(0.224f);
for (int i = 0; i < 2; i++) record_and_run(ns, wall);
uint32_t real_cnt[4] = {0,0,0,0};
download(c, counts, real_cnt, sizeof(real_cnt));
// ================================================================ SPEED ORACLE
//
// THE QUESTION: would batching several chunks into one dispatch actually make
// edge_intersect faster, or is it slow for a reason more threads cannot fix?
//
// This tests the CLAIM without implementing the change. edge_intersect is
// bounded purely by counts[0] (edge_intersect.glsl:161) and has NO early-out
// on sign change — every thread runs all 8 binary-search iterations and all 6
// gradient samples whatever edge it is handed. So handing it N edges costs
// exactly what a real batch of N would cost, and N is ours to choose.
//
// ns/edge FALLS then plateaus -> occupancy-bound, batching wins, and the
// knee is the batch size worth building for.
// ns/edge FLAT -> already saturating something more threads
// do not relieve. Batching buys nothing and
// the fix is fewer chained lookups instead.
//
// TWO VARIANTS, because one of them alone would be arguable:
// REPEAT cycles the real active edges, so every repeat samples the SAME
// world positions — suspiciously cache-friendly next to a real batch.
// SPREAD strides across the whole edge index range, so positions differ.
// Identical instruction count either way. If the two disagree, this sweep is
// measuring cache behaviour and not occupancy, and must not be believed.
set_scale(0.224f);
{ std::vector<double> v; double w; for (int i = 0; i < 2; i++) record_and_run(v, w); }
uint32_t real_n = 0;
{ uint32_t t[4]; download(c, counts, t, sizeof(t)); real_n = t[0]; }
std::vector<uint32_t> real_list(real_n ? real_n : 1, 0);
if (real_n) download(c, active_edges, real_list.data(), real_n * 4);
// Dispatch edge_intersect ALONE with an explicit group count.
auto time_ei = [&](const std::vector<uint32_t>& list) -> double {
uint32_t N = (uint32_t)list.size();
upload(c, active_edges, list.data(), N * 4);
uint32_t cn[4] = { N, 0u, 0u, 0u };
upload(c, counts, cn, sizeof(cn));
uint32_t groups = (N + 255u) / 256u; if (!groups) groups = 1;
Stage& s = st[6]; // edge_intersect
VkCommandBufferBeginInfo bi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
VK(vkBeginCommandBuffer(cmd, &bi));
vkCmdResetQueryPool(cmd, qp, 0, 2);
vkCmdWriteTimestamp(cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, qp, 0);
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, s.pipe);
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, s.plo, 0, 1, &s.dset, 0, nullptr);
vkCmdPushConstants(cmd, s.plo, VK_SHADER_STAGE_COMPUTE_BIT, 0, s.pc_size, &mpc);
vkCmdDispatch(cmd, groups, 1, 1);
vkCmdWriteTimestamp(cmd, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, qp, 1);
VK(vkEndCommandBuffer(cmd));
VkSubmitInfo si{VK_STRUCTURE_TYPE_SUBMIT_INFO};
si.commandBufferCount = 1; si.pCommandBuffers = &cmd;
VK(vkResetFences(c.dev, 1, &fence));
VK(vkQueueSubmit(c.queue, 1, &si, fence));
VK(vkWaitForFences(c.dev, 1, &fence, VK_TRUE, UINT64_MAX));
uint64_t ts[2] = {0, 0};
VK(vkGetQueryPoolResults(c.dev, qp, 0, 2, sizeof(ts), ts, sizeof(uint64_t),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT));
return double(ts[1] - ts[0]) * double(c.ts_period);
};
auto sweep = [&](const char* label, bool spread) {
printf("\n== SPEED ORACLE: edge_intersect vs dispatch size (%s) ==\n", label);
printf("%10s %8s %12s %10s %10s\n", "edges", "groups", "GPU us", "ns/edge", "vs 256");
double base = 0.0;
for (uint32_t N : {256u, 512u, 1024u, 2048u, 4096u, 8192u,
16384u, 32768u, 65536u, 98304u}) {
std::vector<uint32_t> list(N);
for (uint32_t i = 0; i < N; i++)
list[i] = spread ? (uint32_t)(((uint64_t)i * (uint64_t)EC) / N) % (uint32_t)EC
: real_list[i % real_list.size()];
time_ei(list); // warm at this size
std::vector<double> v;
for (int r = 0; r < reps; r++) v.push_back(time_ei(list));
std::sort(v.begin(), v.end());
double m = v[v.size() / 2];
double per = m / double(N);
if (base == 0.0) base = per;
printf("%10u %8u %12.1f %10.1f %9.2fx\n",
N, (N + 255u) / 256u, m / 1000.0, per, per / base);
}
};
printf("\n(real active edges in this chunk: %u)\n", real_n);
sweep("REPEAT real edges", false);
sweep("SPREAD across grid", true);
// real_cnt, captured BEFORE the speed oracle — that sweep overwrites counts
// with its synthetic N, so reading it here reported edges=98304 indices=0.
const uint32_t* cnt = real_cnt;
printf("\ncounts[edges=%u indices=%u voxels=%u] "
"(zero here would mean the numbers above are the empty-chunk early-out)\n",
cnt[0], cnt[1], cnt[2]);
}
// ======================================================= CORRECTNESS ORACLE
//
// DOES DISPATCH BATCHING BREAK ANYTHING?
//
// Two ways to batch. DATA batching merges K chunks into one array — that is
// where the segmented-prefix-scan trap lives, because chunk 2's scan would
// inherit chunk 1's totals and shift vertices rather than remove them. DISPATCH
// batching keeps every chunk's buffers separate and only reorders the command
// buffer: instead of chunk-major (chunk A's 16 stages, barrier after each, then
// chunk B's), go stage-major (every chunk's stage 1, ONE barrier, every chunk's
// stage 2, ...). K dispatches then run between barriers instead of one.
//
// Stage-major needs no shader change, has no scan hazard, and is the version
// worth trying first — so it is the version this proves.
//
// THE CLAIM: stage-major output is byte-identical to chunk-major.
// It should be. Every chunk owns its buffers, and the barrier structure still
// guarantees stage i completes for all chunks before stage i+1 begins for any.
// "Should be" is exactly the kind of statement this repo has learned to distrust.
//
// THE TRAP THIS RIG HAD TO AVOID. If every replica shared one density grid, all
// replicas would produce identical edge_flags, identical scans and identical
// active-edge lists — so cross-replica contamination would be INVISIBLE, and the
// oracle would pass while proving nothing. That is #43 §6's gated oracle exactly.
// Each replica therefore fills its OWN density grid at its OWN cell origin, so
// every intermediate buffer differs and contamination has somewhere to show.
struct Replica {
Buf params, density, nx, ny, nz, brick, edit;
Buf edge_flags, edge_scan, active_edges, edge_data;
Buf voxel_flags, voxel_scan, active_vox, vertex, index;
Buf counts, dispatch_args, blk_sums, blk_scanned, planeset;
Stage density_stage;
std::vector<Stage> mesh;
MeshPushConstants mpc;
float origin[3];
};
static void run_batch_oracle(Ctx& c, Rig& shared, int reps, int K) {
const int V = STRIDE - 1, V3 = V * V * V, EC = V3 * 3;
const int SBK = (EC + 1023) / 1024 + 1;
const int cells = STRIDE * STRIDE * STRIDE;
auto U = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
// Shared, read-only, identical for every chunk: the noise tables and an
// empty edit list. Sharing these is safe precisely because nothing writes them.
const Buf& voro = shared.bufs[5];
const Buf& perm = shared.bufs[6];
// NOT shared — each replica gets its own; see below.
const Buf& bvh0 = shared.bufs[8];
const Buf& bvh1 = shared.bufs[9];
const Buf& bvh2 = shared.bufs[10];
const Buf& convex = shared.bufs[12];
Buf faceproj = make_buf(c, 16, U); zero_fill(c, faceproj);
VkDescriptorPoolSize ps[2] = {
{ VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, (uint32_t)(200 * K) },
{ VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, (uint32_t)(8 * K) },
};
VkDescriptorPoolCreateInfo pi{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO};
pi.maxSets = (uint32_t)(32 * K); pi.poolSizeCount = 2; pi.pPoolSizes = ps;
VkDescriptorPool pool;
VK(vkCreateDescriptorPool(c.dev, &pi, nullptr, &pool));
std::string s_scan_blocks = read_shader("scan_blocks.glsl");
std::string s_scan_single = read_shader("scan_single.glsl");
std::string s_scan_prop = read_shader("scan_propagate.glsl");
std::string s_compact = read_shader("stream_compact_index.glsl");
std::string s_prepare = read_shader("prepare_dispatch.glsl");
std::string s_ei = build_edge_intersect();
std::string s_ec = read_shader("edge_classify.glsl");
std::string s_vc = read_shader("voxel_classify.glsl");
std::string s_bp = read_shader("bake_planeset.glsl");
std::string s_qef = read_shader("qef_solve.glsl");
std::string s_qe = read_shader("quad_emit.glsl");
std::string s_density = _build_default_shader();
const uint32_t MPC = (uint32_t)sizeof(MeshPushConstants), SPC = 16;
std::vector<Replica> R(K);
for (int k = 0; k < K; k++) {
Replica& r = R[k];
r.params = make_buf(c, 256, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT);
r.density = make_buf(c, cells * 4, U);
r.nx = make_buf(c, cells * 4, U);
r.ny = make_buf(c, cells * 4, U);
r.nz = make_buf(c, cells * 4, U);
r.brick = make_buf(c, cells * 4, U);
r.edit = make_buf(c, 16 + 64 * 48, U);
r.edge_flags = make_buf(c, EC * 4, U);
r.edge_scan = make_buf(c, EC * 4, U);
r.active_edges = make_buf(c, EC * 4, U);
r.edge_data = make_buf(c, EC * 8 * 4, U);
r.voxel_flags = make_buf(c, V3 * 4, U);
r.voxel_scan = make_buf(c, V3 * 4, U);
r.active_vox = make_buf(c, V3 * 4, U);
r.vertex = make_buf(c, V3 * 12 * 4, U);
r.index = make_buf(c, EC * 6 * 4, U);
r.counts = make_buf(c, 16, U);
r.dispatch_args = make_buf(c, 32, U | VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT);
r.blk_sums = make_buf(c, SBK * 4, U);
r.blk_scanned = make_buf(c, SBK * 4, U);
r.planeset = make_buf(c, V3 * 64, U);
for (const Buf* b : { &r.density,&r.nx,&r.ny,&r.nz,&r.brick,&r.edit,&r.edge_flags,
&r.edge_scan,&r.active_edges,&r.edge_data,&r.voxel_flags,
&r.voxel_scan,&r.active_vox,&r.vertex,&r.index,&r.counts,
&r.dispatch_args,&r.blk_sums,&r.blk_scanned,&r.planeset })
zero_fill(c, *b);
// DISTINCT ORIGINS. Each chunk sits one chunk-width along X from the last,
// so every replica's density grid, edge set and vertex output differ. That
// difference is what makes cross-replica contamination detectable at all.
r.origin[0] = float(k) * 0.224f * float(V);
r.origin[1] = 0.0f;
r.origin[2] = 0.0f;
GpuParams gp;
fill_params(gp, 0.224f, 8, false);
gp.origin_x = r.origin[0]; gp.origin_y = r.origin[1]; gp.origin_z = r.origin[2];
upload(c, r.params, &gp, sizeof(gp));
// A DIFFERENT EDIT LIST PER CHUNK — the thing the first version of this
// oracle left empty, and the thing the reverted batch path got wrong.
// dc_gpu_thread.h:266 records that batching once uploaded only batch[0]'s
// edits for the whole batch, so chunks were carved with someone else's
// edits ("CSG edits glitch in and out / rippling"). With every replica
// holding an identical EMPTY list that bug is unreachable and the oracle
// passes while testing nothing — a gated oracle, #43 section 6. Chunk k
// gets k+1 spheres positioned at ITS OWN origin, so a chunk that reads
// the wrong list produces visibly wrong geometry.
{
struct GpuEditHost {
uint32_t edit_type;
float pos_x, pos_y, pos_z;
float half_x, half_y, half_z;
float radius, strength;
uint32_t p0, p1, p2;
};
static_assert(sizeof(GpuEditHost) == 48, "GpuEdit is 48 bytes");
uint32_t n = (uint32_t)k + 1u;
std::vector<uint8_t> blob(16 + n * sizeof(GpuEditHost), 0);
memcpy(blob.data(), &n, 4);
GpuEditHost* e = (GpuEditHost*)(blob.data() + 16);
for (uint32_t i = 0; i < n; i++) {
memset(&e[i], 0, sizeof(GpuEditHost));
e[i].edit_type = 6u; // hard sphere, CSG subtract
e[i].pos_x = r.origin[0] + 1.0f + 1.7f * float(i);
e[i].pos_y = r.origin[1] + 0.5f * float(i);
e[i].pos_z = r.origin[2] + 1.0f;
e[i].radius = 1.5f + 0.3f * float(i);
e[i].strength = -1.0f; // subtract
}
upload(c, r.edit, blob.data(), blob.size());
}
memset(&r.mpc, 0, sizeof(r.mpc));
r.mpc.cell_origin[0] = r.origin[0];
r.mpc.cell_origin[1] = r.origin[1];
r.mpc.cell_origin[2] = r.origin[2];
r.mpc.voxel_size = 0.224f;
r.mpc.grid_dims[0] = r.mpc.grid_dims[1] = r.mpc.grid_dims[2] = V;
r.mpc.total_elements = (uint32_t)V3;
r.density_stage = make_stage(c, pool, "density", s_density,
{{0,&r.density,false},{1,&r.nx,false},{2,&r.ny,false},{3,&r.nz,false},
{4,&r.params,true},{5,&voro,false},{6,&perm,false},{7,&r.edit,false},
{8,&bvh0,false},{9,&bvh1,false},{10,&bvh2,false},{11,&r.brick,false},
{12,&convex,false}}, 0);
uint32_t g = (STRIDE + LOCAL - 1) / LOCAL;
r.density_stage.gx = r.density_stage.gy = r.density_stage.gz = g;
auto& M = r.mesh;
M.push_back(make_stage(c, pool, "edge_classify", s_ec,
{{0,&r.density,false},{1,&r.edge_flags,false}}, MPC));
M.back().gx = (V3 + 255) / 256;
M.push_back(make_stage(c, pool, "scan_blocks(e)", s_scan_blocks,
{{0,&r.edge_flags,false},{1,&r.edge_scan,false},{2,&r.blk_sums,false}}, SPC));
M.back().gx = (EC + 1023) / 1024;
M.push_back(make_stage(c, pool, "scan_single(e)", s_scan_single,
{{0,&r.blk_sums,false},{1,&r.blk_scanned,false}}, SPC));
M.push_back(make_stage(c, pool, "scan_propagate(e)", s_scan_prop,
{{0,&r.edge_scan,false},{1,&r.blk_scanned,false}}, SPC));
M.back().gx = (EC + 1023) / 1024;
M.push_back(make_stage(c, pool, "compact(e)", s_compact,
{{0,&r.edge_flags,false},{1,&r.edge_scan,false},{2,&r.active_edges,false},{3,&r.counts,false}}, SPC));
M.back().gx = (EC + 255) / 256;
M.push_back(make_stage(c, pool, "prepare(e)", s_prepare,
{{0,&r.edge_scan,false},{1,&r.edge_flags,false},{2,&r.dispatch_args,false},{3,&r.counts,false}}, SPC));
M.push_back(make_stage(c, pool, "edge_intersect", s_ei,
{{0,&r.density,false},{1,&r.edge_flags,false},{2,&r.edge_scan,false},{3,&r.edge_data,false},
{4,&r.params,true},{5,&voro,false},{6,&perm,false},{7,&r.edit,false},
{8,&r.active_edges,false},{9,&r.counts,false},{10,&r.brick,false},{11,&convex,false}}, MPC));
M.back().indirect_offset = 0;
M.push_back(make_stage(c, pool, "voxel_classify", s_vc,
{{1,&r.edge_flags,false},{4,&r.voxel_flags,false}}, MPC));
M.back().gx = (V3 + 255) / 256;
M.push_back(make_stage(c, pool, "scan_blocks(v)", s_scan_blocks,
{{0,&r.voxel_flags,false},{1,&r.voxel_scan,false},{2,&r.blk_sums,false}}, SPC));
M.back().gx = (V3 + 1023) / 1024;
M.push_back(make_stage(c, pool, "scan_single(v)", s_scan_single,
{{0,&r.blk_sums,false},{1,&r.blk_scanned,false}}, SPC));
M.push_back(make_stage(c, pool, "scan_propagate(v)", s_scan_prop,
{{0,&r.voxel_scan,false},{1,&r.blk_scanned,false}}, SPC));
M.back().gx = (V3 + 1023) / 1024;
M.push_back(make_stage(c, pool, "compact(v)", s_compact,
{{0,&r.voxel_flags,false},{1,&r.voxel_scan,false},{2,&r.active_vox,false},{3,&r.counts,false}}, SPC));
M.back().gx = (V3 + 255) / 256;
M.push_back(make_stage(c, pool, "prepare(v)", s_prepare,
{{0,&r.voxel_scan,false},{1,&r.voxel_flags,false},{2,&r.dispatch_args,false},{3,&r.counts,false}}, SPC));
M.push_back(make_stage(c, pool, "bake_planeset", s_bp,
{{9,&r.edit,false},{10,&convex,false},{11,&r.planeset,false},{12,&faceproj,false}}, MPC));
M.back().gx = (V3 + 255) / 256;
M.push_back(make_stage(c, pool, "qef_solve", s_qef,
{{0,&r.density,false},{1,&r.edge_flags,false},{2,&r.edge_scan,false},{3,&r.edge_data,false},
{4,&r.voxel_flags,false},{5,&r.voxel_scan,false},{6,&r.vertex,false},{7,&r.active_vox,false},
{8,&r.counts,false},{9,&r.edit,false},{10,&convex,false},{11,&r.planeset,false}}, MPC));
M.back().indirect_offset = 12;
M.push_back(make_stage(c, pool, "quad_emit", s_qe,
{{0,&r.density,false},{1,&r.edge_flags,false},{4,&r.voxel_flags,false},{5,&r.voxel_scan,false},
{6,&r.vertex,false},{8,&r.index,false},{9,&r.counts,false}}, MPC));
M.back().gx = (V3 + 255) / 256;
}
uint32_t spc_e[4] = { (uint32_t)EC, 0, 0, 0 };
uint32_t spc_eb[4] = { (uint32_t)((EC + 1023) / 1024), 0, 0, 0 };
uint32_t spc_v[4] = { (uint32_t)V3, 0, 0, 0 };
uint32_t spc_vb[4] = { (uint32_t)((V3 + 1023) / 1024), 0, 0, 0 };
uint32_t spc_pv[4] = { (uint32_t)V3, 3, 2, 0 };
auto push_for = [&](Replica& r, size_t i) -> const void* {
switch (i) {
case 1: return spc_e; case 2: return spc_eb; case 3: return spc_e;
case 4: return spc_e; case 5: return spc_e;
case 8: return spc_v; case 9: return spc_vb; case 10: return spc_v;
case 11: return spc_v; case 12: return spc_pv;
default: return &r.mpc;
}
};
VkCommandBufferAllocateInfo cbi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
cbi.commandPool = c.cmdpool; cbi.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
cbi.commandBufferCount = 1;
VkCommandBuffer cmd; VK(vkAllocateCommandBuffers(c.dev, &cbi, &cmd));
VkFence fence; VkFenceCreateInfo fi{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
VK(vkCreateFence(c.dev, &fi, nullptr, &fence));
VkQueryPool qp; VkQueryPoolCreateInfo qpi{VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO};
qpi.queryType = VK_QUERY_TYPE_TIMESTAMP; qpi.queryCount = 2;
VK(vkCreateQueryPool(c.dev, &qpi, nullptr, &qp));
auto emit = [&](Replica& r, size_t i) {
Stage& s = r.mesh[i];
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, s.pipe);
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, s.plo, 0, 1, &s.dset, 0, nullptr);
if (s.pc_size) vkCmdPushConstants(cmd, s.plo, VK_SHADER_STAGE_COMPUTE_BIT, 0, s.pc_size, push_for(r, i));
if (s.indirect_offset >= 0)
vkCmdDispatchIndirect(cmd, r.dispatch_args.buf, (VkDeviceSize)s.indirect_offset);
else
vkCmdDispatch(cmd, s.gx, s.gy, s.gz);
};
// stage_major=false -> today's shape: chunk A's 16 stages with a barrier after
// each, then chunk B's. stage_major=true -> every chunk's stage i, ONE barrier,
// then stage i+1. Same dispatches, same buffers, same push constants; only the
// ORDER and the barrier count differ (16*K barriers vs 16).
auto run = [&](bool stage_major) -> double {
for (int k = 0; k < K; k++) {
zero_fill(c, R[k].counts);
zero_fill(c, R[k].index);
zero_fill(c, R[k].vertex);
}
VkCommandBufferBeginInfo bi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
VK(vkBeginCommandBuffer(cmd, &bi));
vkCmdResetQueryPool(cmd, qp, 0, 2);
vkCmdWriteTimestamp(cmd, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, qp, 0);
for (int k = 0; k < K; k++) { // density first, always per chunk
Stage& d = R[k].density_stage;
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d.pipe);
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, d.plo, 0, 1, &d.dset, 0, nullptr);
vkCmdDispatch(cmd, d.gx, d.gy, d.gz);
}
barrier(cmd);
if (stage_major) {
for (size_t i = 0; i < R[0].mesh.size(); i++) {
for (int k = 0; k < K; k++) emit(R[k], i);
barrier(cmd);
}
} else {
for (int k = 0; k < K; k++)
for (size_t i = 0; i < R[k].mesh.size(); i++) { emit(R[k], i); barrier(cmd); }
}
vkCmdWriteTimestamp(cmd, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, qp, 1);
VK(vkEndCommandBuffer(cmd));
VkSubmitInfo si{VK_STRUCTURE_TYPE_SUBMIT_INFO};
si.commandBufferCount = 1; si.pCommandBuffers = &cmd;
VK(vkResetFences(c.dev, 1, &fence));
VK(vkQueueSubmit(c.queue, 1, &si, fence));
VK(vkWaitForFences(c.dev, 1, &fence, VK_TRUE, UINT64_MAX));
uint64_t ts[2] = {0,0};
VK(vkGetQueryPoolResults(c.dev, qp, 0, 2, sizeof(ts), ts, sizeof(uint64_t),
VK_QUERY_RESULT_64_BIT | VK_QUERY_RESULT_WAIT_BIT));
return double(ts[1] - ts[0]) * double(c.ts_period);
};
auto capture = [&](std::vector<std::vector<uint8_t>>& out) {
out.assign(K, {});
for (int k = 0; k < K; k++) {
uint32_t cn[4]; download(c, R[k].counts, cn, sizeof(cn));
size_t vbytes = (size_t)V3 * 12 * 4;
size_t ibytes = (size_t)std::min<uint32_t>(cn[1], (uint32_t)(EC * 6)) * 4;
out[k].resize(sizeof(cn) + vbytes + ibytes);
memcpy(out[k].data(), cn, sizeof(cn));
download(c, R[k].vertex, out[k].data() + sizeof(cn), vbytes);
if (ibytes) download(c, R[k].index, out[k].data() + sizeof(cn) + vbytes, ibytes);
}
};
printf("\n== CORRECTNESS ORACLE: chunk-major vs stage-major, K=%d chunks ==\n", K);
// THE CONTROL I FIRST OMITTED, WHICH INVERTS THE READING.
//
// compact() and quad_emit() append through ATOMICS, so the order entries land
// in active_edges/index_buf depends on which threads happen to win the race.
// That varies between any two runs, batched or not. Without a same-ordering
// baseline, "stage-major differs from chunk-major" cannot be told apart from
// "any run differs from any run", and reporting the first as a batching hazard
// would be a false alarm dressed as a finding.
//
// So: compare chunk-major against ITSELF first. Whatever that shows is the
// floor. Only difference ABOVE that floor is attributable to the reordering.
const size_t VBYTES = (size_t)V3 * 12 * 4;
auto compare = [&](const char* label,
std::vector<std::vector<uint8_t>>& x,
std::vector<std::vector<uint8_t>>& y) -> bool {
bool same = true;
printf(" %s\n", label);
for (int k = 0; k < K; k++) {
uint32_t cx, cy; memcpy(&cx, x[k].data(), 4); memcpy(&cy, y[k].data(), 4);
size_t n = std::min(x[k].size(), y[k].size());
size_t dv = 0, di = 0;
for (size_t i = 4; i < n; i++)
if (x[k][i] != y[k][i]) { if (i < 16 + VBYTES) dv++; else di++; }
bool bad = (cx != cy) || dv || di || x[k].size() != y[k].size();
printf(" chunk %d: %6u edges vertex-diff %7zu index-diff %7zu %s\n",
k, cx, dv, di, bad ? "DIFFERS" : "identical");
if (bad) same = false;
}
return same;
};
std::vector<std::vector<uint8_t>> a, a2, b;
run(false); capture(a);
run(false); capture(a2); // same ordering, twice
run(true); capture(b);
bool base_same = compare("chunk-major vs chunk-major (CONTROL: run-to-run noise)", a, a2);
bool same = compare("chunk-major vs stage-major (the actual question)", a, b);
// A BYTE DIFF CANNOT TELL A REORDERING FROM A DIFFERENT MESH.
//
// quad_emit appends triangles through atomics, so the index buffer is a
// permutation that varies every run. Byte-comparing it therefore proves
// nothing either way: identical bytes would be luck, differing bytes could be
// benign reordering OR genuinely different geometry. The question that
// actually matters is whether the SET of triangles is the same.
//
// So sort both index buffers as triples and compare the multisets. Same set,
// different order = the mesh is identical and only its triangle ordering moved.
auto tri_set = [&](const std::vector<uint8_t>& blob) {
uint32_t cn[4]; memcpy(cn, blob.data(), 16);
size_t n = std::min<uint32_t>(cn[1], (uint32_t)(EC * 6));
const uint32_t* idx = (const uint32_t*)(blob.data() + 16 + VBYTES);
std::vector<std::array<uint32_t,3>> t;
for (size_t i = 0; i + 2 < n; i += 3) t.push_back({idx[i], idx[i+1], idx[i+2]});
std::sort(t.begin(), t.end());
return t;
};
printf(" triangle SET (sorted; a permutation is benign, a different set is not)\n");
bool set_same = true;
for (int k = 0; k < K; k++) {
auto ta = tri_set(a[k]), tb = tri_set(b[k]), tc = tri_set(a2[k]);
bool ctl = (ta == tc), qn = (ta == tb);
if (!qn) set_same = false;
printf(" chunk %d: %7zu tris control %s stage-major %s\n",
k, ta.size(), ctl ? "same-set" : "DIFFERENT-SET",
qn ? "same-set" : "DIFFERENT-SET");
}
printf(" -> triangle sets %s under stage-major\n",
set_same ? "MATCH exactly" : "DIVERGE");
// A chunk that produced nothing would compare equal trivially. Say so.
uint32_t total_edges = 0;
for (int k = 0; k < K; k++) { uint32_t ca; memcpy(&ca, a[k].data(), 4); total_edges += ca; }
printf(" -> control %s ; stage-major %s (%u active edges across %d chunks;\n"
" chunks with 0 edges compare equal trivially and prove nothing)\n",
base_same ? "STABLE" : "ALREADY NOISY",
same ? "IDENTICAL" : "DIVERGED", total_edges, K);
if (!base_same)
printf(" NOTE: the control is not stable, so a stage-major difference is\n"
" NOT evidence of a batching hazard — it is the same atomic-append\n"
" nondeterminism the control shows. Compare the magnitudes.\n");
std::vector<double> tm_chunk, tm_stage;
for (int i = 0; i < 2; i++) { run(false); run(true); }
for (int i = 0; i < reps; i++) tm_chunk.push_back(run(false));
for (int i = 0; i < reps; i++) tm_stage.push_back(run(true));
std::sort(tm_chunk.begin(), tm_chunk.end());
std::sort(tm_stage.begin(), tm_stage.end());
double mc = tm_chunk[tm_chunk.size()/2], ms = tm_stage[tm_stage.size()/2];
printf(" chunk-major %10.1f us (%d*16 = %d barriers)\n", mc/1000.0, K, K*16);
printf(" stage-major %10.1f us (16 barriers)\n", ms/1000.0);
printf(" speedup %10.2fx\n", mc / ms);
}
int main(int argc, char** argv) {
int reps = 9;
for (int i = 1; i < argc; i++)
if (!strcmp(argv[i], "--reps") && i + 1 < argc) reps = atoi(argv[++i]);
if (const char* sd = getenv("DC_SHADER_DIR")) shader_dir = sd;
Ctx c;
init_vulkan(c);
init_staging(c, 8u << 20); // 8 MB: covers the largest buffer we zero/read
Rig r(c);
build_rig(r);
const int samples = STRIDE * STRIDE * STRIDE;
printf("device : %s\n", c.devname);
printf("timestampPeriod : %.3f ns/tick\n", c.ts_period);
printf("grid : %d^3 = %d samples, local_size %d^3, %u^3 groups\n",
STRIDE, samples, LOCAL, (STRIDE + LOCAL - 1) / LOCAL);
printf("median of : %d reps (after 1 warm dispatch)\n\n", reps);
bench(r, 0.224f, 8, true, 2); // global warm-up, discarded
// --- LOD sweep, OCTAVES HELD FIXED. ---
// READ THIS ROW SET FOR WHAT IT IS: voxel_scale changes the sample SPACING, not
// the instruction count, so a flat line here is the expected result and not a
// finding. It is the control. The engine does NOT hold octaves fixed across LOD
// — nyquist_octaves() (libdc/src/dc_density.cpp:81) drops them as voxels grow —
// so the real per-LOD cost curve is this sweep CROSSED WITH the octave sweep
// below, and it is not measured here yet. Wiring nyquist_octaves in needs it
// lifted out of that .cpp into a header first, for the same one-copy reason the
// shader strings were lifted.
printf("== LOD sweep, octaves FIXED at 8 (control, normals=off = SHIPPING) ==\n");
printf("%-5s %-12s %12s %12s\n", "LOD", "voxel(m)", "GPU us", "ns/sample");
for (int lod = 0; lod <= 8; lod++) {
float vs = 0.224f * float(1 << lod);
double ns = bench(r, vs, 8, false, reps);
printf("%-5d %-12.3f %12.1f %12.2f\n", lod, vs, ns / 1000.0, ns / samples);
}
// --- Octave sweep. This is what nyquist_octaves() trades against, and what
// procnodes' `pn_max_octaves = 99` refuses to trade at all. ---
printf("\n== octave sweep (voxel=0.224 m, normals=off = SHIPPING) ==\n");
printf("%-9s %12s %12s\n", "octaves", "GPU us", "ns/sample");
for (int oct : {1, 2, 4, 6, 8, 10, 12, 16}) {
double ns = bench(r, 0.224f, oct, false, reps);
printf("%-9d %12.1f %12.2f\n", oct, ns / 1000.0, ns / samples);
}
// --- Normals. MAIN calls evaluate_density 4 extra times for the gradient. ---
printf("\n== compute_normals (voxel=0.224 m, octaves=8) ==\n");
double off = bench(r, 0.224f, 8, false, reps);
double on = bench(r, 0.224f, 8, true, reps);
printf("off %10.1f us\non %10.1f us\nratio %8.2fx\n", off / 1000.0, on / 1000.0, on / off);
run_mesh(c, r, reps);
run_batch_oracle(c, r, reps, 8);
printf("\nNOTE: occupancy is still unmeasured — timestamps give elapsed GPU time,\n"
" not SM utilisation. A stage that is fast because it is trivial and\n"
" one that is fast because it is well occupied look identical here.\n");
return 0;
}