From c1a2db8e44434c57c7516c651d32eaef1a2172ac Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Fri, 19 Jun 2026 07:28:00 +0100 Subject: [PATCH] Add opt-in realtime-like thread priority for the pacer and audio threads Introduce a best-effort, off-by-default scheduling-priority feature for Copperline's two latency-critical host threads: the wall-clock pacer (the main thread) and the cpal audio callback. On a busy host the scheduler can preempt either at the wrong moment, showing up as frame stutter or audio underruns; raising their priority reduces both. It never changes emulated behaviour -- like all pacing, it only schedules host work. "Real-time priority" is portable in neither API nor semantics, so the new src/priority.rs handles each platform on its own terms: - macOS: the pacer thread joins the USER_INTERACTIVE QoS class via pthread_set_qos_class_self_np (the idiomatic unprivileged low-latency request). The audio callback is left alone -- Core Audio already runs it on a real-time thread, and pinning a QoS class onto it would only demote it. Uses the libc QoS API already in the tree, so no macOS build deps. - Windows: both threads are raised to THREAD_PRIORITY_HIGHEST via the thread-priority crate; no privilege required. - Linux/other Unix: raising priority needs privilege (rtprio rlimit, CAP_SYS_NICE, or root); without it the request is logged and declined. thread-priority is scoped to cfg(not(target_os = "macos")) so it never enters the macOS build. audio_thread_priority was deliberately avoided: it pulls libdbus into Linux builds for RtKit, which would need packaging/CI changes. The feature is gated by [emulation] realtime_priority (default false), overridable for one run by COPPERLINE_REALTIME_PRIORITY. The pacer is elevated only when actually pacing (skipped for unthrottled headless captures); the audio thread promotes itself once on its first callback. Documented in the configuration guide, the example config, and the timing and architecture internals. Covered by unit tests that exercise the real macOS QoS syscall and assert the wrappers are panic-free on every platform. --- Cargo.lock | 15 +++ Cargo.toml | 12 ++- copperline.example.toml | 11 +++ docs/guide/configuration.md | 17 ++++ docs/internals/architecture.md | 11 +++ docs/internals/timing.md | 33 +++++++ packaging/flatpak/cargo-sources.json | 13 +++ src/audio.rs | 11 ++- src/config.rs | 14 +++ src/main.rs | 19 +++- src/priority.rs | 141 +++++++++++++++++++++++++++ 11 files changed, 292 insertions(+), 5 deletions(-) create mode 100644 src/priority.rs diff --git a/Cargo.lock b/Cargo.lock index 440f671..9b8453a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -442,6 +442,7 @@ dependencies = [ "ringbuf", "serde", "serde-big-array", + "thread-priority", "toml", "winit", ] @@ -2355,6 +2356,20 @@ dependencies = [ "syn", ] +[[package]] +name = "thread-priority" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b61f78661ff568c3a69890da32f056c54ba191afae3e41065f39cfe9217fa3c" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "libc", + "log", + "rustversion", + "windows 0.62.2", +] + [[package]] name = "tiny-skia" version = "0.11.4" diff --git a/Cargo.toml b/Cargo.toml index b0e82a7..1f108b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,8 +36,9 @@ ringbuf = "0.4" hound = "3.5" rfd = "0.17.2" gilrs = "0.11" -# Already pulled in transitively; used directly only for localtime_r, so -# auto-generated filenames stamp in the host's local time zone. +# Already pulled in transitively; used directly only for localtime_r (local +# time-zone filename stamps) and, on macOS, the pthread QoS class used for the +# optional realtime-priority feature (see src/priority.rs). libc = "0.2" # macOS dock icon: winit's with_window_icon is a no-op on macOS, so the dock @@ -48,6 +49,13 @@ objc2 = "0.6" objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSImage"] } objc2-foundation = { version = "0.3", default-features = false, features = ["NSData"] } +# Optional realtime-priority feature: cross-platform thread scheduling control. +# Only used off macOS -- the macOS path uses the libc pthread QoS API directly +# (Core Audio already runs the audio callback real-time), so this crate (and +# the windows-sys it pulls in) never enters the macOS build. +[target.'cfg(not(target_os = "macos"))'.dependencies] +thread-priority = "3.1" + [lints.clippy] # Chipset pipeline functions take per-signal argument lists that mirror the # hardware; bundling them into structs would obscure the data flow. diff --git a/copperline.example.toml b/copperline.example.toml index 6a94734..7199cc2 100644 --- a/copperline.example.toml +++ b/copperline.example.toml @@ -33,6 +33,17 @@ power_on = true # one run. pacing_budget = "cycles" +# Ask the OS to schedule Copperline's latency-critical threads (the +# wall-clock pacer and the audio callback) above normal, to reduce frame +# stutter and audio glitches when the host is under load. Best effort and off +# by default: on macOS the pacer thread joins the USER_INTERACTIVE QoS class +# (the audio callback is already real-time under Core Audio); on Windows it is +# raised via SetThreadPriority; on Linux raising priority needs privilege (an +# rtprio rlimit, CAP_SYS_NICE, or root) and is otherwise declined without +# failing the run. The COPPERLINE_REALTIME_PRIORITY env var overrides this for +# one run (set it to 0/false/off to force it off). +# realtime_priority = false + [cpu] diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index fe14cc7..2d6ae3f 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -108,6 +108,7 @@ clock works. [emulation] power_on = true # false = start powered off at the test screen pacing_budget = "cycles" # "cycles" (hardware-accurate) or "instructions" +realtime_priority = false # true = raise the pacer/audio thread priority ``` The deterministic cycle-driven core is the only emulation timing. It is @@ -126,6 +127,22 @@ carried no information.) which is cheaper but runs the CPU faster than hardware. `COPPERLINE_REAL_PACING_BUDGET` overrides this for one run. See [](../internals/timing) for the full rationale. +- `realtime_priority = true` asks the OS to schedule Copperline's two + latency-critical threads -- the wall-clock pacer and the audio callback -- + above normal, which reduces frame stutter and audio glitches when the host + is busy. It is best effort and off by default, and never fails the run: + - **macOS** -- the pacer thread joins the `USER_INTERACTIVE` QoS class. The + audio callback is left alone because Core Audio already runs it on a + real-time thread (overriding that would only demote it). + - **Windows** -- both threads are raised via `SetThreadPriority`; no + privilege required. + - **Linux/other Unix** -- raising priority needs privilege (an `rtprio` + rlimit, `CAP_SYS_NICE`, or root). Without it the request is logged and + declined, and the thread keeps normal scheduling. + + `COPPERLINE_REALTIME_PRIORITY` overrides this for one run; set it to + `0`/`false`/`off` to force it off, or to any other value (or leave it empty) + to force it on. ## `[cpu]` diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md index 515b5f8..e7bbbaa 100644 --- a/docs/internals/architecture.md +++ b/docs/internals/architecture.md @@ -31,6 +31,7 @@ src/ rtc.rs # MSM6242-compatible battery RTC serial.rs # Paula serial sink (stdout) audio.rs # AudioSink trait + cpal/WAV/null outputs + priority.rs # opt-in realtime-like thread scheduling (pacer + audio) gamepad.rs # gilrs input + guided calibration screenshot.rs # PNG export helpers recorder.rs # video+audio capture (ZMBV/PCM AVI writer) @@ -97,6 +98,16 @@ The flow of a frame: wall-clock; for headless captures it does not. The emulated result is identical either way -- pacing only schedules host work. +So an interactive run uses three host threads: the **main thread** (event +loop, core, and pacer), the **`copperline-render` worker**, and the +**cpal audio callback** that cpal owns. Only the last two cross a thread +boundary with the main thread, and both do so through owned data (a +`RenderInput`/presentation buffer over a channel, and a lock-free sample ring +buffer) rather than shared mutable state. The pacer and the audio callback are +latency-critical and can optionally be given above-normal scheduling priority +(`[emulation] realtime_priority`, `src/priority.rs`); see +[](timing) for what that does per platform. + ## The Bus `Bus` (`src/bus.rs`) owns all shared machine state: chip/slow/fast RAM and diff --git a/docs/internals/timing.md b/docs/internals/timing.md index f4bce9f..f380ce6 100644 --- a/docs/internals/timing.md +++ b/docs/internals/timing.md @@ -332,6 +332,39 @@ accurate per-instruction costs the CPU's chip-bus slot ratio settles at a physically valid value naturally, which (together with the area-fill C-slot fix) resolved that blanking regression. +### Thread scheduling priority + +Pacing only works if the host actually runs the right thread at the right +moment. Two threads are latency-critical: the **pacer** (the main thread, +which advances the core and calls `thread::sleep` in +`Emulator::sleep_until_realtime_device_time`) and the **cpal audio callback** +(which drains the sample ring buffer the pacer keeps ~150 ms ahead of the +device clock). The `copperline-render` worker ([](architecture)) is a +throughput thread, not a latency one, and is left at normal priority. When the +host is busy, a scheduler that preempts the pacer shows up as frame stutter, +and one that preempts the audio callback shows up as an audible underrun. + +`[emulation] realtime_priority` (off by default; `COPPERLINE_REALTIME_PRIORITY` +overrides it for one run) asks the OS to schedule those two threads above +normal. It is best effort -- `src/priority.rs` logs what it did and never fails +the run -- and, like all pacing, it never changes emulated behaviour; it only +changes when host work is scheduled. The implementation is per-platform because +"real-time priority" is portable in neither API nor semantics: + +- **macOS** -- the pacer thread joins the `USER_INTERACTIVE` QoS class + (`pthread_set_qos_class_self_np`), the idiomatic unprivileged low-latency + request. The audio callback is left untouched: Core Audio already runs it on + a real-time thread, and pinning a QoS class onto it would only *demote* it. +- **Windows** -- both threads are raised to `THREAD_PRIORITY_HIGHEST` via the + `thread-priority` crate; no privilege required. +- **Linux / other Unix** -- raising priority needs privilege (an `rtprio` + rlimit, `CAP_SYS_NICE`, or root); without it the request is logged and + declined, and the thread keeps normal scheduling. + +The pacer sleeps between work chunks rather than spinning, so even the +strongest scheduling class it can land in still yields the CPU and cannot +starve the host -- which is why elevating it is safe to offer. + ## Cross-checking against hardware `timing-test/` is a bootable disk that measures CPU and chip-bus operation diff --git a/packaging/flatpak/cargo-sources.json b/packaging/flatpak/cargo-sources.json index 89f3ce9..e141ee5 100644 --- a/packaging/flatpak/cargo-sources.json +++ b/packaging/flatpak/cargo-sources.json @@ -3171,6 +3171,19 @@ "dest": "cargo/vendor/thiserror-impl-2.0.18", "dest-filename": ".cargo-checksum.json" }, + { + "type": "archive", + "archive-type": "tar-gzip", + "url": "https://static.crates.io/crates/thread-priority/thread-priority-3.1.0.crate", + "sha256": "6b61f78661ff568c3a69890da32f056c54ba191afae3e41065f39cfe9217fa3c", + "dest": "cargo/vendor/thread-priority-3.1.0" + }, + { + "type": "inline", + "contents": "{\"package\": \"6b61f78661ff568c3a69890da32f056c54ba191afae3e41065f39cfe9217fa3c\", \"files\": {}}", + "dest": "cargo/vendor/thread-priority-3.1.0", + "dest-filename": ".cargo-checksum.json" + }, { "type": "archive", "archive-type": "tar-gzip", diff --git a/src/audio.rs b/src/audio.rs index bdf13d1..0fbaede 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -115,7 +115,11 @@ pub struct CpalSink { } impl CpalSink { - pub fn new() -> Result { + /// Build the live cpal output sink. When `realtime_priority` is set, the + /// audio callback thread promotes itself on its first invocation (see + /// [`crate::priority`]); the flag is resolved by the caller from config and + /// the `COPPERLINE_REALTIME_PRIORITY` env var. + pub fn new(realtime_priority: bool) -> Result { let host = cpal::default_host(); let device = host .default_output_device() @@ -170,6 +174,11 @@ impl CpalSink { .build_output_stream( &config, move |data: &mut [f32], _info: &cpal::OutputCallbackInfo| { + // Runs on the cpal-owned audio thread. Latched internally, + // so only the first callback does the scheduling syscall. + if realtime_priority { + crate::priority::promote_audio_thread_once(); + } let chans = channels as usize; if profile_enabled { let frames = data.len() / chans; diff --git a/src/config.rs b/src/config.rs index 23fa4ec..c46d862 100644 --- a/src/config.rs +++ b/src/config.rs @@ -153,6 +153,12 @@ pub struct Emulation { /// `PacingBudget`. The `COPPERLINE_REAL_PACING_BUDGET` env var overrides /// this for one run. pub pacing_budget: PacingBudget, + /// Ask the OS to schedule the latency-critical threads (the wall-clock + /// pacer and the audio callback) above normal, to reduce stutter and audio + /// glitches under host load. Best effort and off by default; see + /// [`crate::priority`]. The `COPPERLINE_REALTIME_PRIORITY` env var + /// overrides this for one run. + pub realtime_priority: bool, } /// Real-mode pacing budget model. @@ -505,6 +511,7 @@ impl Default for Config { emulation: Emulation { power_on: true, pacing_budget: PacingBudget::Cycles, + realtime_priority: false, }, chip_ram_bytes: 512 * 1024, fast_ram_bytes: 0, @@ -782,6 +789,9 @@ struct RawEmulation { speed: Option, power_on: Option, pacing_budget: Option, + /// Best-effort realtime-like thread priority for the pacer and audio + /// threads (default false). See `src/priority.rs`. + realtime_priority: Option, } #[derive(Debug, Default, Deserialize)] @@ -922,6 +932,10 @@ impl TryFrom for Config { None => defaults.emulation.pacing_budget, Some(s) => parse_pacing_budget(s)?, }, + realtime_priority: raw + .emulation + .realtime_priority + .unwrap_or(defaults.emulation.realtime_priority), }; let chip_ram_bytes = match raw.memory.chip.as_deref() { None => defaults.chip_ram_bytes, diff --git a/src/main.rs b/src/main.rs index c2ee7fc..445a1c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ mod harddrive; mod inputrec; mod inputsched; mod memory; +mod priority; mod recorder; mod romsearch; mod rtc; @@ -909,11 +910,18 @@ fn main() -> Result<()> { None => None, }; let floppy = FloppyController::from_config(&cfg.floppy)?; + // Best-effort realtime-like scheduling for the latency-critical threads. + // Resolved once here (env var overrides the config) so the audio sink can + // promote its callback thread and the pacer thread can be raised below. + let realtime_priority = priority::requested(cfg.emulation.realtime_priority); + if realtime_priority { + info!("priority: realtime-like thread scheduling requested (best effort)"); + } let serial = Box::new(StdoutSink::new()); let audio: Box = if let Some(ref wav_path) = cli.audio_wav { Box::new(WavSink::new(wav_path)?) } else if cli.audio_live { - Box::new(CpalSink::new()?) + Box::new(CpalSink::new(realtime_priority)?) } else { Box::new(NullSink) }; @@ -1058,6 +1066,12 @@ fn main() -> Result<()> { about_machine_lines(&cfg), ); + // Elevate the thread that is about to run the event loop and the pacer. + // Only when actually pacing to wall-clock time: headless capture advances + // the core unthrottled, so priority buys it nothing. + if realtime_priority && paced { + priority::elevate_pacer_thread(); + } info!( "entering event loop. {HOST_SHORTCUT_MODIFIER_LABEL}+Q to quit, {HOST_SHORTCUT_MODIFIER_LABEL}+S to screenshot, {HOST_SHORTCUT_MODIFIER_LABEL}+G to capture/release mouse." ); @@ -1099,7 +1113,8 @@ fn run_live_audio_profile(secs: f32) -> Result<()> { "audio profile mode: running Paula DMA to cpal for {:.3}s without window rendering", secs ); - let audio = Box::new(CpalSink::new()?); + // This diagnostic mode loads no config, so the realtime knob is env-only. + let audio = Box::new(CpalSink::new(priority::requested(false))?); let mut paula = Paula::new(Box::new(StdoutSink::new()), audio); paula.set_led_filter_enabled(true); diff --git a/src/priority.rs b/src/priority.rs new file mode 100644 index 0000000..471e6a4 --- /dev/null +++ b/src/priority.rs @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Best-effort host thread-scheduling priority. +//! +//! An interactive emulator has two latency-critical threads: the wall-clock +//! pacer (the main thread, where jitter shows up as frame stutter and audio +//! drift) and the audio callback (a glitch there is instantly audible). On a +//! busy desktop the OS scheduler can preempt either at the wrong moment. When +//! the user opts in (`[emulation] realtime_priority = true` or the +//! `COPPERLINE_REALTIME_PRIORITY` env var), Copperline asks the OS to schedule +//! those threads above normal. +//! +//! "Real-time priority" is portable in neither API nor semantics, so this is +//! deliberately best-effort: every call logs what it did and never fails the +//! run. The whole feature is off by default, so an unprivileged or sandboxed +//! launch behaves exactly as before. +//! +//! Per platform: +//! * **macOS** -- the pacer thread joins the `USER_INTERACTIVE` QoS class +//! (`pthread_set_qos_class_self_np`), the idiomatic way for an app thread to +//! ask for low-latency scheduling without elevated privileges. The audio +//! callback is deliberately left untouched: Core Audio already runs it on a +//! real-time thread, and pinning a QoS class onto it would only *demote* it. +//! * **Windows** -- `SetThreadPriority` raises the thread to `HIGHEST` (via +//! the [`thread_priority`] crate); no privilege required. WASAPI's callback +//! runs on a cpal-spawned thread, so it is raised too. +//! * **Linux / other Unix** -- raising priority needs privilege (an `rtprio` +//! rlimit, `CAP_SYS_NICE`, or root). Without it the request is declined and +//! the thread keeps normal scheduling; that is logged once and is not fatal. + +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Resolve whether realtime-like scheduling was requested: the +/// `COPPERLINE_REALTIME_PRIORITY` env var overrides the +/// `[emulation] realtime_priority` config for one run. A value of +/// `0`/`false`/`off`/`no` forces it off; any other value (including an empty +/// string, i.e. the bare variable) forces it on. +pub fn requested(from_config: bool) -> bool { + match crate::envcfg::var("COPPERLINE_REALTIME_PRIORITY") { + Some(v) => !matches!( + v.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "no" + ), + None => from_config, + } +} + +/// Elevate the pacer (main) thread, which runs the emulator core and the +/// wall-clock pacer. Best effort; logs the outcome. Because the pacer sleeps +/// between work chunks rather than spinning (see +/// `Emulator::sleep_until_realtime_device_time`), even the strongest +/// scheduling class it can land in still yields the CPU and cannot starve the +/// host. +pub fn elevate_pacer_thread() { + elevate_current_thread("pacer"); +} + +/// Promote the calling thread -- the cpal audio callback -- the first time it +/// runs. Idempotent across callbacks via an internal latch, so the one-time +/// scheduling syscall happens on the first callback only and steady-state +/// audio output does no extra work. +pub fn promote_audio_thread_once() { + static PROMOTED: AtomicBool = AtomicBool::new(false); + if PROMOTED.swap(true, Ordering::Relaxed) { + return; + } + #[cfg(target_os = "macos")] + { + // Core Audio already hands this callback a real-time thread; joining a + // QoS class here would only lower it, so leave it as the OS set it. + log::info!("priority: audio thread left as-is (Core Audio runs it real-time)"); + } + #[cfg(not(target_os = "macos"))] + { + elevate_current_thread("audio"); + } +} + +#[cfg(target_os = "macos")] +fn elevate_current_thread(label: &str) { + // QOS_CLASS_USER_INTERACTIVE is the highest standard QoS class and the + // conventional way for a latency-sensitive UI/media thread to request + // low-latency scheduling without privileges. A relative priority of 0 + // keeps the thread at the class's reference priority. + let ret = unsafe { + libc::pthread_set_qos_class_self_np(libc::qos_class_t::QOS_CLASS_USER_INTERACTIVE, 0) + }; + if ret == 0 { + log::info!("priority: {label} thread joined the USER_INTERACTIVE QoS class"); + } else { + log::warn!( + "priority: could not raise {label} thread QoS \ + (pthread_set_qos_class_self_np returned {ret}); continuing at normal priority" + ); + } +} + +#[cfg(not(target_os = "macos"))] +fn elevate_current_thread(label: &str) { + use thread_priority::{set_current_thread_priority, ThreadPriority}; + // On Windows this maps to THREAD_PRIORITY_HIGHEST (no privilege needed). + // On Linux/other Unix it raises to the top of the current scheduling + // policy, which requires privilege to exceed normal; without it the call + // returns an error that we log and shrug off. + match set_current_thread_priority(ThreadPriority::Max) { + Ok(()) => log::info!("priority: {label} thread elevated (ThreadPriority::Max)"), + Err(e) => log::warn!( + "priority: could not elevate {label} thread ({e:?}); \ + continuing at normal priority" + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn elevating_threads_is_always_safe() { + // Best effort and privilege-dependent: on an unprivileged host the + // underlying syscall may decline, but the wrappers must always return + // cleanly rather than panic. This exercises the real macOS QoS path + // (and the thread-priority path elsewhere) on the test thread. + elevate_pacer_thread(); + // The audio promotion latches after its first call; both the first + // call and the latched no-op second call must be safe. + promote_audio_thread_once(); + promote_audio_thread_once(); + } + + #[test] + fn requested_passes_config_through_without_env_override() { + // `requested` only consults the config value when the env override is + // absent. The unit suite sets no COPPERLINE_* vars, so guard on that + // (envcfg snapshots the environment once) to keep the test hermetic. + if crate::envcfg::var("COPPERLINE_REALTIME_PRIORITY").is_none() { + assert!(requested(true)); + assert!(!requested(false)); + } + } +}