编程语言研究书库

小数 — 定点、IEEE 754 与浮点误差的显微镜

小数 — 定点、IEEE 754 与浮点误差的显微镜

这一章讲什么: 之前只会算整数,本章回答「小数怎么存、怎么算、为什么会算错」。 两条路线:定点(二进制点位置固定,数按比例缩成整数)和浮点(IEEE 754, 科学记数法的二进制版)。路线选完还有一半内容:浮点误差的解剖—— 舍入、吸收、抵消,以及「(x+y)+z ≠ x+(y+z)」这个反数学常识的事实。 主线例子: 123.4 + 567.8 在单精度下等于 691.200012,不是 691.2—— 我们用 gdb 把这个误差一路追到存储格式里。

1. 二进制小数:0.9 为什么存不精确

二进制点右边的权重是 2 的倒数幂

十进制小数点右边是 10⁻¹、10⁻²……;二进制点右边是 2⁻¹(1/2)、2⁻²(1/4)、2⁻³(1/8)……。 所以1:

1.6875₁₀ = 1×2⁰ + 1×2⁻¹ + 0×2⁻² + 1×2⁻³ + 1×2⁻⁴ = 1.1011₂   ← 精确
0.9₁₀   = 0.1110011001100…₂                                  ← 无限循环,存不下!

「二进制小数的精确表示仅限于 2 的倒数幂之和」——0.9、0.1 这类常见的十进制小数 在二进制里永远除不尽,只能舍入到固定位数。8 位舍入后 0.9 ≈ 0.11100110₂ = 0.8984375, 误差 0.00156251。「0.1 + 0.2 ≠ 0.3」的根源就在这里,与语言、与编译器都无关。

2. 定点数:把小数点钉死在某两位之间

思想:别存小数,存「放大后的整数」

定点(fixed-point)本质是按比例缩放的整数:二进制点的位置是约定,不存进数据里。 缩放系数是 2 的幂时,乘除就是移位2。书里给的两个程序把这个思想演得很透:

例一:英寸 + 1/16 英寸(缩放系数 16 = 2⁴)。 32 位数里,高 28 位存英寸、低 4 位存十六分之几。读入:sal eax, 4 腾位, & 0xf 保证小数不超范围,相加;显示:shr 4 出整数、 & 0xf 出小数3。加法本身一条普通 add——因为两个数的二进制点在同一位置。 代价:范围缩成 0 ~ 268435455+15/16(32 位范围的 1/16),换到 1/16 的分辨率。

例二:美元 + 美分(缩放系数 100,不是 2 的幂)。 这时移位不够,得真乘:mul 100 把美元化成美分,加美分;显示时 div 100——商是美元、余数(edx)是美分4。范围 0 ~ 42,949,672.95, 分辨率 0.01。定点用范围换精度,位数就是那笔预算。

主线的对照结论

两个例子合起来一句话:定点数的加法和整数完全一样(点位置相同), 代价是程序员必须自己记住点在哪、并且范围被分辨率吃掉一块。 12.2 节的练习里 那个魔鬼细节:输入 $42949672.95 + $0.01 会溢出——42,949,672.95 美分已经顶到 32 位无符号上限,加 1 分就绕回 05。

3. 浮点数:用科学记数法换范围

先立本章最重要的概念

浮点数不是实数。 实数是连续的;浮点数只是实数的有限子集, 相邻浮点数之间有间隙,而且间隙随指数变化——越大的数,间隔越宽。 这些间隙就是大部分浮点错误的来源,而且错误出现在中间结果里,难以调试6。

IEEE 754:符号 + 偏置指数 + 尾数

格式(以 4 字节 float 为例,共 32 位):1 位符号 + 8 位指数 + 23 位尾数7:

  N = (−1)ˢ × 1.f × 2ᵉ

  符号 s:0 正 1 负
  尾数 f:规格化后二进制点左边恒为 1 —— 既然恒为 1,就不存了!这叫「隐藏位」
  指数 e:不是补码,而是「真指数 + 偏置」——float 偏置 127,double 偏置 1023

两个设计决定值得记住为什么8:

  • 隐藏位:白捡一个比特的精度——24 位有效位只用 23 位存储;
  • 偏置指数(biased exponent):70 年代浮点昂贵,而「比较两个数」极常见。 加了偏置的指数可以当无符号整数比,普通的整数比较指令直接用——用空间换速度的先例。

四个特例(全靠指数/尾数的特殊位模式编码)9:

特例位模式用途
±0指数全 0、尾数全 0保留收敛到 0 的符号
去规范化数指数全 0、尾数非 0隐藏位不再假定,填补最小规格化数以下的空隙
±∞指数全 1、尾数全 0非零数 ÷ 0 的结果;还能继续比大小
NaN指数全 1、尾数非 0错误标记(0÷0、未初始化);应被视为程序错误

4. SSE2:x86-64 的浮点硬件

一段简史与一张寄存器图

浮点曾经住在独立协处理器芯片(x87,用 10 字节扩展格式);1997 年 MMX 引入 SIMD (单指令多数据),1999 年 SSE 补上浮点,2011 年 AVX 再加宽。x86-64 至少包含 SSE2, 本书只讲它10:

  • 16 个 xmm 寄存器(xmm0–xmm15),每个 128 位,与整数通用寄存器完全分开;
  • SSE2 指令分标量(只碰 xmm 低位 32/64 位,本书用这个)和向量 (一条指令并行处理 4 个 float 或 2 个 double,本书不展开);
  • 浮点有自己的状态寄存器 mxcsr:位 0–5 是异常标志(精度 PE、被零除 ZE 等), 位 7–12 是对应的屏蔽位——默认全部屏蔽,所以 1.0/3.0 的精度误差安静发生, 不打扰程序;想让它打扰,用 C 的 fenv 库(man fenv)或 stmxcsr/ldmxcsr 指令11;
  • 浮点比较不碰 rflags(有 4 条专门比较指令 comiss/ucomiss 系列例外)—— 整数那套「cmp + jcc」在浮点世界不通用12。

三条指令走一个加法

清单 19-7(addFloats.s)的核心三行13:

movss   xmm2, x[rbp]      # 把 float 从内存搬进 xmm 低 32 位
addss   xmm2, y[rbp]      # 单精度标量加:xmm2 = x + y
cvtss2sd xmm0, x[rbp]     # 转双精度(printf 要 double 参数)

指令命名有套路:ss = 标量单精度、sd = 标量双精度,前缀 mov/add/cvt(转换)组合。 外加一个 ABI 细节:printf 的浮点参数在 xmm 寄存器里传,且必须给 double—— 所以传参前要把 float 全部 cvtss2sd,al 里写 xmm 寄存器的个数(mov eax, 3)13。

5. 主线例子:123.4 + 567.8 的误差解剖

程序输出 123.400002 + 567.799988 = 691.200012。书里的 gdb 实录一层层剥14:

  1. 看存储:x/3xw 显示 x 的位模式 0x42f6cccd。拆:指数 133 − 127 = 6, 尾数 1.11101101100110011001101₂ × 2⁶ = 123.40000152587890625—— 输入那一刻,舍入就已经发生了(float 只有 24 位有效位);
  2. 看寄存器:p $xmm0.v2_double 显示转 double 后的值 123.40000152587891——float→double 的转换只在尾数低位补 0,不增加任何精度; 信息在第一次舍入时就丢了,转换救不回来;
  3. 看输出:printf 的 %f 只显示 6 位小数,两个相近的误差值都四舍五入成 3.600000 的样子——显示的整齐掩盖了存储的参差。

三种典型误差,各一个实证

误差机制书里的实证
舍入位数有限 + 二进制除不尽,每步都可能舍上面 123.4 的全过程14
吸收(absorption)大小悬殊的数相加,小数的高位「够不着」大数的低位,被整体截断16777215.0 + 0.1 = 16777215.0(0.1 被整个吃掉)15
抵消(cancellation)相近数相减,高位相消,结果的有效位只剩低位——低位恰恰是有舍入误差的地方1677721.5 − 1677721.4 = 0.125,相对误差 25%(-1677721.4 被舍入成 -1677721.375)16

抵消还分两级:两个数都精确时是良性抵消(结果仍对),有一个带舍入误差就是 灾难性抵消(误差被放大成主角)16。

最隐蔽的一个:加法不结合

清单 19-8 的 threeFloats:输入 1.1、1.2、1.3,(x+y)+z 得 3.60000014, x+(y+z) 得 3.5999999——数学上相等的两个式子,浮点下不相等, 而 printf 把两者都显示成 3.600000,肉眼根本分不出17。 (x+y)+z ≠ x+(y+z) 意味着:编译器不能随便给你重排浮点加法, 也意味着你重构代码改变运算顺序时,结果会悄悄变。

6. 实用建议与边界

书末给了五条数值精确性建议,照单收18:

  1. 能定点就定点——缩放数据用整数算术,误差可控;
  2. 用 double 不用 float——精度高,而且多数 C/C++ 库函数本来就要 double, 传 float 反而多一次转换;
  3. 安排运算顺序:让大小相近的数先相加减;
  4. 避免复杂算术表达式:会掩盖有问题的中间结果;
  5. 选会难为算法的测试数据:程序处理小数,就故意放没有二进制等值的数(0.1、0.9……)。

外加两个好消息:64 位整数的范围已经是 ±9.2×10¹⁸,很多场景够用了; 要更高精度还有任意精度库。想深入读 Goldberg 1991 年的经典论文 《What Every Computer Scientist Should Know About Floating-Point Arithmetic》; 减少累加误差的实用技术见 Kahan 求和算法(维基百科)——均为作者推荐、我们未复核19。

判断(我们的,不是书里的): 本章 gdb 实录的教学价值在于它示范了 「误差该去哪看」:不是盯着 printf 的输出猜,而是 x/xw 看位模式、 p $xmm0.v2_double 看寄存器、分步执行看中间结果——浮点调试的第一课 是绕过显示层,直接看存储层。显示层(printf、计算器、甚至 gdb 的默认浮点格式) 都会再舍入一次,把真相藏起来。 如果错,会错在: 有些误差在 double 下根本不存在(本章练习 1 让你自己验证), 「一切小数都不安全」是过度泛化;正确的格言是「float 几乎永远不够用,double 是 起点」,而不是「浮点不可用」。

判断(我们的,不是书里的): 定点 vs 浮点的选择,本章给了一个常被忽略的视角: 定点把误差变成「可见的范围损失」,浮点把误差变成「不可见的精度损失」。 对要审计的场景(钱!),可见的溢出远比不可见的舍入好——这就是为什么金融系统 大量用定点/整数(美分、基点),即便浮点硬件更快。 如果错,会错在: 现代 decimal 类型(C#、Python decimal)和分单位整数各有利弊; 且有些高频场景为了追求速度用浮点加事后对账——选择永远看约束,不看教条。

7. 作者的判断与证据

说法性质
二进制小数精确表示限于 2 的倒数幂之和;0.9 是无限循环数学事实,给了 8 位舍入数值1
定点加法是普通 add;范围与分辨率互换由两个程序实证34
浮点不是实数,间隙随指数变化本章立场句,由后续误差实证支撑6
IEEE 754 布局、隐藏位、偏置及四个特例标准事实,教学化呈现89
float→double 转换不增加精度gdb 实证(补 0 而已)14
吸收、抵消、不结合性三个具体数值实验,均可复现151617
五条精确性建议作者的工程经验总结18
Goldberg 论文与 Kahan 算法推荐作者推荐,未复核19

8. 边界与局限

  • x87 与 AVX 都不讲,理由书里给了:x86-64 至少含 SSE2,AVX 向下兼容10。
  • 向量 SIMD 只给了概念(多媒体/科学计算有用),没有指令级演示。
  • 浮点异常的「解除屏蔽」只点到 stmxcsr/ldmxcsr,完整的 fenv 编程没展开。
  • 十进制小数(IEEE 754 decimal 格式)未提——那是另一套标准,本书止步二进制浮点。
  • 比较指令 comiss/ucomiss 一笔带过;浮点与整数混算的转换指令族(cvt 系)只演示了一种。

9. 可带走的

  1. 二进制点右边是 1/2、1/4、1/8……;0.1、0.9 无限循环,「0.1+0.2≠0.3」是表示问题;
  2. 定点 = 缩放的整数;点位置是约定;加法不变,范围换分辨率;
  3. IEEE 754:符号 + 偏置指数 + 尾数;隐藏位白捡一位;float 偏置 127、double 1023;
  4. 四个特例:±0、去规范化、±∞、NaN;NaN 是错误信号;
  5. SSE2:16 个 128 位 xmm;ss=单精度 sd=双精度;标量指令只碰低 32/64 位;
  6. mxcsr 管浮点异常;默认全屏蔽,1.0/3.0 安静舍入;
  7. printf 的浮点参数要 double、走 xmm;
  8. float→double 补 0 不补精度;精度死在第一次舍入;
  9. 三大误差:舍入(每步)、吸收(大小悬殊)、抵消(相近相减;分良性/灾难性);
  10. 浮点加法不结合——重构运算顺序会改结果;
  11. 调试浮点:绕过显示层,看位模式、看 xmm、分步看中间值;
  12. 建议:能整数就整数;用 double;相近先算;拆复杂式;测试数据放「二进制不友好」的数。

10. 原文地图

主题原书章原文位置
二进制小数与 0.9 的舍入第19 章 小数text/25-ch19.txt:18(搜「二进制小数」) · text/25-ch19.txt:39(搜「0.9」)
定点思想(缩放整数)第19 章 小数text/25-ch19.txt:57(搜「按比例缩放」)
英寸 1/16 程序第19 章 小数text/25-ch19.txt:78(搜「清单 19-1」) · text/25-ch19.txt:208(搜「清单 19-3」)
美元美分程序第19 章 小数text/25-ch19.txt:270(搜「清单 19-4」) · text/25-ch19.txt:402(搜「清单 19-6」)
浮点不是实数第19 章 小数text/25-ch19.txt:465(搜「不是实数」)
IEEE 754 布局与隐藏位第19 章 小数text/25-ch19.txt:488(搜「IEEE 754」) · text/25-ch19.txt:508(搜「隐藏位」)
偏置与四个特例第19 章 小数text/25-ch19.txt:513(搜「偏置」) · text/25-ch19.txt:518(搜「零值」)
SSE2 历史与 xmm第19 章 小数text/25-ch19.txt:529(搜「SSE2」) · text/25-ch19.txt:564(搜「xmm0」)
mxcsr 与舍入模式第19 章 小数text/25-ch19.txt:572(搜「mxcsr」) · text/25-ch19.txt:602(搜「舍入模式」)
addFloats 三条指令第19 章 小数text/25-ch19.txt:632(搜「清单 19-7」) · text/25-ch19.txt:677(搜「movss」)
gdb 误差解剖第19 章 小数text/25-ch19.txt:762(搜「0x42f6cccd」) · text/25-ch19.txt:787(搜「v2_double」)
吸收误差第19 章 小数text/25-ch19.txt:825(搜「吸收」) · text/25-ch19.txt:848(搜「0x4b7fffff」)
抵消与灾难性抵消第19 章 小数text/25-ch19.txt:859(搜「抵消」) · text/25-ch19.txt:891(搜「灾难性抵消」)
不结合的加法实验第19 章 小数text/25-ch19.txt:894(搜「结合性」) · text/25-ch19.txt:926(搜「not the same」)
五条精确性建议第19 章 小数text/25-ch19.txt:1011(搜「5 点」)
Goldberg/Kahan 推荐第19 章 小数text/25-ch19.txt:1024(搜「Goldberg」)

Footnotes

  1. 出处:「第19 章 小数」第 18–55 段(text/25-ch19.txt:26,搜「2−1」;text/25-ch19.txt:38,搜「仅限于」;text/25-ch19.txt:39,搜「0.9」;text/25-ch19.txt:54,搜「0.0015625」)。 ↩ ↩2 ↩3

  2. 出处:「第19 章 小数」第 56–65 段(text/25-ch19.txt:57,搜「按比例缩放」;text/25-ch19.txt:61,搜「1234510」)。 ↩

  3. 出处:「第19 章 小数」第 66–257 段(英寸程序,text/25-ch19.txt:126,搜「sal eax, 4」;text/25-ch19.txt:186,搜「shr edi, 4」;text/25-ch19.txt:192,搜「fractionMask」;text/25-ch19.txt:143,搜「1/16」)。 ↩ ↩2

  4. 出处:「第19 章 小数」第 259–451 段(美元程序,text/25-ch19.txt:280,搜「dollar2cents」;text/25-ch19.txt:320,搜「mul ecx」;text/25-ch19.txt:372,搜「div ecx」;text/25-ch19.txt:451,搜「0.01」)。 ↩ ↩2

  5. 出处:「第19 章 小数」第 448–451 段(text/25-ch19.txt:450,搜「42 949 672.95」)。 ↩

  6. 出处:「第19 章 小数」第 464–470 段(text/25-ch19.txt:465,搜「不是实数」;text/25-ch19.txt:468,搜「间隙」)。 ↩ ↩2

  7. 出处:「第19 章 小数」第 471–508 段(text/25-ch19.txt:490,搜「IEEE 754」;text/25-ch19.txt:504,搜「N = (−1)」;text/25-ch19.txt:508,搜「隐藏位」)。 ↩

  8. 出处:「第19 章 小数」第 510–515 段(text/25-ch19.txt:513,搜「偏置」;text/25-ch19.txt:514,搜「127」;允许整数比较的动机见第 511–513 段,text/25-ch19.txt:138,搜「getUInt 函数来读」)。 ↩ ↩2

  9. 出处:「第19 章 小数」第 516–526 段(四个特例,text/25-ch19.txt:518,搜「零值」;text/25-ch19.txt:521,搜「无穷大」;text/25-ch19.txt:523,搜「非数字」;text/25-ch19.txt:526,搜「0 除以 0」)。 ↩ ↩2

  10. 出处:「第19 章 小数」第 529–559 段(SSE2 历史与寄存器,text/25-ch19.txt:531,搜「协处理器」;text/25-ch19.txt:548,搜「至少包括 SSE2」;text/25-ch19.txt:564,搜「xmm0」;标量/向量之分见第 552–559 段,text/25-ch19.txt:557,搜「标量」)。 ↩ ↩2

  11. 出处:「第19 章 小数」第 572–624 段(mxcsr,text/25-ch19.txt:575,搜「mxcsr」;屏蔽默认见第 613–616 段,text/25-ch19.txt:613,搜「PM 位设置为 1」;比较指令例外见第 617–624 段,text/25-ch19.txt:619,搜「comisd」)。 ↩

  12. 出处:「第19 章 小数」第 617–624 段(text/25-ch19.txt:617,搜「没有能够测试」;text/25-ch19.txt:624,搜「条件跳转指令」)。 ↩

  13. 出处:「第19 章 小数」第 627–721 段(addFloats 与指令卡,text/25-ch19.txt:677,搜「movss」;text/25-ch19.txt:678,搜「addss」;text/25-ch19.txt:680,搜「cvtss2sd」;printf 参数要求见第 697–698 段,text/25-ch19.txt:698,搜「双精度」)。 ↩ ↩2

  14. 出处:「第19 章 小数」第 722–811 段(gdb 解剖,text/25-ch19.txt:762,搜「0x42f6cccd」;text/25-ch19.txt:766,搜「123.40000152587890625」;text/25-ch19.txt:788,搜「123.40000152587891」;转换不补精度见第 803–804 段,text/25-ch19.txt:803,搜「注意,从 float 到」)。 ↩ ↩2 ↩3

  15. 出处:「第19 章 小数」第 825–858 段(吸收,text/25-ch19.txt:826,搜「吸收」;text/25-ch19.txt:848,搜「0x4b7fffff」;text/25-ch19.txt:853,搜「已经被浮点加法吸收」)。 ↩ ↩2

  16. 出处:「第19 章 小数」第 859–893 段(抵消,text/25-ch19.txt:860,搜「抵消」;25% 误差见第 872 段,text/25-ch19.txt:872,搜「0.25 = 25%」;灾难性/良性见第 891–893 段,text/25-ch19.txt:891,搜「灾难性抵消」)。 ↩ ↩2 ↩3

  17. 出处:「第19 章 小数」第 894–1001 段(不结合实验,text/25-ch19.txt:897,搜「不等于」;text/25-ch19.txt:926,搜「not the same」;显示舍入掩盖见第 1000–1001 段,text/25-ch19.txt:1001,搜「舍入为 3.600000」)。 ↩ ↩2

  18. 出处:「第19 章 小数」第 1006–1022 段(text/25-ch19.txt:1012,搜「尝试缩放数据」;text/25-ch19.txt:1013,搜「双精度代替单精度」;text/25-ch19.txt:1016,搜「大小相近」;text/25-ch19.txt:1018,搜「测试数据」)。 ↩ ↩2

  19. 出处:「第19 章 小数」第 1023–1027 段(text/25-ch19.txt:1024,搜「Goldberg」;text/25-ch19.txt:1027,搜「Kahan」)。 ↩ ↩2

On this page