Commit Graph
50695 Commits
Author SHA1 Message Date
Henrik Rydgård 87d7e00285 Merge pull request #23041 from hrydgard/profiler-timeline
Profiler: Replace with a per-thread timeline profiler, compiled into every build
2026-11-11 14:32:33 +01:00
Henrik Rydgård 4da13731aa Merge pull request #23040 from hrydgard/savedata-export
Export saves as zip files, and share or save frame dumps
2026-11-11 14:19:57 +01:00
Henrik RydgårdandClaude Opus 5.5 56e3017966 libretro: Build the profiler, which every build has now
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:07:27 +01:00
Henrik RydgårdandClaude Opus 5.5 6f5eab87d9 Profiler: Write the log without fputs, which libretro's stdio doesn't have
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:59:36 +01:00
Henrik RydgårdandClaude Opus 5.5 af59826fe0 Profiler: Compile it into every build, with coarser scopes in rendering
It's now in release builds too, Android's included, and on iOS, UWP and
libretro, so no build runs a rarely tested configuration. NO_PROFILER still
leaves it out.

The scopes per GE CALL (gpu_call), per primitive (execprim) and per draw inside
a flush (renderman_q, applydrawstate, soft) are gone: their time shows in
gpuloop and flush (now named the same on every backend, D3D11 included). A
software-transformed flush is labelled "soft" instead. PROFILE_THIS_SCOPE_DYNAMIC
is gone too; its one user, the curve code, labels a "curve" scope with the kind.

The profiler windows and Tools/profile_report.py show how many scopes run per
frame, and headless ends a profiler frame per emulated one, so --profile-log
works there as it says.

What a scope costs while off, timed in the unit test on a Galaxy S8 (20 million
of them against the same loop without): nothing measurable. It's two
instructions there (a load of the switch and a branch), up to four elsewhere.
GTA: Liberty City Stories runs about 1,640 scopes a frame, so a few thousand
instructions in about 31 million. (The 0.39% more instructions measured over
whole runs is about 64 per scope, so most of it was something else, likely
threads that spin while waiting.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:51:22 +01:00
Henrik RydgårdandClaude Opus 5.5 dacea56aaa SoftGPU: Remove the frame profiler scopes
A sampling profiler shows its costs better. Some were per pixel, and two in
one scope kept USE_PROFILER builds from compiling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:51:22 +01:00
Henrik RydgårdandClaude Opus 5.5 c4f3d7db37 Profiler: Check that it's on before the scope's category
The category's static has a thread-safe init guard, which every scope checked
before the profiler's on switch. Now the lookup is in a lambda that only runs
while the profiler is on, so a scope costs a load and a branch while it's off.
A non-literal name no longer compiles there (the lambda can't capture it); that
takes PROFILE_THIS_SCOPE_DYNAMIC.

Measured on a Galaxy S8 with ppsspp_headless, 20 emulated seconds of GTA:
LCS driving from a savestate, 12 runs each: compiled in and off, it runs 0.39%
more instructions than a build with NO_PROFILER (the runs spread 0.35-0.47%),
and no measurable CPU time difference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:51:21 +01:00
Henrik RydgårdandClaude Opus 5.5 1b92700e54 Docs: Finding stalls with the frame profiler, and driving the app or a phone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:51:21 +01:00
Henrik RydgårdandClaude Opus 5.5 a935279cc4 adhoc_session.py: Attach to a running PPSSPP, also on Android, and branch in scripts
attach drives one PPSSPP that's already running with --debugger, like the
app. With --adb it's the app on an Android device: the port is forwarded and
screenshots are taken with screencap, cut to the game's image (--rect) and
scaled to 480x272, so references and positions mean the same as headless.
screencap also doesn't pause emulation, unlike the debugger's screenshot, so
it can watch a run that's being profiled.

until takes --or REF@X,Y for screens that differ between runs (a menu that
only shows with a save), and a script line "if REF --box X,Y : COMMAND ;
COMMAND" runs the commands only when the screen shows the reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:51:21 +01:00
Henrik RydgårdandClaude Opus 5.5 1db3a7ecce Profiler: Log stalls without the UI, and compile it in with a runtime switch
--profile-log=FILE writes each frame's per-thread totals as JSON lines, and
for each slow frame every scope of at least --profile-span-ms that ended in it,
with its own time. A frame is slow if it takes at least --profile-hitch-ms and
1.5 times the median of the frames before it (a 30 fps game takes 33 ms every
frame). Slow frames also get a line in the log naming their longest scopes.
Tools/profile_report.py tells stalls (frames that fell behind the emulated
time they covered, counted with a new vblank scope) from frames where the
game just didn't flip, and sums up what the stalls' time went to.

PROFILE_LABEL names a scope: syscalls get the function's name, CoreTiming
events (now a scope) the event's, module loads the file and pipeline creation
the pipeline's tag. New scopes cover sleeps (labelled with their reason, and
the frame-idle wait now goes through sleep_us), Vulkan's waits for the GPU, the
render thread, pipelines and shaders, and GPU memory copies, uploads and
framebuffer readbacks.

The profiler is now compiled in everywhere except iOS, UWP, libretro and
Android release builds, and off until --profile-log, the frame profile overlay
or an ImGui profiler window turns it on. Off, a scope costs a load and a
branch. While on, syscalls compiled by the JIT go through CallSyscall, which
has the scope (decided in GetQuickSyscallFunc, instead of at build time in
each backend). The frame profile overlay's slot is no longer build-dependent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:51:21 +01:00
Henrik RydgårdandClaude Opus 5.5 377fa492b3 Profiler: Record per-thread event timelines, and add timeline and graph windows
The frame profiler kept its state in 4 thread slots, and every thread after
the fourth shared the last one's nesting depth and stack without locking.
Several threads entering and leaving scopes at once corrupted the depth,
which then indexed outside the arrays and crashed.

Now each thread that enters a scope gets a lane of its own: a ring of begin
and end events that only it writes. A thread that exits hands its lane to the
next new one; lanes are never freed, so readers can walk them without locks,
and a reader checks that an event wasn't overwritten while it copied it. A
literal scope name is looked up once per call site.

The per-frame totals (time in each category, not counting nested scopes) are
worked out from the events at each frame end, so the on-screen overlay keeps
working. The ImGui debugger gets a CPU profiler window, with a zoomable
timeline of each thread's scopes and frame ends plus a table of per-frame
totals, and a Frame profile window with the overlay's graph, per thread.

The Vulkan render thread has scopes for rendering, submitting and presenting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:50:57 +01:00
Henrik RydgårdandClaude Opus 5.5 78c7ce36b1 Translate "Share"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:45:04 +01:00
Henrik Rydgård 9bd1d59902 Reorder 2026-11-11 12:44:09 +01:00
Henrik RydgårdandClaude Opus 5.5 ad58640cdb Frame dumps: Offer Share, Save or Continue where folders can't be shown
On Android, Save picks a place with the system's save picker, through
a new BROWSE_FOR_FILE_SAVE implementation (ACTION_CREATE_DOCUMENT).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:43:03 +01:00
Henrik Rydgård 7dcd534c43 Merge pull request #23039 from hrydgard/crash-screen-gpu-model
Crash screen: Show the GPU model and emulated time, tighten the spacing
2026-11-11 12:21:13 +01:00
Henrik Rydgård 9cc2d2d378 Reorder the buttons on the SavedataScreen Popup 2026-11-11 12:14:47 +01:00
Henrik RydgårdandClaude Opus 5.5 2fae0dadba Game screen: Export all of a game's saves from its menu
Exporting a single save stays in the savedata manager's popup. Exporting
a whole game's saves is rarer, so it moves to the game's menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:14:46 +01:00
Henrik RydgårdandClaude Opus 5.5 ec36fad065 Savedata manager: Make the highlight outline show up
An outline with a zero radius isn't drawn at all, so use square corners
instead. Medium width, so it doesn't look like the gaps between rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:14:46 +01:00
Henrik RydgårdandClaude Opus 5.5 95e7f2d686 Savedata manager: Export a game's saves from the popup of one of them
The page-wide button depended on the game the screen was opened for, which
the screen doesn't show. The popup knows the game from the save itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:14:46 +01:00
Henrik RydgårdandClaude Opus 5.5 9be62bb7d5 Frame dumps: Share them as zips where folders can't be shown
Where the file can't be shown in its folder (Android, iOS), "Create frame
dump" in the dev menu now zips the dump and opens the share sheet. The
frame dump hotkey does the same when its message is clicked, so the game
isn't interrupted. Both go through OnFrameDumpCreated(), which moves the
share to the main thread, since the recorder calls back on the emulation
thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:14:46 +01:00
Henrik RydgårdandClaude Opus 5.5 f2c049d178 Translate the savedata export strings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:14:46 +01:00
Henrik RydgårdandClaude Opus 5.5 ba94ea9da8 Savedata manager: Export saves as zip files
- Fixes #23031

The savedata popup gets an Export button, which zips the save folder (or
a savestate and its screenshot) and hands it to ShareFilesAsZip: the
share sheet on Android and iOS, a Save As dialog on Windows. The zips
use the layouts the zip import already recognizes, so they import back.

Opened for a game, the manager outlines that game's saves and states,
scrolls to them, and offers to export all of the game's saves in one
zip. The game screen gets a button that opens it that way.

Installed data is never exported. It looks like a save (same PARAM.SFO
fields), but games write the installed files with sceIo rather than the
savedata utility, so they're missing from SAVEDATA_FILE_LIST.
IsInstalledGameData() counts a folder as installed data when the files
outside that list add up to 10 MB or more. The game screen's split
between save and install sizes now uses it too, instead of treating any
folder over 10 MB as installed data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:14:46 +01:00
Henrik RydgårdandClaude Opus 5.5 a7209dc832 Crash screen: Show the emulated time
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:39:01 +01:00
Henrik RydgårdandClaude Opus 5.5 f1f87f7f98 Crash screen: Space the blocks by their measured height
The fixed offsets left big gaps after short blocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:35:43 +01:00
Henrik RydgårdandClaude Opus 5.5 639427ee9c Crash screen: Show the GPU model and the graphics API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 11:33:54 +01:00
Henrik Rydgård 362be19c51 Merge pull request #23037 from hrydgard/vulkan-pipeline-wait-lock
Vulkan: Don't hold a pipeline's lock while waiting for it to compile
2026-11-11 10:44:19 +01:00
Henrik RydgårdandClaude Opus 5.5 70b0bfec34 Vulkan: Don't hold a pipeline's lock while waiting for it to compile
When the render thread reaches a pipeline that isn't compiled yet, it waits
for it, and it did so holding the pipeline's mutex. EndCurRenderStep takes
that mutex for every pipeline a render step used, so the emulation thread
stopped in the middle of a draw until the compile was done: 302 ms in one
frame of a Tekken 6 fight on a Galaxy S8, for a pipeline that waited 251 ms
for its shaders and then compiled in 68 ms.

The render thread now only holds it to find (or make) the Promise, and waits
without it. DestroyVariants, which can drop Promises meanwhile, deletes them
through the deletion queue like the VkPipelines, so they outlive the wait.
The emulation thread now runs on until the frame queue is full.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 08:49:16 +01:00
Henrik Rydgård 6dfe2b1d89 Merge pull request #23036 from hrydgard/module-load-speed
Speed up module loading and unloading
2026-11-11 08:47:46 +01:00
Henrik RydgårdandClaude Opus 5.5 420db34118 MIPSAnalyst: Hash and name only the functions of the module just loaded
Each module load hashed every function found so far, rebuilt a hash-to-
function multimap of all of them to apply the hash map, reinstalled every
replacement and reloaded the builtin hash table. Unloading rebuilt the
multimap too.

Now FinalizeScan() takes the scanned range and does that work only for the
functions there (and any never hashed), looking each up in the hash map
directly, and the builtin table is loaded once. With knownfuncs.ini enabled
it still names functions everywhere, since the file can add names for any.

On a Galaxy S8, Tekken 6's sceUtilityLoadModule went from 67 ms to 13-23 ms,
its LoadModuleDone events from 48-57 ms to 12-18 ms, and unloading modules
from 13-22 ms to under 10.

Regression risk: code that changed in memory outside a module load is no
longer rehashed as a side effect of the next load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 23:17:01 +01:00
Henrik RydgårdandClaude Opus 5.5 7bc99a54ea SymbolMap: Update the active symbols per module on load and unload
Loading or unloading a module flagged the active symbol copies for a full
rebuild, a std::map insert per symbol of every module ever loaded, which the
next lookup paid. MIPSAnalyst's ApplyHashMap looks up labels right after a
load, so that was half the time of loading a firmware module.

Now just that module's symbols are added to or removed from the active
copies, without renumbering every function (the index only alternates
disassembly colors, and SortSymbols() still renumbers). On a Galaxy S8,
Tekken 6's sceUtilityLoadModule went from 126-139 ms to 67 ms, and its
LoadModuleDone events from 68-94 ms to 48-57 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 23:17:00 +01:00
Henrik Rydgård 5108c39cec Merge pull request #23035 from hrydgard/jit-compile-timing
IR JIT: Compile ~38% faster, and measure compile stalls
2026-11-10 21:51:57 +01:00
Henrik RydgårdandClaude Opus 5.5 b912c9e992 IR: Make IRWriter move-only
Nothing copies one: IRFrontend's copy constructor (for the debugger) leaves
its writers out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:50:12 +01:00
Henrik RydgårdandClaude Opus 5.5 16bb808a56 IR JIT: Remove what's left of function preloading
FindPreloadBlock (and HashMatches, which only it used) had no callers, and a
few comments still mentioned preloading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:49:35 +01:00
Henrik RydgårdandClaude Opus 5.5 f3eb70aac4 IR interpreter: Keep blocks' profile stats when the block table grows
JitBlock's move constructor didn't copy profileStats_, so every time the
vector of blocks reallocated, all execution counts and times went back to
zero, and --jit-profile under the IR interpreter only counted what ran since.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:49:09 +01:00
Henrik RydgårdandClaude Opus 5.5 81e1593eb4 Don't save the CPU core picked with --cpu
Like the other command-line overrides (--graphics etc). Running once with
--cpu=ir otherwise left the IR interpreter as the saved setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:32:52 +01:00
Henrik RydgårdandClaude Opus 5.5 d0e12caabb IR: Cheaper bookkeeping in PurgeTemps
Exits and barriers read every reg, and filled all 256 lastReadFrom entries
twice each, for GPRs and FPRs. That's now one index. The FPR lists are only
built for instructions with an FPR operand, and the working list holds IRInsts
rather than IRInstMetas (a quarter of the size). The output is the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:32:17 +01:00
Henrik RydgårdandClaude Opus 5.5 dcc51d8b18 IR: Keep IRWriter's instructions in a FastVec, and inline Write
The compiler didn't inline std::vector<IRInst>::push_back into IRWriter::Write,
which the frontend and every pass call for each instruction. FastVec's append
is a compare and a store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:26:45 +01:00
Henrik RydgårdandClaude Opus 5.5 235c5ba87b IR JIT: Reorder RegStatusNative to drop its padding
8 bytes instead of 12. Start() copies them for each block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:19:51 +01:00
Henrik Rydgård 55d87956fc Merge pull request #23032 from hrydgard/ghidra-client-uncap
Increase response size limit for ImStructViewer to 256Mb
2026-11-10 20:13:40 +01:00
Henrik RydgårdandClaude Opus 5.5 13e275ac24 IR JIT: Reuse scratch buffers instead of allocating for every block
PurgeTemps' and OptimizeLoadsAfterStores' working lists, IRApplyPasses'
buffers, the frontend's output writer, IRJit::Compile's instruction list and
the backends' per-instruction address list all keep their capacity between
blocks. The pass ones are static, since compiling only happens on the
emulation thread.

--jit-bench on a Galaxy S8: 21.4-22.4 -> 20.1-20.6 us per block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 20:11:03 +01:00
Henrik RydgårdandClaude Opus 5.5 1116a2a971 IR JIT: Bit masks for known constants, smaller reg status, skip Vec4 pass
- IRImmRegCache keeps which regs are known and written as bit masks rather
  than bool arrays, so they're quick to clear for each block, and FlushAll
  walks the set bits instead of memchr'ing.
- RegStatusMIPS is packed to 12 bytes from 24. Start() copies all 256 of them
  for each block.
- ReduceVec4Flush returns early for blocks without a Vec4 or matrix operand,
  which is most of them, rather than clearing its three 256-entry arrays.

With the inlining: --jit-bench on a Galaxy S8, 23.5-24.3 -> 21.4-22.4 us per
block. The IR is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:59:44 +01:00
Henrik RydgårdandClaude Opus 5.5 8b05f62e90 IR JIT: Inline the small helpers the passes and backends call per instruction
GetIRMeta(IROp), the IRAnalysis read/write queries (IRReadsFromGPR/FPR,
IRDestGPR, IRDestFPRs, ...), JitOptions::Disabled and the ARM64 emitter's code
pointer getters move to the headers. The passes call the IRAnalysis ones for
each instruction and each register they track.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:59:44 +01:00
Nemoumbra 69bc6ef8d8 Increase response size limit for ImStructViewer to 256Mb 2026-11-10 21:51:45 +03:00
Henrik RydgårdandClaude Opus 5.5 dd3496d63d IR JIT: Find native blocks by start with a DenseHashMap, inline reg checks
FindBlockByStart runs for every exit and link. IsValidGPR/IsValidFPR are asked
all the time by the register cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:45:35 +01:00
Henrik RydgårdandClaude Opus 5.5 7079ea8d18 IR: Keep PurgeTemps and OptimizeLoadsAfterStores linear
PurgeTemps went through every check since the first live one for each
instruction, though most end soon after they start. Ended ones are now
dropped. OptimizeLoadsAfterStores skips looking at what an instruction writes
when it knows of no loads or stores. The output is the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:42:58 +01:00
Henrik RydgårdandClaude Opus 5.5 c046b83ce9 Android: Link ppsspp_jni with -Bsymbolic-functions
Calls between functions in the library went through PLT stubs, since it's
built with default visibility. The stubs were about 5% of JIT compile time on
a Galaxy S8 (the IR passes call many small helpers in other files), and a few
percent of the emulator thread in general. --jit-bench: 27 -> 25 us per block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:40:09 +01:00
Henrik RydgårdandClaude Opus 5.5 c3d4ad3485 ARM64 IR JIT: Fix storing regs in pairs at exits
Since decccf199a, FlushAll's needsFlush() only accepted regs in memory, so no
pair was ever stored with STP, and the loop over all 256 regs found nothing.
It now takes regs that are out of memory (an IMM always counts as dirty),
respects the gprs/fprs arguments, only looks where STP can reach (regs 1-63),
and stores adjacent IMMs in the upper GPR range (like HI and LO) together.
A REG_AS_PTR reg is also unadjusted by its native reg, not the IR reg.

The x86 IR JIT has the same inverted check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:38:24 +01:00
Henrik RydgårdandClaude Opus 5.5 5c2521729d IR JIT: Compile faster
Measured with --jit-bench on a Galaxy S8, recompiling the 11243 blocks of a
Tekken 6 state: 363 ms -> 300-310 ms, with the ARM64 pairing fix.

- FlushAll, which runs at every exit, no longer looks at all 256 regs. FPRs are
  found from the native regs, and the rest are skipped quickly when in memory.
- IRApplyPasses alternates between two buffers that keep their capacity,
  instead of allocating a new one for each pass.
- The ARM64 backend flushes the icache once per block, for the block's code and
  every exit linked to and from it, with one set of barriers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:38:19 +01:00
Henrik RydgårdandClaude Opus 5.5 f4b35e08f7 IR JIT: Add --jit-bench, and time compiles with the CPU counter
--jit-bench=N recompiles every block in the cache five times after N flips
(at the next safe point), and logs each run and the best, split by phase. With
a state loaded, it gives the same set of blocks every time, so changes to the
compiler can be compared run to run. Game frames vary too much for that.

The compile timing reads cntvct_el0 on ARM64 Linux/Android rather than calling
clock_gettime(), through time_now_fast_ticks(). The counter's rate is measured
against time_now_raw(), since a Galaxy S8 reports 0 in cntfrq_el0 (it runs at
26 MHz there).

The IR pass list in DoJit is a fixed array instead of a vector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:38:19 +01:00
Henrik RydgårdandClaude Opus 5.5 6860e78bf7 IR JIT: Time compilation, and log frames that spend long compiling
Totals per phase (frontend, passes, backend, finalize/linking, invalidation and
full clears) go in the JIT stats overlay, and the frame graph draws each frame's
compile time in orange at the bottom of its bar. A frame that spends 2 ms or
more compiling logs a "JIT stall" line with the breakdown.

Each phase boundary reads the clock once, since that costs around 0.3 us on a
Galaxy S8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 19:14:08 +01:00