mirror of
https://github.com/libretro/virtualjaguar-libretro.git
synced 2026-11-07 12:47:58 -10:00
* feat(app): schedule the emulation thread as a real-time thread (#967) The frame loop is a periodic deadline job; ask the OS to schedule it as one when the thread starts (app/src/thread_realtime.h): macOS THREAD_TIME_CONSTRAINT_POLICY (period one frame, computation 30%, constraint 60%, preemptible; runs ahead of every QoS class and on performance cores, so it replaces the user-interactive QoS raise, which stays as the fallback), Windows THREAD_PRIORITY_TIME_CRITICAL plus the MMCSS "Games" task, Linux SCHED_RR at a low real-time priority where RLIMIT_RTPRIO or rtkit allows it. Why: on an M2 Max at a 1-minute load of 250-950 the same Doom gameplay frame cost 5.4 ms with a performance core of its own and 18-20 ms without one (58-86 involuntary context switches a frame; the game ran at 54.6 of 60.05 fps). Never fatal: every refusal leaves the thread as it was, and the log line "[emu] emulation thread on; scheduling ..." says what it got. --emu-priority normal skips it for one run (A/B). emu_thread_unit: testRealtimeNote (asked for by default, off on request, note always set before start() returns). Co-Authored-By: Claude Fable 5.1 <[email protected]> * Mark realtime variable as maybe unused Co-authored-by: Copilot Autofix powered by AI <[email protected]> * Change library loading method for avrt.dll Updated LoadLibraryW to LoadLibraryExW for better library search. Co-authored-by: Copilot Autofix powered by AI <[email protected]> --------- Co-authored-by: Claude Fable 5.1 <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]>
This commit is contained in:
9 files changed
+196
-6
No files matched your search
@@ -587,6 +587,14 @@ without it, turn it off in Settings (persistent) or start with `--emu-thread off
|
||||
the app falls back to the single-thread loop and says so in the log. Headless runs stay single-threaded unless
|
||||
`--emu-thread on` says otherwise.
|
||||
|
||||
When it starts, the thread asks the OS to schedule it as the periodic deadline job it is: on macOS the time-constraint
|
||||
(real-time) policy, which runs ahead of every QoS class and on performance cores; on Windows time-critical priority plus
|
||||
the MMCSS "Games" task; on Linux `SCHED_RR` at a low real-time priority, which the kernel refuses unless `RLIMIT_RTPRIO`
|
||||
or rtkit allows it. On a loaded host this is the difference between the emulation keeping a core and sharing one (an M2
|
||||
Max at load 250 ran the same Doom frame in 5.4 ms with a core of its own and 18-20 ms without). The log's
|
||||
`[emu] emulation thread on; scheduling ...` line says what the thread got; `--emu-priority normal` skips the request for
|
||||
one run.
|
||||
|
||||
With vsync, the window waits until just before each vblank, passing input to the emulation as it arrives, and then
|
||||
shows the newest frame (the late latch, `src/late_latch.h`). It stays off until it has found where the vblanks are,
|
||||
and lets go by itself if frames keep missing them; the log says `[emu] late latch locked` / `released`.
|
||||
|
||||
+2
-1
@@ -778,7 +778,7 @@ void App::startEmuThread() {
|
||||
return;
|
||||
}
|
||||
syncEmuFlags();
|
||||
std::fprintf(stderr, "[emu] emulation thread on\n");
|
||||
std::fprintf(stderr, "[emu] emulation thread on; scheduling %s\n", emu.realtimeNote().c_str());
|
||||
}
|
||||
|
||||
// The emulation thread is busy with a call() the UI waits on (#967): keep the window alive and say so. Called by
|
||||
@@ -2584,6 +2584,7 @@ int App::runInteractive(const CliArgs& a) {
|
||||
applyAudioSettings();
|
||||
audio.open(48043.6);
|
||||
emu.setPreciseWake(a.emuWake != 0); // (read when the thread starts)
|
||||
emu.setRealtime(a.emuPriority != 0); // (same)
|
||||
if (emuThreadThisRun_) startEmuThread(); // after the window and the audio device (design section 7)
|
||||
lateLatchOn_ = a.lateLatch != 0;
|
||||
paceSteerOn_ = a.paceSteer != 0;
|
||||
|
||||
@@ -117,6 +117,7 @@ struct CliArgs {
|
||||
int paceSteer = -1; // dev: --pace-steer on|off, threaded + vsync only (default on; #967 evenness)
|
||||
int pacing = -1; // dev: --pacing jaguar|display, Video > Frame pacing for this run (not saved; #1038)
|
||||
int emuWake = -1; // dev: --emu-wake precise|plain, the emulation thread's wake-ups (default precise)
|
||||
int emuPriority = -1; // dev: --emu-priority realtime|normal, the emulation thread's scheduling class (default realtime)
|
||||
int latchWake = -1; // dev: --latch-wake timer|plain, how the UI thread's timed event waits end (default timer; #1026)
|
||||
// RetroAchievements (#836): the headless test hooks. There is no way to sign in
|
||||
// headless; the only server is the canned-JSON mock, so a test can never reach
|
||||
|
||||
+16
-4
@@ -1,4 +1,5 @@
|
||||
#include "emu_thread.h"
|
||||
#include "thread_realtime.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <exception>
|
||||
@@ -168,11 +169,22 @@ bool EmuThread::advance(unsigned n, std::function<bool()> giveUp) {
|
||||
|
||||
void EmuThread::loop() {
|
||||
threadId_.store(std::this_thread::get_id());
|
||||
// The frame loop is a periodic deadline job; schedule it as one (thread_realtime.h). The period is a hint for the
|
||||
// deadline policies, so the NTSC field is close enough for PAL too. Before the handshake: the starter reads the note.
|
||||
[[maybe_unused]] bool realtime = false;
|
||||
if (realtime_.load()) {
|
||||
const rt::Outcome o = rt::makeSelfRealtime(1.0 / 60.0);
|
||||
realtime = o.realtime;
|
||||
realtimeNote_ = o.note;
|
||||
} else {
|
||||
realtimeNote_ = "normal (not requested)";
|
||||
}
|
||||
#if defined(__APPLE__)
|
||||
// A default-QoS thread gets timer slack: on the Mac Studio its frame wake-ups ran ~2 ms (p99 ~3 ms) after their due
|
||||
// time, which widens the window of frame end times the pacing grid has to keep off the UI's take point (#967).
|
||||
// The frame loop is what the user is watching; it is as interactive as the main thread.
|
||||
if (preciseWake_.load()) pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0);
|
||||
// Fallback when the real-time policy was refused or not asked for. A default-QoS thread gets timer slack: on the
|
||||
// Mac Studio its frame wake-ups ran ~2 ms (p99 ~3 ms) after their due time, which widens the window of frame end
|
||||
// times the pacing grid has to keep off the UI's take point (#967). The frame loop is what the user is watching;
|
||||
// it is as interactive as the main thread. (A real-time thread cannot take a QoS class, and does not need one.)
|
||||
if (!realtime && preciseWake_.load()) pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0);
|
||||
#endif
|
||||
client_->threadStarted();
|
||||
{
|
||||
|
||||
@@ -156,6 +156,11 @@ public:
|
||||
// Default on: user-interactive QoS on Apple (set when the thread starts) and a precise last stretch before each
|
||||
// frame's due time. Off: the plain condition wait (#967 A/B, --emu-wake plain).
|
||||
void setPreciseWake(bool on) { preciseWake_.store(on); }
|
||||
// Default on: when the thread starts it asks the OS to schedule it as a real-time frame loop (thread_realtime.h);
|
||||
// what it got is in realtimeNote() once start() returns. Off: the default class (--emu-priority normal). On Apple
|
||||
// the user-interactive QoS raise above is the fallback when the real-time policy is refused.
|
||||
void setRealtime(bool on) { realtime_.store(on); }
|
||||
const std::string& realtimeNote() const { return realtimeNote_; } // written before the start handshake; read after start()
|
||||
uint64_t framesRun() const { return frames_.load(); }
|
||||
uint64_t droppedFrames() const { return dropped_.load(); }
|
||||
uint64_t phaseSteps() const { return steers_.load(); } // PacingClock::steers()
|
||||
@@ -178,7 +183,8 @@ private:
|
||||
std::atomic<State> state_{State::Inline};
|
||||
std::atomic<std::thread::id> threadId_{std::thread::id()};
|
||||
static constexpr double kPreciseSec = 0.001; // the condition wait ends this long before a frame is due
|
||||
std::atomic<bool> stop_{false}, paused_{false}, ff_{false}, preciseWake_{true};
|
||||
std::atomic<bool> stop_{false}, paused_{false}, ff_{false}, preciseWake_{true}, realtime_{true};
|
||||
std::string realtimeNote_; // loop() sets it before ready_; the starter reads it after start()
|
||||
std::atomic<double> targetMs_{64.0};
|
||||
std::atomic<unsigned> advanceLeft_{0};
|
||||
std::atomic<uint64_t> advanceDone_{0}; // budgeted frames run (bumped before advanceLeft_ drops)
|
||||
|
||||
@@ -28,6 +28,7 @@ static void usage(const char* argv0) {
|
||||
" --pace-steer on|off dev: with the emulation thread and vsync, keep frame ends off the take point (default on)\n"
|
||||
" --pacing jaguar|display dev: Video > Frame pacing for this run (not saved; default: the setting)\n"
|
||||
" --emu-wake precise|plain dev: the emulation thread's frame wake-ups (default precise)\n"
|
||||
" --emu-priority realtime|normal dev: the emulation thread's scheduling class (default realtime; the [emu] log line says what it got)\n"
|
||||
" --latch-wake timer|plain dev: macOS, how the UI thread's timed event waits end (default timer; plain keeps the QoS raise)\n"
|
||||
" --data-dir DIR keep config, saves and states in DIR\n"
|
||||
" (a portable.txt next to the binary does this too)\n"
|
||||
@@ -241,6 +242,14 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
a.emuWake = v == "precise" ? 1 : 0;
|
||||
}
|
||||
else if (!std::strcmp(s, "--emu-priority")) {
|
||||
const std::string v = need(s);
|
||||
if (v != "realtime" && v != "normal") {
|
||||
std::fprintf(stderr, "--emu-priority wants realtime or normal, got '%s'\n", v.c_str());
|
||||
return 2;
|
||||
}
|
||||
a.emuPriority = v == "realtime" ? 1 : 0;
|
||||
}
|
||||
else if (!std::strcmp(s, "--latch-wake")) {
|
||||
const std::string v = need(s);
|
||||
if (v != "timer" && v != "plain") {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// The emulation thread's scheduling class (#967; the thread-side counterpart of #1026's UI-thread QoS raise).
|
||||
//
|
||||
// The frame loop is a periodic deadline job: one frame of work every 16.7 ms (20 ms in PAL), due on time. A thread of
|
||||
// the default class competes with every other runnable thread on the host, and on Apple silicon it can be placed on an
|
||||
// efficiency core. Measured on an M2 Max at a 1-minute load of 250-950 (the user's machine during a build storm):
|
||||
// the same Doom gameplay frame cost 5.4 ms with a performance core of its own and 18-20 ms without one, with 58-86
|
||||
// involuntary context switches per frame; the game ran at 54.6 of its 60.05 frames a second. So the thread asks the OS
|
||||
// to schedule it as what it is:
|
||||
//
|
||||
// macOS THREAD_TIME_CONSTRAINT_POLICY, the policy CoreAudio's I/O thread uses: period = one frame, computation =
|
||||
// 30% of it, constraint = 60%, preemptible. Real-time threads run ahead of every QoS class and, on Apple
|
||||
// silicon, on performance cores. A real-time thread has no QoS class (a later QoS set is a no-op), so this
|
||||
// runs before (and instead of) the user-interactive QoS raise, which stays as the fallback.
|
||||
// Windows THREAD_PRIORITY_TIME_CRITICAL, plus the MMCSS "Games" task when avrt.dll is present.
|
||||
// Linux SCHED_RR at a low real-time priority. Refused (EPERM) unless RLIMIT_RTPRIO allows it or the process is
|
||||
// started through rtkit; the thread then keeps the default class and the note says so.
|
||||
//
|
||||
// Never fatal: every failure leaves the thread as it was. The outcome is a one-line note the app logs after
|
||||
// "[emu] emulation thread on" (and EmuThread::realtimeNote() returns), so a report can say what the thread ran as.
|
||||
// --emu-priority normal skips all of it for one run (A/B).
|
||||
#pragma once
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
#if defined(__APPLE__)
|
||||
#include <mach/mach.h>
|
||||
#include <mach/mach_time.h>
|
||||
#include <mach/thread_policy.h>
|
||||
#include <pthread.h>
|
||||
#include <sys/qos.h>
|
||||
#elif defined(_WIN32)
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <windows.h>
|
||||
#elif defined(__unix__) || defined(__linux__)
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <pthread.h>
|
||||
#include <sched.h>
|
||||
#endif
|
||||
|
||||
namespace vj {
|
||||
namespace rt {
|
||||
|
||||
struct Outcome {
|
||||
bool realtime = false; // the OS accepted a real-time class for the calling thread
|
||||
std::string note; // one line: what it runs as, or why not
|
||||
};
|
||||
|
||||
// Ask the OS to schedule the calling thread as a real-time frame loop with this period. The period is a hint for the
|
||||
// deadline policies (macOS); the priority policies ignore it.
|
||||
inline Outcome makeSelfRealtime(double periodSec) {
|
||||
if (!(periodSec > 0.0005) || !(periodSec < 0.1)) periodSec = 1.0 / 60.0;
|
||||
#if defined(__APPLE__)
|
||||
mach_timebase_info_data_t tb{};
|
||||
if (mach_timebase_info(&tb) != KERN_SUCCESS || tb.numer == 0 || tb.denom == 0)
|
||||
return {false, "normal (mach_timebase_info failed)"};
|
||||
const double ticksPerSec = 1e9 * (double)tb.denom / (double)tb.numer;
|
||||
const auto ticks = [&](double sec) { return (uint32_t)(sec * ticksPerSec + 0.5); };
|
||||
// Start from no QoS class: the policy below replaces it, and a std::thread may have inherited one.
|
||||
pthread_set_qos_class_self_np(QOS_CLASS_UNSPECIFIED, 0);
|
||||
thread_time_constraint_policy_data_t p{};
|
||||
p.period = ticks(periodSec);
|
||||
p.computation = ticks(periodSec * 0.3);
|
||||
p.constraint = ticks(periodSec * 0.6);
|
||||
p.preemptible = TRUE;
|
||||
const kern_return_t kr = thread_policy_set(pthread_mach_thread_np(pthread_self()), THREAD_TIME_CONSTRAINT_POLICY,
|
||||
(thread_policy_t)&p, THREAD_TIME_CONSTRAINT_POLICY_COUNT);
|
||||
if (kr != KERN_SUCCESS) {
|
||||
char buf[96];
|
||||
std::snprintf(buf, sizeof buf, "normal (thread_policy_set time-constraint refused: kern_return %d)", (int)kr);
|
||||
return {false, buf};
|
||||
}
|
||||
char buf[160];
|
||||
std::snprintf(buf, sizeof buf, "real-time (macOS time-constraint: period %.1f ms, computation %.1f ms, constraint %.1f ms)",
|
||||
periodSec * 1e3, periodSec * 0.3 * 1e3, periodSec * 0.6 * 1e3);
|
||||
return {true, buf};
|
||||
#elif defined(_WIN32)
|
||||
if (!SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_TIME_CRITICAL)) {
|
||||
char buf[96];
|
||||
std::snprintf(buf, sizeof buf, "normal (SetThreadPriority TIME_CRITICAL failed: %lu)", (unsigned long)GetLastError());
|
||||
return {false, buf};
|
||||
}
|
||||
std::string note = "real-time (Windows THREAD_PRIORITY_TIME_CRITICAL";
|
||||
// MMCSS: the "Games" task class, through avrt.dll at run time so the binary does not need avrt.lib. The library
|
||||
// handle stays open: MMCSS membership lasts as long as the thread does.
|
||||
typedef HANDLE(WINAPI * AvSetFn)(LPCWSTR, LPDWORD);
|
||||
if (HMODULE avrt = LoadLibraryExW(L"avrt.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32)) {
|
||||
if (AvSetFn fn = (AvSetFn)(void*)GetProcAddress(avrt, "AvSetMmThreadCharacteristicsW")) {
|
||||
DWORD idx = 0;
|
||||
note += fn(L"Games", &idx) ? " + MMCSS Games" : "; MMCSS Games refused";
|
||||
}
|
||||
}
|
||||
note += ")";
|
||||
return {true, note};
|
||||
#elif defined(__unix__) || defined(__linux__)
|
||||
const int lo = sched_get_priority_min(SCHED_RR), hi = sched_get_priority_max(SCHED_RR);
|
||||
if (lo < 0 || hi < 0) return {false, "normal (SCHED_RR unavailable)"};
|
||||
sched_param sp{};
|
||||
sp.sched_priority = lo + (hi - lo) / 10; // low real-time priority: above every normal thread, below audio servers
|
||||
const int rc = pthread_setschedparam(pthread_self(), SCHED_RR, &sp);
|
||||
if (rc != 0)
|
||||
return {false, std::string("normal (SCHED_RR refused: ") + std::strerror(rc) +
|
||||
"; allow it with RLIMIT_RTPRIO or run through rtkit)"};
|
||||
return {true, "real-time (SCHED_RR priority " + std::to_string(sp.sched_priority) + ")"};
|
||||
#else
|
||||
return {false, "normal (no real-time scheduling on this platform)"};
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace rt
|
||||
} // namespace vj
|
||||
@@ -116,6 +116,31 @@ void testInline() {
|
||||
CHECK(e.stop(), "stop is a no-op inline");
|
||||
}
|
||||
|
||||
// The scheduling class (thread_realtime.h): asked for by default, never fatal, always reported; off on request. What the
|
||||
// OS grants depends on the host (Linux refuses SCHED_RR without RLIMIT_RTPRIO), so the note's text is printed, not pinned.
|
||||
void testRealtimeNote() {
|
||||
{
|
||||
EmuThread e;
|
||||
FakeClient c;
|
||||
CHECK(e.start(EmuThread::Mode::Threaded, c), "start (real-time requested)");
|
||||
CHECK(!e.realtimeNote().empty(), "the note is set before start() returns");
|
||||
CHECK(e.realtimeNote().rfind("real-time", 0) == 0 || e.realtimeNote().rfind("normal", 0) == 0, "note: %s",
|
||||
e.realtimeNote().c_str());
|
||||
std::printf("emu_thread_test: scheduling %s\n", e.realtimeNote().c_str());
|
||||
e.advance(2);
|
||||
CHECK(c.frames >= 2, "frames run as a real-time thread");
|
||||
CHECK(e.stop(), "stop");
|
||||
}
|
||||
{
|
||||
EmuThread e;
|
||||
FakeClient c;
|
||||
e.setRealtime(false);
|
||||
CHECK(e.start(EmuThread::Mode::Threaded, c), "start (normal)");
|
||||
CHECK(e.realtimeNote() == "normal (not requested)", "off: %s", e.realtimeNote().c_str());
|
||||
CHECK(e.stop(), "stop");
|
||||
}
|
||||
}
|
||||
|
||||
void testCallSeesPosts() {
|
||||
EmuThread e;
|
||||
FakeClient c;
|
||||
@@ -866,6 +891,7 @@ int main() {
|
||||
timeBeginPeriod(1);
|
||||
#endif
|
||||
testInline();
|
||||
testRealtimeNote();
|
||||
testCallSeesPosts();
|
||||
testProducers();
|
||||
testPauseAdvance();
|
||||
|
||||
@@ -103,6 +103,16 @@ The UI never waits on emulation.
|
||||
for A/B runs (the QoS raise stays), and with `VJ_PERF_TRACE` the stall probe (`app/src/stall_probe.h`) writes every
|
||||
wait that ends over 3 ms late to `<prefix>-stall.txt`, with the run state, CPU time, run-loop transitions and sampled
|
||||
stacks.
|
||||
- *Amended 2026-11-01: the emulation thread's scheduling class* (`app/src/thread_realtime.h`). The thread is a periodic
|
||||
deadline job and asks the OS to schedule it as one when it starts: macOS `THREAD_TIME_CONSTRAINT_POLICY` (period one
|
||||
frame, computation 30%, constraint 60%, preemptible; real-time threads run ahead of every QoS class and on performance
|
||||
cores, so it replaces the user-interactive QoS raise, which stays as the fallback), Windows
|
||||
`THREAD_PRIORITY_TIME_CRITICAL` plus the MMCSS "Games" task, Linux `SCHED_RR` at a low real-time priority where
|
||||
`RLIMIT_RTPRIO` or rtkit allows it. Why: on an M2 Max at a 1-minute load of 250-950 the same Doom frame cost 5.4 ms
|
||||
with a performance core of its own and 18-20 ms without one (58-86 involuntary context switches a frame; the game ran
|
||||
at 54.6 fps). Never fatal; the log's `[emu] emulation thread on; scheduling ...` line says what the thread got.
|
||||
`--emu-priority normal` skips it for one run (A/B); `EmuThread::realtimeNote()` and `emu_thread_unit`'s
|
||||
`testRealtimeNote` cover it.
|
||||
|
||||
### Mode B: display lock
|
||||
- The main thread sends a tick after each swap; the emulation thread runs one frame per tick (one per k ticks when the
|
||||
|
||||
Reference in new issue
Block a user