IEEE-754 Floating Point
A character 20 km from the world origin jitters, a replay desyncs on a teammate's PC, and the audio drops out when a reverb tail fades. All three are float bugs. Most positions, velocities and shader inputs in an engine are 32-bit floats, and floats round in small, specific, reproducible ways. This page builds a float from its 32 bits up, in C++ and Rust, so you can predict how much precision a value has, compare floats safely, keep long sums accurate, and see those three bugs coming.
- Decode a float's 32 bits by hand into its sign, exponent and significand, and spot a subnormal, an infinity or a NaN in a memory dump.
- Work out how much precision a value has at a given magnitude, and explain why a world 20 km across jitters.
- Compare floats with a tolerance that holds at the magnitudes your code sees, and write NaN checks a compiler flag won't delete.
- Keep a long sum accurate with Kahan summation, and name the flags that silently undo it.
- List the causes of cross-machine divergence in a lockstep game, and stop subnormals from stalling an audio thread.
01Why floats
A game world needs a bullet's position to the millimeter and a planet's orbit in the millions of kilometers, often in the same frame. A 32-bit float covers that dynamic range in a fixed number of bits, with arithmetic the CPU and GPU run in hardware. That range, not raw speed, is why games use them: integer add and multiply are usually just as fast, but integers can't cheaply hold fractions or span that range, and fixed-point trades the range away for uniform precision.
Worked example
Spend 32 bits on 16.16 fixed point (16 integer bits, 16 fraction bits) and every value is a multiple of 1/65,536 ≈ 0.0000153, with the largest just under 32,768. The step is the same everywhere, and anything past 32,768 doesn't fit.
A float spends the same 32 bits on relative precision instead: about 7 significant decimal digits at any size. Near 1.0 neighboring floats are 0.00000012 apart; near 20,000 they're 0.002 apart; the largest float is about 3.4 × 1038. If those are meters, a position 20 km out snaps to a 2 mm grid, and a camera-attached weapon hopping between grid points every frame is what you see as jitter.
The catch is that a float is an approximation with exactly defined rounding, and a handful of conventions decide how it rounds. Get those conventions wrong and you get the classic float bugs. Get them right and the bugs become predictable.
02The bit layout
A debugger shows a position as 0x40D00000, or a network packet carries four bytes that should be a speed. To know what number those bits hold, and why some patterns mean infinity or NaN, you need the layout. A binary32 float (IEEE-754's 32-bit format, C++'s float and Rust's f32) is three fields in 32 bits: 1 sign bit, 8 exponent bits, and 23 fraction bits[1][2].
The sign says positive or negative. The fraction holds the digits. The exponent says where the binary point goes, the same job the power of ten does in 6.5 × 103. This formula turns the three fields back into the number, and it's what the hardware does every time it reads a float:
In words: write a 1, a binary point, then the 23 fraction bits, which gives a number from 1 up to (not including) 2. Multiply it by 2 raised to the stored exponent minus 127, and flip the sign if the sign bit is set. The bias of 127 is what lets one unsigned 8-bit field encode both tiny and huge magnitudes. Hover any symbol to see what it stands for.
Need a refresher on binary fractions and 2−k?
Digits after a binary point work like decimal, but in halves. The first place is 1⁄2 (2−1), the next 1⁄4 (2−2), then 1⁄8, and so on. So 1.1012 is 1 + 1/2 + 0/4 + 1/8 = 1.625. That is exactly what 1.fraction means above: a 1, then 23 bits each worth half of the bit before it.
A negative exponent is just division: 2−3 is 1/8. The exponent field slides the binary point left (smaller) or right (larger), which is why one format spans sub-millimeter to kilometer. A value is representable exactly only when it is a sum of these powers of two within the 24-bit budget, which is the reason 0.1 never lands exactly (covered in the rounding section).
Worked example
Decode 0x40D00000. In binary that's 0 10000001 10100000000000000000000.
Sign bit 0: positive. Exponent field 10000001 = 129, and 129 − 127 = 2. Fraction 101 then zeros, so the significand is 1.1012 = 1 + 1/2 + 1/8 = 1.625. The value is 1.625 × 22 = 6.5.
The exponent is stored with a bias of 127, a fixed offset you subtract on the way out, so a stored 1..254 means an actual −126..+127. The leading 1. is implicit for normal numbers and not stored, which is why 23 stored fraction bits give 24 bits of precision. The 1 plus the fraction is the significand, the standard's word for what older texts call the "mantissa". Stored exponent 0 is reserved for zero and subnormals; stored exponent 255 for infinity and NaN (Not a Number, what 0/0 produces). binary64 (a double) uses the same scheme with 11 exponent bits (bias 1023) and 52 fraction bits.
The editor below shows the exact value of any binary32 and color-codes the three fields:
In code, C++20's std::bit_cast reads a float's bits without the undefined behavior of casting through a pointer[5], and Rust's to_bits / from_bits do the same[4]. Masks and shifts then pull the three fields apart:
// C++20: std::bit_cast is the UB-free way to reinterpret. (Reading a float
// through a uint32_t* breaks strict aliasing: undefined behavior.)
#include <bit>
uint32_t bits = std::bit_cast<uint32_t>(value);
float back = std::bit_cast<float>(bits);
uint32_t sign = bits >> 31;
uint32_t exponent = (bits >> 23) & 0xFF; // 8 bits
uint32_t fraction = bits & 0x7FFFFF; // 23 bits
// to_bits / from_bits are the safe, stable way (const since 1.83).
let bits: u32 = value.to_bits();
let back: f32 = f32::from_bits(bits);
let sign = bits >> 31;
let exponent = (bits >> 23) & 0xFF; // 8 bits
let fraction = bits & 0x7F_FFFF; // 23 bits
03Normals & subnormals
A reverb tail fades, a damped spring settles, a velocity is multiplied by 0.98 every frame. Each one shrinks a value toward zero, and sooner or later it gets smaller than the smallest number the normal encoding can hold. What happens next matters for both correctness and speed.
Normal numbers have that implicit leading 1 and a stored exponent of 1..254. When a result gets too small to normalize (below 2−126 for binary32), the format doesn't jump straight to zero. It switches to (also called denormals): stored exponent 0 and an implicit leading bit of 0, which fill the gap down to zero with evenly spaced values. Precision drains away a bit at a time instead of all at once, which is called gradual underflow[1].
Worked example
Start at 1.0 and halve it over and over. After 126 halvings you're at 2−126 ≈ 1.18 × 10−38, the smallest normal float. Without subnormals the next halving would snap to zero.
With them, 23 more halvings still land on representable values, down to 2−149 ≈ 1.4 × 10−45, the smallest subnormal. The 150th halving lands exactly halfway between that and zero, and rounds to 0.
FLT_MIN is not the smallest floatstd::numeric_limits<float>::min() (and Rust's f32::MIN_POSITIVE) is the smallest normal, about 1.18e-38. The smallest positive value is the smallest subnormal, denorm_min, about 1.4e-45, roughly seven orders of magnitude smaller[3]. And Rust's f32::MIN is the most-negative value, about -3.4e38. These three numbers are routinely confused.
Subnormals keep precision degrading gracefully toward zero instead of falling off a cliff. They have a performance story too, on some hardware a sharp one, which gets its own section at the end.
04Signed zero, infinity, NaN
A character's position turns into NaN, and on the next frame its skinned mesh disappears from the screen: every vertex multiplied by a NaN matrix is NaN too. The reserved exponents encode these special values, and each has a sharp edge.
Worked example
A steering function normalizes the direction to its target, and on this frame the target is exactly where the character stands. The direction is (0, 0, 0), its length is 0, and each component becomes 0/0 = NaN.
The velocity built from that direction is NaN as well, and the clamp if (speed > maxSpeed) speed = maxSpeed; on its length doesn't catch it, because every ordered comparison with NaN is false. One frame later the position is position + NaN = NaN, and so is every matrix built from it.
- Signed zero.
+0.0and−0.0compare equal under==but have different bits and behave differently:1.0 / +0.0is+∞,1.0 / −0.0is−∞. Functions likecopysignandatan2tell them apart:atan2(+0.0, −1.0)is +π butatan2(−0.0, −1.0)is −π, so a heading computed from a velocity whose y is −0 reads −180° instead of +180°. - Infinity. From overflow or division by zero. Arithmetic on it mostly stays infinite;
∞ − ∞is NaN. - NaN. From
0.0/0.0,∞ − ∞,sqrt(−1). NaN is not equal to anything, including itself, which is exactly whyx != xis the portable NaN test (usestd::isnan/f32::is_nan).
First, x == NAN is always false; never test for NaN by comparing to it. Second, NaN is not a single bit pattern: any all-ones exponent with a nonzero fraction is a NaN, so there are millions of them. The top fraction bit distinguishes quiet from signaling NaN: a quiet NaN passes silently through arithmetic, while a signaling one is meant to raise an invalid-operation exception when used (on x86 and ARM, a top bit of 1 means quiet). The sign of a NaN is not portable, so don't rely on it[4].
05Rounding, and why 0.1 isn't exact
A spawn timer adds 0.1f every tick and fires when timer == 1.0f, but that check is never true. The reason is rounding: most results of float arithmetic land between two representable floats and have to snap to one of them.
IEEE-754's default rule is round-to-nearest, ties-to-even: snap to the nearest representable value, and on an exact halfway case pick the one whose last significand bit is 0[2].
Worked example
Above 16,777,216 (224) floats are 2 apart, so odd integers fall exactly halfway between two floats. 16,777,217 sits between 16,777,216 and 16,777,218 and rounds down to 16,777,216. 16,777,219 sits between 16,777,218 and 16,777,220 and rounds up to 16,777,220. Both go to the neighbor whose last bit is 0.
Ties go to even, not always up. This removes the upward statistical bias that round-half-up would accumulate over millions of operations. The standard defines four other modes (toward zero, toward +∞, toward −∞, and ties-away, which is required only for decimal formats), but none is the default, and many library functions assume round-to-nearest-even, so changing the mode at runtime is fragile[2].
Rounding is also why 0.1 isn't exact. A fraction is representable in binary only if it's n / 2k. One tenth is 1/(2×5), and that factor of 5 makes it a non-terminating, repeating binary fraction, the same way 1/3 repeats in decimal. The nearest binary32 to 0.1 is slightly larger than one tenth[6].
Worked example
The stored 0.1f is exactly 0.100000001490116119384765625. Add it to a float timer ten times and each addition rounds again. The total is 1.0000001192, one step (2−23) above 1.0, so timer == 1.0f is false and the spawn never happens. Test timer >= 1.0f instead, or count whole ticks in an integer.
In double precision the famous 0.1 + 0.2 != 0.3 follows: three separate roundings, then a fourth on the addition, land on 0.30000000000000004. In binary32 the same errors happen to cancel and 0.1f + 0.2f == 0.3f holds, which is luck, not exactness. Integers, by contrast, are exact up to 224 in binary32 (253 in a double), so not everything is approximate, just any value that isn't a sum of powers of two within the precision budget.
06Machine epsilon and ULP
An open-world game streams in a town 20 km from the world origin, and the player's gun starts to shimmer by a couple of millimeters every frame. Nothing changed in the code. What changed is the size of the numbers, because floats are not evenly spaced.
A (unit in the last place) is the gap between one float and the next, and it doubles at every power of two, because the 23 fraction bits always split the range from one power of two to the next into the same 223 steps. Near 1.0 the gap is 2−23; just below 16,777,216 (224) the gap is 1.0, and above it, 2.0[1].
Worked example
To find the gap at a value, take the largest power of two at or below it, 2e, and divide by 223. In meters:
- At 1 m, e = 0: the gap is 2−23 ≈ 0.00000012 m, about a tenth of a micrometer.
- At 1,000 m, 29 = 512 ≤ 1,000 < 1,024, so e = 9: the gap is 2−14 ≈ 0.000061 m, 0.06 mm.
- At 20,000 m, 214 = 16,384 ≤ 20,000 < 32,768, so e = 14: the gap is 2−9 ≈ 0.00195 m, about 2 mm.
Every position in the town snaps to that 2 mm grid, and the gun shimmers as it hops between grid points.
People use one word, , for two different numbers that both describe float precision at 1.0, and the confusion causes wrong tolerance checks. FLT_EPSILON (which is what std::numeric_limits<float>::epsilon() and Rust's f32::EPSILON return) is 2−23 ≈ 1.19e-7, the gap between 1.0 and the next representable float, one ULP at 1.0[3][4]. The rounding-error bound (Goldberg's version) is half that, 2−24 ≈ 5.96e-8, because round-to-nearest is never off by more than half a step[1]. They differ by 2×. When you write a tolerance, know which one you mean.
The stepper walks one ULP at a time:
Wrong answers, and why: epsilon() is the gap at 1.0, not the smallest float and not the rounding bound; and far-from-origin jitter is a magnitude problem that doubles fix or rounding don't actually solve.
07Catastrophic cancellation
Two crates sit 10 cm apart in that town 20 km out, and the physics code subtracts their positions to get the gap between them. The answer comes back 0.4 mm short, a 0.4% error.
That's catastrophic cancellation: subtract two nearly-equal numbers that already carry rounding error and the matching leading digits cancel, leaving the old error as most of what's left. The subtraction itself is exact; it exposes error that was already there[1].
Worked example
The crates are at x = 20,000.1 and x = 20,000.2. Stored as floats on the 2 mm grid from §6, they become 20,000.099609375 and 20,000.19921875.
Their difference is exactly 0.099609375, and the subtraction made no rounding error at all. The true gap is 0.1, so the result is 0.39% off. The error came from storing the positions: each was off by under a millimeter out of 20 km, and after the subtraction that same sub-millimeter error is 0.39% of the answer. Computing the gap from positions kept small (relative to a nearby origin) avoids it.
Goldberg's quadratic-formula example
Goldberg shows the same effect in 3-digit decimal arithmetic: computing b² − 4ac with b = 3.34, a = 1.22, c = 2.28. The true answer is 0.0292, but b² rounds to 11.2 and 4ac to 11.1, so the result is 0.1, an error of about 70 ULP[1]. The multiplications introduced the error; the subtraction merely revealed it. No rearrangement removes this one: the products themselves must be computed more precisely (in wider arithmetic, or exactly with an FMA). The quadratic formula's other cancellation, −b + √(b² − 4ac) when b² ≫ 4ac, does yield to reformulation: compute the larger-magnitude root first, then get the other from the product of roots, r₁r₂ = c / a[1].
08Summing a million numbers
A profiler adds up a million frame timings, or a stats screen totals the distance a player walked over a long session. Add a long list of floats naively and the running total grows large while each new term stays small, so each addition rounds away part of the term, and the error accumulates.
Kahan summation fixes this by keeping a second float, the compensation, that holds exactly what the last addition rounded away, and feeding it back into the next term[8].
Worked example
Start a float total at 16,777,216 (224), where floats are 2 apart, and add 1.0 twice. Naively, 16,777,216 + 1 is a tie that rounds back to 16,777,216, both times. The total never moves.
With Kahan, the first addition also lands on 16,777,216, but the compensation records the lost amount: (16,777,216 − 16,777,216) − 1 = −1. The second term is corrected to 1 − (−1) = 2, and 16,777,216 + 2 = 16,777,218 is exact. The compensation goes back to 0.
float sum = 0.0f, c = 0.0f; // c carries the lost low-order bits
for (float x : values) {
float y = x - c; // add back last round's lost part
float t = sum + y; // big + small: low bits of y fall off here
c = (t - sum) - y; // recover exactly what fell off
sum = t;
}
let (mut sum, mut c) = (0.0f32, 0.0f32); // c carries the lost low-order bits
for &x in &values {
let y = x - c; // add back last round's lost part
let t = sum + y; // big + small: low bits of y fall off here
c = (t - sum) - y; // recover exactly what fell off
sum = t;
}
Naive summation's error bound grows with the number of terms; Kahan's stays roughly constant, effectively independent of the count until it approaches 1/epsilon (about 8 million terms for a float)[8]. The race below runs the worked example 60,000 times:
09FMA, order, and -ffast-math
A replay recorded on one build plays back differently on another build of the same source, on the same PC. Three facts about how float arithmetic gets evaluated explain most cases like this: fused multiply-add, evaluation order, and -ffast-math.
(FMA) computes a*b + c with a single rounding of the full-precision product-plus-sum, instead of rounding the product and then the sum[9]. It's more accurate for that expression and, on hardware with an FMA unit, often as fast as a bare multiply. C++ spells it std::fma; Rust spells it f32::mul_add.
Worked example
Take a = b = 1.000244140625 (that's 1 + 2−12) and c = −1. The exact product is 1.000488340854644775390625, which needs 25 bits. A float has 24, and the extra bit lands exactly halfway, so the rounded product is 1.00048828125.
Two roundings: 1.00048828125 − 1 = 0.00048828125. One rounding (FMA): 1.000488340854644775390625 − 1 = 0.000488340854644775390625, which a float holds exactly. The two answers differ by 0.012%, and FMA's is the exact one.
float r = std::fma(a, b, c); // a*b + c, rounded once. Fast only if FP_FAST_FMAF is defined
// (hardware FMA); otherwise a slow software routine.
let r = a.mul_add(b, c); // (a*b) + c, rounded once.
The catch: a compiler may contract a plain a*b + c into a single FMA on its own, which changes the result versus the two-rounding form. "More accurate" is not "identical," and whether contraction happens depends on the compiler and flags, so the same source gives different numbers on different toolchains[9][10]. With the numbers above, the line return a*b + c; compiled by GCC 13 at -O2 -mfma becomes one vfmadd instruction and returns 0.000488340854644775390625; add -ffp-contract=off and it returns 0.00048828125.
Float addition is not associative. (a+b)+c can differ from a+(b+c) because each step rounds[1]. So summation order, SIMD lane-reduction order (SIMD runs one instruction on several values at once, then combines the lanes at the end), and multi-threaded partial-sum order all change the result. None of this is random: a fixed order is deterministic, and the answer changes only when the order does.
Worked example
a = 100,000,000, b = −100,000,000, c = 1, all exact as floats. (a + b) + c = 0 + 1 = 1. But a + (b + c) first computes −100,000,000 + 1, and floats near 108 are 8 apart, so that rounds back to −100,000,000, and the total is 0. Same three numbers, two orders, two answers.
-ffast-math trades IEEE behavior for speed, in ways that bite later-ffast-math bundles several relaxations[10]. -fassociative-math lets the compiler reassociate, which is what deletes the Kahan compensation from §8. -ffinite-math-only lets it assume no NaN or infinity, so it can optimize isnan(x) to false and silently break NaN sentinels. -fno-signed-zeros makes x + 0.0 → x (wrong for −0.0). And on GCC and Clang (Linux), linking with -ffast-math or -Ofast adds a startup object (crtfastmath.o) that switches on flush-to-zero and denormals-are-zero for the whole process, changing the behavior of unrelated code; before GCC 13 this happened even when building a shared library, so merely loading that library changed results elsewhere. Audit it per translation unit; never blanket-enable it in a codebase that uses NaN sentinels, compensated sums, or lockstep determinism.
10Comparing floats
A unit test checks that a transformed point lands where it should, and fails with "expected 1048576, got 1048576.25". They're only two floats apart. a == b on floats is exact equality, and after any rounding or reordering that's usually not what you want. There are three tolerance strategies, and each fails somewhere[7].
- Absolute (
|a−b| ≤ ε, with ε a fixed small number): works near zero, fails at large magnitudes, where a fixedεis smaller than one ULP and the test degrades into==. - Relative (
|a−b| ≤ ε·max(|a|,|b|), so the tolerance scales with the values): works across magnitudes, breaks down near zero, and is meaningless across zero (opposite signs). - ULP-based: reinterpret the bits as integers and subtract; the result is the count of representable floats between them. Intuitive, but undefined across signs and gigantic when comparing a tiny number to
0.0.
Worked example
At 1,048,576 (220) one ULP is 2−3 = 0.125, so a = 1,048,576 and b = 1,048,576.25 are 2 ULP apart, |a − b| = 0.25.
- Absolute with ε = 10−6: 0.25 > 0.000001, so not equal. At this size even two neighboring floats can't pass.
- Relative with ε = 10−5: 0.25 / 1,048,576.25 ≈ 2.38 × 10−7 ≤ 10−5, so equal.
- ULP with a limit of 4: 2 ≤ 4, so equal.
Dawson's practical answer: combine an absolute "safety net" for the near-zero case with a relative or ULP comparison otherwise, and pick the absolute tolerance from the scale of your problem. His summary is worth memorizing: "zero is a huge nuisance, avoid it if possible"[7].
The widget runs all three tests on two floats a few ULP apart, at every magnitude from 2−24 to 224:
11Determinism across machines
In a lockstep game, every machine runs the same simulation from the same player inputs and sends only the inputs over the network. A real-time strategy (RTS) game uses it because thousands of units would be too much state to send. It only works if every machine computes bit-identical results, and identical source can produce different float results on different machines. That is why a lockstep RTS or a fighting-game replay can desync on a teammate's PC, and why the Game Loop tutorial, next in the series, needs more than a fixed timestep to make a simulation deterministic.
Worked example
Run the line r = a*b + c from §9 (a = b = 1.000244140625, c = −1) on three builds:
- x86-64 with SSE and contraction off: 0.00048828125.
- The same source contracted into an FMA: 0.000488340854644775390625.
- A 32-bit x87 build that keeps the product in an 80-bit register: the product is exact there, so it also gets 0.000488340854644775390625.
The results differ by 1,024 ULP. A lockstep game that checksums its state every frame flags a desync on the first frame this line runs.
The causes are specific; floats are not random[11]:
- Different compilers and optimization levels reassociate and contract differently.
- FMA contraction on one platform but not another.
- x87 80-bit intermediates vs SSE 32/64-bit on x86. x87 is the original x86 floating-point unit, which keeps intermediates at 80 bits by default; SSE rounds every result to exactly
floatordouble. - Transcendental functions. IEEE-754 requires
+ − × ÷ sqrt fmaand remainder to be correctly rounded, but it does not require it ofsin,cos,exp, so vendor math libraries legitimately differ[11]. - FTZ/DAZ mode differences (next section).
More bits doesn't make two different rounding sequences agree. Cross-platform determinism comes from controlling the operations: fixed-point arithmetic, or strict same-compiler, same-flags builds with contraction and x87 disabled and transcendentals replaced by your own implementations. Glenn Fiedler's writeup quotes a developer of a shipped lockstep game (Battlezone II) whose team wrapped sin, cos, tan and their inverses in non-optimized calls to keep them at single precision, because AMD and Intel processors returned slightly different results for them[11].
12The subnormal cliff
The music stops, the reverb tail fades out, and the audio thread, which was fine during the loud part, suddenly misses its deadline and the sound crackles. On some CPUs a quiet signal costs more than a loud one, because as it decays it fills with subnormals.
On some hardware, arithmetic that produces or consumes subnormals drops into a microcode-assisted slow path that is dramatically slower than the same operation on normal numbers. Bruce Dawson measured a worst case of roughly a 175× slowdown for SSE addition and multiplication with subnormals on a Core 2, and about a 140-cycle penalty on a Sandy Bridge mulps, where he could produce no slowdown at all on addps[12]. The size depends on the CPU and the operation, so quote it with its CPU rather than as a universal number.
Worked example
A one-pole filter keeps a state and, once its input goes silent, multiplies that state by 0.99 every sample. From 1.0 the state drops below the smallest normal float, 1.18 × 10−38, after 8,690 samples, 0.18 seconds at 48 kHz. From then on every multiply touches a subnormal.
It never reaches zero. At 50 × 2−149 ≈ 7.0 × 10−44, multiplying by 0.99 gives just over 49.5 steps of 2−149, which rounds back up to 50, so the state sits there forever and every sample after the first 0.18 s of silence is a subnormal multiply. With flush-to-zero on, the 8,690th sample becomes exactly 0 instead.
The fix is FTZ (flush-to-zero, for outputs) and DAZ (denormals-are-zero, for inputs), two bits in the x86 MXCSR control register (the per-thread register that holds SSE's rounding mode and exception settings) that make the CPU treat subnormals as zero, trading correctness near zero for speed. It is a per-thread CPU mode, so set it on every audio or physics worker thread, not just the main one[13]. Rust has no sound equivalent: its compiler assumes the default MXCSR, flipping these bits is undefined behavior, and the _mm_setcsr intrinsic has been deprecated since Rust 1.75 for that reason[15]. Rust code keeps subnormals out of its feedback paths instead.
#include <xmmintrin.h> // FTZ (flush-to-zero, outputs)
#include <pmmintrin.h> // DAZ (denormals-are-zero, inputs; SSE3)
_MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON); // MXCSR bit 15
_MM_SET_DENORMALS_ZERO_MODE(_MM_DENORMALS_ZERO_ON); // MXCSR bit 6; call per thread
// Rust assumes the default MXCSR, so setting FTZ/DAZ is UB (and
// _mm_setcsr is deprecated). Flush tiny values out of feedback state
// yourself instead, e.g. in a recursive filter or reverb tail.
const DENORMAL_FLOOR: f32 = 1.0e-15; // about -300 dB: inaudible
fn flush_tiny(sample: f32) -> f32 {
if sample.abs() < DENORMAL_FLOOR { 0.0 } else { sample }
}
// state = flush_tiny(input + feedback * state);
Recursive filters and reverb tails decay exponentially toward zero, so when a signal goes quiet (a DAW, the music software hosting the plugin, stops playback and streams zeros into a reverb) they generate a flood of subnormals, and on an affected CPU the audio thread blows its budget and drops out. Enabling FTZ/DAZ on the audio thread is the standard mitigation in C++; flushing tiny values out of the filter state works everywhere. And recall the link-time footgun from §9: -ffast-math turns FTZ on process-wide, which is how most people enable this without meaning to[10].
13Pitfalls
FLT_EPSILON (gap at 1.0) as the rounding bound (half that), or as a usable tolerance far from 1.0.x == NAN is always false; use std::isnan / f32::is_nan (or x != x). And -ffinite-math-only deletes all of them.-fassociative-math / -ffast-math proves the compensation zero and removes it.FLT_MIN is the smallest normal, not the smallest positive (that's denorm_min).(uint32_t*)&f is UB in C++; use std::bit_cast or memcpy[5].14What's next
Next is The Game Loop & Time. Its determinism section picks up from §11: a fixed timestep is necessary for a replayable simulation, and the float behavior on this page is why it isn't sufficient. After it come Platform & Window and Input. This page pairs with 3D Math as the numerics foundation, and the bit tricks here are the same muscle as Bit Shifting: the famous fast inverse square root, Quake III's trick that reads a float's bits as an integer to approximate 1/√x, is exactly this bit-level reinterpretation[14]. The full path is on the series hub.
- David Goldberg. "What Every Computer Scientist Should Know About Floating-Point Arithmetic." ACM Computing Surveys, 1991. docs.oracle.com. Bit layout, ULP and machine epsilon, gradual underflow, catastrophic vs benign cancellation (the 70-ULP quadratic example), non-associativity.
- IEEE. "IEEE Standard for Floating-Point Arithmetic," IEEE 754-2019. ieeexplore.ieee.org. binary32/binary64 definitions, round-to-nearest-ties-to-even as the default, the correctly-rounded operation set.
- cppreference. std::numeric_limits<T>::epsilon / min / denorm_min. en.cppreference.com. epsilon = gap from 1.0 to the next float = FLT_EPSILON; min() = smallest normal; denorm_min() = smallest subnormal.
- Rust. Primitive type f32. doc.rust-lang.org. to_bits/from_bits, mul_add (single rounding), next_up/next_down (stable 1.86), EPSILON = 1.19209290e-07, MIN_POSITIVE, NaN sign non-portability.
- cppreference. std::bit_cast. en.cppreference.com. C++20, the UB-free reinterpretation of a float's bits (vs type-punning through a pointer).
- Exploring Binary. "Why 0.1 Does Not Exist In Floating-Point." exploringbinary.com. The exact nearest value to 0.1 and the repeating-binary reason it isn't representable.
- Bruce Dawson. "Comparing Floating Point Numbers, 2012 Edition." 2012. randomascii.wordpress.com. Absolute vs relative vs ULP comparison, where each fails, the combined approach, and "zero is a huge nuisance."
- "Kahan summation algorithm." Wikipedia. en.wikipedia.org. The compensated-summation algorithm and its error bound versus naive summation (and the Neumaier and pairwise variants).
- cppreference. std::fma. en.cppreference.com. Single-rounding semantics of
a*b+c, FP_FAST_FMA, and the relationship to compiler contraction (FP_CONTRACT). - Simon Byrne. "Beware of fast-math." simonbyrne.github.io. The sub-flags
-ffast-mathenables, including reassociation breaking Kahan, finite-math killing isnan, and GCC enabling FTZ/DAZ process-wide, even from shared libraries until GCC 13. - Glenn Fiedler. "Floating Point Determinism." Gaffer On Games, 2010. gafferongames.com. The causes of cross-platform float divergence, Intel's note that the IEEE operations match across platforms under the same control settings while x87 transcendentals may not, and the Battlezone II lockstep case (transcendentals wrapped to stay at single precision).
- Bruce Dawson. "That's Not Normal: the Performance of Odd Floats." 2012. randomascii.wordpress.com. Measured subnormal slowdowns with the specific CPUs, and the FTZ/DAZ control-register fix.
- Intel. "Set the FTZ and DAZ Flags," oneAPI C++ Compiler Developer Guide. intel.com. The
_MM_SET_FLUSH_ZERO_MODE/_MM_SET_DENORMALS_ZERO_MODEintrinsics and the MXCSR FTZ (bit 15) / DAZ (bit 6) bits. - "Fast inverse square root." Wikipedia. en.wikipedia.org. The Quake III
0x5f3759dfbit-level hack, its ~0.17% error after one Newton step, and the modern hardwarersqrtinstructions that supersede it. - Rust. core::arch::x86_64::_mm_setcsr. doc.rust-lang.org. Deprecated since 1.75: Rust assumes MXCSR's masking, rounding and denormals-are-zero bits stay at their defaults, so changing them is immediate undefined behavior.