Skip to content

tasks/task_save: stop reallocating and copying the undo snapshot on every state load - #19315

Merged
LibretroAdmin merged 2 commits into
masterfrom
savestate-cont
Jul 31, 2026
Merged

tasks/task_save: stop reallocating and copying the undo snapshot on every state load#19315
LibretroAdmin merged 2 commits into
masterfrom
savestate-cont

Conversation

@LibretroAdmin

Copy link
Copy Markdown
Contributor

tasks/task_save: stop reallocating and copying the undo snapshot on every state load

Follow-up to #19314. That PR made the save/load transfer loops budget-bound, which moved the bottleneck: the budgeted quantum loops are now a minority of the cost and never stall a frame, and what's left is unsliced. Profiling the merged code showed the largest remaining single-frame stall on the load path isn't the file I/O at all — it's a serialize nobody looks at.

content_load_state_cb calls content_save_state(path, false) before every state load, to capture the undo snapshot. That's a full core_serialize, on the main thread, in the same tick as the deserialize that follows it. It ran into a freshly malloc'd buffer every time. And content_undo_load_state then malloc'd a full-size temporary and copied the whole state into it a second time.

Two commits: keep the capture's buffer, then stop copying it during undo.


1. Keep the snapshot's allocation instead of reallocating it

A state-sized allocation is served by mmap rather than out of the heap's free lists, so free() hands the pages straight back to the kernel and the next capture faults every one of them in again on first touch. That fault traffic is not a rounding error next to the copy it exists to hold — measured in isolation against a 16 MiB state, a fresh malloc plus the copy costs 5.08 ms where the copy into a retained buffer costs 1.88 ms.

content_serialize_reusing() serializes into a caller-owned buffer, allocating only when what's there is too small. content_save_state() swaps the filled spare with the live snapshot rather than freeing and assigning.

Swapping rather than writing in place is load-bearing, not incidental. Serializing straight over the live snapshot would leave a half-written one that still looks valid, so an undo after a failed capture would restore garbage. Filling the spare first means the live snapshot is untouched until a complete one is ready to replace it — which is exactly what the fresh-allocation form did. There's a test for it.

The spare is only ever held when undo is enabled: content_save_state returns before reaching any of this when save_state_disable_undo is set. That setting is a statement that memory is scarce, and it still means one buffer rather than two.

struct save_state_buf gains a capacity field. Only undo_load_buf needs it — it's the only buffer whose allocation may outlast a snapshot of a different length — but it's maintained on all three users so the next person to reuse the struct can't read it stale.

2. Detach the snapshot during undo instead of copying it

content_undo_load_state restores the snapshot in undo_load_buf, but first captures the current state over that same buffer so the undo is itself undoable. It resolved that with a full-size temporary and a whole-state memcpy — a second state-sized allocation and a second full copy, on top of the capture's own, in one unsliced call on the main thread.

Detaching does the same job for nothing. After the detach undo_load_buf owns no buffer, so the capture serializes into the spare and swaps that in, and neither the capture nor the swap can touch the bytes being restored — which is the only thing the copy was protecting. Afterwards the detached allocation has no owner, so it becomes the next spare rather than going back to the kernel.

In steady state the whole undo path allocates nothing and copies the state exactly once, into the core.

One behaviour is preserved deliberately: if the capture fails, the detached snapshot goes back into undo_load_buf rather than to the spare, leaving the buffer holding what the copy-based form left it holding in that case. Without that, the failure path would silently empty the undo buffer.


Before / after

Median of fifteen. Intel Xeon @ 2.10 GHz (1 vCPU), ext4 on virtio, GCC 13.3.0, -O2.

Measurement note, because it changes the numbers materially: these are measured with the churn a real load puts around the capture — a state-sized buffer allocated, filled, deserialized and freed between each pair of iterations. Measuring back-to-back captures instead leaves the destination in cache and the allocator warm, and understates what a load actually costs. My first pass at this used the tight-loop form and reported a 16 MiB capture at 1.9 ms where the same capture inside a real load sequence measures 8–9 ms. The tables below are the churn-inclusive figures.

Undo snapshot capturecontent_load_state_cb does this on every state load

State Before After  
4 MiB 0.59 ms 0.36 ms −39%
16 MiB 2.29 ms 1.89 ms −17%
64 MiB 39.47 ms 12.17 ms −69%

The file read barely registers, because #19314's budgeted quantum loop already spreads it across ticks — it never lands in the stall. mmap would cut total load ticks (roughly 6 → 2) but wouldn't move the frame stall.

Against that, the plumbing is significant. The loaded buffer doesn't stay in the handler: it flows to task_load_handler_finished, then to content_load_state_cb, which either free()s it or stores it in undo_save_buf — which is then freed in undo_save_state_cb, freed again in content_reset_savestate_backups, and handed to a task that takes ownership of it. A mapping needs a release-kind flag threaded through all of those, plus rzip detection with fallback, plus a VFS path that can produce a real fd, plus pre-faulting under the tick budget so cold-cache faults don't land unsliced in the deserialize. That's a lot of surface for the smallest of three components.

If the load stall is to come down further, the target is the capture itself, and the honest options are behaviour changes rather than optimizations — skipping the capture when undo-load has never been used in the session, or making it lazy (capture on the first undo request rather than on every load, trading one slow undo for zero cost on every load). Both are worth discussing separately.

Out of scope, unchanged

content_auto_save_state still has an unused-settings warning in non-HAVE_COMPRESSION builds (it's used inside the ifdef). Pre-existing and cosmetic; left alone.

libretroadmin added 2 commits July 31, 2026 06:05
…ing it

Profiling the merged transfer-budget work showed the bottleneck had
moved.  The budgeted quantum loops are now a minority of the cost and
never stall a frame; what is left is unsliced, and the largest piece
of it is a serialize nobody looks at.

content_load_state_cb calls content_save_state(path, false) before
every state load, to capture the undo snapshot.  That is a full
core_serialize, on the main thread, in the same tick as the
deserialize that follows it - so it is the hottest serialize the
frontend performs, and it ran into a freshly malloc'd buffer every
time, freeing the previous one.

A state-sized allocation is served by mmap rather than out of the
heap's free lists, so the free hands the pages straight back to the
kernel and the next capture faults every one of them in again on
first touch.  That fault traffic is not a rounding error next to the
copy it exists to hold.  Measured in isolation against a 16 MiB
state, a fresh malloc plus the copy costs 5.08ms where the copy into
a retained buffer costs 1.88ms: 63% of a serialize spent obtaining
memory rather than filling it, and 72% at 4 MiB.

The snapshot now keeps the allocation it hands back.
content_serialize_reusing() serializes into a caller-owned buffer,
allocating only when what is there is too small, and
content_save_state() swaps the filled spare with the live snapshot
rather than freeing and assigning.

Measured on the capture itself, median of fifteen, with the churn a
real load puts around it - a state-sized buffer allocated, filled,
deserialized and freed between each pair of captures.  That churn
matters to the figure: measuring back-to-back captures instead leaves
the destination in cache and understates what a load actually costs.

              before     after
    4 MiB     0.59ms     0.36ms
   16 MiB     2.29ms     1.89ms
   64 MiB    39.47ms    12.17ms

The variance goes with it, which for a frametime is the point: the
after figures repeat to within 0.05ms across runs where the before
figures wander by 0.5ms at 64 MiB and more when the box is busy.  A
capture whose cost depends on how the kernel feels about handing back
pages is a capture that occasionally drops a frame.

Swapping rather than writing in place is what preserves the failure
semantics exactly.  Serializing straight over the live snapshot would
leave a half-written one that still looks valid, so an undo after a
failed capture would restore garbage; filling the spare first means
the live snapshot is untouched until a complete one is ready to
replace it, which is what the fresh-allocation form did.  Pinned by
test, not by argument.

The spare is only ever held when undo is enabled.  content_save_state
returns before reaching any of this when save_state_disable_undo is
set - that setting is a statement that memory is scarce, and it now
means one buffer rather than two, as it did before.

struct save_state_buf gains a capacity field.  Only undo_load_buf
needs it, since it is the only buffer whose allocation may outlast a
snapshot of a different length, but it is maintained on all three
users so the next person to reuse the struct cannot read it stale.

Three tests, all verified against the pre-change tree:

Eight consecutive captures at a fixed size must perform two
state-sized allocations, not eight.  This one needs a counting
allocator - the sample links with -Wl,--wrap=malloc - because pointer
identity cannot see it: munmap hands the region back and the next
request of the same size is usually given the same address, so the
captures look identical whether the buffer was reused or reallocated
while only one of them pays the faults.  The pre-change tree measures
eight.

A reused buffer must still grow.  The serialized length tracks the
core's state size, so a buffer that failed to grow would overflow and
one that grew while keeping a stale length would truncate; undoing
back to each size checks both.

A failed capture must leave the previous snapshot byte-exact and
still undoable.  This is the property the swap exists to preserve and
the one an in-place rewrite would have broken silently.

Not addressed, and next in the same direction: content_undo_load_state
mallocs and copies a full state-sized temporary of its own, and the
load path's read buffer exists only to be memcpy'd into the core -
mapping the state file would remove that one outright.

Verified: sweep clean in all three configurations on both lanes -
plain, ASan/UBSan/LeakSan with detect_stack_use_after_return, and
TSan - with --wrap=malloc confirmed working under each; the
allocation-count test confirmed to measure eight on the pre-change
tree; full RetroArch build and link clean; griffin-shape single-TU
compile clean; workflow YAML parses.
content_undo_load_state restores the snapshot in undo_load_buf, but
first it captures the current state over the top of that same buffer
so the undo is itself undoable.  It resolved that by malloc'ing a
full-size temporary and memcpy'ing the whole state into it - a second
state-sized allocation and a second full copy, on top of the
capture's own, in one unsliced call on the main thread.

Detaching does the same job for nothing.  After the detach
undo_load_buf owns no buffer, so the capture serializes into the
spare and swaps that in, and neither the capture nor the swap can
touch the bytes being restored - which is the only thing the copy was
protecting.  Afterwards the detached allocation has no owner, so it
becomes the next spare rather than being handed back to the kernel.

In steady state the whole undo path therefore allocates nothing and
copies the state exactly once, into the core.  Measured end to end,
median of fifteen, with a load's churn between iterations - a
state-sized buffer allocated, filled, deserialized and freed - since
back-to-back undos leave both buffers in cache and flatter the
result:

              before     after
    4 MiB     3.81ms     0.70ms
   16 MiB    18.82ms     4.12ms
   64 MiB    92.42ms    24.29ms

Most of that is the copy this removes rather than the allocation: an
undo used to touch the state four times over (copy out, serialize in,
deserialize, and the allocator zeroing pages behind it) and now
touches it twice.

One behaviour is preserved deliberately.  If the capture fails, the
detached snapshot goes back into undo_load_buf rather than to the
spare, which leaves the buffer holding what the copy-based form left
it holding in that case - the state that was just restored, still
undoable.  Without that the failure path would silently empty the
undo buffer.

Pinned by a test that measures allocations rather than time: six
consecutive undos in steady state must perform zero state-sized
allocations, where the copy-based form performs six.  The same test
checks that an even number of undos of a two-state alternation lands
back on the original state, which is what shows the buffers were
genuinely swapped rather than merely not allocated - a version that
detached without restoring correctly would pass the allocation count
alone.

Verified: sweep clean in all three configurations on both lanes -
plain, ASan/UBSan/LeakSan with detect_stack_use_after_return, and
TSan; the allocation test confirmed to measure six on the pre-change
tree; full RetroArch build and link clean; griffin-shape single-TU
compile clean.
@LibretroAdmin
LibretroAdmin merged commit bbdd852 into master Jul 31, 2026
86 checks passed
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.

1 participant