All tutorials Mighty Professional
Build a Game Engine · Math & numerics

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.

Time~65 min LevelBeginner to mid PrereqsYou can read C++ or Rust. Bit Shifting helps; the layout is all bit fields. StackC++ & Rust
◂ Build a Game Engine Phase 2 · Math & numerics Next · The Game Loop & Time ▸
What you'll be able to do by the end

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:

eq. 1 · binary32 value value = (−1)sign × 1.fraction × 2(exponent − 127)

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:

Presets:
The top row is the 32 bits: red sign, blue exponent, green fraction. Click any bit to flip it (or focus the bit row and use the arrow keys and Space), or start from a preset. The lines below decode it, and the last line is the exact value with no display rounding. Press 0.1: stored exponent 123 (2−4), significand ≈ 1.6000000, and exact = 0.100000001490116119384765625, a hair above one tenth. Zero the exponent field and the number goes subnormal (the readout says the implicit bit is now 0); set it to all ones and you're in infinity/NaN territory.

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:

Look at the bits
// 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.

Trap: FLT_MIN is not the smallest float

std::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.

Two NaN traps

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.

Trap: the default is banker's rounding, not round-half-up

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:

Every position in the town snaps to that 2 mm grid, and the gun shimmers as it hops between grid points.

Trap: there are two "machine epsilons"

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:

The ticks are nine neighboring floats on a number line, centered on x (purple), with the gap to the next float marked "1 ULP" and the values printed below. Drag the magnitude slider or step with the buttons. At the default 1.0 the readout shows next = 1.0000001192092896 and 1 ULP = 1.1920928955078125e-7. Ticks sit at their true positions, so at an exact power of two the floats below are packed twice as densely as the floats above. Press "jump to 224": next = 16777218 and 1 ULP = 2, so the odd integers are gone. Step down one ULP to 16777215 and the gap is back to 1. A double only postpones this growth (and GPU pipelines run f32 in practice); the fix is to keep the values near the origin with local coordinate systems or camera-relative rendering.

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.

Kahan compensated summation
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:

Blue is the naive total and green is Kahan, both plotted as the amount added above the 224 seed; the dashed line is the true sum. The race starts when it scrolls into view and runs 60,000 additions of 1.0. Each naive +1.0 is an exact tie that rounds back to the even neighbor, so the blue line stays flat and ends at 16,777,216 with an error of 60,000. Kahan ends at 16,837,216 with an error of 0; mid-race its error readout flickers between 0 and ±1 because the total itself must be a float. Tick "compile Kahan with -ffast-math" (a GCC and Clang flag that trades IEEE rules for speed) and the race restarts with the compensation deleted, because the optimizer can prove it's "algebraically zero". Kahan collapses onto the naive line. That's a real footgun, covered next.

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.

Fused multiply-add
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.

Trap: -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].

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.

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:

Each bar is one test swept across magnitude: green where it calls the two floats equal, red where it doesn't. The yellow marker and the readout below follow the magnitude slider; the gap slider sets how many ULP apart the floats are. The default is the worked example: at 220 the readout shows |a−b| = 2.50e-1 and relative = 2.38e-7, and only the absolute test says NOT equal. Slide the magnitude down and the absolute test (ε = 1e-6) passes at 22, where |a−b| = 9.54e-7, and fails from 23 up, where it's 1.91e-6: past that point ε is smaller than the gap, and the test degrades toward ==. Relative (1e-5) and ULP (up to 4 ULP) stay green because they scale with the values, though relative still breaks down comparing against zero and ULP is undefined across signs, the cases the list above warns about. Push the gap past 4 ULP and the ULP bar turns red at every magnitude: its threshold is a count of floats, not a distance.

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:

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]:

"Use doubles" does not fix determinism

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.

Enable FTZ and DAZ (x86)
#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);
Where this actually bites: audio

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

Wrong epsilon toleranceTreating FLT_EPSILON (gap at 1.0) as the rounding bound (half that), or as a usable tolerance far from 1.0.
Jitter far from originULP grows with magnitude. Keep coordinates camera-relative, don't just reach for doubles.
NaN check that never firesx == NAN is always false; use std::isnan / f32::is_nan (or x != x). And -ffinite-math-only deletes all of them.
Kahan sum reverts to naive-fassociative-math / -ffast-math proves the compensation zero and removes it.
Replay desyncs on another PCDiffering compilers, FMA, x87-vs-SSE, or transcendentals. Doubles don't fix it.
Audio dropouts when quietReverb tails generate subnormals; enable FTZ/DAZ per audio thread.
Smallest-float confusionFLT_MIN is the smallest normal, not the smallest positive (that's denorm_min).
Type-punning UBReading float bits via (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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. cppreference. std::bit_cast. en.cppreference.com. C++20, the UB-free reinterpretation of a float's bits (vs type-punning through a pointer).
  6. 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.
  7. 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."
  8. "Kahan summation algorithm." Wikipedia. en.wikipedia.org. The compensated-summation algorithm and its error bound versus naive summation (and the Neumaier and pairwise variants).
  9. cppreference. std::fma. en.cppreference.com. Single-rounding semantics of a*b+c, FP_FAST_FMA, and the relationship to compiler contraction (FP_CONTRACT).
  10. Simon Byrne. "Beware of fast-math." simonbyrne.github.io. The sub-flags -ffast-math enables, including reassociation breaking Kahan, finite-math killing isnan, and GCC enabling FTZ/DAZ process-wide, even from shared libraries until GCC 13.
  11. 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).
  12. 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.
  13. Intel. "Set the FTZ and DAZ Flags," oneAPI C++ Compiler Developer Guide. intel.com. The _MM_SET_FLUSH_ZERO_MODE / _MM_SET_DENORMALS_ZERO_MODE intrinsics and the MXCSR FTZ (bit 15) / DAZ (bit 6) bits.
  14. "Fast inverse square root." Wikipedia. en.wikipedia.org. The Quake III 0x5f3759df bit-level hack, its ~0.17% error after one Newton step, and the modern hardware rsqrt instructions that supersede it.
  15. 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.

See also