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    saved: u64,
37}
38
39/// MXCSR bit 15: flush-to-zero on output denormals.
40#[cfg(target_arch = "x86_64")]
41const MXCSR_FTZ: u32 = 1 << 15;
42/// MXCSR bit 6: denormals-are-zero on input.
43#[cfg(target_arch = "x86_64")]
44const MXCSR_DAZ: u32 = 1 << 6;
45
46impl DenormalGuard {
47    /// Set FTZ/DAZ (`x86_64`) or FZ (`aarch64`). On other targets this
48    /// is a no-op and the guard is a zero-sized stub.
49    ///
50    /// Implemented via inline asm rather than the `_mm_getcsr` /
51    /// `_mm_setcsr` intrinsics: those are deprecated in current
52    /// stable Rust and the `_MM_DENORMALS_ZERO_ON` constant isn't
53    /// always available alongside them. The two-instruction
54    /// `stmxcsr` / `ldmxcsr` pair is the same machine code the
55    /// intrinsics emit, just spelled differently in source.
56    #[inline]
57    pub fn new() -> Self {
58        // Miri can't interpret inline asm, and the FPU control word
59        // has no observable effect in an interpreter anyway - the
60        // guard degrades to the zero-sized stub there.
61        #[cfg(all(target_arch = "x86_64", not(miri)))]
62        {
63            let mut saved: u32 = 0;
64            // SAFETY: SSE2 (which defines MXCSR) is part of x86_64's
65            // baseline target feature set; stmxcsr / ldmxcsr always
66            // available on this arch.
67            unsafe {
68                std::arch::asm!(
69                    "stmxcsr [{0}]",
70                    in(reg) &raw mut saved,
71                    options(nostack, preserves_flags),
72                );
73                let new = saved | MXCSR_FTZ | MXCSR_DAZ;
74                std::arch::asm!(
75                    "ldmxcsr [{0}]",
76                    in(reg) &raw const new,
77                    options(nostack, preserves_flags),
78                );
79            }
80            return Self {
81                saved: u64::from(saved),
82            };
83        }
84        #[cfg(all(target_arch = "aarch64", not(miri)))]
85        {
86            let saved: u64;
87            // SAFETY: FPCR is accessible from EL0 on AArch64;
88            // reading and writing it is a normal user-mode op.
89            unsafe {
90                std::arch::asm!(
91                    "mrs {0}, fpcr",
92                    out(reg) saved,
93                    options(nomem, nostack, preserves_flags),
94                );
95                let new = saved | (1u64 << 24);
96                std::arch::asm!(
97                    "msr fpcr, {0}",
98                    in(reg) new,
99                    options(nomem, nostack, preserves_flags),
100                );
101            }
102            return Self { saved };
103        }
104        #[allow(unreachable_code)]
105        Self { saved: 0 }
106    }
107}
108
109impl Default for DenormalGuard {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115impl Drop for DenormalGuard {
116    #[inline]
117    fn drop(&mut self) {
118        #[cfg(all(target_arch = "x86_64", not(miri)))]
119        {
120            // SAFETY: see `new()`.
121            #[allow(clippy::cast_possible_truncation)]
122            let restore: u32 = self.saved as u32;
123            unsafe {
124                std::arch::asm!(
125                    "ldmxcsr [{0}]",
126                    in(reg) &raw const restore,
127                    options(nostack, preserves_flags),
128                );
129            }
130        }
131        #[cfg(all(target_arch = "aarch64", not(miri)))]
132        {
133            // SAFETY: see `new()`.
134            unsafe {
135                std::arch::asm!(
136                    "msr fpcr, {0}",
137                    in(reg) self.saved,
138                    options(nomem, nostack, preserves_flags),
139                );
140            }
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn guard_construct_drop_doesnt_panic() {
151        // Smoke test only; verifying the control word actually
152        // flipped requires raw FPU reads that the std intrinsics
153        // don't expose portably. The cycles-stalled bench in
154        // `truce-simd/benches` is the real-world check.
155        let _guard = DenormalGuard::new();
156    }
157
158    #[test]
159    fn nested_guards_restore_in_lifo_order() {
160        // Two guards in succession should each restore on drop;
161        // verifies the Drop impl doesn't trash unrelated MXCSR
162        // bits.
163        let outer = DenormalGuard::new();
164        {
165            let _inner = DenormalGuard::new();
166        }
167        drop(outer);
168    }
169}