perf(agc): stage read and written buffer elements a dispatch sweeps

A written V# element bound in place in a host import makes every load a
PCIe round trip. A particle update that loads and stores a 10 MiB pool
(2048x64 threads over 131072 records of 80 bytes), above the 2 MiB written
window, took about 4.8 ms of GPU per frame.

An element the shader also reads (DescriptorBinding::bufferRead), written by
a direct dispatch whose threads times the V# stride cover at least half of
it, now takes device-local staging up to 16 MiB: that dispatch drops to
about 0.06 ms plus 0.9 ms of bulk copies. The coverage test keeps a kernel
that updates about 12k records of a 16 MiB pool in place, where staging all
of it would cost 1.3 ms of copies for a 0.08 ms kernel. Write-only
elements, indirect dispatches and raw (stride 0) buffers keep the 2 MiB
window.
This commit is contained in:
Dean Galvin committed 2026-10-10 23:47:30 +13:00
1 parent 2fb7a77acc
commit 2688b76463
8 files changed
+103 -18

No files matched your search

@@ -219,7 +219,7 @@ public:
// records. Null when nothing is prepared: the resource cache may serve the dispatch (its
// Revalidate stays under the mutex), or APS5_LOCKED_BUILD=1 keeps the whole build under it as
// before. `shader` and `snapshots` must outlive the dispatch.
std::shared_ptr<PreparedDispatch> PrepareDispatch(const ShaderRecompiler::RecompileResult& shader, std::span<const Graphics::GuestMemorySnapshot> snapshots);
std::shared_ptr<PreparedDispatch> PrepareDispatch(const ShaderRecompiler::RecompileResult& shader, std::span<const Graphics::GuestMemorySnapshot> snapshots, std::uint64_t dispatchThreads = 0);
// APS5_PROFILE_DRAW: the parts of a PrepareDispatch in milliseconds, in the order key, find,
// precollect, presync, stage A (the driver's 'prepare:' rows).
static std::span<const double, 5> PreparePhaseMs(const PreparedDispatch& prepared);
@@ -321,6 +321,7 @@ void Driver::dispatch(QueueState& queue, std::span<const std::uint32_t> packet,
groups[axis] = (groups[axis] + threads - 1) / threads;
}
}
const std::uint64_t dispatchThreads = indirectArguments != 0 ? 0 : std::uint64_t{groups[0]} * groups[1] * groups[2] * compute.numThreads[0] * compute.numThreads[1] * compute.numThreads[2];
static const bool traceIo = std::getenv("APS5_TRACE_DISPATCH_IO") != nullptr;
if (traceIo) {
@@ -363,7 +364,7 @@ void Driver::dispatch(QueueState& queue, std::span<const std::uint32_t> packet,
std::shared_ptr<PreparedDispatch> prepared;
if (recipeHit == nullptr || VulkanDevice::VerifyRecipes()) {
try {
prepared = localDevice->PrepareDispatch(compiled, snapshots);
prepared = localDevice->PrepareDispatch(compiled, snapshots, dispatchThreads);
} catch (const std::exception& error) {
if (rethrow(error)) return;
}
@@ -2815,6 +2815,7 @@ struct PreparedDispatch {
// APS5_PROFILE_DRAW: stage A's time, added to the [dispatch] phase totals by the dispatch, and
// the prepare's parts for the driver's 'prepare:' rows (PreparePhase order).
double prepareMs = 0;
std::uint64_t dispatchThreads = 0;
enum PreparePhase : std::size_t { PrepareKey, PrepareFind, PreparePrecollect, PreparePresync, PrepareStageA, PreparePhaseCount };
std::array<double, PreparePhaseCount> phaseMs{};
// The stage-A pre-sync (see PrepareDispatch): the newest recorder serial waited for without the
@@ -3258,7 +3259,7 @@ std::uint64_t VulkanDevice::presync(std::span<const std::pair<std::uint64_t, std
return serial;
}
std::shared_ptr<PreparedDispatch> VulkanDevice::PrepareDispatch(const ShaderRecompiler::RecompileResult& shader, std::span<const Graphics::GuestMemorySnapshot> snapshots) {
std::shared_ptr<PreparedDispatch> VulkanDevice::PrepareDispatch(const ShaderRecompiler::RecompileResult& shader, std::span<const Graphics::GuestMemorySnapshot> snapshots, std::uint64_t dispatchThreads) {
ShaderRecompiler::RuntimeAbi::RequireVersion(shader.runtimeAbiVersion);
// APS5_LOCKED_BUILD=1: the whole build under the mutex, as before the split.
static const bool lockedBuild = std::getenv("APS5_LOCKED_BUILD") != nullptr;
@@ -3268,6 +3269,7 @@ std::shared_ptr<PreparedDispatch> VulkanDevice::PrepareDispatch(const ShaderReco
const Graphics::CompiledShader compute{ShaderRecompiler::ShaderStage::Compute, &shader, 0};
const auto context = graphicsContext();
auto prepared = std::make_shared<PreparedDispatch>();
prepared->dispatchThreads = dispatchThreads;
// A cached build (the map locks itself) is revalidated under the mutex by the dispatch, which
// looks it up again by the key made here; the object stays local, only its surfaces matter
// below. Anything inserted between here and the dispatch is simply replaced by this build.
@@ -3287,7 +3289,7 @@ std::shared_ptr<PreparedDispatch> VulkanDevice::PrepareDispatch(const ShaderReco
phase(PreparedDispatch::PrepareFind);
}
if (cached == nullptr) {
prepared->resources = std::make_shared<Graphics::ShaderResources>(context, compute, snapshots, true);
prepared->resources = std::make_shared<Graphics::ShaderResources>(context, compute, snapshots, true, dispatchThreads);
phase(PreparedDispatch::PrepareStageA);
}
if (cached != nullptr && CachedPrecollect()) {
@@ -3698,7 +3700,7 @@ VulkanDevice::IndirectOutcome VulkanDevice::dispatch(const ShaderRecompiler::Rec
}
if (resources == nullptr) {
const auto buildStart = profile ? std::chrono::steady_clock::now() : std::chrono::steady_clock::time_point{};
resources = std::make_shared<Graphics::ShaderResources>(context, shaders[0], snapshots);
resources = std::make_shared<Graphics::ShaderResources>(context, shaders[0], snapshots, false, prepared != nullptr ? prepared->dispatchThreads : 0);
if (profile) timer.add(PhaseResourcesFullBuild, std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - buildStart).count());
if (cacheable) {
++d.cacheMisses;
@@ -183,7 +183,7 @@ public:
// (DescriptorBinding::bufferWritten): the CPU never has to wait for it. `atomic` marks an
// element the shader updates atomically (DescriptorBinding::bufferAtomic), staged in device
// memory whatever its size (see AllowDeviceStaging).
void AddWritable(std::uint64_t address, std::size_t bytes, bool atomic = false);
void AddWritable(std::uint64_t address, std::size_t bytes, bool atomic = false, bool swept = false);
void AddReadable(std::uint64_t address, std::size_t bytes);
// Device-local staging of written and atomic elements inside host imports (see
// GuestBufferMemory.cpp): allowed only for a build whose every use records its work and then
@@ -294,6 +294,7 @@ private:
bool copiedBack = false;
// An element the shader updates atomically lies inside (AddWritable's `atomic`).
bool atomic = false;
bool swept = false;
// The gpuCopy buffer is a device-local staging shadow (see stagingEligible): no host
// mapping, so nothing is ever stored from it by the CPU, and the region is taken even when
// the import could bind it in place.
@@ -327,7 +328,7 @@ private:
// The region of a descriptor-bound range (AddWritable/AddReadable), committed pages only. A
// range inside a base region of the space adds nothing: the region serves it, as today's merge
// of the two did.
void addDescriptorRegion(std::uint64_t address, std::size_t bytes, bool atomic);
void addDescriptorRegion(std::uint64_t address, std::size_t bytes, bool atomic, bool swept);
// Gives a region a buffer of its own with its bytes (guest memory for host-backed and writable
// ranges, plus the write-back's reference copy for a range a descriptor writes; else its snapshot).
void copyRegion(Region& region, bool addressable);
@@ -109,7 +109,7 @@ public:
// then no other member may be used. `compute` and `snapshots` must outlive Complete(). An
// address-based shader (BDA tables) builds entirely in Complete(): its lease acquisition
// reconciles imports and refreshes mirrors, which needs the lock.
ShaderResources(const Context& context, const CompiledShader& compute, std::span<const GuestMemorySnapshot> snapshots, bool deferred);
ShaderResources(const Context& context, const CompiledShader& compute, std::span<const GuestMemorySnapshot> snapshots, bool deferred, std::uint64_t dispatchThreads = 0);
void Complete();
bool Completed() const { return completed; }
~ShaderResources();
@@ -304,7 +304,7 @@ private:
// `written` is the element's DescriptorBinding::bufferWritten: a read-only element binds the
// same way but is left out of the write set (no write-back, no pending-write note). `atomic`
// is its bufferAtomic (see GuestBufferMemory::AddWritable).
std::size_t addGuestBuffer(std::span<const std::uint32_t> words, const ColorTarget* target, std::uint64_t indexAddress, std::size_t indexBytes, bool written, bool atomic);
std::size_t addGuestBuffer(std::span<const std::uint32_t> words, const ColorTarget* target, std::uint64_t indexAddress, std::size_t indexBytes, bool written, bool atomic, bool read);
std::size_t addDataBuffer(std::span<const std::uint32_t> words);
// Stage A: the layout entry of an image binding (samplers are taken at once, the sampler cache
// locks itself); stage B looks the sampled textures and storage images up (resolveImageBinding).
@@ -422,6 +422,7 @@ private:
// notes (counted in MarkGpuWrites for the [buffers] line).
std::size_t readOnlyBuffers = 0;
bool drawBuild = false;
std::uint64_t dispatchThreads = 0;
std::vector<std::shared_ptr<Texture>> textures;
std::vector<bool> textureFirstLayer;
std::vector<std::shared_ptr<StorageTexture>> storageTextures;
@@ -1997,7 +1997,7 @@ const GuestBufferMemory::Region* GuestBufferMemory::owner(std::uint64_t address)
return own;
}
void GuestBufferMemory::addDescriptorRegion(std::uint64_t address, std::size_t bytes, bool atomic) {
void GuestBufferMemory::addDescriptorRegion(std::uint64_t address, std::size_t bytes, bool atomic, bool swept) {
validate(address, bytes);
const auto begin = address & ~std::uint64_t{3};
const auto end = begin == address ? address + bytes : (address + bytes + 3) & ~std::uint64_t{3};
@@ -2015,6 +2015,7 @@ void GuestBufferMemory::addDescriptorRegion(std::uint64_t address, std::size_t b
// the shader stores to them; what is written back is decided by Writes() alone.
Region region{begin, end, true, {}, nullptr};
region.atomic = atomic;
region.swept = swept;
auto committed = GuestMemory::DescribeCommitted(begin, static_cast<std::size_t>(end - begin));
if (!committed.whole) {
// A GPU heap bound whole while the guest commits its pages on demand, or a descriptor left
@@ -2025,8 +2026,8 @@ void GuestBufferMemory::addDescriptorRegion(std::uint64_t address, std::size_t b
regionsSorted = false;
}
void GuestBufferMemory::AddWritable(std::uint64_t address, std::size_t bytes, bool atomic) {
addDescriptorRegion(address, bytes, atomic);
void GuestBufferMemory::AddWritable(std::uint64_t address, std::size_t bytes, bool atomic, bool swept) {
addDescriptorRegion(address, bytes, atomic, swept);
writes.emplace_back(address, address + bytes);
}
@@ -2034,7 +2035,7 @@ void GuestBufferMemory::AddReadable(std::uint64_t address, std::size_t bytes) {
// Not in `writes`: no reference copy (copyRegion), no write-back, no pending-write note, no
// direct-write mark, and UploadPrepare copies it without the device lock. A written descriptor
// overlapping the range still covers it through its own Writes() entry.
addDescriptorRegion(address, bytes, false);
addDescriptorRegion(address, bytes, false, false);
}
void GuestBufferMemory::AddSnapshot(const GuestMemorySnapshot& snapshot) {
@@ -2157,6 +2158,8 @@ VkBufferUsageFlags gpuCopyUsage(bool addressable) {
// APS5_WRITTEN_SHADOW_MIN_KIB / APS5_WRITTEN_SHADOW_MAX_KIB (16 / 2048) bound the written window
// (a kernel streaming once through a large buffer, the engine's memcpy kernel over 4-8 MiB video
// frames, would only gain the two copies), APS5_ATOMIC_STAGE_MAX_KIB (1024) the atomic one.
constexpr std::uint64_t SweptShadowMax = std::uint64_t{16} << 20u;
bool writtenShadowEnabled() {
static const bool disabled = std::getenv("APS5_NO_WRITTEN_SHADOW") != nullptr;
return !disabled;
@@ -2297,7 +2300,7 @@ bool GuestBufferMemory::stagingEligible(const Region& region, bool addressable)
const auto bytes = region.end - region.begin;
if (!WritesOverlap(region.begin, static_cast<std::size_t>(bytes))) return false;
if (region.atomic && atomicStagingEnabled() && bytes <= atomicStageMax()) return true;
return writtenShadowEnabled() && bytes >= writtenShadowMin() && bytes <= writtenShadowMax();
return writtenShadowEnabled() && bytes >= writtenShadowMin() && bytes <= (region.swept ? std::max(writtenShadowMax(), SweptShadowMax) : writtenShadowMax());
}
void GuestBufferMemory::UploadPrepare(bool addressable) {
@@ -2338,6 +2341,7 @@ void GuestBufferMemory::UploadPrepare(bool addressable) {
mergeBacked(previous, region);
if (region.end > previous.end) previous.direct = nullptr;
previous.atomic = previous.atomic || region.atomic;
previous.swept = previous.swept || region.swept;
// A range starting before the mirror (only possible after the swap) keeps its prefix; the
// earlier merged region ends at or before it, so the merged list stays sorted.
previous.begin = std::min(previous.begin, region.begin);
@@ -1003,7 +1003,7 @@ bool UsesAddressTables(const CompiledShader& compute) {
}
ShaderResources::ShaderResources(const Context& context, const CompiledShader& compute, std::span<const GuestMemorySnapshot> snapshots, bool deferred) : context(context), guestMemory(context), deferredCompute(compute), deferredSnapshots(snapshots) {
ShaderResources::ShaderResources(const Context& context, const CompiledShader& compute, std::span<const GuestMemorySnapshot> snapshots, bool deferred, std::uint64_t dispatchThreads) : context(context), guestMemory(context), dispatchThreads(dispatchThreads), deferredCompute(compute), deferredSnapshots(snapshots) {
Require(compute.stage == ShaderRecompiler::ShaderStage::Compute, "compute resources require a compute shader");
// Every use of a compute build is a recorded dispatch that calls MarkGpuWrites, which staged
// buffers need (a synchronous draw's use would not).
@@ -1268,7 +1268,8 @@ void ShaderResources::buildPrepare(std::span<const CompiledShader> shaders, cons
// vector) counts as written, like imageWritten below.
const bool written = element >= binding.bufferWritten.size() || binding.bufferWritten[element];
const bool atomic = element < binding.bufferAtomic.size() && binding.bufferAtomic[element];
const auto index = addGuestBuffer(std::span<const std::uint32_t>(binding.guestDescriptor).subspan(static_cast<std::size_t>(element) * 4, 4), target, indexAddress, indexBytes, written, atomic);
const bool read = element < binding.bufferRead.size() && binding.bufferRead[element];
const auto index = addGuestBuffer(std::span<const std::uint32_t>(binding.guestDescriptor).subspan(static_cast<std::size_t>(element) * 4, 4), target, indexAddress, indexBytes, written, atomic, read);
const auto& push = shader.program->pushConstants;
if (!push.empty()) {
const auto position = shader.program->memoryOffsetDword * 4u + element;
@@ -2590,7 +2591,7 @@ DescriptorCache::Stats DescriptorCache::Counters() const {
return stats;
}
std::size_t ShaderResources::addGuestBuffer(std::span<const std::uint32_t> words, const ColorTarget* target, std::uint64_t indexAddress, std::size_t indexBytes, bool written, bool atomic) {
std::size_t ShaderResources::addGuestBuffer(std::span<const std::uint32_t> words, const ColorTarget* target, std::uint64_t indexAddress, std::size_t indexBytes, bool written, bool atomic, bool read) {
Require(words.size() == 4, "buffer descriptor must contain four DWORDs");
Require((words[1] & 0x40000000u) == 0, "buffer descriptor has reserved bits set");
const ShaderRecompiler::ShaderBufferResource descriptor{{words[0], words[1], words[2], words[3]}};
@@ -2609,7 +2610,8 @@ std::size_t ShaderResources::addGuestBuffer(std::span<const std::uint32_t> words
static const bool allWritten = std::getenv("APS5_ALL_BUFFERS_WRITTEN") != nullptr;
Require(!written || !overlap(address, size, indexAddress, indexBytes), "writable shader buffer aliases the index buffer");
written = written || allWritten;
if (written) guestMemory.AddWritable(address, size, atomic);
const bool swept = read && dispatchThreads != 0 && descriptor.Stride() != 0 && dispatchThreads * descriptor.Stride() * 2u >= byteSize;
if (written) guestMemory.AddWritable(address, size, atomic, swept);
else {
guestMemory.AddReadable(address, size);
++readOnlyBuffers;
@@ -1144,6 +1144,79 @@ void resourceReadTests(const Device& device, Recorder& recorder) {
HostImportFor(context, address, bytes);
}
void readWrittenStagingTests(const Device& device, Recorder& recorder) {
const auto& context = device.GetContext();
if (context.hostImportAlignment == 0) {
std::cout << "host imports unavailable: read and written staging not tested\n";
return;
}
constexpr std::size_t bytes = 8u << 20u;
#ifdef _WIN32
void* block = VirtualAlloc(nullptr, bytes, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
#else
void* block = std::aligned_alloc(65536, bytes);
#endif
Require(block != nullptr, "cannot allocate the test block");
std::memset(block, 0x11, bytes);
const auto address = reinterpret_cast<std::uint64_t>(block);
{
GuestAllocations::Mutation mutation;
mutation.Add(block, bytes, true, true, true);
}
if (HostImportFor(context, address, bytes) == nullptr) {
std::cout << "host import of the test block refused: read and written staging not tested\n";
return;
}
const auto boundInPlace = [&](std::uint64_t element, std::size_t elementBytes, bool read) {
GuestBufferMemory memory(context);
memory.AllowDeviceStaging();
memory.AddWritable(element, elementBytes, false, read);
memory.Upload(false);
const auto reads = memory.InPlaceReads();
const bool direct = reads.size() == 1 && reads[0].first == element && reads[0].second == element + elementBytes;
Require(direct || reads.empty(), "an element was bound neither in place nor staged");
memory.RecordCopyBacks(recorder);
recorder.Sync();
return direct;
};
Require(!boundInPlace(address + 4096, 1u << 20u, false), "a written 1 MiB element was not staged");
Require(boundInPlace(address + 4096, 4u << 20u, false), "a written-only 4 MiB element was staged past the written window");
Require(!boundInPlace(address + 4096, 4u << 20u, true), "a read and written 4 MiB element was bound in place");
const auto buildInPlace = [&](std::uint64_t dispatchThreads, bool read) {
const auto element = address + 4096;
constexpr std::uint32_t stride = 16;
constexpr std::uint32_t records = (4u << 20u) / stride;
ShaderRecompiler::RecompileResult program;
ShaderRecompiler::DescriptorBinding binding;
binding.kind = ShaderRecompiler::DescriptorKind::StorageBuffer;
binding.role = ShaderRecompiler::DescriptorRole::GuestBuffers;
binding.descriptorSet = 0;
binding.binding = 0;
binding.count = 1;
binding.guestDescriptor = {static_cast<std::uint32_t>(element), (static_cast<std::uint32_t>(element >> 32u) & 0xffffu) | (stride << 16u), records, 0x31000000u};
binding.bufferWritten = {true};
binding.bufferRead = {read};
program.bindings.push_back(binding);
const CompiledShader compute{ShaderRecompiler::ShaderStage::Compute, &program, 0};
ShaderResources resources(context, compute, {}, false, dispatchThreads);
const auto reads = resources.InPlaceReads();
const bool direct = std::any_of(reads.begin(), reads.end(), [&](const auto& range) { return range.first <= element && range.second >= element + records * stride; });
resources.MarkGpuWrites(recorder);
recorder.Sync();
return direct;
};
Require(!buildInPlace((4u << 20u) / 16u, true), "a dispatch sweeping a read and written 4 MiB element bound it in place");
Require(buildInPlace(1024, true), "a dispatch touching 16 KiB of a read and written 4 MiB element staged all of it");
Require(buildInPlace(0, true), "a dispatch of unknown size staged a read and written 4 MiB element");
Require(buildInPlace((4u << 20u) / 16u, false), "a dispatch sweeping a written-only 4 MiB element staged it");
Require(std::all_of(static_cast<const std::byte*>(block), static_cast<const std::byte*>(block) + bytes, [](std::byte value) { return value == std::byte{0x11}; }), "a staged copy-back changed the guest bytes");
{
GuestAllocations::Mutation mutation;
mutation.Remove(block);
}
HostImportFor(context, address, bytes);
}
void misalignedRegionTests(const Device& device, Recorder& recorder) {
const auto& context = device.GetContext();
const auto alignment = context.limits.minStorageBufferOffsetAlignment;
@@ -3887,6 +3960,7 @@ int main(int argc, char** argv) {
closeRaceTests(device, recorder);
keyProofTests(device, recorder);
resourceReadTests(device, recorder);
readWrittenStagingTests(device, recorder);
misalignedSnapshotTests(device, recorder);
misalignedRegionTests(device, recorder);
drawSnapshotReuseTests(device, recorder);