A CHD was read through one decoded hunk and a thread that decoded one
hunk ahead. A game that plays CD audio while it streams data reads the
two from different places a sector at a time, so each threw out the
other's hunk and aimed the thread away from it, and the hunks were
decoded on the emulation thread: a stutter a GDI never shows.
Each stream (data tracks, audio tracks) now reads through its own
libretro-common vfs_prefetch, whose thread decodes the hunks ahead on an
image handle of its own, one window per hunk; sectors are lent from the
windows in place, and a read command's Prefetch() sets the data
stream's thread going before the first sector is asked for. The core
defines HAVE_THREADS where it builds threads, so the prefetcher is not
compiled to stubs. tools/chd/chd_streams_test, run from tools/chd/run.sh
and now in CI, reads audio and data at once and fails when the reading
thread does the decoding.
The loader thread worked out the hash a texture's replacement is filed
under itself: from video memory, which with threaded rendering the game
is writing again by then, and from the texture's own fields, which the
renderer may be updating for the next request. It also looked at the
texture's "dirty" stamp, a plain variable of the renderer's.
The renderer does the hashing now, when it asks - it is updating the
texture at that moment, and the game is held while it does - and leaves
the three names for the loader in the texture before it counts the
request. The loader reads the count and then the names, loads, and
takes its count off: if a request came in meanwhile, the count it took
off is not all there was, it goes round again, and what it loaded from
names that may have been half one request's and half the next's is
dropped. The image and its size are still only looked at by the renderer
once the count is back to zero. Nothing else of a texture is read by the
loader.
(The "dirty" check saved loading for a texture that had been invalidated
since it asked. Such a texture asks again when it is next updated, and
the image loaded for nothing is freed then, or with the texture.)
tools/threads: the model of the loader's queue carries a three-word
request the same way and checks that what was made of each node's last
request is that request's, whole; under TSan and ASan + UBSan.
Run: the test disc with a pack of sixteen magenta replacements for the
textures it dumps, threaded rendering on - the screenshot has 41,148
magenta pixels against 24,228 without the pack; with the option on and
no pack, 24,228.
Not done: the audit's bound on what is queued. A texture is on the list
at most once and holds at most one loaded image, so both are bounded by
the number of textures, not by bytes.
Three were left in the core's own sources: the count of custom-texture
load requests in BaseTextureCacheData, the reset request flag, and an
unused <atomic> include in gles.h. They are retro_atomic like the rest;
the mirror of the texture counter in tools/threads/threads_test.cpp goes
the same way. No change in behaviour.
With Threaded Rendering on, nothing tied the emulation to retro_run. The
emulation thread ran free, held back only by the audio it pushed to the
frontend itself, 512 samples at a time, from its own thread, whenever
that call blocked. retro_run waited up to 100 ms for whatever render
turned up and returned a duplicate frame if none did. A render the game
started while the last one was still being drawn was thrown away. So a
retro_run could last any length, carry a new frame or not, and its audio
arrived some other time from some other thread.
Each retro_run now makes exactly one frame, the same one it makes with
Threaded Rendering off:
- retro_run wakes the emulation thread, which runs the machine up to
vblank and goes back to sleep, as dc_run() does in retro_run itself
when rendering is not threaded;
- when the game starts a render the emulation thread hands it to the
libretro thread and sleeps; that thread reads what the frame takes
from the game's memory (display lists, textures, framebuffer), lets
the emulation thread go on, and draws. A render to a texture, which
ends up back in the game's memory, keeps it asleep until it is done;
- every render is drawn. The emulation thread waits for the one before
to be finished; none is dropped;
- at vblank the frame's audio is sent from the libretro thread in one
batch, together with its video.
The renderers read some PVR registers while they draw, by which time the
emulation has moved on and may have changed them. Each render now
carries a copy of the registers made when the game started it, and the
renderers' files read through that (PVR_REGS_FOR_RENDERER), so they draw
from what non-threaded rendering would have read.
EmuBaton (core/libretro/emu_baton.h) is that handshake. There is no
lock, no sleep and no timeout in it: every change of hands is one atomic
operation and a thread with nothing to do sleeps on an eventcount. A
save state from the frontend's task thread takes the machine between
two frames.
The "synchronous rendering" and "delay frame swapping" options have
nothing left to choose and are hidden.
tools/threads/live.sh runs the test disc for the same 300 frames with
Threaded Rendering on and off and requires the same screenshot, byte
for byte, and the same save state (live_same.py leaves out the host
addresses a state carries). The old threaded path fails that: its state
differs from the non-threaded one. tools/threads/run.sh puts the new
EmuBaton under ThreadSanitizer.
The texture cache write-protects the pages of video memory its textures
were read from, and a write to one of them faults. Everything that
followed sat under vramlist_lock: the fault handler on the emulation
thread took it to walk the page's lock list, mark the textures changed,
free the lock blocks and unprotect the page, and the render thread took
it for every texture it locked or deleted. That was a mutex taken
inside a signal handler and held by the other thread across mprotect
calls.
The lock lists now belong to the render thread alone. On a fault the
emulation thread unprotects the page and marks it in a cMarkSet
(core/lockfree.h): the mprotect calls and two atomic operations, with
no lock, no list and no malloc or free. The render thread collects the
marks before it looks at a texture and takes the locks off those pages
itself, which is what marks their textures changed.
Unprotecting before marking is what keeps this sound. A page the
emulation thread has opened up is always marked afterwards, so the
render thread can go on assuming that a page with locks on it is
protected: where it is not, a mark is coming that removes those locks.
The same mutex guarded vmem32's list of protected ranges, which the
emulation thread walked when it mapped video memory into the 32-bit
address space of an MMU game. That list is gone. _vmem_protect_vram()
and _vmem_unprotect_vram() keep one bit per page, and vmem32 protects
the pages of a new mapping whose bit is set.
This was the last user of cMutex, which goes too.
tools/threads/run.sh has one thread mark and another collect under
ThreadSanitizer and checks that no mark is lost. With the mark taken
out of the fault path, tools/threads/live.sh fails on its screenshot:
the screen stays on the colour the texture had before.
In threaded rendering the libretro thread samples input once a frame
and the emulation thread reads it whenever the game polls a controller.
Both did it under input_mtx, the last mutex taken on every frame of
normal play.
The libretro thread now fills a snapshot of all four ports and
publishes it whole through a cTripleBuffer (InputLatch,
core/libretro/input_latch.h); the emulation thread reads the newest
one. Neither waits for the other, and a read never sees half of one
frame's state and half of the next. In non-threaded rendering the one
thread there is samples and reads the globals as before, now without
taking a lock it had no use for.
Mouse motion goes through the latch as running totals, and the reader
takes the difference from what it was last given. Under the mutex each
frame's motion overwrote the last, so motion from a frame the game did
not poll in was dropped; it is now carried into the next poll, and
polling twice in a frame does not count it twice.
tools/threads/run.sh publishes and reads the latch from two threads
under ThreadSanitizer, checking that every read is one whole frame and
that the mouse motion adds up exactly. The live test's disc polls its
controller every frame, so the emulation thread's read runs there too.
The render thread queued textures for the custom texture loader thread
in a vector under a mutex. The queue is now cMpscList
(core/lockfree.h): a push is one compare-and-swap, the loader takes
everything queued in one exchange, and the link lives in the texture so
nothing is allocated.
A texture is on the list at most once. It is pushed when its
custom_load_in_progress count leaves zero; requests that arrive while
it is queued or being loaded only raise the count, and the loader goes
round again until it has answered them all.
Terminate() used to empty the queue without lowering those counts, so
a texture that was still waiting stayed "loading" for good: it could
not be deleted and never showed its custom image. The loader and
Terminate() now clear the count of everything they drop.
The CHD reader decodes the next hunk on a worker thread. The worker and
the disc read shared their state under a std::mutex and a condition
variable, and every read of a new hunk took the lock.
HunkPrefetch (core/imgread/hunk_prefetch.h) does the same job with no
lock. The two threads own one buffer each and swap through a third
(cTripleBuffer, core/lockfree.h), so the reader either finds the hunk it
wants in one atomic exchange or decodes it itself, and never waits for
the worker. The worker sleeps on an eventcount until the reader aims it
at another hunk. It uses rthreads instead of std::thread.
A hunk the worker could not decode is never handed over; the reader
decodes it itself and reports the failure as before.
tools/threads/run.sh reads a fake image through HunkPrefetch,
sequentially with seeks, under ThreadSanitizer, checking every hunk.
In threaded rendering the emulation thread runs the machine, and a save
state, load state, reset or unload has to take it away first. That was
done with two mutexes and two polling loops: the caller waited for the
CPU to report running by sleeping a millisecond at a time, stopped it,
then tried the main-loop mutex once a millisecond until the emulation
thread had let go, giving up after five seconds either way.
A second request arriving before the emulation thread had resumed from
the first could not work. The second caller took mtx_serialization
ahead of the emulation thread, which was still queued on that same
mutex, and then waited for the CPU to start running; it could not, so
the call sat out its five seconds and failed. The frontend saves states
from a background thread in this mode, so two callers at once is normal.
There is now one atomic state word (EmuBaton, core/libretro/emu_baton.h)
saying who has the machine: the emulation thread, or the one caller it
has parked for. Each change of hands is a single compare-and-swap, the
emulation thread and any caller that has to wait sleep on an eventcount
until the word changes, and a caller that finds the machine already
held simply takes it next. No mutex, no sleep, no clock.
Unload now parks the emulation thread before telling it to exit. It
used to stop the CPU and join, and a stop that landed while the thread
was between two runs of the CPU was overwritten by the next run.
tools/threads/live.sh boots a test disc in RetroArch with Threaded
Rendering on and sends save state, load state and reset back to back
while it runs. Against the old handshake it fails with "Failed to load
state" and "could not serialize core state"; it passes now.
tools/threads/run.sh puts EmuBaton under ThreadSanitizer with two
callers and a stand-in for the CPU loop.
Three kinds of mutex sat on the path every frame takes from the
emulation thread to the render thread:
mtx_rqueue guarded the one-entry render queue. The queue is a single
pointer the emulation thread fills and the render thread empties, so it
is now an atomic pointer.
mtx_pool guarded the vector of spare TA contexts both threads recycle
into. The spares now live in cSlotCache (core/lockfree.h): three atomic
slots, a compare-and-swap to put a context back, an exchange to take
one out.
Every TA context carried two mutexes of its own. thd_inuse was never
taken. rend_inuse was taken by the renderer while it parsed a context
and by Reset(), but a context in the render queue belongs to the render
thread until FinishRender() and the emulation thread cannot reach it,
so the two never met. Both are gone, and with them two mutex
allocations per context and a lock/unlock pair on every Reset().
cResetEvent was a mutex and a condition variable: every Set() took the
lock, and so did every Wait(). The render thread and the emulation
thread signal each other through these several times a frame.
It is now an atomic flag and a retro_eventcount. Set() is one store,
plus a wake when a thread is actually asleep in Wait(); a Wait() that
finds the event already set is one exchange. No lock is taken on either
side. cEventCount wraps the eventcount for the other places that sleep
until another thread moves.
The untimed Wait() also waited on its condition variable once and
returned whether or not the event had been set, so a stray wake-up
passed for a signal. Both waits now loop on the flag; the timed one
gives a stray wake a few more laps and stays bounded.
retro_eventcount comes from RetroArch's libretro-common.
tools/threads/run.sh builds a stress test against the core's own
headers and runs it under ThreadSanitizer and under ASan + UBSan.