Moving GS vertex assembly onto the producer thread
==================================================

Why this is being considered
----------------------------
Need for Speed Underground 2 runs at ~50 fps on a 9950X3D with a 5090 and
paraLLEl-GS. Profiled on that hardware from a savestate of a race in progress:

  GS thread idle          1.0 - 2.1%   (saturated)
  EE thread blocked       68.6 - 75.2% (idle, waiting in WaitGS)
  GS gpu wait             0.00 ms, 0 calls
  frame                   20.3 ms

The GPU is never waited on. The frame is limited by one CPU thread -- the
libretro frontend thread, which is also the MTGS consumer -- while the thread
that runs the EE, IOP, VU, VIF and SPU2 is idle for roughly 70% of every frame.

That thread's per-frame budget:

  GS transfer (parse loop)   9.8 ms   2923 calls
  GS drawkick (assembly)     5.7 ms  12622 calls
  GS vsync                   0.8 ms

Reaching 60 fps needs about 3.6 ms moved off it.

What is not worth moving
------------------------
The producer already decodes GIF tags: Gif_Tag::analyzeTag walks them to find
packet boundaries and EOP, and the consumer then walks the same tags again in
gif_transfer. That duplication is real but worthless to remove -- instrumented
per-path inside gif_transfer, tag handling netted to zero against 1341480
calls, all of it instrument overhead. Handing the producer's decoded tag
structure to the consumer would save nothing measurable.

The cost is vertex assembly, not tag walking.

Hardware data, seven games, correct attribution
-----------------------------------------------
The figures below the next heading were taken before the profiler's zone
cursor was made thread-local, so their split between zones was scrambled --
see the commit that fixed it. These are the corrected numbers, captured on a
9950X3D with a 5090 across NFSU2, Ridge Racer V, Tekken Tag Tournament, DMC3,
Silent Hill 2, FFX-2 and Black.

Two things hold in every one of them:

  GS gpu wait     0.00 ms, 0 calls        the GPU is never the limit
  EE blocked      66-96% of wall          the producer thread is idle

The games fall into three shapes, and they want different things:

  parse-bound     NFSU2 (GS transfer 32-39%, kick tex 7-8%), Black
  texture-bound   Ridge Racer V (kick tex 38-41%, up to 3400 ns/call)
  not GS-bound    FFX-2, DMC3, Silent Hill 2 (zone totals 12-22% of wall)

So the case for moving assembly to the producer rests specifically on the
parse-bound shape. On NFSU2 the device-independent work -- GS transfer plus
drawkick -- is about 42% of a 21.7 ms frame, roughly 9 ms, against kick tex at
7-8%. That is close to what the earlier local probe suggested and is the
number the project turns on.

Ridge Racer V is the counter-example worth keeping in view: its cost is
texture-cache resolution, which cannot leave the consumer, so this work would
do nothing for it. An outlier of 3400 ns/call against NFSU2's 50 ns is
probably a bug in its own right and is a cheaper thing to chase.

What the vsync path turned out to be
------------------------------------
GS vsync reads 3-10% of frame in all seven games, including ones that draw
almost nothing, which looked like a fixed cost worth attacking. Broken into
phases on hardware it is not a cost at all:

  GS vs flush     0.00 ms, 0 calls        never waiting on queued draw work
  GS vs circ      ~110 us/call, steady    the actual scanout shading
  GS vs image     88-105 us/call, flat    allocation, not a cache miss
  GS vs merge     58 us to 936 us/call    16x swing, inversely with GS load

Merge takes longest when the GS thread has least to do -- 936 us/call in an
FFX-2 block where transfer and drawkick were near idle, 59 us/call in a block
of the same game with five times the draw work. Work does not behave that way;
waiting does. The merge render pass is where the GS thread absorbs the frame's
slack against the display, i.e. it is the frame pacing. Confirmed by a hitching
block at 83 ms/frame where EE blocked collapsed to 19% and merge stayed at
182 us: no slack, no wait.

There is nothing to optimize there, and GS idle and merge-wait are two names
for the same thing.

What can move, and what cannot
------------------------------
drawing_kick_append splits cleanly in two. Timing drawing_kick_update_texture
against the whole kick, 200000 calls per sample:

  NFSU2         15.2% texture resolution, 84.8% device-independent
  Tekken Tag    81.4% texture resolution, 18.6% device-independent

The device-independent part -- position and attribute assembly, snapping, the
UV bounding box, culling and the primitive bounding box -- needs no Vulkan
device and no texture cache. The rest does:

  drawing_kick_update_texture   consults the renderer's texture cache, which
                                owns GPU images
  render_pass.instances[...]    appends into an in-progress render pass, tied
                                to framebuffer allocation and hazard tracking

So the split is: assembly and cull on the producer, texture resolution and
render-pass append on the consumer.

Note the ratio is game-dependent and inverts between these two titles. This is
not a general speedup. It is worth ~4.8 ms/frame on a geometry-heavy title like
NFSU2 and approximately nothing on a texture-heavy one like Tekken Tag.

What the producer needs
-----------------------
Vertex assembly reads RegisterState: two ContextState blocks (tex0, tex1,
clamp, xyoffset, miptbp x2, scissor, alpha, test, fba, frame, zbuf) plus prim,
rgbaq, st, uv, fog, prmodecont, texclut, texa, fogcol, dimx, dthe, colclamp,
pabe, bitbltbuf, trxpos, trxreg, trxdir, scanmsk, internal_q and cached_cbp.
Roughly 42 64-bit registers, a few hundred bytes, plain POD with no pointers.

The GIF stream is deterministic, so a shadow of this state driven by the same
packets in the same order is correct by construction. The producer would
maintain that shadow and assemble primitives against it; register writes are
carried in the same stream, so the shadow must be advanced in stream order,
interleaved with assembly, exactly as the consumer does today.

The hard parts, in the order they will bite
-------------------------------------------
1. The MTGS contract widens from "bytes at an offset" to a primitive IR:
   assembled vertices plus the register snapshot they were kicked under. Ring
   entries currently carry offset and size into the path buffer; the IR needs
   its own storage and lifetime.

2. Partial packets. A GIF packet can span transfers, so the producer's shadow
   and its assembly must suspend and resume across calls, as gif_transfer's
   tag/loop/reg state does now.

3. Path switches. NFSU2's traffic alternates constantly -- measured, 1997 of
   2000 merge-run breaks were path switches -- and GS commands must be
   processed in submission order, so per-path assembly cannot be reordered.

4. WaitGS barriers, savestate freeze/defrost and MTVU each assume the GS state
   lives on one side. Any state that moves has to be captured, restored and
   handed over at those points.

5. Texture-dependent decisions inside assembly. Anything the device-independent
   half computes that depends on the resolved texture (feedback mode, the
   arrayed/per-sample paths) has to be either deferred to the consumer or
   proven independent of it.

How to evaluate it without building all of it
---------------------------------------------
The measurements above cost one instrumented run each and killed the cheap
variant outright. The same discipline applies to the expensive one: before the
IR is designed, confirm on hardware that NFSU2's 84.8% device-independent share
holds there. This host is a software rasterizer, where a texture cache miss
runs an upload on the CPU and inflates the texture side -- Tekken's 81.4% is
probably overstated, and NFSU2's 15.2% is the one more likely to survive.

And throughput cannot be validated here at all. Local timing has a +/-14%
run-to-run spread, and the software rasterizer never enters the regime where
the consumer keeps up. A previous packet-merging attempt passed every
correctness check here -- byte-identical frames, byte-identical audio, clean
software renderer regression -- and cost 170 -> 140 fps on hardware, because
its failure mode only appears when the consumer is fast. Every stage of this
work needs a build benchmarked on the target machine before it lands.


Texture resolution: where the calls come from, and how redundant they are
-------------------------------------------------------------------------
drawing_kick_update_texture opens with a fast path that returns the previous
texture index when STATE_DIRTY_TEX_BIT is clear. Measured, it never fires:
0 hits in 100000 calls on NFSU2, 0 in 18000 on Ridge Racer V.

Tallying which site sets the bit, per 100000 kicks on NFSU2:

  framebuffer feedback (gs_interface.cpp:2169)   87308
  mark_texture_state_dirty                        6222
  miptbl / texture state                           0 / 1

So the fast path is not broken. It is bypassed by design: the game samples the
render target it is writing, and in feedback mode the code deliberately
rechecks texture state every draw, as its own comment says it must.

But the recheck is mostly redundant. Comparing each resolution's result with
the previous one, 93.9% return the identical texture index -- the full
descriptor walk and hazard analysis runs ~38000 times a frame on NFSU2 to
produce the same answer nineteen times out of twenty.

That suggests a narrower fast path than the existing one: memoize on the
descriptor inputs (tex0, tex1, clamp, feedback mode, and the sampled window)
rather than on a dirty flag, and invalidate when VRAM in the sampled region is
actually written rather than on every draw while feedback is active. The
difficulty is exactly the part that makes feedback correct today -- in
feedback the framebuffer is being written by the draws themselves, so
"actually written" has to be tracked at a finer grain than "a draw happened".

Not attempted here. It is a change to the hazard model in the path that keeps
Ridge Racer V rendering correctly where hardware renderers corrupt it, and
this sandbox can verify correctness but not throughput.

Ridge Racer V, for the record, marks 73% of its resolutions long-term
cacheable against NFSU2's 12%, so it is not failing to cache -- it caches and
is then invalidated, which matches the game overwriting VRAM in one format and
reading it back in another. Its 3400 ns/call is likely the price of modelling
that correctly rather than a bug.
