OverviewFilesCommitsIssuesDocsReleasesPeers

dc-terrain / tests / test_gpu_stages.cpp

1539 lines · cpp ref:
// ============================================================================
// 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, &region);
    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,&params,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;
}