Foundations/Floating Point

Lesson 1.42,254 words

Floating Point

IEEE-754 trades the exactness of integers for enormous range by storing numbers as sign, exponent, and fraction — scientific notation in binary. We lay out the single and double formats, the bias that encodes the exponent, the three regimes (normalized, denormalized, special), a worked encode/decode, the four rounding modes and round-to-even at the bit level, why addition is not associative, the pitfalls of float-int conversion, and why 0.

╌╌╌╌

Integers are exact but cramped: a 32-bit int cannot reach a billionth or a trillion. Floating point gains range by devoting bits to an exponent, storing a number as a sign, a fractional significand, and a scale — binary scientific notation. In exchange, most values are stored approximately, and the rules governing that approximation are subtle. This lesson lays out the IEEE-754 formats, decodes and encodes a concrete value, and explains the rounding behavior that makes 0.1 inexact.

The IEEE-754 form

Every IEEE-754 number is built from three fields — a sign bit , an exponent field of bits, and a fraction field of bits — packed sign-first, exponent next, fraction last. The two standard widths fix and :

FormatTotalSignExponent Fraction Bias
Single (float)321823127
Double (double)64111521023
The 32-bit single-precision layout: one sign bit, an 8-bit exponent field, and a 23-bit fraction field, packed from the most significant bit downward.

The general value, when the number is normalized, is

Two design choices make this compact. The significand has an implied leading 1 that is never stored, gaining a free bit of precision. And the exponent is stored biased — as , an unsigned field — so that the bit patterns sort in the same order as the numbers, letting integer comparison hardware compare floats.

Three regimes: normalized, denormalized, special

The exponent field does double duty as a selector among three encodings. The all-zeros and all-ones patterns are reserved; everything between is normalized.

  • Normalized ( neither all-0 nor all-1): the usual case above, with implied leading one, .
  • Denormalized (): the leading digit becomes , so , and the exponent is fixed at . This represents zero (when , with a signed ) and the tiny values that fill the gap between zero and the smallest normalized number, giving gradual underflow.
  • Special ( all-1): encodes (from overflow or division by zero); encodes NaN, the result of an undefined operation like or .
The value line for non-negative single precision. Denormalized values pack densely just above zero, normalized values cover the wide middle, and the all-ones exponent is reserved for infinity and NaN.

The denormalized region's purpose is continuity: without it there would be a sudden jump from the smallest normalized number straight to zero, and many small differences would flush to zero. Denormals fill that gap with evenly spaced values so if and only if . The numbers make the design visible in single precision. The smallest normalized value is ; the denormals below it march down in uniform steps of , from just under the boundary to the smallest positive value of all, . As a value decreases through this band it loses fraction bits to leading zeros, trading precision for range — gradual underflow, and the reason the newer IEEE vocabulary calls these values subnormal.

Density: precision is relative, not absolute

Fixed-point spacing is what integers have: neighbors sit exactly apart everywhere. Floating point instead keeps relative spacing constant. Within one binade the fraction patterns are evenly spaced apart, and stepping into the next binade doubles the gap. A toy format with three fraction bits shows the shape:

A toy format with three fraction bits. Inside each binade the eight significands are evenly spaced; every new binade doubles the spacing. Values near 1 sit 1/8 apart, values near 8 a full 1/2 apart.

The same geometry at full width explains a fact worth remembering: single precision represents every integer up to exactly, then starts skipping. At the gap between neighbors reaches , so has no float. A double, with fraction bits, holds every integer through and then develops the same holes. Precision is about one part in of the magnitude, wherever on the line the value lives.

A worked decode and encode

Take the single-precision value . To encode it, first write it in binary: . Read off the three fields. The sign is (positive). The exponent is , so the stored field is . The fraction is the bits after the implied leading one: (23 bits, trailing zeros).

Encoding 6.5 = 1.101 x 2^2 into single precision: sign 0, exponent field 2 + 127 = 129, fraction 101 padded to 23 bits. Decoding reverses it: 1.625 x 2^(129-127) = 6.5. The packed word is 0x40d00000.

To decode in reverse, read , so , and . Then , recovering the original. The packed 32-bit word is 0x40D00000.

A tiny 8-bit toy format makes the three regimes visible in numbers small enough to check by hand. Give it one sign bit, a 4-bit exponent (bias ), and a 3-bit fraction. Three encodings from this format, one per regime, show the selector at work.

Bits ()RegimeValue
0 0110 100normalized
0 0000 100denormalized
0 1111 000special

The denormal row is the instructive one: because pins the exponent at (not ) and drops the implied leading one, the values slide continuously down toward zero instead of leaving a gap. Its neighbor 0 0000 001 decodes to , the smallest positive value this toy format can represent, and 0 0000 000 is . Line them up and the denormals fill the interval below the smallest normal with evenly spaced steps of .

Rounding: four modes, one default

Most reals cannot be hit exactly, so every operation ends by rounding its true result to a representable neighbor. IEEE-754 defines four ways to choose the neighbor:

  • Round-to-nearest-even (the default): pick the closer neighbor; on an exact tie, pick the one whose last fraction bit is .
  • Round-toward-zero: truncate, dropping the excess bits. This is the mode C uses for float-to-int casts.
  • Round-down (toward ) and round-up (toward ): the directed pair, useful for bracketing a true result between guaranteed bounds.

Rounding the decimal values , , , , and to integers separates the four:

Mode
Nearest even
Toward zero
Down
Up

At the bit level, round-to-even looks at the tail, the bits below the last position kept, and compares it against one half of the last kept unit, the pattern Rounding to two fraction bits (nearest quarter):

ValueTail against halfResult
, below half
, above half
exact tie, kept LSB is odd
exact tie, kept LSB is even
Round-to-nearest-even as a decision procedure. Only an exact tie consults the kept LSB, and it always leaves that bit 0: ties round to the even neighbor.

Why bother with the even rule? Always rounding ties the same direction (say, up) introduces a systematic drift across a long sum; ties to even go up half the time and down half the time, and the bias cancels. The same principle in decimal is the familiar round half to even (banker's rounding): rounds to , but and both round to .1

Rounding twice is not rounding once

A subtle corollary: rounding to an intermediate precision and then to the final one can give a different answer than rounding directly, even when every step is correctly rounded. Take and round it to one fraction bit. Directly, the tail is above half, so the result is . Now go through two fraction bits first: the tail rounds down to , and is an exact tie at one fraction bit, so the even rule picks . Direct rounding gives ; double rounding gives . The first rounding discarded the information that the value sat above the halfway point.

This occurs in real hardware. The x87 floating-point unit that predates x86-64's vector registers computes everything in 80-bit extended precision and rounds again when a result spills to a 64-bit double, so the same C expression could yield different bits depending on when the compiler spilled. The same effect occurs in float f = d1 + d2;, which rounds the sum to double and then rounds that to float.2

Why 0.1 is not exact

A binary fraction can represent exactly only those values whose denominator is a power of two. The decimal has a factor of in its denominator, so in binary it is a repeating expansion, just as repeats in decimal:

Stored in a 23- or 52-bit fraction, this infinite tail is truncated and rounded, so the value held is near but not equal to it. The stored double is actually , which is why the classic test fails.

point_one.cc
#include <stdio.h>
int main(void) {
  double sum = 0.1 + 0.2;
  printf("%.17f\n", sum);        /* 0.30000000000000004 */
  printf("%d\n", sum == 0.3);    /* prints 0: NOT equal  */
  return 0;
}

Neither , , nor is exactly representable, and the rounding errors in and do not cancel against the rounding error in the stored . This is not a bug in any one machine; it is inherent to base-2 fractions, and the practical rule is to never compare floats with ==, testing instead whether the difference is below a small tolerance.3

Addition that refuses to associate

Because every operation rounds, algebraic identities that hold for reals fail for floats. The most consequential loss is associativity. Floating-point addition is still commutative, and it is monotonic, but the grouping of a sum changes its value:

assoc.cc
float a = (3.14f + 1e10f) - 1e10f;   /* 0.0f     */
float b = 3.14f + (1e10f - 1e10f);   /* 3.14f    */

The mechanism is absorption, and it is visible at the bit level. To add two floats the hardware must first align their radix points, shifting the smaller operand's significand right by the difference in exponents. Here and , an exponent gap of about ; shifting a 24-bit significand right by slides every bit past the edge of the fraction, so the addition contributes nothing and the parenthesized sum is exactly . Subtracting then leaves . Group the other way and first, preserving intact.

Absorption during alignment. Adding 3.14 to 10^10 shifts the smaller significand right by the exponent gap of 32 bits; all 24 of its bits slide past the fraction's edge and are rounded away.

The practical consequence: the order in which a program sums a list changes the answer, so compilers do not reorder floating-point arithmetic unless explicitly told correctness does not matter, and numerical code that adds values of wildly different magnitudes must choose the order that loses less.4

Crossing between int and float

C converts freely between the integer and floating families, and each direction has a failure mode worth knowing cold.

  • int to float rounds. A float fraction holds bits, so integers above are not all representable: 16777217, which is , rounds to the even neighbor . Conversion to double is exact for every int () but rounds for large long values.
  • float to int truncates, rounding toward zero: 3.9 becomes 3, -3.9 becomes -3.
  • Out-of-range conversions have no answer. Casting 1e10f or a NaN to int is undefined; x86-64 hardware returns the integer indefinite pattern 0x80000000, which is , regardless of sign.
convert.cc
int   big  = 16777217;      /* 2^24 + 1 */
float f    = big;           /* rounds to 16777216.0f */
int   back = (int) f;       /* 16777216: the round trip lost the 1 */
int   t    = (int) -3.9f;   /* -3: truncation toward zero */
int   boom = (int) 1e10f;   /* undefined; x86-64 yields INT_MIN */

The round trip is the case that surfaces in real systems: an integer stored in a floating type comes back changed once it crosses the fraction width, for float and for double — the latter being why integer identifiers silently lose precision past in every language whose only number type is a double, with no warning at either conversion.5

The low-precision formats of machine learning

CS:APP covers the two IEEE-754 widths that existed when it was written, single and double. Machine learning has since driven a proliferation of narrower formats, and they are a direct application of everything above: each is the same sign-exponent-fraction layout, trading fraction bits (precision) against exponent bits (range) for a specific workload.

The IEEE half precision (fp16, 1/5/10) has a 5-bit exponent, so its range tops out near — small enough that gradients in a deep network overflow to infinity. Google's bfloat16 (1/8/7) keeps float's full 8-bit exponent and its range, spending only 7 bits on the fraction; it deliberately sacrifices precision to keep the dynamic range — the trade training tolerates and overflow does not.6 Training is far more sensitive to range (not overflowing) than to the last few bits of precision, so truncating a float to its top 16 bits — which is all bfloat16 is — costs almost nothing.

FormatBitsExpFracMax finiteUse
double641152general
float32823general
bfloat161687ML training
fp1616510ML inference
fp8 (E4M3)843ML inference

The trend has run all the way down to 8-bit floats, standardized by the OCP in 2023 in two flavors — E4M3 (more precision) and E5M2 (more range) — for the weights and activations of large models, where storing a number in one byte instead of four quarters the memory traffic that dominates inference cost.7 Each new format is the sign-exponent-fraction layout of this lesson, re-partitioned. Reading the low-precision literature comes down to two questions about any format: how many exponent bits (what is its range and where does it overflow), and how many fraction bits (how coarse is its spacing) — the same two knobs that separate float from double.

Floating point and integers cover the numbers; the final foundations lesson returns to the bits themselves as logical objects, in boolean algebra and bit manipulation.

Footnotes

  1. Bryant & O'Hallaron, CS:APP, §2.4.4 — Rounding: the four modes, round-to-nearest-even as the IEEE default, and why it avoids the statistical bias of always rounding ties one way.
  2. Bryant & O'Hallaron, CS:APP, §2.4.6 — Floating Point in C: the x87 extended-precision registers and the double-rounding hazards of computing wider than the stored type.
  3. Bryant & O'Hallaron, CS:APP, §2.4.2–2.4.6 — Floating-Point Representation and Operations: rounding error makes most decimal fractions inexact in binary, so equality tests on floats are unreliable.
  4. Bryant & O'Hallaron, CS:APP, §2.4.5 — Floating-Point Operations: addition is commutative and monotonic but not associative, and multiplication does not distribute over addition.
  5. Bryant & O'Hallaron, CS:APP, §2.4.6 — Floating Point in C: conversion rules among int, float, and double, including rounding on int-to-float and truncation with undefined out-of-range behavior on float-to-int.
  6. bfloat16 originates in Google's TPU work; the rationale that neural-network training needs float's dynamic range far more than its precision, so the format keeps the 8-bit exponent and truncates the fraction to 7 bits, is documented in the TPU and TensorFlow numerics literature (e.g. Wang & Kanwar, BFloat16: The secret to high performance on Cloud TPUs, 2019).
  7. Micikevicius et al., FP8 Formats for Deep Learning (2022), and the Open Compute Project OCP 8-bit floating-point specification (2023): the two 8-bit formats E4M3 and E5M2 that trade one exponent bit for one fraction bit, adopted for the weights and activations of large models.

╌╌ END ╌╌