Files
LibretroAdmin 6516f15e04 GS/SW: the rasterizer job queues park without the process-wide barrier
A rasterizer worker is fed a job a draw by the GS thread and drains
them faster than they come, so it parks many times a frame, and each
park on the asymmetric eventcount is a process-wide barrier: 157 a
frame on a game in play. The asymmetric protocol spares the EE a
locked instruction a GS packet; here the producer is the GS thread
and a locked increment a draw is nothing against the barriers. The
eventcount gains a symmetric init, and the job queues use it: a park
is a kernel wait and no more. MTGS and MTVU are as they were.

200 frames of the game on the software renderer, twice each: 157
barriers a frame become 19, wall 10.2 and 9.7 s become 9.3 and 9.1,
CPU 17.1 and 16.3 s become 15.7 and 15.3.

tests/spsc worksema: the producer/worker contract on the symmetric
pair too, plain and under TSan.
2026-11-05 17:36:29 +01:00

279 lines
11 KiB
C++

/* The worker-notify handshake in pcsx2/WorkEventCount.h.
*
* This is what replaced common/Threading.h's WorkSema class: two
* libretro-common eventcounts and three ints that MTGS and MTVU embed and
* drive through free functions. The seven operations keep the contract
* the class had, and MTGS and MTVU depend on it exactly, so this pins
* each part:
*
* 1. NotifyOfWork then CheckForWork returns true once, then false.
* 2. WaitForWork returns at once when work is pending.
* 3. WaitForWork parks with nothing pending and wakes on notify.
* 4. WaitForEmpty returns only after the worker has consumed every
* notify so far and gone idle -- including a notify that lands
* between the worker's last check and its park.
* 5. WaitForWorkTimed returns false only on a timeout with nothing
* pending, true whenever work arrived.
* 6. Kill makes every wait return and WaitForEmpty report false;
* Reset revives.
* 7. A producer/consumer pair run hard, with WaitForEmpty interleaved,
* loses no wake and never sees WaitForEmpty return while work is
* outstanding.
*
* Run under TSan: the two threads share nothing but the primitive.
*
* As with every eventcount test in this series, the lost-wake half of 7
* is only load-bearing on more than one core.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "common/Pcsx2Types.h"
#include "pcsx2/WorkEventCount.h"
extern "C" {
#include <rthreads/rthreads.h>
}
#include <unistd.h>
/* The header wants this from its host; MTGS.cpp defines it in the core.
* Two here, so the spin path is exercised even on a one-core box. */
s32 WorkEventCount_SpinBudget(void) { return 2000; }
static int fails = 0;
#define CHECK(c, msg) do { if (!(c)) { printf(" FAIL: %s\n", msg); fails++; } } while (0)
/* For a check inside a loop: one line if it ever fails, not sixty-four. */
#define CHECK_QUIET(c) do { if (!(c)) { if (!quiet_failed) { printf(" FAIL: state looked empty mid-burst\n"); quiet_failed = 1; } fails++; } } while (0)
static int quiet_failed;
static int g_quiet_pair;
/* ---- 1, 2, 5, 6: single-threaded contract ------------------------- */
static void contract_single(void)
{
WorkEventCount ws;
work_eventcount_init(&ws);
CHECK(!work_eventcount_check(&ws), "fresh sema reports no work");
work_eventcount_notify(&ws);
CHECK(work_eventcount_check(&ws), "notify makes CheckForWork true");
CHECK(!work_eventcount_check(&ws), "a second CheckForWork is false");
work_eventcount_notify(&ws); work_eventcount_notify(&ws); work_eventcount_notify(&ws);
CHECK(work_eventcount_check(&ws), "three notifies are one check");
CHECK(!work_eventcount_check(&ws), "and consumed together");
work_eventcount_notify(&ws);
work_eventcount_wait(&ws); /* must not block */
CHECK(!work_eventcount_check(&ws), "WaitForWork consumed the pending notify");
CHECK(!work_eventcount_wait_timed(&ws, 5), "timed wait with nothing pending: false");
work_eventcount_notify(&ws);
CHECK(work_eventcount_wait_timed(&ws, 5), "timed wait with work pending: true");
/* "Empty" is the worker having gone idle after consuming, not merely
* having consumed: a worker that took work and has not yet checked
* again is still processing, and WaitForEmpty must block for it. The
* old state machine did exactly this, checked. So the worker goes
* idle first -- one CheckForWork that finds nothing -- and only then
* is WaitForEmpty immediate. */
CHECK(!work_eventcount_check(&ws), "worker drains and finds nothing: idle");
CHECK(work_eventcount_wait_empty(&ws), "idle worker: WaitForEmpty returns at once, true");
work_eventcount_kill(&ws);
CHECK(!work_eventcount_check(&ws), "dead: CheckForWork false");
work_eventcount_wait(&ws); /* must not block */
CHECK(work_eventcount_wait_timed(&ws, 1000), "dead: timed wait true at once");
CHECK(!work_eventcount_wait_empty(&ws), "dead: WaitForEmpty false");
work_eventcount_reset(&ws);
CHECK(!work_eventcount_check(&ws), "reset: alive and idle");
CHECK(work_eventcount_wait_empty(&ws), "reset: WaitForEmpty true");
/* And the first sequence again: a fresh object is idle. */
{
WorkEventCount fresh;
work_eventcount_init(&fresh);
CHECK(work_eventcount_wait_empty(&fresh), "fresh object: WaitForEmpty true at once");
work_eventcount_free(&fresh);
}
work_eventcount_free(&ws);
printf(" contract (single thread): %s\n", fails ? "see above" : "ok");
}
/* ---- 3, 4, 7: the pair ---------------------------------------------- */
static WorkEventCount g_ws;
static retro_atomic_int_t g_queue; /* items the producer has pushed */
static retro_atomic_int_t g_taken; /* items the worker has drained */
static retro_atomic_int_t g_stop;
static retro_atomic_int_t g_bad_empty; /* WaitForEmpty returned with work outstanding */
static void worker(void*)
{
while (!retro_atomic_load_relaxed_int(&g_stop))
{
work_eventcount_wait(&g_ws);
/* Drain: take everything that has been pushed. */
for (;;)
{
int q = retro_atomic_load_acquire_int(&g_queue);
int t = retro_atomic_load_relaxed_int(&g_taken);
if (t >= q) break;
retro_atomic_store_release_int(&g_taken, t + 1);
}
}
}
/* symmetric: the pair on the symmetric protocol (the rasterizer job
* queues' choice), which parks without the process-wide barrier and
* must keep the same contract. */
static void contract_pair(int symmetric)
{
sthread_t* th;
int i, empties = 0;
retro_atomic_store_relaxed_int(&g_queue, 0);
retro_atomic_store_relaxed_int(&g_taken, 0);
retro_atomic_store_relaxed_int(&g_stop, 0);
retro_atomic_store_relaxed_int(&g_bad_empty, 0);
if (symmetric)
{
work_eventcount_init_symmetric(&g_ws);
CHECK(!retro_asym_eventcount_is_asymmetric(&g_ws.work), "symmetric init runs the symmetric protocol");
}
else
work_eventcount_init(&g_ws);
th = sthread_create(worker, NULL);
for (i = 0; i < 20000; i++)
{
int q = retro_atomic_load_relaxed_int(&g_queue);
int burst = 1 + (i % 4), b;
for (b = 0; b < burst; b++)
{
retro_atomic_store_release_int(&g_queue, q + b + 1);
work_eventcount_notify(&g_ws);
}
/* Every so often, the thing MTGS does at vsync. */
if ((i % 7) == 0)
{
CHECK(work_eventcount_wait_empty(&g_ws), "WaitForEmpty true while alive");
empties++;
if (retro_atomic_load_acquire_int(&g_taken)
!= retro_atomic_load_acquire_int(&g_queue))
retro_atomic_fetch_add_int(&g_bad_empty, 1);
}
/* A short pause so the worker actually parks sometimes. */
if ((i % 50) == 0) usleep(200);
}
CHECK(work_eventcount_wait_empty(&g_ws), "final WaitForEmpty");
CHECK(retro_atomic_load_acquire_int(&g_taken)
== retro_atomic_load_acquire_int(&g_queue), "final drain complete");
CHECK(retro_atomic_load_acquire_int(&g_bad_empty) == 0,
"WaitForEmpty never returned with work outstanding");
retro_atomic_store_relaxed_int(&g_stop, 1);
work_eventcount_kill(&g_ws);
sthread_join(th);
work_eventcount_free(&g_ws);
if (g_quiet_pair && !fails)
return;
printf(" pair%s: %d items, %d WaitForEmpty calls, %d returned early\n",
symmetric ? " (symmetric)" : "",
retro_atomic_load_relaxed_int(&g_queue), empties,
retro_atomic_load_relaxed_int(&g_bad_empty));
}
/* 8. The state a WaitForEmpty waiter reads is one state, not two.
*
* The one that shipped broken: "the worker has consumed everything" and
* "the worker is idle" were two atomics, so a waiter could read idle=1
* from before the worker picked the work up and the epoch from after,
* and return with work outstanding. It takes two cores and the right
* timing to see that as a failure, and this box may have one, so the
* property is checked directly instead: whatever the worker is doing,
* every intermediate state a waiter can observe is one the worker was
* actually in.
*
* Drive the worker's transitions one at a time, from this thread, and
* read the state after each. The interleaving that used to lie -
* notify, worker consumes, waiter looks - must never report empty. */
static void contract_state_is_one_word(void)
{
printf(" state is one word: ");
WorkEventCount ws;
int epoch;
work_eventcount_init(&ws);
CHECK(work_eventcount_is_empty(&ws), "fresh: empty");
/* Work arrives. Not empty, whatever else is true. */
work_eventcount_notify(&ws);
CHECK(!work_eventcount_is_empty(&ws), "notified, worker has not looked: not empty");
/* The worker picks it up. Still not empty: it is processing. This is
* the moment the two-atomic version could report empty, having taken
* idle from before this and the epoch from after. */
CHECK(work_eventcount_check(&ws), "worker sees the work");
CHECK(!work_eventcount_is_empty(&ws), "worker processing: not empty");
/* It finishes and finds nothing more. Now empty. */
CHECK(!work_eventcount_check(&ws), "worker finds nothing more");
CHECK(work_eventcount_is_empty(&ws), "worker idle and caught up: empty");
/* A notify landing while the worker is idle: not empty again, and the
* worker must be able to see it. */
work_eventcount_notify(&ws);
CHECK(!work_eventcount_is_empty(&ws), "notify to an idle worker: not empty");
CHECK(work_eventcount_check(&ws), "idle worker sees the new work");
/* Many notifies while processing collapse into one epoch, and none of
* them may make the state look empty. */
for (epoch = 0; epoch < 64; epoch++)
{
work_eventcount_notify(&ws);
CHECK_QUIET(!work_eventcount_is_empty(&ws));
}
CHECK(work_eventcount_check(&ws), "worker sees the burst");
CHECK(!work_eventcount_is_empty(&ws), "burst consumed, processing: not empty");
CHECK(!work_eventcount_check(&ws), "worker finishes the burst");
CHECK(work_eventcount_is_empty(&ws), "after the burst: empty");
work_eventcount_free(&ws);
printf("ok\n");
}
int main(void)
{
long n = sysconf(_SC_NPROCESSORS_ONLN);
/* The pair is a race: one run of it proves little, and the failure it
* exists to catch showed up a dozen times in fifty thousand items on
* a busy multi-core host and not at all on a quiet one. WORKSEMA_REPS
* repeats it; CI sets it, and anyone chasing a suspected lost wake
* can set it higher. */
const char* reps_env = getenv("WORKSEMA_REPS");
int reps = reps_env ? atoi(reps_env) : 1;
int i;
if (reps < 1)
reps = 1;
setvbuf(stdout, NULL, _IONBF, 0);
printf("work_eventcount\n cpus: %ld%s\n", n,
n > 1 ? "" : " (lost-wake half not load-bearing on one core)");
contract_single();
contract_state_is_one_word();
for (i = 0; i < reps; i++)
{
/* One line per repetition is noise at two hundred: the interesting
* run is the one that fails, and contract_pair prints its own
* counts, so only the last is shown when all of them pass. */
g_quiet_pair = (reps > 1 && i + 1 < reps);
contract_pair(0);
if (fails)
break;
contract_pair(1);
if (fails)
break;
}
printf(fails ? "worksema: FAILED (%d)\n" : "worksema: ok\n", fails);
return fails != 0;
}