feat(agc-driver): null V#s access nothing, as on hardware

A V# without records (all zero, say, for a slot the taken path never
uses) makes every access out of bounds on hardware: loads read zero or the
format's default, stores and atomics are dropped. The recompiler now
specializes such a buffer as empty, so every access through it is out of
bounds, and the driver binds a small per-device placeholder buffer to the
element instead of rejecting the V#. A V# with records at address zero
still throws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
STL1te 2026-09-29 11:19:48 +03:00
parent 21cb5676c7
commit 029dcdacac
8 changed files with 31 additions and 2 deletions

View File

@ -218,6 +218,8 @@ struct VulkanDevice::State {
std::map<std::uint64_t, std::shared_ptr<ComputePipelineObjects>> computePipelines;
std::unique_ptr<Graphics::GpuColorTransfer> colorTransfer;
std::shared_ptr<Graphics::BufferPool> bufferPool;
// Bound for null V#s (Context::emptyBuffer).
std::unique_ptr<Graphics::Buffer> emptyBuffer;
std::unique_ptr<Graphics::TextureCache> textureCache;
std::unique_ptr<Graphics::PipelineCache> pipelineCache;
std::unique_ptr<Graphics::DescriptorCache> descriptorCache;
@ -502,6 +504,7 @@ struct VulkanDevice::State {
resourceCache.Clear();
Graphics::ClearCachedTextures(device);
descriptorCache.reset();
emptyBuffer.reset();
samplerCache.reset();
textureCache.reset();
detiler.reset();
@ -869,6 +872,7 @@ VulkanDevice::VulkanDevice(const PresentationWindow* window) : state(std::make_u
poolInfo.queueFamilyIndex = family;
check(state->DeviceFunction<PFN_vkCreateCommandPool>("vkCreateCommandPool")(state->device, &poolInfo, nullptr, &state->pool), "vkCreateCommandPool");
state->bufferPool = std::make_shared<Graphics::BufferPool>(graphicsContext());
state->emptyBuffer = std::make_unique<Graphics::Buffer>(graphicsContext(), Graphics::EmptyBufferBytes, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT);
state->pipelineCache = std::make_unique<Graphics::PipelineCache>(graphicsContext());
state->detiler = std::make_unique<Graphics::TextureDetiler>(graphicsContext());
state->textureCache = std::make_unique<Graphics::TextureCache>(graphicsContext());
@ -2167,6 +2171,7 @@ Graphics::Context VulkanDevice::buildContext() const {
context.multiDrawIndirect = state->multiDrawIndirect;
context.drawIndirectCount = state->drawIndirectCount;
context.occlusionQueryPrecise = state->occlusionQueryPrecise;
context.emptyBuffer = state->emptyBuffer ? state->emptyBuffer->Handle() : VK_NULL_HANDLE;
context.copiedWriters = state->copiedWriters.get();
context.functions = state->functionsReady ? &state->deviceFunctions : nullptr;
context.descriptorIndexing = state->descriptorIndexing;

View File

@ -74,6 +74,9 @@ inline std::uint64_t& DeviceProcLookups() {
return count;
}
// The size of Context::emptyBuffer.
inline constexpr std::size_t EmptyBufferBytes = 16;
struct Context {
VkDevice device;
VkPhysicalDevice physical;
@ -118,6 +121,9 @@ struct Context {
bool drawIndirectCount = false;
// Occlusion queries count exact samples (occlusionQueryPrecise), not just any-passed.
bool occlusionQueryPrecise = false;
// A small storage buffer bound for a null V# (no records): the recompiler specializes such a
// buffer as empty (BufferResource::empty), so shaders never access it.
VkBuffer emptyBuffer = VK_NULL_HANDLE;
// The device's list of recorded dispatches whose copied written buffers await a CPU write-back
// (VulkanDevice's State::copiedWriters; the draw counterpart is DrawCopiedWriters): an indirect
// draw whose records one of them produces reads them on the CPU. Null in tests.

View File

@ -34,6 +34,10 @@ void ShaderResources::prepareAddressBindings(std::span<const CompiledShader> sha
VkDescriptorBufferInfo ShaderResources::descriptor(const Allocation& allocation) const {
if (allocation.guest) return guestMemory.Descriptor(allocation.address, allocation.size);
if (allocation.role == ShaderRecompiler::DescriptorRole::GuestBuffers) {
Require(context.emptyBuffer != VK_NULL_HANDLE, "no placeholder buffer for a null V#");
return {context.emptyBuffer, 0, allocation.size};
}
if (allocation.role == ShaderRecompiler::DescriptorRole::BdaPagetable || allocation.role == ShaderRecompiler::DescriptorRole::FaultBuffer) {
Require(bda != nullptr, "BDA descriptors have no memory owner");
return allocation.role == ShaderRecompiler::DescriptorRole::BdaPagetable ? bda->Table() : bda->Fault();

View File

@ -2015,8 +2015,12 @@ std::size_t ShaderResources::addGuestBuffer(std::span<const std::uint32_t> words
Require(descriptor.Type() == 0u, "buffer descriptor uses an unsupported type");
const auto address = descriptor.Base48();
const auto byteSize = descriptor.GetSize();
if (byteSize == 0) {
// A null V#: every access is out of bounds in the shader (BufferResource::empty).
allocations.push_back({0, EmptyBufferBytes, false, nullptr, ShaderRecompiler::DescriptorRole::GuestBuffers, false});
return allocations.size() - 1;
}
Require(address != 0, "null shader buffer descriptor address");
Require(byteSize != 0, "empty shader buffer descriptor");
Require(byteSize <= context.limits.maxStorageBufferRange, "shader buffer exceeds descriptor range limit");
Require(byteSize <= std::numeric_limits<std::size_t>::max(), "shader buffer size exceeds host address space");
const auto size = static_cast<std::size_t>(byteSize);

View File

@ -25,6 +25,9 @@ struct BufferResource {
bool atomic = false;
bool formatted = false;
bool scalar = false;
// Specialized: the V# has no records (a null V#), so every access is out of bounds: loads read
// zero (or the format's default), stores and atomics are dropped, as on hardware.
bool empty = false;
bool operator==(const BufferResource& other) const = default;
};

View File

@ -13,6 +13,7 @@ struct ResourceSpecialization {
std::uint32_t packedStride = 0;
IrBufferFormat descriptorFormat = IrBufferFormat::Invalid;
std::uint32_t descriptorSwizzle = ShaderImageIdentitySwizzle;
bool empty = false;
bool operator==(const Buffer& other) const;
};

View File

@ -507,6 +507,7 @@ void buildResourceSpecialization(const IrResourcePlan& plan, ResourceSnapshot& s
entry.packedStride = packedStride;
entry.descriptorFormat = buffer.formatted ? decoded.Format() : IrBufferFormat::Invalid;
entry.descriptorSwizzle = buffer.formatted ? decoded.DstSelXYZW() : DstSel(4, 5, 6, 7);
entry.empty = decoded.GetSize() == 0u;
result.buffers.push_back(entry);
}
@ -590,6 +591,7 @@ void ResourceMaterializer::Apply(IrProgram& program, const ResourceSpecializatio
buffers[i].packedStride = specialization.buffers[i].packedStride;
buffers[i].descriptorFormat = specialization.buffers[i].descriptorFormat;
buffers[i].descriptorSwizzle = specialization.buffers[i].descriptorSwizzle;
buffers[i].empty = specialization.buffers[i].empty;
}
auto images = resources.info.images;
@ -874,7 +876,7 @@ void ResourceMaterializer::CountBindlessRejection(BindlessRejection reason) {
}
bool ResourceSpecialization::Buffer::operator==(const Buffer& other) const {
return packedStride == other.packedStride && descriptorFormat == other.descriptorFormat && descriptorSwizzle == other.descriptorSwizzle;
return packedStride == other.packedStride && descriptorFormat == other.descriptorFormat && descriptorSwizzle == other.descriptorSwizzle && empty == other.empty;
}
bool ResourceSpecialization::Image::operator==(const Image& other) const {

View File

@ -111,6 +111,10 @@ MemoryResourceAccess PrepareStorageBufferResourceAccess(SpirvEmitterState& state
access.byteOffset = state.memoryByteOffsets.at(arrayIndex);
access.length = state.module.AllocateId();
state.module.AddFunction(spv::OpArrayLength, TypeU32(state), access.length, access.objectPointer, 0u);
// A null V# binds a placeholder the shader must not touch: nothing is in bounds.
if (mem.resource < state.program.Info().buffers.size() && state.program.Info().buffers[mem.resource].empty) {
access.length = ConstantU32(state, 0u);
}
return access;
}