2026-07-06
IEEE 754 defines subnormal numbers (also called denormals) as the tiny values below the smallest normal float — roughly below 1.18e-38 for float32. They preserve gradual underflow: as values shrink past the normal range, precision degrades slowly instead of snapping to zero. The problem is that handling them in hardware is a nightmare, so every modern CPU ships with a switch that just... turns them off.
The hardware pain: normal floats have an implicit leading 1 bit in the mantissa, so the multiplier and adder can assume a fixed operand width. Subnormals break that assumption — the leading bit is 0, and the true value is encoded by how many leading zeros the mantissa has. To operate on a subnormal, the FPU has to normalize it on the fly: count leading zeros, shift the mantissa, and adjust the exponent below its usual range. Most FPUs don't have this path in the fast pipeline. Instead, subnormal inputs or results trigger a microcode assist — a slow trap that can take 100–250 cycles versus the usual 4–5 cycles for an FP op.
The switches: x86 exposes two bits in MXCSR — FTZ (Flush-To-Zero) forces subnormal results to zero, and DAZ (Denormals-Are-Zero) treats subnormal inputs as zero. ARM's FPSCR has FZ. When both are set, the FPU never sees a subnormal, so no microcode assist ever fires. The math is no longer IEEE 754 compliant, but it runs at full speed.
Real-world example: audio processing with reverb tails. As a reverb decays exponentially, its signal drops through the subnormal range and stays there for milliseconds. A JUCE plugin author reported CPU usage jumping from 2% to 40% when a reverb tail entered subnormal territory — every sample was triggering a microcode assist. Fix: set FTZ+DAZ at the top of the audio callback. Same result perceptually (silence is silence), 20x faster. Every serious DSP library sets these bits on entry.
Rule of thumb: a subnormal-triggered microcode assist costs ~100 cycles. If your inner loop touches subnormals even 1% of the time, you're paying an amortized cost of ~1 cycle per iteration — often more than the entire loop's normal work. Anything below ~1e-30 in float32 is suspect.
The gotcha: FTZ/DAZ are per-thread, set in MXCSR. If a library sets them and forgets to restore, downstream numerical code (root solvers, iterative refinement) can silently lose precision near zero. Compilers with -ffast-math set FTZ/DAZ globally at startup — one reason "fast-math" occasionally produces mysteriously wrong results in unrelated code.
