perf(shader): memoize SPIR-V emission failures

The source entry remembers a resource plan that failed to build, but
not a failed emission: findOrCompileVariant kept only successful
variants, and the first attempt used up the prepared program, so every
request for a program whose emission throws ran PrepareResourceProgram
and the emitter again before the draw was skipped.

compileVariant now reports an exception from SpirvEmitter::Emit (and
from SPIRV-Tools validation when enabled), and the source entry keeps
it per code address, binding layout and resource specialization; the
next request for that variant rethrows it before the disk-cache lookup.
Descriptor population depends on the snapshot and an out-of-memory
error is transient, so neither is kept. Nothing reaches the disk cache,
and APS5_NO_FAILURE_MEMO=1 turns the memo off as for plan failures.
This commit is contained in:
Celegans12 2026-10-07 02:49:35 +02:00
parent ee391a5614
commit bcdf19ee4e
2 changed files with 131 additions and 8 deletions

View File

@ -1,5 +1,6 @@
#include "ShaderDiskCache.hpp"
#include "ShaderCacheDirectory.hpp"
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdio>
@ -7,6 +8,7 @@
#include <cstring>
#include <filesystem>
#include <functional>
#include <future>
#include <iostream>
#include <random>
#include <stdexcept>
@ -436,6 +438,96 @@ void verifyAcrossProcesses(const char* self) {
require(std::system(command.c_str()) == 0, "the loading process failed");
}
struct ClockRequest {
std::vector<std::uint32_t> code{0xf4900100u, 0x00000000u, 0xbf8cc07fu, 0x7e020204u, 0xe0700000u, 0x80000100u, 0xf800180fu, 0x01010101u, 0xbf810000u};
std::array<std::uint32_t, 4> userData{0x10000000u, 0x00000000u, 0x40u, 0x00027facu};
std::array<std::uint32_t, 1> capabilities{1u};
RecompileRequest request{};
explicit ClockRequest(std::uint32_t stride = 0) {
userData[1] = stride << 16u;
ShaderPixelStageInfo pixel{};
pixel.inputAddr = PixelInputBit(PixelInput::PerspectiveCenter);
pixel.hasPerspectiveCenterVgpr = true;
pixel.targetOutputMode[0] = 9u;
pixel.targetExportMapping.fill(0xe4u);
request.shader = {ShaderStage::Fragment, 0x30000u, code, 0, {}};
request.context.waveSize = 64;
request.context.userDataBaseRegister = 0;
request.context.userData = userData;
request.context.pixel = pixel;
request.target.vulkanVersion = 0x00401000u;
request.target.spirvVersion = 0x00010300u;
request.target.subgroupSize = 32;
request.target.supportedCapabilities = capabilities;
request.layout.pushConstantSizeBytes = 128;
}
};
std::string emissionFailure(const RecompileRequest& request) {
try {
static_cast<void>(Recompile(request));
} catch (const std::runtime_error& error) {
const std::string message = error.what();
require(message.find("reads the shader clock, which needs the device's VK_KHR_shader_clock") != std::string::npos, "unexpected failure: " + message.substr(0, message.find("RecompileRequest:")));
return message;
}
throw std::runtime_error("a shader clock read compiled without VK_KHR_shader_clock");
}
std::uint64_t diskMisses() {
return ShaderDiskCache::Totals().misses;
}
void verifyEmissionFailureMemo() {
const auto before = ShaderDiskCache::Totals();
ClockRequest clock;
const auto first = emissionFailure(clock.request);
require(diskMisses() == before.misses + 1, "the first compile was not looked up");
require(emissionFailure(clock.request) == first, "the second compile failed with another message");
require(diskMisses() == before.misses + 1, "the second compile looked up and emitted the program again");
ClockRequest layout;
layout.request.layout = {0, 0, 16, 112};
static_cast<void>(emissionFailure(layout.request));
require(diskMisses() == before.misses + 2, "another binding layout reused the failure");
ClockRequest stride(16);
static_cast<void>(emissionFailure(stride.request));
require(diskMisses() == before.misses + 3, "another buffer specialization reused the failure");
ClockRequest relocated;
relocated.request.shader.codeAddress += 0x1000u;
static_cast<void>(emissionFailure(relocated.request));
require(diskMisses() == before.misses + 4, "a relocated copy reused the failure");
require(emissionFailure(clock.request) == first && diskMisses() == before.misses + 4, "the other requests replaced the failure");
ClockRequest concurrent;
concurrent.request.layout.pushConstantSizeBytes = 64;
std::array<std::future<std::string>, 4> failures;
for (auto& future : failures) future = std::async(std::launch::async, [&concurrent] { return emissionFailure(concurrent.request); });
std::vector<std::string> messages;
for (auto& future : failures) messages.push_back(future.get());
require(std::all_of(messages.begin(), messages.end(), [&](const std::string& message) { return message == messages.front(); }), "concurrent requests failed with different messages");
require(diskMisses() == before.misses + 5, "concurrent requests emitted the program " + std::to_string(diskMisses() - before.misses - 4) + " times");
ShaderDiskCache::Flush();
require(ShaderDiskCache::Totals().writes == before.writes, "an emission failure was stored in the disk cache");
}
int runWithoutFailureMemo() {
ClockRequest clock;
const auto first = emissionFailure(clock.request);
require(emissionFailure(clock.request) == first, "the second compile failed with another message");
require(diskMisses() == 2, "APS5_NO_FAILURE_MEMO=1 did not emit the program again (" + std::to_string(diskMisses()) + " lookups)");
std::cout << "APS5_NO_FAILURE_MEMO=1 emitted the failing program twice\n";
return 0;
}
void verifyFailureMemoSwitch(const char* self) {
setEnvironment("APS5_NO_FAILURE_MEMO", "1");
const std::string command = "\"" + std::string(self) + "\" --no-failure-memo";
require(std::system(command.c_str()) == 0, "the process with APS5_NO_FAILURE_MEMO=1 failed");
}
void verifyDefaultDirectory(const char* self) {
setEnvironment("ANYPS5_SHADER_CACHE_DIR", "");
setEnvironment("ANYPS5_NO_SHADER_CACHE", "1");
@ -452,6 +544,7 @@ void verifyDefaultDirectory(const char* self) {
int main(int argc, char** argv) {
try {
if (argc == 2 && std::string_view(argv[1]) == "--load") return runLoadingProcess();
if (argc == 2 && std::string_view(argv[1]) == "--no-failure-memo") return runWithoutFailureMemo();
const auto directory = std::filesystem::temp_directory_path() / ("aps5-shader-disk-cache-test-" + std::to_string(std::random_device{}()));
std::filesystem::remove_all(directory);
verifyDefaultDirectory(argv[0]);
@ -462,6 +555,8 @@ int main(int argc, char** argv) {
verifyKeySensitivity();
verifyStore();
verifyAcrossProcesses(argv[0]);
verifyEmissionFailureMemo();
verifyFailureMemoSwitch(argv[0]);
std::error_code error;
std::filesystem::remove_all(directory, error);
std::cout << "shader disk cache tests passed\n";

View File

@ -9,6 +9,7 @@
#include "ShaderDiskCache.hpp"
#include <list>
#include <mutex>
#include <new>
#include <shared_mutex>
#include <unordered_map>
#include "ControlFlow/include/ControlFlow/GraphBuilder.hpp"
@ -183,6 +184,13 @@ struct ResultMemoEntry {
std::shared_ptr<const RecompileResult> result;
};
struct EmissionFailure {
std::uint64_t codeAddress;
BindingLayout layout;
ResourceSpecialization specialization;
std::exception_ptr failure;
};
struct SourceEntry {
std::mutex mutex;
// The code the entry was built for: the key carries only a hash of it, so a candidate entry is
@ -196,6 +204,7 @@ struct SourceEntry {
std::exception_ptr planFailure;
std::unique_ptr<IrProgram> program;
std::vector<std::shared_ptr<const CompiledVariant>> variants;
std::vector<EmissionFailure> emissionFailures;
// The result memo, most recently used first, at most ResultMemoEntries (under mutex).
std::list<ResultMemoEntry> memo;
std::unordered_map<std::uint64_t, std::list<ResultMemoEntry>::iterator> memoIndex;
@ -225,6 +234,11 @@ struct SourceKeyHash {
}
};
bool FailureMemo() {
static const bool memo = std::getenv("APS5_NO_FAILURE_MEMO") == nullptr;
return memo;
}
std::shared_ptr<SourceEntry> getSource(const RecompileRequest& request) {
static std::shared_mutex mutex;
// Entries whose code hashes alike share a bucket; the code comparison picks the right one.
@ -266,14 +280,13 @@ std::shared_ptr<SourceEntry> getSource(const RecompileRequest& request) {
{
std::lock_guard lock(source->mutex);
if (source->plan == nullptr) {
static const bool memoFailures = std::getenv("APS5_NO_FAILURE_MEMO") == nullptr;
if (memoFailures && source->planFailure) std::rethrow_exception(source->planFailure);
if (FailureMemo() && source->planFailure) std::rethrow_exception(source->planFailure);
try {
ResourceProgram resource(request);
source->plan = std::move(resource.plan);
source->program = std::move(resource.program);
} catch (...) {
if (memoFailures) source->planFailure = std::current_exception();
if (FailureMemo()) source->planFailure = std::current_exception();
throw;
}
}
@ -290,7 +303,7 @@ std::uint64_t nextVariantId() {
return variants.fetch_add(1, std::memory_order_relaxed) + 1;
}
CompiledVariant compileVariant(const RecompileRequest& request, IrProgram program, const ResourceSnapshot& resourceSnapshot, const ResourceSpecialization& resourceSpecialization) {
CompiledVariant compileVariant(const RecompileRequest& request, IrProgram program, const ResourceSnapshot& resourceSnapshot, const ResourceSpecialization& resourceSpecialization, std::exception_ptr* emissionFailure = nullptr) {
const auto inputInfo = RequestInputInfo(request);
constexpr DeadCodeEliminator deadCodeEliminator;
constexpr ResourceMaterializer resourceMaterializer;
@ -320,11 +333,17 @@ CompiledVariant compileVariant(const RecompileRequest& request, IrProgram progra
constexpr SpirvEmitter spirvEmitter;
RecompileResult result;
result.variantId = nextVariantId();
result.spirv = spirvEmitter.Emit(program, inputInfo, bindings, targetOptions);
try {
result.spirv = spirvEmitter.Emit(program, inputInfo, bindings, targetOptions);
#if ANYPS5_ENABLE_SPIRV_TOOLS
result.spirv = ValidateAndOptimizeSpirv(result.spirv, request.target.vulkanVersion, request.target.spirvVersion, request.target.nonConstantImageOffsets);
result.spirv = ValidateAndOptimizeSpirv(result.spirv, request.target.vulkanVersion, request.target.spirvVersion, request.target.nonConstantImageOffsets);
#endif
} catch (const std::bad_alloc&) {
throw;
} catch (...) {
if (emissionFailure != nullptr) *emissionFailure = std::current_exception();
throw;
}
result.bdaAbiVersion = program.Info().usesDma ? request.target.bdaAbiVersion : 0u;
result.memoryOffsetDword = bindings.layout.memoryOffsetDword;
@ -383,6 +402,9 @@ std::shared_ptr<const CompiledVariant> findOrCompileVariant(SourceEntry& source,
return candidate;
}
}
for (const auto& failure : source.emissionFailures) {
if (failure.codeAddress == request.shader.codeAddress && sameLayout(failure.layout, request.layout) && failure.specialization == specialization) std::rethrow_exception(failure.failure);
}
cacheHit = false;
const bool disk = ShaderDiskCache::Enabled() && !DebugProbeActive();
std::vector<std::byte> diskKey;
@ -400,7 +422,13 @@ std::shared_ptr<const CompiledVariant> findOrCompileVariant(SourceEntry& source,
if (variant == nullptr) {
auto program = source.program != nullptr ? std::move(*source.program) : PrepareResourceProgram(request);
source.program.reset();
variant = std::make_shared<const CompiledVariant>(compileVariant(request, std::move(program), snapshot, specialization));
std::exception_ptr emissionFailure;
try {
variant = std::make_shared<const CompiledVariant>(compileVariant(request, std::move(program), snapshot, specialization, &emissionFailure));
} catch (...) {
if (emissionFailure != nullptr && FailureMemo()) source.emissionFailures.push_back({request.shader.codeAddress, request.layout, specialization, emissionFailure});
throw;
}
if (disk) ShaderDiskCache::Store(std::move(diskKey), variant);
}
source.program.reset();