Skip to content

perf(agc): share one image table per heap and write epoch through capture, compare, resolution and descriptors - #6454

Merged
adriadam10 merged 53 commits into
boykopovar:mainfrom
EagleBearerYS:perf/shader-image-table-reuse-v3-pr
Oct 19, 2026
Merged

adriadam10 merged 53 commits into
boykopovar:mainfrom
EagleBearerYS:perf/shader-image-table-reuse-v3-pr

Conversation

@EagleBearerYS

@EagleBearerYS EagleBearerYS commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

What

A bindless heap like Resident Evil Village's (32,768 T# records at a 48-byte stride, 4,096 distinct textures) was read, compared, resolved and written again for every dispatch or draw that used it, even when nothing had written it. In Resident Evil Village's menu (PPSA01556, discussion #5169) on the PC's tree with draws not pipelined, capture_resources costs 1,319.7 ms per frame, about 1.58 ms per draw over 835 draws per frame. In a Resident Evil Village-like bench with three queue workers on main (lavapipe), a cached dispatch spent about 36 ms comparing the table, a resource build about 12 ms resolving it, and about 9 ms creating a descriptor pool and set for it.

This keeps one immutable object per heap, record shape and write epoch, and has every stage hold it by reference:

  1. Capture. A table column is kept once per queue worker as an immutable block of words with a shared stamp cell (the write epoch). A capture that finds the column unwritten checks that with one write-tracker query over the block's span, before taking any stamp, and takes the block by reference: no read, no copy. A compare that finds the words unchanged renews the epoch for everyone sharing the block. The column memo this builds on (reuse of an unwritten column, and sharing its column, words and resolution by reference) comes first in the series.
  2. Compare. A cached draw or dispatch variant remembers which words came from a block. A hit vouches for each block once and skips its regions by range. The words around it are compared as before, and anything pending over the span falls back to the per-run checks.
  3. Resolution. A reused bound table replays each element's recorded bind outcome instead of rerunning the per-element checks, while the binding's sampler masks and the stage's sampler words are unchanged.
  4. Descriptor writes. A freed dedicated descriptor set (a 4,096-element table needs one) is kept, up to 8. A build with the same layout and sizes takes it instead of creating a pool, and leaves the table's writes out of vkUpdateDescriptorSets when the set already holds the same views and layouts.

With pipelined draws, a vouch never waits for queued draws. A queued draw that writes anywhere in a block's span refuses the vouch, and the per-run checks then drain exactly where they did before.

Every check uses the same evidence as before, over a superset of the bytes:

  • A store anywhere in a block's span (between records included), a pending or finished GPU write, a label, a DMA fill or copy, CP WRITE_DATA, a compute or storage-image result, or an unwatched range falls back to reading and comparing exactly as before.
  • A guest mapping change (map, unmap, protect or remap) ends the epoch through the guest allocation registry's generation, which the block's stamp records. Ranges that alias other mappings aren't write-watched, so they are always compared.

Each case has a test. APS5_NO_TABLE_SNAPSHOT_REUSE=1 turns the reuse off. APS5_NO_VOUCHED_VALIDATION=1, APS5_NO_TABLE_REPLAY=1 and APS5_KEPT_DESCRIPTOR_SETS=0 turn the later stages off one at a time.

It also closes two stamp ordering races found on the way:

The flush hook's "is an image pending here" is answered from a published snapshot instead of the storage registry's mutex, which three workers contended at 65-81 µs per call. The two reuse switches no longer change the shader disk cache's keys, so turning them off doesn't rebuild the cache. FormatVersion stays at #6243's 37, since no plan or table encoding changes, and the runtime ABI stays at 12.

#6243, #6455 and #6537, whose heads this branch merged, are all in main, so the diff holds only this PR's commits.

Overlap

Seven open PRs change the same image table code. Merges that run this PR with #6440 and #6340 are on the fork: test/v3-x-6440, test/v3-x-6340 and test/v3-x-6440-6340.

Other conflicts, in shared lines (an overlap check against main 668a7dd and the open PRs, 2026-10-16). Whichever merges second keeps both sides:

More general than this PR, for a follow-up: #6340's per-entry reuse (this PR's replay is all or nothing per table; a follow-up would add per-entry reuse and keep the whole-table replay as the fast path when every entry is current), and from #6440 the layout reuse on changed words, narrowed columns and a process-wide memo. A follow-up could take them into this PR's memo. In the other direction, #6440 and #6340 have no unwritten-heap hit, identity share or kept descriptor sets.

Disk cache format: main has FormatVersion 37 since #6243, and this branch keeps it: its own disk-cache change only keeps its two reuse switches out of the cache keys. #6280, #6412 and #6488 also set 37, so each needs the next number; git won't flag it.

Benches (one Linux VM, lavapipe, trees alternated).

A 19,000-record column, per ShaderMemory::Capture, p50 / p90:

Case main #6440 this PR this PR + #6440
unchanged heap 2.97 / 4.40 ms 1.93 / 3.20 ms 0.004 / 0.004 ms 0.004 / 0.006 ms
written heap, equal words 2.70 / 3.45 ms 1.97 / 2.96 ms 1.04 / 1.28 ms 0.92 / 1.11 ms
one record changed each capture 2.78 / 3.33 ms 2.70 / 3.90 ms 2.69 / 3.47 ms 2.85 / 4.00 ms
40 tables in turn¹ 2.68 / 3.74 ms 2.79 / 3.84 ms 2.91 / 3.84 ms 2.07 / 3.81 ms

¹ With #6440, 125 of the 200 captures hit and 75 miss, so the p50 varies by round. The mean per capture: 2.93 ms (main), 3.00 ms (#6440), 3.15 ms (this PR), 2.68 ms (this PR + #6440).

A Resident Evil Village-like load (a 32,768-record heap, three queues, 21 s runs):

main #6440 #6340 this PR this PR + #6440 this PR + #6340
table dispatches/s, watched heap 43–46 44–47 44–47 304–313 310–319 271–295
dispatch-cache compare, p50 per dispatch 26.1 ms 28.5 ms 28.5 ms 0.16 ms 0.16 ms 0.15 ms
resolveImageTables, p50 per build 1.05 ms 1.05 ms 2.10 ms 0.68 ms 0.71 ms 1.69 ms

With an unwatched heap (the Linux default for imports) every tree measures 30–35 table dispatches/s.

Merge order: either works. #6440 and #6340 don't conflict with each other, and each conflicts with this PR in its own file. If they land first, this PR takes the resolutions on the three test/v3-x-* branches; if this PR lands first, the same resolutions apply to them. #6481 and #6488 don't conflict with #6440, #6340 or #6402, except #6488 with #6440 in tests/ShaderMemory.cpp.

Tested

Checklist

MarkWritten and StoreOwnBytes return 0 for a range that is not wholly
watched, and they stamped none of its blocks. The watched blocks such a
store wrote kept their old stamps, so UnchangedSince and StoredOver over
them still read as unchanged after a GPU write that crossed into an
unwatched import. A vouched shader table column or a cached range then
kept bytes that had been replaced.

Both now stamp each block whose part of the store starts or ends in
watched memory, as the wholly watched path stamps every block. They still
return 0 and note the store as unstamped. This costs one or two coverage
lookups per block, and only on this path.
A driver store and an own store across a watched block and an unwatched
one must stamp the watched block and no other. On Linux, a store across
the halves of a split block must stamp it, and a store into its unwatched
half alone must not.
… is unwritten

Every capture of a shader with an image table read every record of each column and looked each
record up among the snapshot's distinct descriptors. A 32,768-record column of distinct T#s cost
6.1 ms per capture here (2.3 ms with 16 distinct T#s), and Resident Evil Village's title captures
such columns on nearly every draw.

TableSnapshotter now keeps the last 16 column snapshots per thread (at most 4 Mi words in all,
least recently used first out), keyed by heap base and size, record shape and key count: the runs
it read, their words, each record's state and code, and the distinct T#s. A narrowed, selected or
pointer-indirect column, or one with an unmapped record, is not kept. A capture of a kept column
takes the codes and the distinct T#s from it with no per-record work. When they are the snapshot's
first words they are taken as they are, and the words index is built only if a later column needs
it, so the result is the snapshot a fresh walk would produce, codes and words order included.

The runtime gets three optional callbacks: stampWrites gives a stamp for a range before any of it is
read, unchangedSince tells whether nothing wrote the range since, and supplyWords hands words that
equal guest memory to the capture as read, so they stay in the captured regions the draw cache
compares. ShaderMemory implements them with the write tracker. The stamp is CollectWrites's
generation, refused when this ShaderMemory already fetched a page of the range or recorded GPU work
writes it. A range is unchanged while no block of it is stamped after the stamp (collected CPU
stores, the driver's Write, WriteChanged and StoreOwnBytes, MarkWritten for GPU writes into
imports), no recorded GPU work writes it and the guest allocation generation is the stamp's. When
every run is vouched for, the column is not read at all; otherwise its runs are read and the
snapshot is reused only when they are equal word for word.

The 32,768-record column now costs 0.48 ms per capture with no word read when unwritten, and about
0.9 ms outside the write watch. APS5_NO_TABLE_SNAPSHOT_REUSE=1 turns the reuse off. The [image-table]
profile line counts the reuses, and ResourceMaterializer::TableReuse returns them with the words the
snapshotter read.
agc_shader_table_snapshot_reuse (agc_shader_memory_tests --table-snapshot-reuse) captures a
4,096-record image table in write-watched guest arena memory and checks each capture's snapshot and
captured words against the table:
- an unchanged table is reused without reading a word, and its snapshot equals the first one;
- a CPU store, a driver store (GuestMemory::Write, as a CP write or a DMA copy or fill makes it), a
  GPU result stored by the driver (StoreOwnBytes) and a pending GPU write with a known value make it
  read again, and the new T# is in the snapshot;
- a MarkWritten stamp (a GPU write into an import), a pending write the capture syncs on and a guest
  mapping change make it compared again;
- after the reuses, the map selects each key's record;
- a table outside the write watch is compared and reused, and a store into it is seen;
- the memo keeps at most 16 columns.
Without write watching, the captures of an unchanged table expect a compared reuse instead.
…table column

Every read and store through GuestMemory calls the flush hook first. Recorder's FlushForAccess waits
for recorded GPU work over the range, records a queued label that writes it, publishes the unit
shadows over it and stores the storage image results kept on the GPU for it. A storage image result
is stamped only when it is stored (its write-back's MarkWritten), so ShaderMemory's stamp and check,
which never read the column, could vouch for words a read would have replaced: the column skipped
its read and the snapshot kept the T#s from before the result.

stampWrites and unchangedSince now call GuestMemory::FlushGpuWrites over the range after the pending
write query and before the tracker, as a read does, and as the dispatch cache stores pending images
before it validates an entry. The stamp then covers what the flush stored, so the capture after it
skips the read again.

agc_shader_table_snapshot_reuse installs a flush hook that stores a new T# into the table on its first
call over it. Without the flush in unchangedSince the reused snapshot keeps the old T#; without the
one in stampWrites the next capture compares the table instead of skipping its read.
…read

A kept column the runtime cannot vouch for is read and compared with its kept words. When they
differ, the fresh walk read the same runs again, so a capture that follows every change to a table
read each word twice: 1,048,576 words per capture for a 65,536-record column, 12% slower than
before the reuse with the write watch and 16% slower outside it.

The compare now leaves its words with the snapshotter, and the fresh walk takes them instead of
reading when its plan has the same runs: words the capture has already read read the same again.
Such a capture reads each word once, as before the reuse, and the 65,536-record column costs 7%
(12% outside the write watch) more than before the reuse; the rest is keeping the column. With 16
distinct T#s it costs 5 to 8% less. agc_shader_table_snapshot_reuse expects a changed table to be
read exactly once per capture.
…ot that reuses it

A capture that reused a memoized image table column still copied it: each record's state and code
and the distinct T#s went from the memo into the snapshot, the snapshot rebuilt its codes from the
states, and the column's per-record vectors were allocated before the memo was even looked up. Every
Recompile then hashed the codes and the distinct T#s word by word for the result memo. On a
32,768-record column of distinct T#s that was about 155 us of copying per capture and 410 us of
hashing per Recompile here, for a column nothing wrote.

A column is now built once into immutable shared values (SharedValues: its codes, its distinct T#s
and its read ranges) and the memo keeps that column. A capture that reuses it takes a reference, so
two captures of an unchanged table share one column and copy nothing, and the per-record vectors
are allocated only when the column is walked. The codes index the column's own distinct T#s
(ImageTableColumnSnapshot::words) instead of a list shared by the shader's columns, so every column
of a shader is shared whole, not only the first. A column read again after a write is a new one:
the snapshots holding the old one keep it unchanged. The result memo hashes each shared value once
and keeps the hash in it; equal contents hash equal whether they are shared or not.

ImageTableReuseCounts gains the reuses that shared a column and the columns built.
… shader memory

When the write tracker vouched for an unwritten image table column, the capture still copied each of
its words into ShaderMemory's pages (supplyWords) so they would be in the captured regions, and
Regions() then found them again bit by bit. On a 32,768-record column that is a 1 MiB copy per
capture (about 120 us here) and a scan of 262,144 read bits per Regions() call (about 230 us), and
the draw path calls TakeRecentRegions() and Regions() once each per stage.

The memo now keeps a column's words in one immutable block (SrtGuestWords), and supplyWords hands
that block over. ShaderMemory keeps a reference and reports its runs page by page, the regions a
read of the same words reports, merged in address order with the pages and the initial regions; a
second stage that supplies the same block only marks it recent again. The words reach pages only
when they must: a later read in the same ShaderMemory that touches them first moves the whole block
into its pages (spilled), so a word is never reported twice, and a block that overlaps pages already
fetched or another block's runs is copied as before. A spilled block stays referenced until the
ShaderMemory goes, so the regions already taken from it stay valid. ShaderMemory::Supplies() counts
the words shared, copied and spilled.
…e on a result memo miss

A result memo miss resolves every image table of the snapshot again: ResolveImageTables decodes each
distinct T#, builds the element and poison indices and fills one map record per key, which is most
of a miss for a large table. A miss whose table did not change is common: another buffer, flat SRT
word or user data word changes the result key while the column is the one the column memo shares.

ResolveImageTablesShared keeps the last resolutions and hands one out by reference when the variant
(its id), the reducing state of the direct samplers paired with table images and every column match:
base, size, records, keys, fault, outside, and the column's codes and distinct T#s by identity. The
memo holds those shared values, so a block it compares cannot be freed and reused for other words;
a column read again is a new block and resolves again even when its words are equal.

It keeps at most 4 resolutions per variant and 256 in all within 8 Mi words, counting the columns
their keys keep alive, least recently used first out, under one mutex. A resolution is immutable
once made and is shared between threads read-only. APS5_NO_TABLE_RESOLUTION_REUSE=1 turns it off.
ImageTableReuseCounts gains the resolutions made and the ones shared.
…e draw cache

The draw cache sets each fresh stage capture against the variants it keeps (insertDrawEntry), and
the dispatch cache sets a fresh capture against its front variant, by comparing every captured word.
A 32,768-record table column is 262,144 of them, so each compare went through 1 MiB on each side
even when both captures had copied the column from the same shared block.

A variant now records the words it copied from a block the shader memory shares (SharedWords: where
they sit in the capture and in the block, and a weak reference to the block; consecutive pages of
one block join into one entry). SameCapturedWords takes the words both captures copied from the
same place of the same block as equal, since a block never changes, and compares the words around
them as before. Two blocks with equal words, words read into pages and a released block fall back
to the word compare. The reference is weak, so a kept variant holds no block alive, and a released
block's identity cannot pass to a new one: its control block stays until the last weak reference
goes.
…e's regions

cacheDrawStages and insertDispatch grew a new variant's words one region at a time. A table column
read from pages, or shared and reported per page, is one region per 4 KiB page, so the words of a
32,768-record column grew through 256 resizes and about eight reallocations, each copying what was
already there. Both now reserve the runs and the words once before copying. Built the same way in
the image table microbenchmark, a 262,156-word capture's variant took a median of 191 us growing
per region and 79 us reserved.
…rites before stamping

A table column's stamp vouches that nothing wrote its memory after the stamp's generation and
nothing was pending at the time. stampWrites asked for pending writes before it took the
generation, and two GPU write paths stamped their blocks before they noted the write pending:
a label or store kept behind completions, and a DMA copy. A capture on another queue worker that
ran between those two steps saw no pending write and took a generation newer than the write's
stamp, so once the batch was reaped the column vouched for the bytes from before the write.

stampWrites now flushes, takes the layout and generation, and asks for pending writes last. The
label path notes the completion before it stamps the store. The DMA copy still stamps first, so a
newer copy's ring entry never meets an older one's generation, then notes the write pending and
stamps again, and its ring entry carries the second generation. A store noted after the pending
query is now always stamped after the generation the column keeps.

The new two-thread test stamps a GPU write from another thread while the capture asks for
pending writes; the next capture must compare the table instead of reusing it.
… before stamping it

An unwritten column was vouched for run by run: ShaderMemory::unchangedSince, with its pending
write query, flush hook, collect and tracker lock, once per contiguous run of the words the
column read. A heap whose records are wider than the descriptor they hold, like Resident Evil
Village's T#s of 8 dwords at a 48-byte stride, makes every record its own run: 32,768 calls per
reuse there. Each capture also took a new stamp before it looked at the memo, so a hit paid the
pending query, flush and collect twice.

The memo's words now carry the range their runs span, and a reuse vouches for that range in one
call. The range covers more than the runs (the bytes between records), so a store between two
records refuses the vouch and the column is compared instead: still exact, never stale. A hit
is tried before the capture stamps the column; the stamp is taken only when the memo cannot be
reused unwritten. ShaderMemory::Vouches counts the vouch calls.

The test reads a strided table of 4,096 records and checks that its reuse vouches in one call,
that a store between records compares and keeps the column, and that a store into a record
reads it again.
…ck's vouch

A cache hit compared every captured region against guest memory, the table's words included:
about 1 MiB and 32,768 regions for Village's heap, 77-81 us per compare. When a capture shared
a column's words by reference (the memo's block), the variant records that block for those
words, and an unwritten block now vouches for them instead: one ShaderMemory::Vouched call per
block (the layout, no pending write, the flush hook, collect and UnchangedSince over the block's
span), then validateCaptured skips the regions the block holds. Regions noted pending are always
compared, and so is everything when the block is gone or refused.

The block keeps its stamp in a shared cell instead of the memo, so a compare that finds the
column unchanged after a store elsewhere in its span renews the stamp for every variant sharing
the block, not just for the thread that compared it. The cell is a sequence lock: a reader that
races a renewal gets no stamp and compares.

APS5_NO_VOUCHED_VALIDATION=1 turns the vouch off. ShaderMemory::Vouches counts vouched and
refused blocks and the words they covered.

Tests: VouchSharedRegions over a strided table (only the table's regions, one block, one call),
refused for a pending GPU write into a record or between records, a changed mapping, CPU, driver
and GPU-result stores, a store between records until a compare renews the stamp, and a released
block; and agc_driver_image_table_vouched_lookups, which dispatches over a write-watched heap
(APS5_WRITE_WATCH_IMPORTS=watch, as on Windows) and checks that hits are vouched for and that a
CPU store, a CP WRITE_DATA pending ahead of the dispatch and a driver store each refuse the vouch
and capture the table again.
…without the registry mutex

Every guest read through GuestMemory (and every table vouch) runs the recorder's flush hook,
which asks the storage-texture registry for pending images over the range: under the registry's
mutex, even when no pending image is anywhere near it. With queue workers recording and storing
images, that mutex is contended: a Village-like bench (three workers, a pending storage image
per submission) spent 72 us per hook call waiting for it, 200 us per table vouch.

The registry now publishes the merged ranges of its pending images with the version they were
built from, rebuilt under the mutex as before. overlappingPending answers a range that no
published range overlaps without the mutex, when the published version is the registry's
current one; any other case takes the mutex as before. A change bumps the current version with
release order before the mutex is released, so a reader that could see the change through any
other path cannot read the old ranges as current.

In the bench the hook drops from 65-81 us to 4.5 us per call and a table vouch from about 200 us
to 23 us. The existing storage-texture tests cover the pending images the hook must still find.
…ion at a time

A table of strided records is one captured region per record: 32,768 regions for Village's
heap. Validation by vouch still walked them all: VouchSharedRegions marked each one, and
validateCaptured asked the pending view about each one and tested its mark, about 100 us and
150 us per hit in the Village-like bench.

Each shared run now records the regions it was built from (AppendSharedWords takes the region's
index and joins only consecutive regions), so VouchSharedRegions returns ranges of regions with
their word count and the span of their block, one per shared run. validateCaptured asks the
pending view once per range, over the block's span, and skips the range in one step when nothing
is pending there; a range the view overlaps is checked region by region as before.

The identity test checks that runs of regions that are not consecutive stay apart.
… walk its runs

validateVariant asked about every run of the variant before it compared anything: the pending
view for each run (syncPendingRuns), the storage-texture registry and the unit shadows for all
runs at once, and every queued label against every run. With a strided table that is 32,768
runs per hit, about 3.2 ms per validation in the Village-like bench, all of it spent finding
nothing.

The shared blocks are now vouched for first, and a vouched range whose block span the pending
view overlaps is dropped from the vouch. The other checks then run over the variant's other runs
plus one range per vouched block, its span: the same questions over a superset of the bytes, so
anything they would have found over the table's runs they still find. When the registry or the
shadows report an image over those ranges, the vouch is dropped and the checks run per run as
before.

In the bench a hit's validation drops from about 3.2 ms to 25 us.
Every unwritten reuse handed the column's block to ShaderMemory::supplyWords, which checked the
block's runs (each in order, aligned, not overlapping, adding up to its words) and asked
GuestMemory::Accessible once per group of pages: one pass over 32,768 runs per capture for
Village's heap, about 200 us in the Village-like bench.

The column memo checks the runs once when it keeps them and marks the block ordered, so a supply
takes the block's span and word count as they are. Accessibility is asked once for the span's
pages; only when that fails (a page between records unmapped, which a stride wider than a page
can leave) are the runs' pages asked group by group as before.

The supply drops to about 3 us. The table vouch test checks that a strided table is kept as one
ordered block over its records' span.
…ing each one again

A reused table (FindReusedTable, TableCurrent and CachedTableTextures all hold) still bound every
element through the same path as a fresh one: the filter-minmax, border-swizzle and seamless-cube
requirements, and for each sampler the element is used with, a decode of the sampler's words for
the border and anisotropy checks. For a table of 4,096 elements that was most of a hit, about
1.2 ms per table on lavapipe.

Those checks depend only on the element (its words, resource and texture, which a reused table
holds unchanged), the sampler mask the binding gives the element, and the stage's sampler
sources. A table now records, for each element, whether its bind succeeded or the message that
rejected it, with the binding's sampler masks and the stage's sampler words and flags. A reuse
whose masks and sampler sources are the same replays that: a bound element takes its texture,
layer flag and described range directly, a rejected one is rejected with the recorded message
in the same order, so the fault codes are the ones a full bind assigns. Any other reuse binds
every element with the checks and records the outcome for the next. APS5_NO_TABLE_REPLAY=1 turns
the replay off.

ImageTableReuses reports the replayed tables (also counted as reused). The image table reuse test
checks that the three dispatches of one table replay twice; every case it samples runs both with
and without the replay.
…ng a table it still holds

An image table binding holds one descriptor per distinct T# of the table (4,096 for a heap of
32,768 strided records), more than the shared pool chain takes, so every resource build created
a dedicated descriptor pool, allocated the set from it, wrote every table descriptor, and
destroyed the pool when the resources were released: 8-11 ms per build on lavapipe for the pool
and set alone.

A released dedicated set is now kept (up to eight, APS5_KEPT_DESCRIPTOR_SETS, 0 turns it off)
with a record of the table descriptors it holds: the written runs, each element's view and
layout, and the textures behind them, which the record keeps alive so a view handle cannot be
reused while the set refers to it. A build whose layout, pool sizes and variable count match a
kept set takes it instead of creating a pool, and when the table descriptors it would write are
exactly the ones the set holds (same runs, same views, same layouts) the table's writes are
left out of vkUpdateDescriptorSets; every other binding is written as before, and the full write
list is kept for the draw snapshot sets that replay it. A set is released only after the batches
that used it completed (the recorder keeps the resources), so a kept set is idle when it is
rewritten.

What invalidates a kept table: any element whose texture was replaced (a new view), whose image
layout changed, or a different set of elements; each makes the build write the whole table.

ImageTableReuses reports kept and made table writes. The image table reuse test checks three
dispatches of one table keep their writes, a texture cache drop makes one write and keeps the
next, and two tables alternating make every write; it samples every case. The descriptor cache
unit test now expects a freed oversized set to be kept and taken back by a set of the same layout
and sizes, a set of other sizes to get its own pool, at most eight kept pools, and none left
when the cache goes.
…t at every build

Every build of an image table binding made a record of the table descriptors it wrote, so that
a set kept after release could later skip rewriting them: a reference to each element's texture
and a copy of each element's image info. For 4,096 elements that was 4,096 shared_ptr copies and
a 96 KiB copy per build, about 70 us in the Village-like bench (table image infos 112 us per
build against main's 40 us), paid by every build, including those whose set came from the pool
chain and is never kept.

A build now notes only where its table's image infos sit among the ones it wrote, and the runs
it wrote, and only for a set from a dedicated pool. When it took a kept set, it compares that
set's record with its own infos in place. The record is made when the resources are released and
the set is kept: the runs, a copy of the infos, and the build's texture references moved over
once and shared by both sets' records, so the textures stay alive while the set refers to them.

A record is made only once the build's descriptor writes were issued; a build that threw before
vkUpdateDescriptorSets leaves its set without a record, so a later build writes the whole table
instead of trusting writes that never happened. Before, the record was set before the call.

The kept and made table writes are counted as before for sets from a dedicated pool; a set from
the pool chain is no longer counted. The image table reuse test's kept and made writes and the
descriptor cache unit test's kept-set contract are unchanged and pass.
…witched off

The shader disk cache keys every plan and variant with the recompiler's APS5_* switches that are
set (ShaderDiskCache's switch key), except the ones listed as neutral. APS5_NO_TABLE_SNAPSHOT_REUSE
and APS5_NO_TABLE_RESOLUTION_REUSE were not listed, so turning the table reuse off for a comparison
run gave every shader a new key: a cache filled with the reuse on missed throughout the boot, and
the other way round, and the comparison measured recompiles instead of the reuse.

Both switches only choose between building a column or a resolution and taking the same contents
by reference, so the output does not depend on them: they are now neutral switches. The cache
format is unchanged and FormatVersion stays as it is; only a process that sets one of them now
finds the entries stored without it.

The plan loading test gains a process with both switches set, which must load every stored plan
and variant without running the front end and produce the same output as the first process.
Before this change it failed with every plan and variant missed (17 plan misses, 17 variant
misses); now it loads all 17 with the same output.
Brings the series onto main 4e9a2a8 (boykopovar#1193, boykopovar#1201, boykopovar#1202, boykopovar#6060,
boykopovar#6333 and the descriptor word tracking of 682b07e).

Resolutions:
- ShaderMemory: the column memo's supplied and spilled blocks now sit
  on boykopovar#1193's page vector, page pool and word masks. Pages are found by
  binary search, since a spilled block can add many pages, and a block
  placed into a lazily read page fills the rest of the 16-word blocks it
  touches, so a later lazy read never overwrites placed words.
- ResourceMaterializer and SharedValues: the selected-record path and
  the words index carry availableDwordMask, as the snapshot hash does.
  A selected record's dwords past the end of its buffer count as read
  zeros, which is what a scalar buffer load returns there, so a key past
  the heap still selects a null image without reading the heap.
- DescriptorCache: kept table sets live beside boykopovar#1201's recycled sets.
- Tests and CMake: both sides' tests are kept.
A table vouch and a capture's stamp ask the pending-write query about a
table's whole span, gaps between records included. With pipelined draws
that query drains queue 0 when a queued draw writes anywhere in the
range, so a vouch could wait for draws that main's per-record checks
never wait for.

Whole-span checks now go through Driver::QueryPendingSpan, which treats
a queued draw writing into the span as a pending write and refuses
instead of draining. The cached variant or the capture then falls back
to the per-record checks, which drain exactly where they did before.
TableRequest keeps a span of the user data it is given, so the request must not be built from a temporary.
…to unwatched memory

A driver store and a GPU result stored by the driver, each across the last record of a write-watched heap into unwatched memory after it, must refuse the table's vouch and make the next capture read the table again. Without the stamp of a partly watched store's watched blocks, the vouch still passes.
@github-actions github-actions Bot added perf Performance change perf numbers missing perf pull request without a before and after summary line unblocks title Names a title ID on the Unblocks line labels Oct 18, 2026
@github-actions

Copy link
Copy Markdown

This pull request is labeled perf, so it needs the summary line of a capture on main and one with the change, from the same title, scene and machine, as the Performance report form asks. Please paste both aps5-summary.jsonl lines, attach the two traces and add the output of tools/trace_digest.py.

@github-actions github-actions Bot added perf numbers missing perf pull request without a before and after summary line needs rebase Conflicts with main, rebase needed and removed perf numbers missing perf pull request without a before and after summary line needs rebase Conflicts with main, rebase needed labels Oct 18, 2026
@adriadam10 adriadam10 self-assigned this Oct 19, 2026
…tamp

The first vouch's pending-write query created the driver, and the driver's construction ends a guest-allocation mutation, which moves the layout generation the stamp holds. The second vouch then refused the table on a layout change the queued draws didn't make. The test now creates the driver first, so both vouches compare the stamp against the layout the draws run under.
Keeps this branch's tagVariantRuns declaration next to main's dumpRequest suffix parameter in Driver.hpp, and reads boykopovar#6232's pointer-chain test words from its table's snapshot, where this branch keeps them.
The data-hit verification keeps this branch's pending-span query on its ShaderMemory and takes boykopovar#5958's CaptureInvocation, which retries a capture with converted samples.

@adriadam10 adriadam10 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed numbers and for the Village/007 runs. Three things block this head:

  • Merge with current main doesn't build/merge cleanly.
    • Driver.hpp: keep both dumpRequest(..., std::string_view suffix = {}) and tagVariantRuns.
    • Dispatch/DataHitVerification.cpp: the data-hit capture is now CaptureInvocation(*verifyMemory, ...); build the ShaderMemory with &QueryPendingSpan as before.
    • tests/ShaderMemory.cpp:3386 (verifyChainedTableOffset, from #7527) still reads tables.words; with per-column words it must read snapshot.words.
  • agc_driver_image_table_vouch_queued_draws fails on CI's lavapipe (Mesa 25.2.8 / LLVM 20.1.2) on the merged tree, at ImageTableCacheBounds.cpp:319: "a queued draw writing outside the table: the table was not vouched for". Your own description reports the same failure once, passing afterwards. A test of this PR that fails intermittently will turn the Linux job red for everyone, so please find the cause (an elsewhere range at OutputOffset sharing a write-tracked page/granule with the table, or QueryPendingSpan being coarser than the span, would explain a layout-dependent result) and make the expectation deterministic.
  • Please keep the perf label's requirement in mind: the bot asks for both aps5-summary.jsonl lines for each capture and the traces; the body has the lines for one pair, the Village A/B only has the figures.

Once these are fixed and CI is green on the new head we'll look at the rest again.

@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 19, 2026
@Scramblrrrr

Copy link
Copy Markdown
Contributor

Current-main Windows integration update: 9f9a532 on main 876bd17 passes 90/90 selected native checks (32.37 s), including shared-table reuse, vouched lookups/queued draws, image-table cases, draw relocation, shutdown, and the new APR read tests. Public stack: https://github.com/Scramblrrrr/AnyPS5/tree/codex/first-light-oct18-resume-main . Title run is still cold-starting; no new FPS claim.

Two test-only adaptations for newer main if useful:

  • e6e5705 updates newly introduced selected-record/pointer-chain assertions from the old global table words to the shared per-column words.
  • 9f9a532 initializes Driver::Get before taking the queued-vouch fixture's allocation stamp. Main now lazily maps/registers the driver's FS-table memory. Without initialization, the first pending-span query changes the allocation-layout generation after the fixture's stamp; the next unchanged-vouch check fails correctly. Temporary diagnostics confirmed layout 2 -> 3 with unchanged table words; removed afterwards. Revised fixture passed five repeats, then the full selected set. Production validation was not weakened.

AI-assisted: Codex; raw title data remains local.

@adriadam10
adriadam10 merged commit 23629c2 into boykopovar:main Oct 19, 2026
10 checks passed
ycarogabriel-lgtm added a commit to ycarogabriel-lgtm/AnyPS5 that referenced this pull request Oct 19, 2026
The pending storage list from main (boykopovar#6454) publishes its range
snapshot through std::atomic<std::shared_ptr>, a C++20 library feature
that Apple's libc++ does not provide, so the AGC driver stopped
building on macOS. Where __cpp_lib_atomic_shared_ptr is not defined,
the snapshot is a plain shared_ptr read and written with
std::atomic_load_explicit and std::atomic_store_explicit, which give
the same acquire and release ordering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ycarogabriel-lgtm added a commit to ycarogabriel-lgtm/AnyPS5 that referenced this pull request Oct 19, 2026
…otection

agc_shader_table_snapshot_reuse, new in main with boykopovar#6454, protects one
page of a write-watched table through GuestArenaSetProtection and
expects the next table read to see it as unmapped. On macOS the write
watch keeps its own record of each watched page's protection, and the
AGC page query trusts that record over the host's, so the page still
read as readable and the stale snapshot was reused. The guest's mprotect
already reports protection changes to the watch; the arena's own
protection call now does too. The watch ignores the report on other
hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ycarogabriel-lgtm added a commit to ycarogabriel-lgtm/AnyPS5 that referenced this pull request Oct 19, 2026
…ted pool

The image table reuse test (boykopovar#6454) expects three dispatches of one
table to keep their table descriptor writes. Kept sets, by that PR's
design, are only the sets from dedicated pools, which a table gets
when its descriptors do not fit the shared pool chain; a set from the
chain is not counted. On MoltenVK the sampler table is small (192
descriptors), the set fits the chain, and no write is counted, so the
test failed although the driver did what it was designed to do. The
test now reads the descriptor cache's dedicated pool count: with
dedicated pools it checks the kept and made writes as before, and
without them it checks that no write was counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TheBlenDed pushed a commit to TheBlenDed/AnyPS5 that referenced this pull request Oct 25, 2026
Brings combo19 (async lane, gpuind, chain-A, 1a f32 mix, boykopovar#7489, S1,
bindless images, staging diff, HTILE work, epoch 5) up to upstream main
of 2026-10-19. 118 conflicted files, resolved by area; notes in
work/research/structural/merge19/notes-*.md.

- Versions: FormatVersion 48, RuntimeAbi 18, request serialization 37
  (upstream's 31..36 as upstream writes them, our single-workgroup flag
  is 37, gpuind's trailing block last; includes b75c4e6's fix).
- Image tables: upstream's boykopovar#6454 reuse model, scratch indexes, selected
  records and pointer chains, with our bindless columns and resolve memo
  in front.
- Pairs kept with ours as the default: boykopovar#6210 presenter epoch only with
  APS5_PRESENT_EPOCH=1; boykopovar#6402 table stores (our path); pending HTILE
  fills only with APS5_PENDING_HTILE_FILLS=1. boykopovar#6208 takes upstream's
  final code with DMA pipelined by default (APS5_NO_PIPELINE_DMA=1 off).

AI-assisted: yes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agc libSceAgc* build docs libc perf Performance change shader core/shader unblocks title Names a title ID on the Unblocks line

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants