OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes / tests / test_emitted_density_parity.cpp

404 lines · cpp ref:
// ============================================================================
// EMITTED-DENSITY PARITY — is the shader's field the same field as the CPU's?
//
// WHAT THIS SEES THAT NOTHING ELSE DOES.
//
// test_emitted_op_parity asks whether a node's arithmetic survives emission,
// one node at a time, at one position. Every one of those can pass while the
// GRAPH emits a density that is wrong or absent, because "which axis is the
// density read from" and "was this graph authored in Blender's frame" are
// properties of the whole function and of no node in it.
//
// Measured 2026-09-08 on the bridged voronoi mountain: thirty nodes of Voronoi,
// cone and noise emitted correctly, computed the height into `pos.z` because
// the graph was authored in Blender, and the emitted tail read `pos.y -
// query_y`. The density was identically zero. `evaluate_density_batch` returned
// a real mountain for the same graph, because GraphExecutor converts the frame
// at the end (graph_executor.cpp, _to_godot_frame) and the emitter has never
// heard of the flag that drives it -- grep is_blender_frame src/gpu/ is empty.
// So the CPU and the GPU were two different worlds and nothing compared them.
//
// THE INVARIANT, and it is deliberately not a tolerance on character:
// proc_density(p) from the shader EQUALS evaluate_density_batch([p]) from the
// CPU, at every p. If those two disagree the terrain gets whichever one runs,
// and which one runs depends on whether a RenderingDevice came up.
//
// The lattice comes from stage 1 and is asymmetric on purpose. A frame error is
// a permutation of the axes, so samples along a diagonal -- where x == z --
// agree under a swap and prove nothing.
//
// Build (no engine, no godot-cpp, no scons):
//   g++ -O2 -std=c++17 -o /tmp/density_parity tests/test_emitted_density_parity.cpp -lvulkan
//
// Run:
//   godot --headless --path demo --script res://glsl_density_gate_dump.gd -- graph.json /tmp/dgate
//   /tmp/density_parity /tmp/dgate
//
// Exit status is 0 when every point agrees.
// ============================================================================

#include <vulkan/vulkan.h>
#include <sys/resource.h>

#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>

// A float32 op on two machines will not be bit-identical: the CPU uses libm and
// the GPU its own approximations, and sin/pow/log are where they part company.
// 1e-5 relative is far tighter than any difference that would change a surface
// and far looser than the last bit.
static const double TOL_REL = 1e-5;
static const double TOL_ABS = 1e-6;

// ⚠ THIS GATE EMITS ONE FUNCTION PER CASE, so its shader grows with the case
// list. That is fine for cheap arithmetic and NOT fine for anything with an
// octave loop in it: the sibling gate, built the same way, twice took a 31 GB
// machine to global OOM once perlin cases were added to it
// (tests/test_shared_noise_parity.cpp, which is now data-driven and does not).
// Keep the expensive families over there and only enough cases here to show
// the EMITTER wires a node correctly. The cap makes a mistake fail alone.
static const rlim_t MEM_LIMIT_BYTES = (rlim_t)4 * 1024 * 1024 * 1024;

static void cap_own_memory() {
	struct rlimit rl;
	if (getrlimit(RLIMIT_AS, &rl) != 0) { return; }
	if (rl.rlim_cur != RLIM_INFINITY && rl.rlim_cur <= MEM_LIMIT_BYTES) { return; }
	rl.rlim_cur = (rl.rlim_max == RLIM_INFINITY || rl.rlim_max > MEM_LIMIT_BYTES)
			? MEM_LIMIT_BYTES : rl.rlim_max;
	if (setrlimit(RLIMIT_AS, &rl) != 0) {
		fprintf(stderr, "WARNING: setrlimit failed; running with NO memory cap\n");
	}
}

static void die(const char *what, VkResult r) {
	fprintf(stderr, "FATAL: %s -> VkResult %d\n", what, (int)r);
	struct rlimit rl;
	if (getrlimit(RLIMIT_AS, &rl) == 0 && rl.rlim_cur != RLIM_INFINITY) {
		fprintf(stderr, "       Capped at %.1f GiB of address space, deliberately.\n"
				"       If it is being hit, the case list has outgrown a gate that\n"
				"       emits one function per case.\n",
				(double)rl.rlim_cur / (1024.0 * 1024.0 * 1024.0));
	}
	exit(1);
}
#define VK(x) do { VkResult _r = (x); if (_r != VK_SUCCESS) die(#x, _r); } while (0)

struct Sample {
	int idx = 0;
	float x = 0, y = 0, z = 0;
	double cpu = 0;
};

static std::string read_file(const std::string &path) {
	std::ifstream f(path);
	if (!f) {
		fprintf(stderr, "FATAL: cannot read %s\n", path.c_str());
		exit(1);
	}
	std::stringstream ss;
	ss << f.rdbuf();
	return ss.str();
}

static std::vector<uint32_t> compile_glsl(const std::string &src) {
	char in_path[] = "/tmp/proc_op_parity_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 = std::string(in_path) + ".spv";
	std::string cmd = "glslc -fshader-stage=comp -o " + spv + " " + in_path;
	if (system(cmd.c_str()) != 0) {
		fprintf(stderr, "FATAL: glslc failed (source left at %s)\n", in_path);
		exit(1);
	}
	FILE *sf = fopen(spv.c_str(), "rb");
	fseek(sf, 0, SEEK_END); long n = ftell(sf); fseek(sf, 0, SEEK_SET);
	std::vector<uint32_t> out(n / 4);
	if (fread(out.data(), 1, n, sf) != (size_t)n) { fprintf(stderr, "FATAL: short read\n"); exit(1); }
	fclose(sf);
	remove(in_path); remove(spv.c_str());
	return out;
}

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;
	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);
}

static void init_vulkan(Ctx &c) {
	VkApplicationInfo app{VK_STRUCTURE_TYPE_APPLICATION_INFO};
	app.pApplicationName = "proc-nodes emitted-op parity";
	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()));
	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);
	snprintf(c.devname, sizeof(c.devname), "%s", props.deviceName);
	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) { c.qfam = i; found = true; break; }
	}
	if (!found) { fprintf(stderr, "FATAL: no compute queue\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));
}

// Replace every occurrence of `from` with `to`.
static void replace_all(std::string &s, const std::string &from, const std::string &to) {
	size_t p = 0;
	while ((p = s.find(from, p)) != std::string::npos) {
		s.replace(p, from.size(), to);
		p += to.size();
	}
}

int main(int argc, char **argv) {
	cap_own_memory();
	const std::string dir = (argc > 1) ? std::string(argv[1]) : std::string("/tmp/proc_density_gate");

	std::vector<Sample> pts;
	{
		std::ifstream mf(dir + "/manifest.txt");
		if (!mf) { fprintf(stderr, "FATAL: no manifest.txt in %s\n", dir.c_str()); return 1; }
		std::string line;
		while (std::getline(mf, line)) {
			if (line.empty()) continue;
			std::istringstream is(line);
			Sample s;
			is >> s.idx >> s.x >> s.y >> s.z >> s.cpu;
			pts.push_back(s);
		}
	}
	if (pts.empty()) { fprintf(stderr, "FATAL: no sample points\n"); return 1; }

	const std::string glsl = read_file(dir + "/density.glsl");
	const std::string fn = "float proc_density";
	const size_t p = glsl.find(fn);
	if (p == std::string::npos) { fprintf(stderr, "FATAL: no proc_density in density.glsl\n"); return 1; }

	// ONE CALL SITE, and the positions arrive in a buffer.
	//
	// The first version baked each sample as a literal `proc_density(vec3(...))`
	// -- the shape the op gate uses, where each case is a different function.
	// Here it is ONE function called N times, and glslc inlines every call
	// before it optimises: a density carrying a Voronoi cell search inside an
	// octave loop, inlined a hundred times, took the compiler past this gate's
	// own 4 GB cap and it died with LLVM ERROR: out of memory. A gate that
	// takes the machine down is a gate nobody runs, which is why that cap is
	// there at all.
	const std::string prelude = glsl.substr(0, p);
	const std::string body = glsl.substr(p);
	std::string src =
			"#version 450\n"
			"layout(local_size_x = 64) in;\n"
			"layout(std430, binding = 0) buffer Out { float v[]; } out_v;\n"
			"layout(std430, binding = 1) readonly buffer In { vec4 p[]; } in_p;\n" +
			prelude + body +
			"void main() {\n"
			"    uint i = gl_GlobalInvocationID.x;\n"
			"    if (i >= " + std::to_string(pts.size()) + "u) return;\n"
			"    out_v.v[i] = proc_density(in_p.p[i].xyz);\n"
			"}\n";

	Ctx c{};
	init_vulkan(c);
	printf("device: %s\npoints: %zu\n\n", c.devname, pts.size());

	std::vector<uint32_t> spv = compile_glsl(src);

	VkBuffer buf, inbuf;
	VkDeviceMemory mem, inmem;
	void *mapped = nullptr;
	void *inmapped = nullptr;
	const VkDeviceSize bytes = sizeof(float) * pts.size();
	const VkDeviceSize inbytes = sizeof(float) * 4 * pts.size();
	{
		VkBufferCreateInfo bi{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
		bi.size = bytes;
		bi.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
		VK(vkCreateBuffer(c.dev, &bi, nullptr, &buf));
		VkMemoryRequirements mr;
		vkGetBufferMemoryRequirements(c.dev, 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, &mem));
		VK(vkBindBufferMemory(c.dev, buf, mem, 0));
		VK(vkMapMemory(c.dev, mem, 0, VK_WHOLE_SIZE, 0, &mapped));
		memset(mapped, 0, bytes);
	}
	{
		// vec4 per point, not vec3: std430 pads a vec3 to 16 bytes anyway, and
		// writing three floats into a stride of four is the kind of mismatch
		// that reads as a field error rather than as a packing one.
		VkBufferCreateInfo bi{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
		bi.size = inbytes;
		bi.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT;
		VK(vkCreateBuffer(c.dev, &bi, nullptr, &inbuf));
		VkMemoryRequirements mr;
		vkGetBufferMemoryRequirements(c.dev, inbuf, &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, &inmem));
		VK(vkBindBufferMemory(c.dev, inbuf, inmem, 0));
		VK(vkMapMemory(c.dev, inmem, 0, VK_WHOLE_SIZE, 0, &inmapped));
		float *fp = (float *)inmapped;
		for (size_t i = 0; i < pts.size(); i++) {
			fp[i * 4 + 0] = pts[i].x;
			fp[i * 4 + 1] = pts[i].y;
			fp[i * 4 + 2] = pts[i].z;
			fp[i * 4 + 3] = 0.0f;
		}
	}

	VkShaderModuleCreateInfo smi{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
	smi.codeSize = spv.size() * sizeof(uint32_t);
	smi.pCode = spv.data();
	VkShaderModule mod;
	VK(vkCreateShaderModule(c.dev, &smi, nullptr, &mod));

	VkDescriptorSetLayoutBinding b[2]{};
	for (int i = 0; i < 2; i++) {
		b[i].binding = (uint32_t)i; b[i].descriptorCount = 1;
		b[i].descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
		b[i].stageFlags = VK_SHADER_STAGE_COMPUTE_BIT;
	}
	VkDescriptorSetLayoutCreateInfo dli{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO};
	dli.bindingCount = 2; dli.pBindings = b;
	VkDescriptorSetLayout dlayout;
	VK(vkCreateDescriptorSetLayout(c.dev, &dli, nullptr, &dlayout));
	VkPipelineLayoutCreateInfo pli{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO};
	pli.setLayoutCount = 1; pli.pSetLayouts = &dlayout;
	VkPipelineLayout playout;
	VK(vkCreatePipelineLayout(c.dev, &pli, nullptr, &playout));

	VkPipelineShaderStageCreateInfo st{VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO};
	st.stage = VK_SHADER_STAGE_COMPUTE_BIT; st.module = mod; st.pName = "main";
	VkComputePipelineCreateInfo cpi{VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO};
	cpi.stage = st; cpi.layout = playout;
	VkPipeline pipe;
	VK(vkCreateComputePipelines(c.dev, VK_NULL_HANDLE, 1, &cpi, nullptr, &pipe));

	VkDescriptorPoolSize ps{VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 2};
	VkDescriptorPoolCreateInfo dpi{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO};
	dpi.maxSets = 1; dpi.poolSizeCount = 1; dpi.pPoolSizes = &ps;
	VkDescriptorPool dpool;
	VK(vkCreateDescriptorPool(c.dev, &dpi, nullptr, &dpool));
	VkDescriptorSetAllocateInfo dai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
	dai.descriptorPool = dpool; dai.descriptorSetCount = 1; dai.pSetLayouts = &dlayout;
	VkDescriptorSet dset;
	VK(vkAllocateDescriptorSets(c.dev, &dai, &dset));
	VkDescriptorBufferInfo dbi[2] = {{buf, 0, VK_WHOLE_SIZE}, {inbuf, 0, VK_WHOLE_SIZE}};
	VkWriteDescriptorSet w[2]{};
	for (int i = 0; i < 2; i++) {
		w[i].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
		w[i].dstSet = dset; w[i].dstBinding = (uint32_t)i; w[i].descriptorCount = 1;
		w[i].descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; w[i].pBufferInfo = &dbi[i];
	}
	vkUpdateDescriptorSets(c.dev, 2, w, 0, nullptr);

	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));
	VkCommandBufferBeginInfo bbi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
	bbi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
	VK(vkBeginCommandBuffer(cmd, &bbi));
	vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pipe);
	vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, playout, 0, 1, &dset, 0, nullptr);
	vkCmdDispatch(cmd, (uint32_t)((pts.size() + 63) / 64), 1, 1);
	VK(vkEndCommandBuffer(cmd));
	VkSubmitInfo si{VK_STRUCTURE_TYPE_SUBMIT_INFO};
	si.commandBufferCount = 1; si.pCommandBuffers = &cmd;
	VK(vkQueueSubmit(c.queue, 1, &si, VK_NULL_HANDLE));
	VK(vkQueueWaitIdle(c.queue));

	const float *got = (const float *)mapped;
	int bad = 0;
	int gpu_all_zero = 1;
	int cpu_all_zero = 1;
	for (auto &s : pts) {
		const double g = got[s.idx];
		if (g != 0.0) gpu_all_zero = 0;
		if (s.cpu != 0.0) cpu_all_zero = 0;
		const double diff = fabs(g - s.cpu);
		const double scale = fabs(s.cpu) > 1.0 ? fabs(s.cpu) : 1.0;
		const bool nan_both = std::isnan(g) && std::isnan(s.cpu);
		const bool ok = nan_both || diff <= TOL_ABS || (diff / scale) <= TOL_REL;
		if (!ok) {
			bad++;
			if (bad <= 12) {
				printf("  FAIL (%8.3f,%8.3f,%8.3f)  cpu=%-14g gpu=%-14g  d=%g\n",
						s.x, s.y, s.z, s.cpu, g, diff);
			}
		}
	}
	if (bad > 12) printf("  ... %d more\n", bad - 12);
	// A FIELD THAT IS FLAT EVERYWHERE IS THE SPECIFIC FAILURE THIS GATE WAS
	// BUILT FOR, and it deserves to be said rather than left to be read out of
	// a column of zeroes. Both directions: an all-zero CPU means the graph
	// computes nothing, an all-zero GPU with a live CPU means the emission
	// dropped the field on the floor.
	if (gpu_all_zero) printf("\n  ** THE EMITTED DENSITY IS ZERO AT EVERY POINT **\n");
	if (cpu_all_zero) printf("\n  ** THE CPU DENSITY IS ZERO AT EVERY POINT **\n");
	printf("\n%d/%zu points agree\n", (int)pts.size() - bad, pts.size());
	return bad ? 1 : 0;
}