OverviewFilesCommitsIssuesDocsReleasesPeers

proc-nodes / tests / test_emitted_op_parity.cpp

381 lines · cpp ref:
// ============================================================================
// EMITTED-GLSL OPERATION PARITY — does the shader compute what the graph does?
//
// WHY IT HAS TO BE THIS SHAPE.
//
// proc-nodes has two implementations of every node's arithmetic: the C++ that
// runs on the CPU, and the GLSL that compile_to_glsl_function emits for the
// terrain's density shader. Nothing compared them. `gate_dc_vs_lookdev` walks
// the CPU path and is blind to an emitted-GLSL change; the in-game oracle
// SUBMITS density fills rather than observing, which is what broke the terrain
// on 2026-09-03. So the only instrument that can see this is a standalone
// program with its own VkDevice, and this is it.
//
// NEITHER SIDE IS WRITTEN FOR THE TEST. The CPU answers come from
// ProcNodeGraph::evaluate_socket -- i.e. bmath::math_op, the function the
// Blender parity suites gate at 71/71 over all 41 operations. The GLSL is the
// ENGINE'S OWN EMISSION, dumped by demo/glsl_op_gate_dump.gd, not a
// transcription made here. That matters: the erosion filter's lesson was that a
// transcription used AS the reference can only agree with itself. A second
// expression CHECKED against the shipping one, over every branch, is a
// different thing.
//
// HOW. Stage 1 (headless Godot) writes a directory: manifest.txt with one line
// per case -- index, node, operation, a, b, c, the CPU's answer -- and
// case_NNNN.glsl holding the whole emitted function. This program renames each
// case's proc_density to case_NNNN, concatenates them under ONE copy of the
// prelude, compiles the result with glslc, and dispatches a single shader that
// writes every case's value into an SSBO. One compile and one dispatch for all
// of them, because 246 pipelines to answer 246 questions is a slow gate nobody
// runs.
//
// Build (no engine, no godot-cpp, no scons):
//   g++ -O2 -std=c++17 -o /tmp/op_parity tests/test_emitted_op_parity.cpp -lvulkan
//
// Run:
//   godot --headless --path demo --script res://glsl_op_gate_dump.gd -- /tmp/opgate
//   /tmp/op_parity /tmp/opgate
//
// Exit status is 0 when every case 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 Case {
	int idx = 0;
	std::string node;
	int op = 0;
	float a = 0, b = 0, c = 0;
	double cpu = 0;
	std::string glsl;
};

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) ? argv[1] : "/tmp/proc_op_gate";

	std::vector<Case> cases;
	{
		std::ifstream mf(dir + "/manifest.txt");
		if (!mf) { fprintf(stderr, "FATAL: no manifest at %s\n", dir.c_str()); return 1; }
		std::string line;
		while (std::getline(mf, line)) {
			if (line.empty()) continue;
			Case k;
			std::istringstream ls(line);
			ls >> k.idx >> k.node >> k.op >> k.a >> k.b >> k.c >> k.cpu;
			char buf[64];
			snprintf(buf, sizeof(buf), "/case_%04d.glsl", k.idx);
			k.glsl = read_file(dir + buf);
			cases.push_back(k);
		}
	}
	if (cases.empty()) { fprintf(stderr, "FATAL: no cases\n"); return 1; }

	// ONE shader: the prelude once, then every case's function renamed.
	const std::string fn = "float proc_density";
	// EVERY case's prelude, not case 0's. Taking the first one alone was a bug
	// this gate caught in itself: case 0 is a Math case, so the Vector Math
	// cases 246 onward referenced helpers that had been discarded and 288
	// compile errors came back. The preludes carry #ifndef guards, so
	// concatenating all of them is safe even where one contains another.
	std::string prelude;
	for (auto &k : cases) {
		size_t p = k.glsl.find(fn);
		if (p == std::string::npos) { fprintf(stderr, "FATAL: no proc_density in case %d\n", k.idx); return 1; }
		std::string pre = k.glsl.substr(0, p);
		if (!pre.empty() && prelude.find(pre) == std::string::npos) {
			prelude += pre;
		}
	}
	std::string body;
	for (auto &k : cases) {
		size_t p = k.glsl.find(fn);
		if (p == std::string::npos) { fprintf(stderr, "FATAL: no proc_density in case %d\n", k.idx); return 1; }
		std::string f = k.glsl.substr(p);
		char name[64];
		snprintf(name, sizeof(name), "case_%04d", k.idx);
		replace_all(f, "proc_density", name);
		body += f + "\n";
	}
	std::string calls;
	for (auto &k : cases) {
		char c[128];
		snprintf(c, sizeof(c), "    out_v.v[%d] = case_%04d(vec3(0.0));\n", k.idx, k.idx);
		calls += c;
	}
	std::string src =
			"#version 450\n"
			"layout(local_size_x = 1) in;\n"
			"layout(std430, binding = 0) buffer Out { float v[]; } out_v;\n" +
			prelude + body +
			"void main() {\n" + calls + "}\n";

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

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

	VkBuffer buf;
	VkDeviceMemory mem;
	void *mapped = nullptr;
	const VkDeviceSize bytes = sizeof(float) * cases.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);
	}

	VkDescriptorSetLayoutBinding bind{};
	bind.binding = 0;
	bind.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
	bind.descriptorCount = 1;
	bind.stageFlags = VK_SHADER_STAGE_COMPUTE_BIT;
	VkDescriptorSetLayoutCreateInfo dli{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO};
	dli.bindingCount = 1; dli.pBindings = &bind;
	VkDescriptorSetLayout dsl;
	VK(vkCreateDescriptorSetLayout(c.dev, &dli, nullptr, &dsl));

	VkPipelineLayoutCreateInfo pli{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO};
	pli.setLayoutCount = 1; pli.pSetLayouts = &dsl;
	VkPipelineLayout playout;
	VK(vkCreatePipelineLayout(c.dev, &pli, nullptr, &playout));

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

	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, 1};
	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 = &dsl;
	VkDescriptorSet dset;
	VK(vkAllocateDescriptorSets(c.dev, &dai, &dset));
	VkDescriptorBufferInfo dbi{buf, 0, VK_WHOLE_SIZE};
	VkWriteDescriptorSet w{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
	w.dstSet = dset; w.dstBinding = 0; w.descriptorCount = 1;
	w.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; w.pBufferInfo = &dbi;
	vkUpdateDescriptorSets(c.dev, 1, &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, 1, 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 last_op = -1;
	for (auto &k : cases) {
		const double g = got[k.idx];
		const double want = k.cpu;
		const double diff = fabs(g - want);
		const double scale = fabs(want) > 1.0 ? fabs(want) : 1.0;
		const bool nan_both = std::isnan(g) && std::isnan(want);
		const bool ok = nan_both || diff <= TOL_ABS || (diff / scale) <= TOL_REL;
		if (!ok) {
			bad++;
			printf("  FAIL %s op %-2d  a=%-12g b=%-12g c=%-12g  cpu=%-14g gpu=%-14g  d=%g\n",
					k.node.c_str(), k.op, k.a, k.b, k.c, want, g, diff);
		}
		last_op = k.op;
	}
	(void)last_op;
	printf("\n%d/%zu cases agree\n", (int)cases.size() - bad, cases.size());
	return bad ? 1 : 0;
}