← quay lại đọcBản inđang dàn trang…
Chapter I

Floating point numbers

how is 0.1 held inside a computer?
maxubrq.space29 tháng 1, 2026 · 22 phút đọc
maxubrqFloating point numbersi

mục lục

A tour of IEEE 754: how do you compress an infinite real number line into a 32 bit register? Build a float by hand in binary, then see why the gaps between floats are uneven and what that means for every calculation you have ever written.

“A computer does not store 0.1 exactly. It stores the closest value it can manage.”

During the Gulf War, on 25 February 1991, a Patriot missile battery at Dhahran, Saudi Arabia, failed to intercept an incoming Iraqi Scud. The Scud landed on a barracks. Twenty-eight American soldiers died and close to a hundred were wounded.

The House of Representatives asked the GAO (General Accounting Office) to investigate. The report found no operator error and no hardware fault. The cause was accumulated floating-point drift. The Patriot's internal clock counted time in units of one tenth of a second. The number 0.10.10.1, as we are about to see, cannot be stored exactly in a computer. Every tick carried a minuscule error with it. After roughly 100 hours of continuous operation, those tiny errors had added up to 0.340.340.34 seconds.

A third of a second sounds like nothing. But a Scud travels at around 1,676 metres per second, so in that 0.340.340.34 seconds it covers more than half a kilometre, far enough to fall outside the window the Patriot's radar had predicted. The intercept system passed over it, and twenty-eight people died.

The Patriot failure is documented in GAO IMTEC-92-26 www.gao.gov/assets/imtec-92-26.pdf. The accumulated error after 100 hours of operation was 0.3433 seconds, produced by repeated multiplication of a truncated representation of 1/10. The irony is that another part of the software had already been fixed to use a more accurate representation, but the patch was not applied uniformly, so two places in the same system disagreed with each other.

The same class of floating-point error bled value out of the Vancouver Stock Exchange index for 22 months, because every rounding shaved off a sliver. It has made medical systems deliver the wrong dose. It has made physics engines fire characters through walls. These incidents are not, strictly speaking, mistakes. Floating point was designed to behave this way. It is the best attempt anyone has made at squeezing the infinite real number line into a register with a finite number of bits.

The standard is called IEEE[01] 754, published in 1985, and at its core it has barely changed since.

§ 01The core problem: an infinite line into a finite number of bits

Between any two numbers on the real line there are infinitely many others. Between 000 and 111 there is 0.50.50.5. Between 000 and 0.50.50.5 there is 0.250.250.25. And so on, forever, without end. The real line is a continuum, infinitely dense.

A register in a CPU is a different kind of object. It has a finite number of bits. It is a row of switches, each one on or off (0 or 1). A 32 bit register has exactly 2322^{32}232 possible states, about 4.3 billion, no more and no less.

Here is the whole tragedy in one sentence: we have to map an infinite set into a finite number of bits. That is impossible, at least mathematically, if you want to preserve every property of the numbers. Any way of storing real numbers in a computer is forced to throw away almost all of them, keep a sparse subset, and round everything else to the nearest survivor. The question is not "is anything lost", it is "which numbers do we choose to keep".

What about fixed-point? What goes wrong there

The simplest answer you can think of is fixed-point: nail the point down at one position. With 32 bits, for instance, you could give 16 bits to the integer part and 16 bits to the fractional part. That lets you represent every real number of the form integer.fraction at a fixed resolution (a fixed gap) of 2−16≈0.00001532^{-16}\approx0.00001532−16≈0.0000153.

This is tidy and predictable. The gap between two neighbouring numbers is the same everywhere, always 2−162^{-16}2−16, whether you are near 000 or near the largest representable value. That is both the strength and the fatal flaw of the scheme. The strength is that it does not produce the kind of drift that accumulates into the disasters above.

The fatal flaw is the range of values it can represent. With only 16 bits of integer part, the largest number it can hold is about 65,535. Want to store the mass of a galaxy? No. The radius of an atom? Also no, because 2−162^{-16}2−16 is the smallest thing it can express. You are trapped between two walls, a ceiling that is far too low and a floor that is far too high. This representation skips over almost the entire real line.

And more importantly, it wastes bits. When you compute with very small numbers, most of the integer bits are useless zeros. When you compute with very large numbers, the fractional bits are useless too. You pay for 32 bits and only ever get to use a handful of them.

Fixed-point hands out precision equally to every number. But we rarely need precision spread evenly, we need it relative, sized so that it stays meaningful whether the number is enormous or tiny.

What "meaningful" actually means

So what is "meaningful"? How do you know whether a digit carries any meaning?

Take a simple case. Google Maps tells you there are "150 metres to your destination", or it tells you "150.001 metres to your destination". How much do those two differ? In absolute terms, one millimetre. What does one millimetre mean to you while you are moving down a road? I would guess nothing. It does not carry enough meaning to change anything you do. And yet that same one millimetre of error, while you are etching a chip, is a catastrophe.

So not every error carries the same weight, even when it is numerically the same error. At larger scales we accept larger errors, and at very small scales the error has to shrink to match. Put differently: when we compute at small scale we want to keep as many digits after the point as possible, and at large scale we want as many digits before it.

Science already had a notation for exactly this wish. Look at

1.5×1031.5 \times 10^31.5×103 and 1.5×10−31.5 \times 10^{-3}1.5×10−3

Both are 1.51.51.5, but 10310^3103 and 10−310^{-3}10−3 tell you which scale each number lives at, one is 15,00015,00015,000 and the other is 0.00150.00150.0015. The 1.51.51.5 is the part carrying the meaning we care about. A physicist writes the mass of an electron as 9.109×10−319.109 \times 10^{-31}9.109×10−31 kg and the mass of the Sun as 1.989×10301.989 \times 10^{30}1.989×1030 kg with the same number of significant digits, even though the two numbers are 61 orders of magnitude apart. The 10310^3103 and 10−310^{-3}10−3 also tell us where the point really sits: 10310^3103 says slide the point three places to the right, 10−310^{-3}10−3 says slide it three places to the left to recover the true value.

This is precisely what fixed-point cannot do, and precisely what we want from a meaningful number: keep a fixed count of significant digits, and record separately where the point belongs.

Floating-point: which part floats

The name floating-point stands in direct opposition to fixed-point. Instead of nailing the point to one position in the bit string, we let it drift. Use a handful of bits to record the significant digits, and another handful to record where the point has drifted to. The same move scientific notation makes, only this time in binary.

In decimal:

1.510×103=1500101.5_{10} \times 10^3 = 1500_{10}1.510​×103=150010​

In binary:

1.12×23=11002=12101.1_{2} \times 2^3 = 1100_2 = 12_{10}1.12​×23=11002​=1210​

The mantissa, 1.1 in binary, holds the significant digits. The exponent, 3, says where the point belongs. The exponent is the part that floats. Change it and the point slides, so the same handful of mantissa bits can express 0.001120.0011_{2}0.00112​ or 110021100_{2}11002​ or 1.1×2401.1 \times 2^{40}1.1×240. That is how float32, with the same 32 bits, covers a range from about 10−3810^{-38}10−38 up to 103810^{38}1038, a span fixed-point could not reach in its dreams.

Everything has a price, and the price here is that the gaps between representable numbers are no longer even. The gap is minuscule near 000 and enormous near the large values. That is where the bugs make their nests. But first we need to know how to write a decimal number out in binary.

§ 02The basic conversions

To build a float by hand you need one background skill: converting a decimal number to binary. The integer part and the fractional part follow two different rules.

Converting the integer part to binary

The rule for integers is divide by 2, keep the remainder, read upward. Take 13:

13 ÷ 2 = 6 remainder 1   ← lowest bit
 6 ÷ 2 = 3 remainder 0
 3 ÷ 2 = 1 remainder 1
 1 ÷ 2 = 0 remainder 1   ← highest bit

Read the remainders from the bottom up: 1310=1101213_{10} = 1101_21310​=11012​. You can check it: 1×23+1×22+0×21+1×20=8+4+0+1=131\times2^3 + 1\times2^2 + 0\times2^1 + 1\times2^0 = 8 + 4 + 0 + 1 = 131×23+1×22+0×21+1×20=8+4+0+1=13.

The integer part always terminates, because dividing by 2 repeatedly gets you to 000 sooner or later.

Converting the fractional part to binary

The fractional part follows a different rule: multiply by 2, take the integer part, read downward. Take 0.7560.7560.756:

0.756 × 2 = 1.512 → 1   (keep 0.512)
0.512 × 2 = 1.024 → 1   (keep 0.024)
0.024 × 2 = 0.048 → 0
0.048 × 2 = 0.096 → 0
0.096 × 2 = 0.192 → 0
...

Read from the top down: 0.756≈0.11000…20.756 \approx 0.11000\ldots_20.756≈0.11000…2​. And this is where the problem appears. With the integer part the division is guaranteed to terminate. With the fraction it is not. For most decimal numbers this doubling runs forever without ever hitting 000, falling instead into an endless repeating cycle.

Why? Because a fraction pq\frac{p}{q}qp​ terminates in base bbb only when every prime factor of qqq divides bbb. In decimal (base 10=2×510 = 2 \times 510=2×5), fractions whose denominators are built from 2 and 5 terminate: 14=0.25\frac{1}{4} = 0.2541​=0.25, 18=0.125\frac{1}{8} = 0.12581​=0.125. In binary (base 222), only fractions whose denominator is a power of 2 terminate: 12=0.12\frac{1}{2} = 0.1_221​=0.12​, 14=0.012\frac{1}{4} = 0.01_241​=0.012​. Everything else repeats forever, including 110\frac{1}{10}101​, the familiar 0.10.10.1.

Try it in the explorer below. Switch to the Binary fraction tab and type 0.1. You will see the four digits 0011 repeat from the fifth step onward.

0.1 in binary is 0.000110011001100110011… A number that runs on forever and never arrives. The computer cuts it off at a finite number of bits and rounds, and rounding, of course, produces error.

§ 03The anatomy of a floating-point number

Now we put the pieces together. In IEEE 754 a single precision floating-point number, float32, uses exactly 32 bits, divided into three fields:

FieldBitsJob
Sign1Positive or negative
Exponent8Where the binary point sits
Mantissa23The significant digits

Filling in all 32 bits does not directly spell out a position on the real line. It gives us the values to feed into a formula, and the formula produces the actual number. For an ordinary float32 that formula is:

(−1)sign×2E−127×1.mantissa(-1)^{\text{sign}} \times 2^{E - 127} \times 1.\text{mantissa}(−1)sign×2E−127×1.mantissa

Two details in there are worth noticing, the 1. in front of the mantissa and the subtraction of 127127127 from the exponent. Those two are the cleverest inventions in the standard. We will take them one at a time and see why they are needed.

Normalisation? Why bother

Think back to scientific notation. How many ways are there to write the number 1.231.231.23?

Certainly more than one. You can write it 123×10−2123\times10^{-2}123×10−2, or 12.3×10−112.3\times10^{-1}12.3×10−1, or 0.0123×1020.0123\times10^20.0123×102.

Because binary representation of reals borrows from scientific notation, it inherits the same problem: there is more than one way to write a given number. Left unconstrained, one value would have many bit patterns, which is both wasteful and confusing when comparing. Normalisation exists to end the argument with a single rule: always slide the point so that there is exactly one 1 to the left of it. Every normal number therefore has the form 1.something×2e1.\text{something} \times 2^{e}1.something×2e.

But if the digit to the left of the point is always 1, why store it? This is the trick at the heart of IEEE 754. The leading bit is implicit. It is always there, so it does not need to be written down. The 23 bit mantissa holds only what comes after the point. The result: we get 24 bits of effective precision while only paying for 23 bits of storage.

What the bias is for

The exponent has to cover both positive powers (2302^{30}230 for large numbers) and negative ones (2−302^{-30}2−30 for small ones). The obvious approach is to give the exponent its own sign bit, but that is clumsy, it makes comparing two floats complicated, and it wastes space.

Instead IEEE 754 uses a bias, a fixed offset. For float32 the bias is 127127127. The true exponent eee is stored as E=e+127E = e + 127E=e+127. The true exponent runs from −126-126−126 to +127+127+127, so the stored value EEE runs from 111 to 254254254, always positive, and fits neatly into 8 bits with no sign bit. To decode, subtract again: e=E−127e = E - 127e=E−127. (The two values E=0E = 0E=0 and E=255E = 255E=255 are reserved for special cases, which we will get to.)

You might wonder why 127127127 was picked as the offset. There is nothing profound about it. We have 8 bits for the exponent, 8 bits covers 000 to 255255255, so the tidiest symmetric choice is the value in the middle.

The payoff of the bias is that since both exponent and mantissa are stored unsigned, and the exponent comes first, two positive floats can be compared as though they were integers over the same bit pattern.

Building −13.756 by hand

Now let us assemble everything above by hand. Take −13.756-13.756−13.756 as the sample value.

Sign bit: the number is negative, so the sign bit is 1. Integer part: 13=1101213 = 1101_213=11012​. Fractional part: 0.756≈0.11000…20.756 \approx 0.11000\ldots_20.756≈0.11000…2​ (a non-terminating string). Put together: 13.75610≈1101.11000…213.756_{10} \approx 1101.11000\ldots_213.75610​≈1101.11000…2​.

Now normalise, sliding the point to just after the first 1: 1.10111000…2×231.10111000\ldots_2 \times 2^31.10111000…2​×23. So the true exponent is e=3e = 3e=3. Add the bias: E=3+127=130=100000102E = 3 + 127 = 130 = 10000010_2E=3+127=130=100000102​. The mantissa is the first 23 bits after the point, 10111000…10111000\ldots10111000…, with the last bit rounded.

To let the machine walk through these steps for you, open the component below. It builds the number up from a decimal value into 32 bits, rather than decoding backwards the way the explorer above does.

Tương tác — thử trên web
Float builder (manual encoding)
maxubrq.space

The interaction below shows more clearly how the bits are actually stored.

Tương tác — thử trên web
Float explorer
maxubrq.space
❦

§ 04The gaps between floating-point numbers

Earlier I said the gaps between floating-point numbers are uneven. Now we go into it, because this is exactly where the two philosophies of number representation part ways, and "the gap" is where the difference shows up most plainly, measurable as a number rather than left vague.

Fixed-point: the gap is an absolute constant

In fixed-point the position of the point is decided in advance. Say we use 32 bits, 16 for the integer part and 16 for the fraction. A value is read as:

v=n216v = \frac{n}{2^{16}}v=216n​

with n a 32 bit integer running from 0 to 2³² − 1 (unsigned, to keep the arithmetic simple). The gap between two neighbouring representable values is, by definition, the distance between n and n+1 after dividing by 2^16:

gap=n+1216−n216=1216≈0.0000152587890625\text{gap} = \frac{n+1}{2^{16}} - \frac{n}{2^{16}} = \frac{1}{2^{16}} \approx 0.0000152587890625gap=216n+1​−216n​=2161​≈0.0000152587890625

Notice that this number does not depend on n. Whether the value sits near 000 or near the maximum of 65535.9999865535.9999865535.99998, the gap between neighbours is exactly 1/655361/655361/65536. It is a fixed absolute gap across the whole line. Plotted against the value, it is a horizontal line, a constant.

Floating-point: the gap is a fixed ratio

In floating point a value has the form v=(1.f)×2Ev = (1.f) \times 2^Ev=(1.f)×2E, with the mantissa f holding p bits. The next value up comes from incrementing the last bit of f, which adds 2−p2^{-p}2−p to the mantissa, then multiplying by the same 2E2^E2E:

ULP(v)=2E×2−p=2E−p\text{ULP}(v) = 2^E \times 2^{-p} = 2^{E-p}ULP(v)=2E×2−p=2E−p

This is the formula for the ULP (unit in the last place), the smallest unit at the final position. The fundamental difference is that E appears in the formula. The gap depends directly on the magnitude of v, because v≈2Ev \approx 2^Ev≈2E. Rewritten as a ratio:

ULP(v)v≈2E−p2E=2−p\frac{\text{ULP}(v)}{v} \approx \frac{2^{E-p}}{2^E} = 2^{-p}vULP(v)​≈2E2E−p​=2−p

The ratio between the gap and the value is constant across the whole line (outside the subnormal region, which I will come to). Plotted against the value on a logarithmic axis, it is no longer a horizontal line but a staircase, doubling each time v crosses a power of 2. Drag the slider and you can watch the steps widen one notch at a time.

Tương tác — thử trên web
Fixed-point vs floating-point spacing
maxubrq.space

The two side by side, in real numbers

Take fixed-point Q16.16 (16 integer bits, 16 fractional bits) as the baseline, and float32 (p = 24 bits, counting the implicit bit). A few concrete values tell the entire story.

ValueULP fixed-point Q16.16ULP float32Finer
0.0011.53e−51.16e−10float32, ~131,000×
11.53e−51.19e−7float32, 128×
1281.53e−51.53e−5a tie
1,0001.53e−56.10e−5fixed-point, 4×
60,0001.53e−53.91e−3fixed-point, 256×
10⁹out of range64fixed-point cannot

At 0.001 float32 is finer than fixed-point by five orders of magnitude, because fixed-point has already spent half its bit budget on counting integers it does not need here. By 60,000 the position reverses: the float32 ULP swells to 3.91e−3, 256 times coarser than fixed-point. And 10⁹ is somewhere Q16.16 can never reach, since its ceiling is 65,536. That is the crux: neither representation wins outright. They are two ways of dividing a finite bit budget according to two different philosophies.

Why floating-point takes this trade

With the same 32 bits, fixed-point Q16.16 has to choose in advance the range it will serve (from 0 to 65535.99998), and inside that range the resolution is fixed. If your real value falls outside that range, larger or smaller, you cannot represent it at all.

Floating-point does not choose a range in advance. It splits the bit budget differently: part of it for the position on a logarithmic axis (the exponent), part for the resolution at that position (the mantissa). The direct consequence is that resolution is no longer uniform, it stretches and shrinks with magnitude. The price: you lose the promise of "absolute error smaller than X" that fixed-point can make. In exchange, floating point makes a different promise: "relative error smaller than X", and that is the guarantee science actually needs, because most measurements in nature care about significant digits rather than absolute units.

The practical consequence: why money uses fixed-point

Here is where the two philosophies hit a concrete application. Money needs absolute precision down to the cent, at every magnitude. A million dollars and a single cent have to add and subtract exactly, to the last digit. That is exactly what fixed-point provides and what floating point cannot commit to. An accounting system that adds money with double will accumulate small rounding errors across millions of transactions, and because floating-point gaps stretch with magnitude, the error on a large transaction will be bigger than the error on a small one, an inconsistency that is hard to defend when the books are reconciled. That is why accounting standards tend to count in integer "cents" (implicit fixed-point), or use decimal floating point (a base 10 variant), rather than the binary floating point of IEEE 754.

Which brings the tension between "absolute" and "relative" back around to the Patriot at the top of this essay. That system's clock needed absolute precision in units of 0.1 seconds accumulated over hundreds of hours, but it was represented in a floating-point scheme that only promises relative precision. The distance between the two philosophies, the one you just watched open up under the slider, is the distance that let that missile through.

§ 05IEEE 754, the extended formats, and the special values

Back to the two exponent values we set aside: E=0E = 0E=0 and E=255E = 255E=255. They do not follow the ordinary formula, they are reserved for the edge cases.

Exponent E=0E = 0E=0. If the mantissa is also 000, the value is ±0\pm 0±0. Yes, there are two zeros: +0+0+0 and −0-0−0 compare as equal but differ in the sign bit (and 1/+0=+∞1/{+0} = +\infty1/+0=+∞ while 1/−0=−∞1/{-0} = -\infty1/−0=−∞). If the mantissa is nonzero, the number is a subnormal: we drop the implicit 1, which allows values smaller than the smallest normal number and fills in the gap around 000.

Exponent E=255E = 255E=255. If the mantissa is 000, the value is ±∞\pm\infty±∞, the result of overflow or of 1/01/01/0. If the mantissa is nonzero, it is a NaN (Not a Number), produced by 0/00/00/0, −1\sqrt{-1}−1​, ∞−∞\infty - \infty∞−∞. NaN is contagious: every operation that touches it returns NaN. And it has one strange property, NaN === NaN evaluates to false, making it the only value not equal to itself. Try ∞ and NaN in the interaction above and watch the exponent field fill up with 1s.

Float32 is only one member of a family. The same set of ideas (sign, biased exponent, normalised mantissa) scales into several sizes:

FormatTotal bitsExponentMantissaBiasUsed for
half (float16)1651015GPUs, graphics, ML inference
bfloat161687127ML training (keeps float32's range)
single (float32)32823127The common default, graphics, games
double (float64)6411521023Science, finance, JavaScript Number
quad (float128)1281511216383Computation needing very high precision

The pair worth noticing is float16 and bfloat16: the same 16 bits, split very differently. float16 favours the mantissa (10 bits), so it is more precise but narrow in range. bfloat16 cuts the mantissa to 7 bits in order to keep the full 8 bit exponent of float32, trading precision for reach. In machine learning people choose bfloat16 because when you are training a neural network, overflow (losing range) is more fatal than losing a few significant digits.

Beyond the binary family there are two branches worth knowing. Decimal floating point (decimal64, decimal128 in IEEE 754-2008) stores digits in base 10, so 0.10.10.1 is exact, which is precisely what finance and databases need (the DECIMAL/NUMERIC types are this). The cost is speed, since hardware is optimised for base 2. Posit (unum, proposed by John Gustafson) is a modern redesign: precision that tapers, dense around 111 where we usually compute and thinning out toward both extremes, using the same bits more efficiently. Posit is not yet common in hardware but it is an interesting candidate for the future.

§ 06In the field: rules for using floats in real systems

Four rules, distilled from everything above.

Do not compare floats with == or ===. Two computations that should be mathematically identical can differ in the last bit. The correct approach is to check whether their difference is below an acceptable threshold (an epsilon). What the threshold should be depends on your problem.

// Fragile — can fail
if (a + b === 0.3) {
  /* ... */
}
 
// Solid
if (Math.abs(a + b - 0.3) < 1e-10) {
  /* ... */
}

Never use floats for money. Money is absolutely exact. 11,000đ is 11,000đ, not 10,999.999999đ. Store it as an integer (count cents, not dollars), or use your database's decimal/NUMERIC type. Round off half a cent each time, multiply by millions of transactions, and you get the disaster that drained the Vancouver index for 22 months.

Know where your float came from. A sensor reading is already an approximation, so float error usually sinks below measurement noise and is not worth worrying about. A financial or cryptographic value is exact by definition, and there float error always matters. The same float carries a different level of danger depending on what it means.

Errors accumulate. One float operation sows a tiny error. A thousand operations can sow a visible one. This is why the Patriot battery failed to intercept after only 100 hours of operation. If you are accumulating in a loop, think about techniques like Kahan summation, or simply resetting periodically.

§ 07A closing note

Go back to the explorers above. Type 1/3 and watch the cycle appear in the binary fraction table. Drag the gap slider and see the difference between floating point and fixed-point open up. And now you can explain it to someone else.

Nguồn tham khảo

01IEEE Standard for Floating-Point Arithmetic (IEEE 754) — IEEE, 1985Standarden.wikipedia.org/wiki/IEEE_754
02Patriot Missile Defense: Software Problem Led to System Failure (IMTEC-92-26) — U.S. Government Accountability Office, 1992Reportwww.gao.gov/products/imtec-92-26