diff --git a/.gitignore b/.gitignore index 4e9c87a..b85ab43 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ build/ -bin/ \ No newline at end of file +bin/ +pipeline_cache.bin diff --git a/implementations/Crafter.Graphics-ComputeShader.cpp b/implementations/Crafter.Graphics-ComputeShader.cpp index 3edc369..6df3ea2 100644 --- a/implementations/Crafter.Graphics-ComputeShader.cpp +++ b/implementations/Crafter.Graphics-ComputeShader.cpp @@ -82,7 +82,7 @@ void ComputeShader::Load(const std::filesystem::path& spvPath) { .layout = VK_NULL_HANDLE, }; Device::CheckVkResult(vkCreateComputePipelines( - Device::device, VK_NULL_HANDLE, 1, &info, nullptr, &pipeline)); + Device::device, Device::pipelineCache, 1, &info, nullptr, &pipeline)); } void ComputeShader::Dispatch(VkCommandBuffer cmd, diff --git a/implementations/Crafter.Graphics-Device.cpp b/implementations/Crafter.Graphics-Device.cpp index 25b18fb..bcd668c 100644 --- a/implementations/Crafter.Graphics-Device.cpp +++ b/implementations/Crafter.Graphics-Device.cpp @@ -133,8 +133,99 @@ void Device::CheckVkResult(VkResult result) { } throw std::runtime_error(string_VkResult(result)); - } -} + } +} + +bool Device::PipelineCacheDataCompatible(std::span data) { + // VkPipelineCacheHeaderVersionOne is a fixed 32-byte little-endian header + // (Vulkan spec 16.5.2): u32 headerSize, u32 headerVersion, u32 vendorID, + // u32 deviceID, then VK_UUID_SIZE bytes of pipelineCacheUUID. Parse the + // fields by offset rather than reinterpret_cast'ing the struct so the + // check is independent of the C struct's alignment/padding. + constexpr std::size_t headerBytes = 16 + VK_UUID_SIZE; + if (data.size() < headerBytes) { + return false; + } + + auto readU32 = [&](std::size_t offset) { + std::uint32_t value; + std::memcpy(&value, data.data() + offset, sizeof(value)); + return value; + }; + + const std::uint32_t headerSize = readU32(0); + const std::uint32_t headerVersion = readU32(4); + const std::uint32_t vendorID = readU32(8); + const std::uint32_t deviceID = readU32(12); + + if (headerSize < headerBytes) { + return false; + } + if (headerVersion != VK_PIPELINE_CACHE_HEADER_VERSION_ONE) { + return false; + } + if (vendorID != deviceProperties.vendorID || deviceID != deviceProperties.deviceID) { + return false; + } + return std::memcmp(data.data() + 16, deviceProperties.pipelineCacheUUID, VK_UUID_SIZE) == 0; +} + +void Device::LoadPipelineCache() { + std::vector initialData; + + std::error_code ec; + if (std::filesystem::exists(pipelineCachePath, ec)) { + std::ifstream file(pipelineCachePath, std::ios::binary | std::ios::ate); + if (file) { + const std::streamoff size = file.tellg(); + if (size > 0) { + initialData.resize(static_cast(size)); + file.seekg(0); + file.read(reinterpret_cast(initialData.data()), size); + if (!file) { + initialData.clear(); // partial/short read — start cold + } + } + } + } + + // Drop a blob the current driver/device wouldn't accept: a header from a + // different GPU (or a corrupt/empty file) would at best be ignored and at + // worst rejected. Starting empty just costs a one-time cold compile. + if (!PipelineCacheDataCompatible(initialData)) { + initialData.clear(); + } + + VkPipelineCacheCreateInfo info { + .sType = VK_STRUCTURE_TYPE_PIPELINE_CACHE_CREATE_INFO, + .initialDataSize = initialData.size(), + .pInitialData = initialData.empty() ? nullptr : initialData.data(), + }; + CheckVkResult(vkCreatePipelineCache(device, &info, nullptr, &pipelineCache)); +} + +void Device::SavePipelineCache() { + if (pipelineCache == VK_NULL_HANDLE) { + return; + } + + // Two-call idiom: query size, then fetch. Best-effort — a failure here only + // forfeits the next run's warm start, so it must never throw out of an + // atexit handler. + std::size_t size = 0; + if (vkGetPipelineCacheData(device, pipelineCache, &size, nullptr) != VK_SUCCESS || size == 0) { + return; + } + std::vector data(size); + if (vkGetPipelineCacheData(device, pipelineCache, &size, data.data()) != VK_SUCCESS) { + return; + } + + std::ofstream file(pipelineCachePath, std::ios::binary | std::ios::trunc); + if (file) { + file.write(reinterpret_cast(data.data()), static_cast(size)); + } +} VkBool32 onError(VkDebugUtilsMessageSeverityFlagBitsEXT severity, VkDebugUtilsMessageTypeFlagsEXT type, const VkDebugUtilsMessengerCallbackDataEXT* callbackData, void* userData) { @@ -603,6 +694,9 @@ void Device::Initialize() { .pNext = &rayTracingProperties }; vkGetPhysicalDeviceProperties2(physDevice, &properties2); + // Keep the core properties around for the pipeline-cache identity check + // (vendorID / deviceID / pipelineCacheUUID). + deviceProperties = properties2.properties; // NVIDIA's brand-new VK_EXT_descriptor_heap acceleration-structure read // path faults (see #7); enable the SPIR-V rewrite workaround there. Other @@ -810,6 +904,14 @@ void Device::Initialize() { memoryDecompressionSupported = false; } } + + // Create the shared pipeline cache (seeded from disk when a compatible + // blob exists) and arrange for it to be written back at process exit. The + // device handle is a never-destroyed static, so it is still valid when the + // atexit handler runs. Registered once — Initialize is the single device + // bring-up. + LoadPipelineCache(); + std::atexit(SavePipelineCache); } std::uint32_t Device::GetMemoryType(uint32_t typeBits, VkMemoryPropertyFlags required, VkMemoryPropertyFlags preferred) { diff --git a/interfaces/Crafter.Graphics-Device.cppm b/interfaces/Crafter.Graphics-Device.cppm index 11853d2..a5b4a68 100644 --- a/interfaces/Crafter.Graphics-Device.cppm +++ b/interfaces/Crafter.Graphics-Device.cppm @@ -161,6 +161,27 @@ export namespace Crafter { inline static VkPhysicalDeviceMemoryProperties memoryProperties; + // Core physical-device properties, captured once at Initialize from the + // VkPhysicalDeviceProperties2 query. Kept because the pipeline-cache + // persistence path needs vendorID / deviceID / pipelineCacheUUID to + // decide whether an on-disk blob was written by this exact device. + inline static VkPhysicalDeviceProperties deviceProperties = {}; + + // One process-wide pipeline cache fed to every vkCreate*Pipelines call + // (compute UI/user shaders + RT pipelines). Pipeline compilation is a + // one-time cold-start cost, not a per-frame one; a single shared cache + // lets the driver reuse compiled shader binaries across the several + // pipelines built at startup and — once persisted to disk (see + // LoadPipelineCache / SavePipelineCache) — across runs. VK_NULL_HANDLE + // until LoadPipelineCache runs; passing VK_NULL_HANDLE to a create call + // is valid and simply means "no cache", so the create sites need no + // null check. + inline static VkPipelineCache pipelineCache = VK_NULL_HANDLE; + // Where the serialized cache lives. Relative to the working directory, + // matching the gpu_crash_dump-* convention. Set before Initialize to + // relocate it. + inline static std::filesystem::path pipelineCachePath = "pipeline_cache.bin"; + inline static VkPhysicalDeviceDescriptorHeapPropertiesEXT descriptorHeapProperties = { .sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DESCRIPTOR_HEAP_PROPERTIES_EXT }; @@ -199,6 +220,23 @@ export namespace Crafter { // ComputeShader read the offset off the pipeline they record. static void CheckVkResult(VkResult result); + + // ─── Pipeline cache persistence (issue #69) ───────────────────── + // LoadPipelineCache creates `pipelineCache`, seeding it from + // pipelineCachePath when the file exists *and* its header matches this + // device (PipelineCacheDataCompatible) — a stale or foreign blob is + // discarded so the driver never rejects it. Called once from + // Initialize. SavePipelineCache writes the driver's current cache blob + // back out; Initialize registers it with std::atexit so the cache is + // serialized at process shutdown without any explicit teardown call. + static void LoadPipelineCache(); + static void SavePipelineCache(); + // True when `data` is a VkPipelineCache blob whose header was written by + // a device with the same vendorID / deviceID / pipelineCacheUUID as + // `deviceProperties`. Pure logic over the standard 32-byte cache header, + // so it is driven directly by the PipelineCacheValidation test with no + // GPU device. A blob too short to hold the header is incompatible. + static bool PipelineCacheDataCompatible(std::span data); // Selects a memory type index from typeBits that satisfies `required`. // When `preferred` bits are also given, a type satisfying both is // chosen first; if none exists we fall back to required-only rather diff --git a/interfaces/Crafter.Graphics-PipelineRTVulkan.cppm b/interfaces/Crafter.Graphics-PipelineRTVulkan.cppm index c0374ec..9da555e 100644 --- a/interfaces/Crafter.Graphics-PipelineRTVulkan.cppm +++ b/interfaces/Crafter.Graphics-PipelineRTVulkan.cppm @@ -82,7 +82,7 @@ export namespace Crafter { .layout = VK_NULL_HANDLE }; - Device::CheckVkResult(Device::vkCreateRayTracingPipelinesKHR(Device::device, {}, {}, 1, &rtPipelineInfo, nullptr, &pipeline)); + Device::CheckVkResult(Device::vkCreateRayTracingPipelinesKHR(Device::device, {}, Device::pipelineCache, 1, &rtPipelineInfo, nullptr, &pipeline)); std::size_t dataSize = Device::rayTracingProperties.shaderGroupHandleSize * rtPipelineInfo.groupCount; shaderHandles.resize(dataSize); diff --git a/project.cpp b/project.cpp index d928210..951aa06 100644 --- a/project.cpp +++ b/project.cpp @@ -616,6 +616,35 @@ extern "C" Configuration CrafterBuildProject(std::span a hitImpls.emplace_back("tests/InputFieldHitTest/main"); hc.GetInterfacesAndImplementations(ifaces, hitImpls); cfg.tests.push_back(std::move(hitTest)); + + // Issue #69: the engine feeds one shared Device::pipelineCache to every + // vkCreate*Pipelines call and persists it across runs, discarding an + // on-disk blob whose header doesn't match the current GPU. That gate — + // Device::PipelineCacheDataCompatible — is pure logic over the standard + // VkPipelineCache header and Device::deviceProperties, so this test + // stamps synthetic device identities + headers and drives it directly, + // no GPU device needed at runtime. + Test cacheTest; + Configuration& cc = cacheTest.config; + cc.path = cfg.path; + cc.name = "PipelineCacheValidation"; + cc.outputName = "PipelineCacheValidation"; + cc.type = ConfigurationType::Executable; + cc.target = cfg.target; + cc.march = cfg.march; + cc.mtune = cfg.mtune; + cc.debug = cfg.debug; + cc.sysroot = cfg.sysroot; + cc.dependencies = cfg.dependencies; + cc.externalDependencies = cfg.externalDependencies; + cc.compileFlags = cfg.compileFlags; + cc.linkFlags = cfg.linkFlags; + cc.defines = cfg.defines; + cc.cFiles = cfg.cFiles; + std::vector cacheImpls(impls.begin(), impls.end()); + cacheImpls.emplace_back("tests/PipelineCacheValidation/main"); + cc.GetInterfacesAndImplementations(ifaces, cacheImpls); + cfg.tests.push_back(std::move(cacheTest)); } return cfg; diff --git a/tests/PipelineCacheValidation/main.cpp b/tests/PipelineCacheValidation/main.cpp new file mode 100644 index 0000000..a55b693 --- /dev/null +++ b/tests/PipelineCacheValidation/main.cpp @@ -0,0 +1,157 @@ +/* +Crafter®.Graphics +Copyright (C) 2026 Catcrafts® +catcrafts.net + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License version 3.0 as published by the Free Software Foundation; + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +// Regression test for issue #69: the engine feeds a shared Device::pipelineCache +// to every vkCreate*Pipelines call and persists it across runs. A blob written +// by a different GPU (or a corrupt/short file) must be rejected before it is +// handed to vkCreatePipelineCache, otherwise the driver ignores or rejects it. +// Device::PipelineCacheDataCompatible is that gate: pure logic over the standard +// 32-byte VkPipelineCacheHeaderVersionOne header (headerSize, headerVersion, +// vendorID, deviceID, pipelineCacheUUID) compared against Device::deviceProperties. +// It needs no GPU, so this test stamps synthetic device identities and headers +// and drives it directly, mirroring MemoryTypeFallback / UploadStrategy. + +#include +#include +#include "vulkan/vulkan.h" + +import Crafter.Graphics; +import std; +using namespace Crafter; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +// Stamp the device identity the validator compares against. +void SetDevice(std::uint32_t vendorID, std::uint32_t deviceID, + const std::array& uuid) { + Device::deviceProperties = {}; + Device::deviceProperties.vendorID = vendorID; + Device::deviceProperties.deviceID = deviceID; + std::memcpy(Device::deviceProperties.pipelineCacheUUID, uuid.data(), VK_UUID_SIZE); +} + +// Build a 32-byte VkPipelineCacheHeaderVersionOne blob, optionally with extra +// trailing payload bytes (the real cache body). Fields are written little-endian +// at fixed offsets, matching how the driver lays the header out on disk. +std::vector MakeHeader(std::uint32_t headerSize, + std::uint32_t headerVersion, + std::uint32_t vendorID, + std::uint32_t deviceID, + const std::array& uuid, + std::size_t trailing = 0) { + std::vector blob(16 + VK_UUID_SIZE + trailing, std::byte{0xAB}); + auto put = [&](std::size_t off, std::uint32_t v) { + std::memcpy(blob.data() + off, &v, sizeof(v)); + }; + put(0, headerSize); + put(4, headerVersion); + put(8, vendorID); + put(12, deviceID); + std::memcpy(blob.data() + 16, uuid.data(), VK_UUID_SIZE); + return blob; +} + +constexpr std::uint32_t kVendor = 0x10DE; // NVIDIA +constexpr std::uint32_t kDevice = 0x2204; // some GPU device id +constexpr std::array kUuid = { + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10 +}; +constexpr std::uint32_t kV1 = VK_PIPELINE_CACHE_HEADER_VERSION_ONE; +constexpr std::uint32_t kHeaderSize = 16 + VK_UUID_SIZE; // 32 + +} // namespace + +int main() { + SetDevice(kVendor, kDevice, kUuid); + + // --- the happy path: a header written by this exact device -------------- + { + auto blob = MakeHeader(kHeaderSize, kV1, kVendor, kDevice, kUuid); + Check(Device::PipelineCacheDataCompatible(blob), + "matching vendor/device/UUID header is accepted"); + + auto withBody = MakeHeader(kHeaderSize, kV1, kVendor, kDevice, kUuid, /*trailing*/ 4096); + Check(Device::PipelineCacheDataCompatible(withBody), + "matching header followed by a cache body is accepted"); + } + + // --- foreign / stale blobs must be rejected ----------------------------- + { + auto otherVendor = MakeHeader(kHeaderSize, kV1, 0x1002 /*AMD*/, kDevice, kUuid); + Check(!Device::PipelineCacheDataCompatible(otherVendor), + "different vendorID is rejected"); + + auto otherDevice = MakeHeader(kHeaderSize, kV1, kVendor, 0x9999, kUuid); + Check(!Device::PipelineCacheDataCompatible(otherDevice), + "different deviceID is rejected"); + + std::array otherUuid = kUuid; + otherUuid[15] ^= 0xFF; // a driver update bumps the UUID + auto staleUuid = MakeHeader(kHeaderSize, kV1, kVendor, kDevice, otherUuid); + Check(!Device::PipelineCacheDataCompatible(staleUuid), + "different pipelineCacheUUID (e.g. driver update) is rejected"); + } + + // --- malformed headers -------------------------------------------------- + { + auto badVersion = MakeHeader(kHeaderSize, 0xDEAD, kVendor, kDevice, kUuid); + Check(!Device::PipelineCacheDataCompatible(badVersion), + "unknown headerVersion is rejected"); + + auto smallHeaderSize = MakeHeader(8, kV1, kVendor, kDevice, kUuid); + Check(!Device::PipelineCacheDataCompatible(smallHeaderSize), + "headerSize smaller than the 32-byte header is rejected"); + + Check(!Device::PipelineCacheDataCompatible({}), + "empty blob (no file / cold start) is rejected"); + + std::vector truncated(20, std::byte{0}); + Check(!Device::PipelineCacheDataCompatible(truncated), + "blob too short to hold the header is rejected"); + } + + // --- identity follows the active device --------------------------------- + { + // Re-stamp as a different device; a blob valid for the old one is now + // foreign. Guards against the validator caching identity anywhere but + // Device::deviceProperties. + auto blob = MakeHeader(kHeaderSize, kV1, kVendor, kDevice, kUuid); + SetDevice(0x8086 /*Intel*/, 0x1234, kUuid); + Check(!Device::PipelineCacheDataCompatible(blob), + "a blob from the previous device is rejected after the device changes"); + SetDevice(kVendor, kDevice, kUuid); + Check(Device::PipelineCacheDataCompatible(blob), + "...and accepted again once the matching device is restored"); + } + + if (failures != 0) { + std::println("{} check(s) failed", failures); + return EXIT_FAILURE; + } + std::println("all checks passed"); + return EXIT_SUCCESS; +}