# Notes
What was learned while porting inFAMOUS 2 (BCUS98125 01.00). Addresses are
effective addresses in that executable's memory map unless marked LS (SPU local
store).
## How the port is put together
- **PPU.** `ppu_loader.py` finds the functions and imports. `ppu_lifter.py`
translates the ~39,000 functions (346 jump tables) to C in `work/src/recomp`.
The port links that with the ps3recomp runtime. System modules are HLE.
- **SPU.** Every SPU image the game runs is lifted to C (`work/tools/lift_spu.py`):
- the WWS job manager's policy module (EA 0x887080, at LS 0xA00);
- the 27 job modules (`C0DEC0DE`, at LS 0x4000);
- the 5 plugins of the ambient-occlusion job (at LS 0x6C00);
- the 30 SPURS job-chain binaries (Wwise among them);
- job-chain binaries the game loads from its data files instead of the
EBOOT. A run with `SPU_DUMP_MISS=
` (`work/play.sh` sets it to
`work/runs/spujob-miss/`) writes each one out, and `lift_spu.py` lifts
what it finds there;
- the Bink decoder and EDGE zlib.
The lifted code keeps SPU registers in vector locals (`--local-regs`), and
the SPU translation units are built with clang (`work/port/tools/spu_cc.sh`).
- **RSX.** ps3recomp's draw engine with the Vulkan backend runs the game's own
vertex and fragment programs, decompiled to HLSL and compiled to SPIR-V with
glslang.
## The frame's critical path
A 3D frame of the opening cutscene used to spend about 7 ms in the SPU
ambient-occlusion job and 9 ms in the SPU lighting job, one after the other:
- t+4.5..5.5 ms: NV3089 copies the depth (the Z24S8 depth buffer itself, read
as A8R8G8B8, to 0x47777B80) and then the normals (a colour target, to
0x473F3B80) into main memory. Each is copied as two strips, 1024 and 256
pixels wide; the second strip's source and destination are 0x1000 further
on.
- SSAO job P075 (job module cc13c684dd3e) reads them and writes label 0x890.
- Lighting job P071 (job module 9a537c60d338) lights them in place and writes
label 0x870.
- The RSX waits on those labels before the composite. Its wait on 0x870 is
for the previous frame's value, so the composite can sample the lighting
while the job still writes it.
Both jobs now have GPU replacements. Measured on the same build:
| | 3D menu | 3D cutscene |
|---|---|---|
| SPU jobs | 42 fps | ~31 fps |
| GPU SSAO | 57 fps | ~40 fps |
| GPU SSAO + lighting | ~64 fps | ~45 fps |
| G-buffer copies left out of guest memory | ~79 fps | ~52 fps |
| SPU runtime: overlay index, idle DMA, wider copies | ~88 fps | ~56 fps |
### After the GPU passes: the threads themselves
With both jobs on the GPU, no single wait dominates. `PS3_CLOCK_STATS` showed
the RSX thread 5% idle in the cutscene (65-80% turning the FIFO into Vulkan
commands, 15-29% at present waiting on the GPU). Stack samples of every thread
(`eu-stack` 24 times over 10 s) showed:
- the main PPU thread running 80% of the time;
- the SPU threads idle about half of it.
These changes followed:
- **The GPU starts on a frame while it is being recorded.** Every
`PS3RECOMP_VK_SPLIT` operations (512) the batch so far is submitted without
a fence. A flush waits on the last submission's fence, which covers the
earlier ones. The wait at present went from 4.7 to 1.8 ms.
- **The window blit goes out with the frame**, in the same `vkQueueSubmit`,
instead of a submit-and-wait of its own. Engine mode also stopped reading
every presented frame back (only a dump needs it).
- **Short `usleep`s are exact.** The main thread polls flags with
`usleep(1..10)` about 4,500 times a second (`PS3_POLLTOP=10` names the
sites). Linux held each one to the 50 us timer slack, ~60 us a turn. Sleeps
of up to 20 us now spin (yielding) to the deadline, and the slack is 1 us.
- **The store path's SPU-reservation check is inline.** As a call it was 7%
of the main thread.
Together they added about 8% to the frames rendered over the cutscene. The
host is shared (a browser and Steam ran during the measurements), so single
runs differ by about ±5%.
### The main PPU thread and the compiler
With the geometry cache in, walking on the pier ran at 98 fps and the main
PPU thread was the limit: 96% busy in a `perf` profile, against 32% for the
RSX thread and 72% for each SPU thread. Its time went 57% to game code, 25% to
the guest memory accessors and 7% to the VMX float helpers. The accessors are
`static inline`, but GCC stops inlining once a translation unit has grown 40%,
and every lifted unit does.
- **Accessors inlined.** `ppu_recomp.h` marks them `always_inline`, and their
guard is one compare against `g_vm_inl_lim` (0 while a memory diagnostic is
armed, otherwise the mapped range less 16 bytes) plus the gcm control word.
The four SIMD float ops in `ppu_vmx.h` are forced inline too.
- **Lifted PPU code compiled by clang++** (`work/port/tools/ppu_cxx.sh`).
Every lifted function takes its context pointer `__restrict`, so a guest
load or store through `vm_base` cannot alias a guest register. On x86 the
inline store and its reservation re-check are volatile accesses instead of
being separated by a compiler barrier. The barrier made every guest
register reload from the context after every store. clang then keeps
registers in host registers across guest memory accesses and across the
stores' cold out-of-line calls, as long as the context has not been passed
to a call yet. LLVM gives up on that question past 32 basic blocks, so the
launcher raises the limit to 1000. GCC does not use `__restrict` across
calls at all.
- **`reserve_addr` is volatile.** Another thread breaks a reservation by
writing it, and `ppu_stwcx32` reads it through `g_active_ctx`. With a
restrict-qualified context, the store `lwarx` makes could otherwise be
dropped as dead.
clang also builds a lifted unit in about 70 s and 2 GB, where GCC takes about
4 minutes and 5 GB, so a full build at `-j 4` takes about 8 minutes. With
these changes the pier runs at about 119 fps and the tutorial at 122. The
main thread is still about 95% busy.
A second round, measured on the idle 3D main menu (the steadiest scene the
title has; the main PPU thread is ~99% busy there), three boots of each build
alternating: 109.9 against 105.6 fps, ahead in every pair.
- **Stack accesses skip the checks.** The lifter sends a load or store whose
address comes from r1 (prologues, epilogues, spills: ~40% of the guest's
memory operations) to accessors that test nothing but the diagnostic switch;
stores keep the SPU-reservation check. `PPU_LIFT_STACK=0` lifts without.
- **Indirect calls.** With no call diagnostic armed, a call to a lifted
function goes straight from the hash lookup to the call; the full dispatcher
read a dozen variables per call (~3% of the thread).
- **The profiler breadcrumb** every syscall and HLE call leaves is the host
return address; it is turned into a guest function (a binary search) only
when something reads it.
- **Short usleeps spin with PAUSE**, not a `sched_yield` syscall per turn.
- **More VMX float ops in SIMD:** vmaxfp, vminfp, vrefp, vrsqrtefp, vcfsx,
vctsxs, and the int/float conversions scale by a power of two built from
its bits instead of calling `ldexpf`. `runtime/ppu/tests/test_vmx_simd.c`
checks them against the scalar loops bit for bit (and the conversions
against the old `ldexpf` spelling).
Then (not yet measured on its own): vperm as two byte shuffles and a blend,
vsldoi as one `palignr` (they were a 16-step byte loop and a round trip
through a 32-byte stack buffer; ~10,000 and ~28,000 sites in the lift), the
float compares and the four roundings in SIMD, and lvebx/lvehx/lvewx as the
quadword load the Cell makes. That last one also helps the vspltw that
usually follows: it read 16 bytes over a narrow store at a variable offset,
which the CPU cannot forward. About half of the main thread's guest time is
in functions that use VMX.
Logging cost more than expected. `work/play.sh` pipes the game through `tee`,
and with stderr not a terminal the runtime logs every SPU job and every wait:
thousands of lines a second, all through one lock. That gave 84 fps at the
pier against 98 without it. `play.sh` and the benchmark now set
`PS3_VERBOSE=0`.
### The main thread waiting on the SPUs
After the compiler work, a profile of the pier walk put a quarter of the main
thread's samples in `sys_timer_usleep`. `PS3_POLLTOP=10` names the loop:
`func_00371000` calls `func_00504D90`, which takes the minimum of seven
per-SPU progress counters, with `usleep(1)` between tries, about 220,000
times a second. The main thread waits for SPU work for about a fifth of the
frame, while each SPU thread is busy about 80% of the time. Two things in the
SPU runtime made that wait longer than the work itself:
- **Idle SPUs slept through new work.** An idle SPURS kernel sleeps until
the PPU library wakes it, or for 1 ms. inFAMOUS 2 also hands work over
without a library call: readyCount is changed by its own atomics and by
the WWS policy module's PUTLLC. `SPURS_IDLE_STATS=1` counted about 100
sleeps a second that ran to the timeout and then found work. An idle SPU
now polls for 200 us before it sleeps (`SPURS_IDLE_SPIN_US`, set by
`main.cpp`). On the idle 3D menu, three alternating pairs went from 110.1
to 114.3 fps. 50 us and 1000 us did no better.
- **WWS job modules always took the slow branch resolver.** Every indirect
branch made while a job-module overlay (id 200 and up) was resident went
through `spu_indirect_branch_slow`, for the sake of LBP-era job hooks that
are all diagnostics. Those modules do most of the SPU work here. The fast
path now takes them when no diagnostic is on.
With those two in, the pier walk ran at ~122 fps, and the main thread still
spent about 30% of its time in that loop. Sampled together, the SPU threads
were busy 91% of the milliseconds the main thread waited and 88% of the rest:
the wait is SPU throughput now, not latency. Two cuts to what each SPU pays
per DMA:
- **Fences.** Every MFC command went through four full fences: two in the
lock-line path, which every `MFC_Cmd` write enters first, and two around
the DMA itself. On x86, which only lets a load pass an earlier store, the
lock-line pair is now kept to lock-line commands, and a GET (loads only)
skips the fence after it. About 70% of `spu_wrch`'s samples sat on those
fences.
- **A channel read built its 128-bit result as a 4-byte store under an
8-byte reload**, which the CPU cannot forward: three quarters of
`spu_rdch`. It is now written as two 64-bit halves.
**The integer helpers were scalar on x86.** Past the pier tutorial (the
drive in `work/runs/drive_tut.sh` hangs from a ledge at ~65-85 fps) one job
module, `jobmod_6d1c65171899` (image 209), is 38% of all SPU time. By its
DMA it reads mesh data from main memory and PUTs vertex data into RSX local
memory: geometry processing.
Its hottest function counted bits one at a time. `spu_cntb` had NEON code
and, on x86, only the per-byte reference loop. The same was true of 45 more
integer ops: `xsbh`, `ceqbi`, `xshw`, the immediate rotates, `fsm`, `clz`,
`gb` and the rest of `runtime/spu/spu_helpers.h`'s NEON list. Their
references are loops over the `u128` union. The compiler turned them into
general-register code, or into 30 instructions where one does, and every
call paid a round trip through the stack from the vector registers the
lifted code keeps its operands in.
They now have SSE4.1 forms, with AVX2 for the per-word shifts and AVX-512
BW/VL for the per-halfword shifts and `lzcnt`. `tests/test_spu_int_neon.c`
checks every one against its reference: 2.8 million random cases, gcc and
clang, x86-64-v2/v3/v4. Two alternating pier runs: gameplay 112 -> 123 fps,
cutscene 83 -> 88.
The Wwise chain still falls behind under load (see "Falling behind under
load"): a job then runs a descriptor the title has already refilled, loops
past its step budget, loops on the interpreter as well, and the chain used to
report it with a synthetic completion. The WWS handlers took that for a
message and called through NULL, as with the empty slots before. With
`SPURS_JC_NO_SYNTH=1` (set by `main.cpp`) such a job is dropped silently too.
**Profile-guided optimisation: tried, no gain.** Two attempts on the lifted
code, which both run through clang:
- **Instrumented** (`-fprofile-instr-generate`). The five SPU threads bump
the same counters, and the game ran at 2-7 fps: no profile of anything
real.
- **Sampled** (AutoFDO). `perf record -b` on the Zen 5's LbrExtV2 during
the tutorial and the walk, `llvm-profgen` to a 10 MB profile, then
`-fprofile-sample-use`. In two alternating pairs of pier benchmarks against
the same build without the profile, gameplay ran at 121.5 and 123.0 fps
against 123.1 and 123.4, and the cutscene at 82.8 and 82.4 against 83.4
and 83.1.
The plumbing was removed. `bench_pier.sh` keeps `BENCH_PERF_AT=tutorial` and
`BENCH_PERF_ARGS` from it.
### Local memory write tracking (`PS3RECOMP_WTRACK`)
A gameplay profile had the RSX thread 97% busy, and most of that time went to
textures and vertices it had already seen.
**Textures.** The engine hashed every texture in use once a frame (35-90 MB)
to notice changes. Every one of them lives in RSX local memory.
**How the tracking works** (`runtime/memory/guest_wtrack.c`):
- Once a range has been read, its pages become read-only.
- The first write, from any thread and by any path, faults. The SIGSEGV
handler makes the page writable again and stamps it from a global clock.
- A range is unchanged since stamp S while no page in it carries a later
stamp.
- Host code that has the kernel write guest memory (fread, recv) brackets the
call. Otherwise the kernel would fail with EFAULT instead of faulting.
**Results.**
- Texture cache lookups went from 3.7 ms to 0.3 ms a frame.
- `=2` keeps hashing and reports any change the tracking missed. The
cutscene reports none.
### Geometry cache (`PS3RECOMP_GEO_CACHE`)
The same tracking validates a cache of each draw's decoded vertices and
rebuilt indices:
- The key is everything else the result depends on.
- A draw over unwritten memory skips the index decode, the remap, the fetch
and the topology rebuild.
- Geometry the SPUs rewrite every frame is let go after two misses, so its
pages are not armed to fault every frame.
**Results.**
- In the cutscene, 87% of draws hit.
- Vertex work went from 3.7 ms to 1.5 ms a frame.
- `=2` rebuilds every hit and compares the results; the cutscene reports
none differing.
### The Wwise job chain and its guard
The gameplay freezes all came from the audio job chain. A lifted Wwise job
would loop forever, and a NULL call followed in the WWS handlers.
**What `SPURS_JC_TRACE=1` showed.** The audio thread sometimes notifies the
chain's job guard two or three times in a burst. The runtime banked each
notify as a credit, so the chain ran extra laps. Those laps read descriptors
the title was still writing for its next frame, sometimes only half written.
**The fixes.**
- **Guard count as on hardware:** a notify never takes it below zero, and
a notify while the guard is already open is lost.
- **The chain wakes on a condition variable**, instead of polling every
1 ms.
- **The job manager's grab:** a lap's descriptors are copied the moment the
guard opens.
- **Each job reads its descriptor once.** It used to read it twice: once to
pick the lift, again to stage. In between, the title could swap the
binary.
**Falling behind under load.** The audio thread queues work two submissions
deep (two descriptor banks, alternating) and notifies in bursts, a few
submissions within a millisecond. At the pier a lap takes ~0.5 ms and the
walker always waits at the guard when the notify comes (`SPURS_JC_TRACE`:
notify to open under 100 us, no descriptor or command rewritten during a
lap). In the Beast fight, laps reached 8-10 ms; the thread refilled a bank
the walker had not finished, and the walker read one slot zeroed. Two
things then made a skipped job fatal, and both are gone:
- the walker sent a synthetic "job done" for the job it skipped, to every
queue attached to the chain; the WWS job-manager threads never get an
event from this chain, read it as a message and called through NULL;
- a guard closed for 20 s ended the walk with another synthetic event.
A garbage slot is now skipped with no event, and a guard is waited on for as
long as its chain runs. The heaviest jobs are Wwise DSP binaries at
0x008AE400 (~0.7 ms), 0x008A7080 and 0x00898880 (~0.35 ms each); a lap that
runs many of them is where the time goes.
**Diagnostics.**
- A job that runs past its step budget (`SPURS_JOB_LIFT_BUDGET`) is run
again on the interpreter, from the very local store it was given.
- Its store is kept (`SPU_DUMP_STUCK`) for `PS3RECOMP_SPU_REPLAY=`.
### Ambient occlusion on the GPU (`PS3RECOMP_NATIVE_SSAO`)
- **Passes.** Five fragment passes:
- half-size linear depth;
- 12 samples per pixel;
- a depth-aware 5-tap blur across;
- the same blur down;
- a depth-aware upsample to the R8 image at 0xCF800000 (B8, linear,
1280x720).
They run at the depth copy, from the normals target and an RGBA8 snapshot of
the depth buffer. The texture unit that samples 0xCF800000 gets the result.
- **SPU side.** The plugins' entry (LS 0x6C30, images 240-244) is gated. It
returns at once while the game samples the GPU's result, and runs as usual
otherwise.
- **Parameters change every frame with the camera's field of view.** They sit
in the job's 0x140-byte block at 0x00A26780:
- +0x00: 1/z = a·d24/(2^24−1) + b;
- +0x10 / +0x40 / +0x70: the sample tables;
- +0xA0: the camera matrix, whose first three columns rotate a normal into
pixel offsets;
- +0xE0: the unoccluded total, the projection scale, the sphere radius, the
falloff and the depth clamp;
- +0x100: the output remap and the blur and upsample weights.
Fixed constants taken from one capture draw wrong halos in half the scenes.
- **Checked** with `=2`, which computes without substituting and diffs against
the SPU's output: at most 0.08% of pixels are more than 8 levels off, and the
mean error is under 1.4 levels (SPU float truncation).
### Lighting on the GPU (`PS3RECOMP_NATIVE_LIGHTING`)
- **Passes.** Five compute passes:
- per light;
- bounds of the view-space positions per fifth of a tile;
- the lights that reach each of the 40x18 tiles;
- per pixel;
- clearing tiles where no pixel was in range.
Up to 256 point and spot lights. Output:
- diffuse: RGB square-root encoded with a shared scale in A;
- specular: the intensity, in A.
Both are written over the copies, as the job does.
- **Inputs.**
- The two images are latched (privately copied) at their NV3089 copies.
- The parameters are taken when the job loads them: a DMA watch on its
256-byte block at 0x00A25A80. The block holds the position matrix at
+0x10, the clip matrix at +0x50, the light count at +0x90, the light table
at +0x94 (48-byte records) and the colour scale at +0x98.
- The lighting is computed when the game first samples one of the two
copies.
- **SPU side.** The job's per-tile function (LS 0x4AC8 of image 212) is gated.
Nothing below it touches a channel or DMA, so the job's transfers, queue and
label stay as they are.
- **Checked** with `=2`:
- Most frames have under 0.2% of pixels more than 1 level off.
- In a few frames, up to ~0.4% of pixels are more than 4 off, at the edges
of lit objects.
- Some tiles disagree on "lit by nothing" against "empty". Both decode to
black.
- Taking the job's own parameters instead of reading them at the copy did
not change these numbers, and neither did disabling the tile culling.
### What the GPU passes made unnecessary
While both passes are live, the copies have no reader left.
`rsx_draw_engine_copy_note` returns nonzero and the NV3089 copy skips the guest
side: no GPU readback (each one was a flush and a wait, five per frame), no
pixel conversion, no CPU copy. That took the frame from about six GPU flushes
to one.
The gated jobs still run their DMA. `spu_dma_skip_range` makes their
transfers of the G-buffer, the SSAO scratch and the occlusion image complete
without moving bytes, which removes ~20 MB of copies a frame.
A looser rule for the second strip, which accepted any destination in the
first row, broke the video segment. That memory is reused during loading. The
strip must come from the same source at the same offset.
### Gates and fallback
`spu_gate_function` (ps3recomp patch) makes a lifted SPU function return as
soon as it is entered, but only while a flag says the GPU's result is what the
game samples:
- The flag is set by a successful substitution and cleared by any failure: a
backend refusal, an unsupported light, or a texture bound in a format or
size the GPU image does not fit.
- When the flag is clear, the SPU does that frame's work.
## Particles and fragment depth export
The game draws its particles (smoke, fire, sparks, fog sheets) into a 512x288
colour target (`0xB9E000`) over a 512x288 depth buffer (`0x0F770000`), then
blends the result over the 1280x720 picture in the final composite. The
particle shader reads that depth too and discards a fragment behind the scene
(soft particles). The depth buffer is filled each frame by one full-screen
pass that writes depth from the fragment program: `r1.z`, with
SHADER_CONTROL's depth-export bits (`0x0E`) set.
The fragment program translator had no depth export, so that pass left the
buffer at its clear value. Particles were depth-tested and soft-tested against
a flat 0.5, so most of them were dropped and large sprites were cut along
straight lines -- the likely source of the flat, dark rectangles reported over
the water in a cutscene (not yet checked in that scene). Now `r1.z` becomes `SV_Depth` (as RPCS3 does, from r1 whatever
the colour export width), and the downsampled buffer matches the scene.
`PS3RECOMP_ENG_DRAWTRACE_PX=,` names the draws that change one pixel,
which is how the pass was found.
## Water
The water is drawn after the lit scene (`0x011C0000`) and its 256x256
reflection (`0x0F6F0000`), in two passes over the same mesh:
1. A shading pass writes the water's colour into `0x003B0000`. It reads the
scene, the reflection, the scene depth and a 512x512 normal map.
2. A copy pass draws the mesh again into the scene. Its fragment program only
samples `0x003B0000` at the fragment's own place.
For that place, the vertex program writes the screen uv times w into a
texcoord. The fragment program reads it with SRC2 bit 31 set, the
perspective-correction flag, which multiplies the interpolated value by 1/w.
RPCS3 does the same, as `tc * gl_FragCoord.w`. The translator ignored the flag,
so the copy sampled uv x w, outside the water, where `0x003B0000` is black. The
New Marais swamp and the sea at the Empire City pier came out black.
The flag now becomes `tc * RSX_FRAG_RCP_W`. That is `SV_Position.w` under
glslang, which follows Vulkan's `FragCoord`, and `1 / SV_Position.w` under
D3DCompile, which defines `RSX_DX_POSITION_W`. In a New Marais frame, 183 of
the 507 fragment programs read a texcoord this way. Most of them are
full-screen passes, where w is 1.
`TEX_COORD_CONTROL` (`0x0B40 + 4i`, bit 0: texcoord i is 2D, its z and w read
0 and the fragment's w) is not modelled. The drawtrace logs where the game
sets it.
## Resolution scale (`PS3RECOMP_RES_SCALE`)
`PS3RECOMP_RES_SCALE=2` (or 3, 4) draws every colour and depth target that
many times the guest's size each way; textures stay at the guest's size. The
engine still deals in guest pixels everywhere -- target sizes, viewports,
scissors, readback rectangles, snapshot extents -- and the Vulkan backend
converts at the edge, so every size the engine compares (the texture/surface
match, the writeback pitch check, the small-target writeback, the G-buffer
copy checks of the native passes) stays guest against guest. What the
backend does:
- the viewport and scissor x N, and the depth-bias slope x N (a scaled pixel
sees 1/N of the slope);
- a readback, which is how the CPU sees a target (auto-exposure, NV3089
copies), filtered down to guest pixels: colour through a linear blit,
depth and stencil by taking the middle texel of each N x N block;
- a colour target seeded from guest memory goes up through a nearest blit;
- ZPASS counts divided by N^2;
- the native lighting's image size words rewritten for the scaled images
(SSAO already scales with its image);
- the window gets the full-size frame; `s_vk.color` stays the guest-size one
the dumps read.
The one engine-side change is WPOS: the GPU hands the fragment program image
pixels, so `fp_alpha.w` carries 1/N.
One change sits outside the renderer. Edge Geometry, the SPU job that
prepares skinned meshes, culls triangles that cover no pixel centre of the
viewport it is given, and the game gives it 1280x720. At 2x and above those
triangles do cover pixels, so faces, ears and necks showed dark specks and
holes; the G-buffer pass already lacked the triangles, and no fragment
discarded anything. The job reads its `EdgeGeomViewportInfo` through one
128-byte GET (u16 scissor `0,0,1280,720`, then depth range `-1,1`, a 4x4
matrix, viewport scales at +80 and offsets at +96), about 8,500 times a
second in the city. `spu_dma.h` scales the scissor, scales and offsets in the
job's local-store copy by N when the signature matches; guest memory keeps
the original. `PS3RECOMP_EDGE_VP=0` turns it off. Ruled out on the way: SSAO,
the native lighting, LOD bias, a half-pixel viewport shift, the depth-as-RGBA8
size and the depth-bias slope.
At 2x the pier, the tutorial and the cutscene render with the same light,
particles and exposure as 1x, and nothing falls back to the SPU. At first it
cost a lot: the pier walk at ~63 fps and the tutorial at ~89, against ~123.
The RSX thread waited for the GPU at the end of every frame (46% of its time
at 2x, 18-22% at 1x), so GPU time added to the frame instead of overlapping
it. With frame pipelining (below) the pier walk runs at ~114 fps at 2x
(2560x1440), and the 3D menu at ~65 fps at 3x (3840x2160), where the GPU
becomes the limit.
## Frame pipelining (`PS3RECOMP_VK_PIPELINE`)
A present used to submit the frame and wait for the GPU to finish it. Now it
submits and goes on: the next frame records into a second set of everything
a batch holds while the GPU renders the first, and a set is waited on only
before it is recorded into again (two frames in flight). The set is:
- the command-buffer chain and its fence;
- the window blit's command buffer and acquire semaphore;
- the vertex, uniform, staging and readback rings;
- the dead buffers;
- the four descriptor pools;
- the ZPASS queries and reports;
- the deferred readbacks.
`vk_slot_swap` exchanges the two sets, so nothing that uses the globals
changed. `vk_flush` keeps its meaning: it submits what is open and waits for
both. Two things had to change with it:
- **ZPASS.** A report recorded before any CLEAR in its batch also counts what
the batches before it counted since the clear. That count is only known
once they have completed, so it travels as a carry from batch to batch.
- **The small-target writeback** (the 20x11 luminance the CPU reads) is now
recorded into the frame's own batch before the present, and the rows reach
guest memory when that batch completes (`readback_deferred`). Before, it
forced a wait right after every present.
The parked frame is also polled (`vkGetFenceStatus`, every 64 operations) and
completed as soon as the GPU is done with it, so the reports and the
luminance arrive about a frame later, which is when the game reads them.
Effect: the RSX thread's time at present went from 21-33% to 0-4% at 1x, the
2x pier walk from ~63 to ~114 fps, and the 3D menu at 1x from ~117 to
~120-125. `PS3RECOMP_VK_PIPELINE=0` restores the wait.
Not scaled yet: lines and points (1 pixel), NV3089 copies (CPU copies of a
downsampled readback, so water and blur sources stay at 720p).
## What limits the frame rate where (2026-10-16)
Two scenes, two different limits:
- **Under the Empire City pier** (the "Blast open the gate" sequence, reached
by `work/runs/drive_tut.sh`). The RSX thread drains 97% of the time
(`PS3_CLOCK_STATS`). The frame has ~10,500 draws, against ~4,500 at the
pier and ~3,700 in New Marais.
- **New Marais** (`work/runs/nm_bench.sh`, from a copy of a save). The RSX
thread is busy about 70% of the time, and the main PPU thread spends ~40%
of its time in `usleep` polls. Waits at `0x370640`/`0x370668` and
`0x3710B8`/`0x3710E4` make up ~55% of the frame, and all five SPURS SPUs are
busy through them: the frame waits on SPU throughput.
- SPU time divides into job code (~50%: `jobmod_6d1c` geometry 16%, `ec5f`
8%, `bc11` 8%, `5cff` 7%), dispatch (~17%: `spu_drain_call`,
`spu_task_launch_check`, `spu_lookup`, `spu_indirect_branch`) and DMA
(~11%).
- `SPURS_NSPUS_MIN=7` changes nothing, nor does raising every workload's
maximum contention to 7 with it: the game's work does not spread over
SPUs it did not ask for.
- A longer idle spin (`SPURS_IDLE_SPIN_US=2000`) is worth ~3%.
`PS3_TIMELINE=,` now also records each `usleep` (thread, caller)
and the SPURS idle mask every 250 us, which is how the second picture was
taken.
### Cheaper draws on the RSX thread
All of these leave the output unchanged:
- **VS constants by range.** The Vulkan backend now takes
`bind_vs_constants_range`: only the rows the vertex program reads, and the
viewport tail, are compared and copied. Before, every draw copied the whole
8 KB register file three times, and compared it once; under the pier that
was 86 MB a frame. `PS3RECOMP_ENG_VPC_FULL=1` forces the whole block.
- **Vertex fetch.** A draw fetched over its whole index range (the remap's
ranged path) has consecutive elements: the fetch walks the source by stride
instead of resolving each reference. SNORM16, SINT16 and UINT8 got their
own loops; they went through the per-element reference decoder, and SNORM16
is the most common format under the pier. The per-vertex element range is
computed once per draw instead of once per attribute.
`tests/test_vertex_fetch.c` covers the consecutive case now.
- **Texture binding memo.** A unit whose registers repeat within a frame, and
which names a plain texture (no colour or depth target at its address),
reuses the handle and sampler it got earlier in the frame. The texture
cache checks a texture for changes once a frame anyway. `s_tex_gen` drops
the memo when a target is created or a texture handle is replaced or
evicted. `PS3RECOMP_ENG_TEXMEMO=0` turns it off.
- **The FIFO feeds the old state machine less.** With the register-file
engine drawing, `rsx_process_method` (the vtable path's `rsx_state`) has no
backend and no reader. It is now fed only the methods with effects outside
that state: the reference, the back-end labels and the draws. It was ~8%
of the RSX thread. `PS3RECOMP_FIFO_LEGACY=1` feeds it everything.
- **SPU function lookup.** Each SPU thread caches `spu_lookup`'s answers. The
registry only changes while it is built.
Measured: under the pier, 27-29 fps became 33-37. In New Marais, ~74 became
~80.
### The rings that flushed every frame
The engine's per-draw data lives in host-visible rings (vertices and indices,
uniform blocks, staging). The rings are reset when a batch completes. When one
filled up mid-batch, `vk_ring_alloc` or `vk_eng_draw` called `vk_flush`, which
submits the batch and waits for the GPU. That wait in the middle of the frame
also undid the frame pipelining for that frame.
Each draw writes its 8.7 KB VS block into the 16 MB uniform ring when the block
changed. `PS3RECOMP_VK_FLUSH_CALLERS=1` with `PS3RECOMP_VK_STATS=1` named the
draw as the flusher:
| Scene | Draws | Flushes | GPU wait |
|---|---|---|---|
| New Marais | ~3,700 | 1 per frame | ~1.2 ms |
| Under the pier | ~10,000 | several per frame | — |
A ring that runs out now doubles at its next empty start, up to 1 GB. The
descriptor pool's per-batch set count went from 4,096 to 16,384 for the same
reason.
| Scene | Before | After |
|---|---|---|
| New Marais | ~79 fps | 85-88 fps |
| Hanging under the pier | 27 fps (33-37 after round 1) | ~105-110 fps |
`[vk-stats]` now shows 0.1 flushes a second.
### The depth snapshots that crossed the bus
`PS3RECOMP_VK_GPUPROF=1` (new) puts timestamps around every render pass
instance and every other engine operation (copies, snapshots, clears,
uploads, native passes). It prints GPU time per pass size and per operation,
by caller offset, every 300 batches. Both ends of a span are bottom-of-pipe
stamps, so the spans do not count each other.
At 2x in New Marais the GPU was 90% busy (`gpu_busy_percent`) for ~70 fps.
Per frame:
| Work | GPU time |
|---|---|
| All draws | 2.9 ms |
| Six `vk_eng_depth_snapshot` calls | 6.6 ms (~1.1 ms each) |
| Their RGBA8 conversions | 1.8 ms |
A depth image cannot be copied straight into a colour one, so the snapshot
bounces through a buffer. That buffer was host-visible and made on every
call: the bytes crossed the bus twice, and each call allocated memory. The
stencil snapshot did the same. Both now use one device-local scratch buffer,
grown as needed. Each use starts with a transfer barrier against the
previous one.
New Marais now runs at the same rate at every scale:
| Scale | Before | After |
|---|---|---|
| 1x | 86 fps | ~93.5 fps |
| 2x | 70 fps | ~93 fps |
| 3x (native 4K) | — | ~93 fps |
### SPU: fewer trips through the drain loop
- **`--merge-chunks`.** The SPU lifter splits code at every branch target.
Without this option, each split became a separate C function, joined by
trampolines. Each hop returned to `spu_drain_call`, wrote every register
back to the context, looked up the next function and reloaded the
registers. The geometry job's inner loops (`jobmod_6d1c`, 0xD7F4 ->
0xD8A8 -> 0xD9E8) paid this on every block. `--merge-chunks` (an option of
the toolkit's `spu_lifter.py`) emits each run of contiguous chunks as one C
function, with gotos where the trampolines were. Every chunk is still
registered for indirect entry. `work/tools/lift_spu.py` now passes it, and
the lift is still reproducible (a relift diffs clean). Effects:
- in New Marais, the SPUs are busy 3.2 of 5 during the main thread's
waits, against 4.0 before;
- the pier tutorial runs at 125-134 fps, against ~122-127.
- **What came with the merge.**
- Frames get bigger, up to ~7.7 KB in one job function, with recursion on
top. The SPURS kernel threads and the job-chain thread went from 1 MB to
16 MB of stack, as lv2's SPU threads have.
- The lifter no longer turns a branch to LS 0 into a goto inside a group: a
job lifted at 0 ends there, and the drain has to see it.
- The lifter's register localisation no longer spills around the MFC
parameter writes.
- Still open: the nops-plus-self-branch halt idiom is only recognised for a
whole function, not for a chunk inside a group. Today's lift has none.
- **Inline hop check.** `spu_task_launch_check` now has an inline fast path,
`spu_task_launch_hop`, for when no diagnostic is on and no stack-reset
entry is registered.
- **Inline MFC parameter writes.** The writes to the MFC parameter channels
(`MFC_LSA`, `EAH`, `EAL`, `Size`, `TagID`), which only latch a value, are
done inline in lifted code (`spu_wrch_fast`).
### Heavy area, round 2 (2026-10-18)
Measured in the heavy area (`work/runs/heavy_bench.sh`, ~9,000 draws a
frame) with two new tools that other load on the machine barely moves, unlike
fps: `runs/spu_busy.sh` (SPURS busy ms per frame, minus the idle spin, and the
main thread's share in `usleep` polls) and `runs/spu_cost.sh` (instructions
per frame per thread).
- **DMA fast path.** `mfc_do_transfer` and `mfc_submit_body` tested ~45
opt-in diagnostics per transfer. With none of their variables set, a plain
GET/PUT now goes through `mfc_do_transfer_fast`, which does only what the
full path does for a well-formed transfer. `SPU_DMA_SLOW=1` forces the full
path. The port's get-watch (native lighting) runs inside the fast path.
- **Inline drain.** `spu_drain_call` is answered inline when the callee came
back to the return pc (nothing pending, or a plain indirect branch there).
- **`--const-bi` (lifter).** `ila $rN,