Skip to main content

truce_core/
denormal.rs

1//! Denormal flush guard for the audio thread.
2//!
3//! FTZ (flush-to-zero) and DAZ (denormals-are-zero) on `x86_64`,
4//! FZ (flush-to-zero) on `aarch64`. Set on entry to a plugin's
5//! `process()` and restored on drop, so the FPU control word the
6//! audio thread observes stays consistent across hosts and other
7//! plugins on the same thread.
8//!
9//! ## Why this matters
10//!
11//! IIR filters with feedback can drive their state values below
12//! the smallest normal float (`~1.18e-38` for f32). The CPU then
13//! treats every operation on those values as a denormal-arithmetic
14//! microcode trap, which on a hot core takes 50-100x longer than
15//! the same op on a normal float. A reverb decaying to silence is
16//! the classic case; on x86 without FTZ it can spike CPU 30x at
17//! the tail. Flushing denormals to zero loses 7 bits of dynamic
18//! range at the very bottom of the float range - inaudible in
19//! audio, mandatory in any non-trivial DSP path.
20//!
21//! ## Lifetime
22//!
23//! `DenormalGuard::new()` reads the current control word, ORs in
24//! the flush bits, writes it back, and stashes the original.
25//! `drop()` restores. The format wrappers' bridge layer
26//! (`truce_plugin`) wraps every `process()` call in a guard, so
27//! plugin authors get the right FPU state without opting in. A
28//! plugin that needs gradual underflow (extremely rare in audio)
29//! can construct an opposite guard inside `process()` to flip the
30//! bits back for the duration.
31
32/// RAII guard that enables denormal-flush mode on construction and
33/// restores the prior FPU control word on drop. See module docs.
34#[must_use = "denormal flush state reverts when this guard is dropped"]
35pub struct DenormalGuard {
36    // Only the hardware paths save and restore a control word. Under Miri
37    // (no inline asm) or an arch without a flush register the guard is a
38    // zero-sized stub, so the field would be dead there.
39    #[cfg(all(not(miri), any(target_arch = "x86_64", target_arch = "aarch64")))]
40    saved: u64,
41}
42
43/// MXCSR bit 15: flush-to-zero on output denormals.
44#[cfg(all(target_arch = "x86_64", not(miri)))]
45const MXCSR_FTZ: u32 = 1 << 15;
46/// MXCSR bit 6: denormals-are-zero on input.
47#[cfg(all(target_arch = "x86_64", not(miri)))]
48const MXCSR_DAZ: u32 = 1 << 6;
49
50impl DenormalGuard {
51    /// Set FTZ/DAZ (`x86_64`) or FZ (`aarch64`). On other targets this
52    /// is a no-op and the guard is a zero-sized stub.
53    ///
54    /// Implemented via inline asm rather than the `_mm_getcsr` /
55    /// `_mm_setcsr` intrinsics: those are deprecated in current
56    /// stable Rust and the `_MM_DENORMALS_ZERO_ON` constant isn't
57    /// always available alongside them. The two-instruction
58    /// `stmxcsr` / `ldmxcsr` pair is the same machine code the
59    /// intrinsics emit, just spelled differently in source.
60    #[inline]
61    pub fn new() -> Self {
62        // Miri can't interpret inline asm, and the FPU control word
63        // has no observable effect in an interpreter anyway - the
64        // guard degrades to the zero-sized stub there.
65        #[cfg(all(target_arch = "x86_64", not(miri)))]
66        {
67            let mut saved: u32 = 0;
68            // SAFETY: SSE2 (which defines MXCSR) is part of x86_64's
69            // baseline target feature set; stmxcsr / ldmxcsr always
70            // available on this arch.
71            unsafe {
72                std::arch::asm!(
73                    "stmxcsr [{0}]",
74                    in(reg) &raw mut saved,
75                    options(nostack, preserves_flags),
76                );
77                let new = saved | MXCSR_FTZ | MXCSR_DAZ;
78                std::arch::asm!(
79                    "ldmxcsr [{0}]",
80                    in(reg) &raw const new,
81                    options(nostack, preserves_flags),
82                );
83            }
84            Self {
85                saved: u64::from(saved),
86            }
87        }
88        #[cfg(all(target_arch = "aarch64", not(miri)))]
89        {
90            let saved: u64;
91            // SAFETY: FPCR is accessible from EL0 on AArch64;
92            // reading and writing it is a normal user-mode op.
93            unsafe {
94                std::arch::asm!(
95                    "mrs {0}, fpcr",
96                    out(reg) saved,
97                    options(nomem, nostack, preserves_flags),
98                );
99                let new = saved | (1u64 << 24);
100                std::arch::asm!(
101                    "msr fpcr, {0}",
102                    in(reg) new,
103                    options(nomem, nostack, preserves_flags),
104                );
105            }
106            Self { saved }
107        }
108        // No flush register to touch (Miri, or any other arch): a
109        // zero-sized stub. The three arms are mutually exclusive, so
110        // exactly one is compiled and is the function's tail expression.
111        #[cfg(not(all(not(miri), any(target_arch = "x86_64", target_arch = "aarch64"))))]
112        {
113            Self {}
114        }
115    }
116}
117
118impl Default for DenormalGuard {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl Drop for DenormalGuard {
125    #[inline]
126    fn drop(&mut self) {
127        #[cfg(all(target_arch = "x86_64", not(miri)))]
128        {
129            // SAFETY: see `new()`.
130            #[allow(clippy::cast_possible_truncation)]
131            let restore: u32 = self.saved as u32;
132            unsafe {
133                std::arch::asm!(
134                    "ldmxcsr [{0}]",
135                    in(reg) &raw const restore,
136                    options(nostack, preserves_flags),
137                );
138            }
139        }
140        #[cfg(all(target_arch = "aarch64", not(miri)))]
141        {
142            // SAFETY: see `new()`.
143            unsafe {
144                std::arch::asm!(
145                    "msr fpcr, {0}",
146                    in(reg) self.saved,
147                    options(nomem, nostack, preserves_flags),
148                );
149            }
150        }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn guard_construct_drop_doesnt_panic() {
160        // Smoke test only; verifying the control word actually
161        // flipped requires raw FPU reads that the std intrinsics
162        // don't expose portably. The cycles-stalled bench in
163        // `truce-simd/benches` is the real-world check.
164        let _guard = DenormalGuard::new();
165    }
166
167    #[test]
168    fn nested_guards_restore_in_lifo_order() {
169        // Two guards in succession should each restore on drop;
170        // verifies the Drop impl doesn't trash unrelated MXCSR
171        // bits.
172        let outer = DenormalGuard::new();
173        {
174            let _inner = DenormalGuard::new();
175        }
176        drop(outer);
177    }
178}