Skip to content

perf(agc): integrate draw-ahead preparation with pipelined commits - #69

Draft
JJenkx wants to merge 11 commits into
mainfrom
validation/draw-ahead-760
Draft

JJenkx wants to merge 11 commits into
mainfrom
validation/draw-ahead-760

Conversation

@JJenkx

@JJenkx JJenkx commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

Integrate mitul72's six-commit upstream #6188 draw-ahead preparation with main's now-default queue-0 draw pipeline (boykopovar#1201/boykopovar#1202), preserving authorship. Eleven linear commits on main 8276a1d0594313a5d037fd902957f6cacf943bdc; public head bf8fd65b8324c50896e7f603b471b3f07842c858.

The integration retains the pipeline drain before color metadata passes and indirect draws. Queued commits keep their matched draw entry and compiled stages alive and consume its prepared record, falling back to normal preparation if the device changes. Submission completion is enqueued only after draw-ahead retirement, so idle cannot precede the last speculative read/publication.

Main's newer lazy shader-memory pages attempt a flush that speculative readers refuse. Speculative page captures now use the existing pinned, kernel-mediated GuestMemory::Read; ordinary captures retain main's lazy path. Raw shader cache comparisons and fresh code reads now drain overlapping queued writes. The existing variant-validation drain is retained for captured descriptor/table ranges.

The previous watched-snapshot fixture correction remains: shared-backed Linux direct allocations exercise watched imports; new CPU contents are checked after advancing the collect epoch. Driver-store checks retain immediate MarkWritten assertions. No unsafe import-policy override is used.

Validation: Linux x86-64 GCC Release full runtime libraries and all selected test executables built. All 21 selected CTest cases passed with zero skips on Radeon RX 6650 XT / RADV Mesa 26.2.4 and lavapipe. Coverage includes actual rendered-pixel comparisons, prepared-record use, inline and pipelined draws, validation mode, cache eviction, retirement/pinned reads, raw-code and captured-table writes, snapshots, PM4 failures, metadata, draw-write ranges, pipelined DMA/fills and predication. Conventions and whitespace pass.

Controls establish that the regressions detect the integration errors:

  • The mechanically rebased implementation consumed 0 prepared records where 4 were expected.
  • The raw-code regression failed with “a raw shader cache hit ignored a queued write to its code” before the raw-read drain was added.
  • Removing only the production variant-validation overlap drain makes the isolated queued-table case fail with “a draw compiled ahead ignored a queued write to its captured table”. Its executable SHA-256 is unchanged through the fault and restored-source builds. The ordinary suite also catches this fault in its pre-existing queue-write case.
  • The held-read test failed when lazy captures attempted a forbidden speculative flush. It passes using guarded reads. The retirement assertion also caught publication after idle before retirement was moved ahead of deferred completion.

All temporary faults were removed and the full runtime rebuilt before the final positive runs. The private validation tree differs from this public head only by the previously disclosed SPIRV-Tools teardown-repair gitlink. That dependency repair is excluded; these runs do not independently validate the official pin. NVIDIA and Windows retests, full-suite results and title gameplay validation remain pending.

Completed title comparison on this exact base: main 8276a1d versus this integration, same Linux/Radeon host and idle Astro Crash Site scene, first-present-relative 300:60 trace. Main: 230 presents /60.200989 s = 3.821 FPS, p50 263.672 ms, GPU busy 180.453 ms/present. Integration: 321 /60.022567 s = 5.348 FPS, p50 177.557 ms, GPU busy 152.091 ms/present. +39.98% throughput in one pair, not repeated statistical evidence or broad gameplay coverage. Main has advanced since this measured base.

Both used initially byte-identical independent warm-cache copies (1946 shader hits, zero misses/writes, 1659 resource-plan hits), the same private dependency repair, inactive windows, no input, and no concurrent builds/tests. Both traces report no dropped events. Compositor images at 325/415 s show the same idle camera/HUD with different animation poses; both runs ended at their scheduled 425 s watchdog, not normal title shutdown. The committer CPU thread was not registered, so those spans are absent despite no overflow; follow-up upstream #6341 addresses that gap. Inclusive phase times are not global CPU work.

Full timing evidence and protocol is now linked in the upstream Astro discussion. The queued raw-code read fix has been split out as upstream #6326. This integration draft retains the parent feature's authorship and is not a duplicate upstream feature submission. The older 042eb7c8 result (3.388 → 4.869 FPS, +43.7%) remains separately archived; results on different bases are not pooled.

@JJenkx
JJenkx force-pushed the validation/draw-ahead-760 branch 2 times, most recently from 3e9fba4 to 0eb0d89 Compare October 15, 2026 05:03
@JJenkx JJenkx changed the title perf(agc): validate draw-ahead on current main and Radeon perf(agc): validate latest draw-ahead revision on Radeon Oct 15, 2026
@JJenkx
JJenkx force-pushed the validation/draw-ahead-760 branch 3 times, most recently from 60d1893 to a6a868d Compare October 15, 2026 10:02
mitul72 and others added 11 commits October 15, 2026 07:12
PrepareDrawBindings gave every read-only input it snapshotted per draw a
pooled Buffer of its own. When the unwritten-snapshot map missed, it
collected the range's writes and probed the reuse cache, and on a miss it
allocated a Buffer, copied, and recorded the Buffer in both caches.
Inputs of up to 4 KiB now probe only the map of unwritten slices: a hit
binds the slice an earlier draw of the same collect epoch and tracker
generation copied, and a miss copies the input into a slice of a 4 MiB
host-visible chunk owned by the recorder (a bump allocation and one copy)
and notes it there under the same rule as the unwritten snapshots. Without
a collect epoch (APS5_NO_COLLECT_MEMO), which alone bounds that rule for
CPU stores nothing collected, small inputs are copied every time. The
descriptor points at the slice's offset and range. Larger inputs and moved
shader data keep their reuse caches. Offsets only grow within a chunk and a
full chunk is replaced, never rewound, so no two slices share bytes; the
draw's bindings hold the chunk until their batch completed, and replacing
the chunk forgets the noted slices, so the map pins at most the current
chunk.

Each new slice charges the open batch the chunk space it consumed,
alignment padding included, and the slice that replaces a chunk also
charges the tail it leaves unused, so a chunk's whole capacity is charged
once and a batch's kept bytes cover the chunks it retains (at most one
chunk shared with an earlier, completed batch is not counted). A new
slice counts as kept by its batch, so reusing it there charges nothing; a
slice reused by a later batch is charged once by that batch, as a reused
snapshot is.

The descriptor tests compared snapshot descriptors against offset 0 of a
buffer of their own; they now compare against the snapshot's range, and
the reuse test uses an input above the slice limit. A new test covers slice
placement, alignment, disjointness across chunks, the charge of a chunk
filled by 4-byte slices, the reuse of an unchanged slice within a collect
epoch without a second charge (and its copy every time with collect
memoization off, agc_draw_snapshots_no_collect_memo) and its replacement after a store and a new epoch, and the chunk's
lifetime; agc_draw_snapshots runs the draw snapshot tests.
VulkanDevice copied the cached Graphics::Context (device functions,
limits, the buffer pool reference) for every KnownDrawRejection, Draw and
DrawFromRecipe call, twice per draw on the queue-0 thread. These calls now
take a reference to the context the device built at creation; only
APS5_NO_CONTEXT_CACHE builds one per call, as before.
Every queue-0 draw resolved its parameters, ran the register precheck and
built the draw key on the recording thread (with the incremental register
fingerprints the key still hashes the user words and looks up the five
shader addresses in the registry). A scan thread now executes each queue-0
submission on a copy of the queue state while queue 0 records it, and
publishes per draw packet, in order, the key with its shape, the
parameters and the precheck outcome. Queue 0 takes a record instead of computing them when it
is certain and still matches.

A record is exact for the state the scan executed. Packets queue 0 may skip
(COND_EXEC ranges, predicated packets while predication is armed) or whose
value comes from memory (COPY_DATA into a register) mark the registers they
write as divergent; an unconditional write ends the divergence, CLEAR_STATE,
CONTEXT_STATE and a reset carry it like the state itself, and a whole-state
change under a condition loses the scan until an unconditional reset. A
certain record lists at most 64 divergent registers with the values the scan
had, and queue 0 uses it only when these, INDEX_BASE, the index type, the
instance count, the device and the submission all match its own state; any
other draw takes the full path. Indirect draws are never published.

The precheck's register part becomes PrecheckDrawState so both threads
share it; the metadata pass and the trace stay on queue 0. The scan notes
a dispatch's initiator and group counts in the shader registers as queue 0
does. Pm4 exposes the register offset decode and the registers a COPY_DATA
writes.
Draws whose register key is new (per-frame ring addresses in user data make
most keys new every frame) captured and recompiled their stages on the
queue-0 thread, or, when their shape has an entry, re-read its user words
and recompiled the stages whose captured reads changed. The draw-ahead scan now queues every such draw it publishes
with a Proceed precheck to worker threads (a quarter of the hardware
threads, one to four), which decode the draw from the scan state, capture
and materialize its stages with the same request the queue-0 miss path
builds (DrawStageRequest, DrawStageVariant, shared with it, with the
position export layout the miss path gives the vertex stage, and the pure
data positions of each stage) and insert the variants into the draw cache
under the draw's key and shape. Queue 0 then finds an entry and validates
it as it validates any cached variant, against current memory.

The key holds registers only, and validation compares captured memory, so
a worker compiles only draws whose compile request depends on nothing else
(DrawAhead::CompilesAhead). A mesh draw takes the descriptor of its index
buffer from its packet into its hidden user words, and an instanced
tessellation draw numbers its hull patches by its packet's counts (queue 0
does not cache those): a skipped draw of one buffer would otherwise leave
its compile under the key an executed draw of another buffer finds. Both
are left to queue 0, as indirect draws are.

Admission. The driver caches a key at its second sight, or at its first
when its shape was seen before and has no entry, so that a key that never
comes back does not push a live entry out. A key the workers compile is
consumed by queue 0 at its own draw, so the scan compiles keys on their
first sight too. An entry inserted ahead is no sighting: it goes on
probation, whatever the key's earlier sightings, since queue 0 may skip its
draw. It lives in the draw cache beside the others, in its own list bounded
by the cache's size, so probation entries push out only each other and
never an admitted entry. A queue-0 draw that found a probation entry
counts as a sighting under the same rule at the point where the driver
sights an absent key, so only a draw it would consider caching sights it
(no instanced tessellation draw, no indirect draw drawn on the CPU), once
per draw (the entry itself does not count as its shape's entry); the
sighting that admits the key promotes it into the cache's order, so a key
is admitted at the execution that would have admitted it without
compile-ahead. The sighting and the decision to insert queue 0's own
compile are one step under the cache mutex (admitDrawKey), so a probation
entry a worker inserts while queue 0 compiles the same key counts this
execution's sighting as the absent key would have. A worker never inserts into an existing
entry, and it builds its entry before it takes the cache mutex, so queue
0's lookups wait for no more than the map and list update. An insert builds the entry and its list node before it changes the
cache, and traces and reserves the evictions that follow before it, so it
either completes, evictions included, or leaves the map, the lists, the
shapes and the variant accounting as they were. APS5_TRACE_DRAW_CACHE,
whose eviction trace can still throw, keeps the workers out. Entries an insert evicts or replaces
are released after the cache mutex is, so queue 0's lookups do not wait for
their destruction.

Speculative reads. A worker may read guest memory before queue 0 passes the
waits that order it after CPU or GPU stores, and memory a skipped or
divergent draw names may be unmapped meanwhile. Under a
GuestMemory::SpeculativeReadScope, Read copies through the kernel
(process_vm_readv on the own process, ReadProcessMemory on Windows) into
the capture's private buffer: no C++ evaluation of the worker accesses guest
memory, so a concurrent store is not a data race, and an unmapped page
fails the copy instead of faulting. No flush hook runs, and every other
guest memory access (compares, committed reads, stores, FlushGpuWrites)
throws in that scope. The compile reads only the private copy, and the
variant records exactly the words it was compiled from; queue 0 compares
them with memory after its waits and flushes, so a capture holding stale or
torn words is used only when memory then holds exactly those words.
Without a working kernel copy no workers run.

Lifetime. Every guest read of a job and its cache insertion are pinned to
the job's draw. Queue 0 passes a draw before executing it and retires its
submission when it ends (also on an exception); passing waits for pinned
sections at or before that position, later pins are refused, and retiring
drops the submission's queued jobs and scans and stops its scan; a scan
checks for retirement before it allocates its records. So no worker reads
guest memory for, or inserts an entry of, a draw queue 0 passed or a
submission that retired, including draws inside a false COND_EXEC.

The scan decodes ahead only with registered shaders: raw programs would be
read from memory and NoteRawGraphicsDraw, which refuses later shader
registration, must only follow a draw queue 0 executes. Submissions
without a direct draw are not posted, nor is a submission that arrives
while the scanner is still busy with an earlier one, so queue 0 copies a
submission for the scanner only when the scanner can start on it. A debug probe or APS5_INSERT_COMPARE
keeps the workers out. The print-once flags in DecodeState become atomics
now that two threads decode. Driver::DrawAheadStatistics counts published
records, records queue 0 used, compiled and dropped jobs (a draw whose
decode fails ahead is dropped), and APS5_PROFILE_DRAW reports them per 10 s
([draw-ahead]).

DrawCacheReuse counts queue 0's own admissions, data hits and inserts, so
it turns compile-ahead off (DrawAhead::SetCompileAhead). The new test
holds queue 0 behind a WAIT_REG_MEM and requires every draw behind it to be
compiled ahead, its published record used, reused without misses and
to render the pixels of the uncached path (debug probe), then to keep
their entries through their second and third sight; a draw executed once,
then compiled ahead inside a false COND_EXEC, stays on probation and is
admitted at its next execution; two executions of an indirect draw the
queue draws on the CPU do not promote the probation entry of a skipped draw
with the same registers, and the direct draw is then admitted at its
second execution, as without compile-ahead; mesh, instanced tessellation and indirect
draws are not compiled ahead; a submission whose
1024 draws lie in a false COND_EXEC inserts nothing after it retired; a
table read by the fragment shader, stored by the CPU while queue 0 waits or
by WRITE_DATA before the draw, renders the stored value. It runs again
under APS5_VERIFY_DRAW_ENTRIES, where queue 0 recompiles each hit and
compares its capture and its compilation (SPIR-V module, variant,
bindings, push constants) with the workers'. DrawAheadRaw requires that a raw
draw the queue skips leaves shader registration open.
A draw that misses its recipe (every draw of an entry the workers just
compiled) validated its shaders against the state, looked up or built the
vertex input layout and built the pipeline stage mask and the resource
content key on queue 0.
These depend only on the decoded state and the compiled stages, so the
draw-ahead worker computes them once (Graphics::PrepareDraw) and stores
them on the entry, tied to the exact variants it compiled (weak references,
so a released or replaced variant never matches). Queue 0 passes them to
Graphics::Draw when its hit matched those variants, refreshed no stage data
words, is not indirect and the stage count is unchanged; otherwise, and for
rect lists and emulated barycentrics, it computes them as before. The
prepared vertex input layout is shared with the draw like a retained one. Rotated and replaced entries keep the record.

A speculative failure never surfaces. An exception in the scan, a draw's
resolve or decode, or a worker's capture and compile means only that the
draw has no precomputed result (for a scan exception, the rest of the
submission has none): no record is published and no entry inserted. An
exception in PrepareDraw drops only the prepared state: the compiled entry
is still inserted and queue 0 prepares the draw itself. PrepareDraw's
shader validation keeps its failure to itself: only queue 0 records one in
the failures KnownValidationFailure reports, since the draw that fails it
ahead may be one queue 0 skips or draws nothing for (a draw of zero
instances returns before its shaders are validated). Queue 0 then
performs the draw exactly as without draw-ahead, on current memory.
This is not a silent fallback: that serial path is the authoritative implementation and throws on a genuine error,
while what the speculation saw (stale, torn or not yet mapped memory, a
context stack it cannot follow past a skipped packet, a vertex layout the
draw never builds) is not an error of the draw. A guest read refused to a
speculative reader, whose draw the queue has already passed or whose range
is inaccessible, throws GuestMemory::SpeculativeReadRefused.

The test renders, as the uncached path does, a draw whose buffer
descriptor is invalid while the worker compiles (the capture records the
invalid words) and repaired before the draw, a draw whose table is mapped only after the worker's read, a draw
after a CONTEXT_STATE push in a skipped COND_EXEC (the scan executes it,
fails on the next push and publishes nothing more), and an indexed draw
of restart indices only whose vertex stride exceeds the device limit (its
prepared layout fails, the draw builds none). An indexed draw of fetched
vertices compiled ahead draws through its prepared vertex input layout and
writes the pixels of the uncached path, and a zero-instance indexed draw
whose vertex program exports no position, compiled and prepared ahead,
draws nothing as without draw-ahead; a speculative read of an inaccessible
range is refused. Each pass sets NUM_INSTANCES.
Every cache hit validates each stage's captured runs, and two of its
checks scanned linearly: StorageTexture::AnyPendingOverlaps compared every
run with every pending image, and AnyShadowedOverlaps took the shadow
registry's mutex once per run. With compile-ahead almost every draw is a
hit, and these checks were two thirds of a stage validation in ASTRO BOT's
crash-site hub.

AnyPendingOverlaps now asks the pending list's sorted, merged range index
(PendingList::MayOverlap, the one PendingStorageOverlaps uses), which holds
exactly the union of the pending images' guest ranges, so its answer is
unchanged; images being flushed are still compared one by one.
AnyShadowedOverlaps takes the registry mutex once for all runs.

The unit shadow test and the store-over-untracked test check a list of
runs that overlaps only through its last run, and one that does not
overlap at all.
@JJenkx JJenkx changed the title perf(agc): validate latest draw-ahead revision on Radeon perf(agc): integrate draw-ahead preparation with pipelined commits Oct 15, 2026
@JJenkx
JJenkx force-pushed the validation/draw-ahead-760 branch from a6a868d to bf8fd65 Compare October 15, 2026 11:16

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants