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}