feat(app): schedule the emulation thread as a real-time thread (#1026) (#1135)

* 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:
authored and GitHub committed 2026-11-01 15:10:16 -05:00
1 parent d559e2f031
commit 4feed33e2d
9 files changed
+196 -6

No files matched your search

+8
View File
@@ -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
View File
@@ -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;
+1
View File
@@ -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
View File
@@ -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();
{
+7 -1
View File
@@ -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)
+9
View File
@@ -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") {
+117
View File
@@ -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
+26
View File
@@ -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();
+10
View File
@@ -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