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)); + } + } +}