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}