M01 | 浮点数与误差:数值计算的世界观

M01 | 浮点数与误差:数值计算的世界观

在写任何算法之前,先理解这台机器的数值世界观。

本模块地图 :数学原理部分建立 IEEE 754 与条件数两块基石,回答「误差从哪来、问题本身有多敏感」;算法设计部分给出评判算法的金标准------后向稳定性,以及灾难性抵消的稳定化改写工具箱;库级实现把这一切写进 matlib 的第一个模块并上机实测;工业级解析看 Eigen 与 NumPy 如何暴露同样的机器参数,以及为什么 log1phypot 早在 C99 就进了标准库。

学习目标

  1. 能说出 IEEE 754 double 的位结构,并从位级解释 0.1 + 0.2 != 0.3
  2. 能写出程序实测本机机器精度,并与理论值 2⁻⁵² 对上;
  3. 能解释为什么「后向稳定 + 条件数」取代「前向误差小」成为评判算法的金标准;
  4. 能识别至少四种灾难性抵消场景,并给出稳定化改写(求和、二次求根、log1phypot);
  5. 能用 ULP 距离判断浮点相等,说清 == 比较浮点数为什么不可靠。

1. 数学原理

1.1 实数轴上有洞:有限位宽装不下实数

double 只有 64 位,最多表示 2⁶⁴ ≈ 1.8×10¹⁹ 个不同的值;而实数是无穷的。结论很简单:绝大多数实数在机器里根本不存在,每个浮点运算都是在「把真实结果搬到一个最近的可表示数」。

对工程师意味着什么:浮点误差不是 bug,是这套表示系统的固有契约。我们要做的不是消灭误差,而是知道误差有多大、什么时候会被放大

1.2 IEEE 754 double 的取值公式

double 的 64 位分成三段:

位数 含义
符号 s 1 0 正 / 1 负
偏移指数 e 11 实际指数 = e − 1023(1023 为 bias)
尾数 f 52 小数部分,规格化数隐含前导 1

规格化数的取值公式:

(−1)s×2e−1023×1.f(-1)^s \times 2^{e-1023} \times 1.f(−1)s×2e−1023×1.f

两个特殊区域要记住:

  • 非规格化数(e = 0):值为 (−1)^s × 2⁻¹⁰²² × 0.f,填补 0 附近的空隙,让下溢「逐渐发生」(gradual underflow)而不是断崖归零;
  • 特殊值(e = 0x7FF):f = 0 时为 ±∞,f ≠ 0 时为 NaN。

例:0.1 的二进制是无限循环小数 0.0001100110011...₂,52 位尾数只能截断存储,实际存下的是

0.110⟶0.1000000000000000055511151231257827...0.1_{10} \longrightarrow 0.1000000000000000055511151231257827\ldots0.110⟶0.1000000000000000055511151231257827...

这不是十进制转二进制的 bug,而是「有限位宽装不下」的直接后果。

1.3 ULP:浮点数的间距随量级变化

ULP(Unit in the Last Place) 是相邻两个可表示浮点数之间的距离。在区间 [2^k, 2^{k+1}) 内,所有数的尾数部分均匀分布,因此间距固定为:

ULP=2k×2−52=2k−52\mathrm{ULP} = 2^{k} \times 2^{-52} = 2^{k-52}ULP=2k×2−52=2k−52

指数每加 1,间距翻倍:1.0 附近的 ULP 是 2⁻⁵²,10¹⁶ 附近的 ULP 已经是 2。

对工程师意味着什么:同一个绝对误差,对小数值可能是致命的,对大数值可能无关紧要。浮点世界必须用相对误差说话------这正是下一节的铺垫。

1.4 机器精度 εmach 与舍入单元 u

机器精度 εmach 定义为 1.0 到下一个可表示数的距离。由上一节,1.0 落在 [2⁰, 2¹) 区间,所以:

εmach=2−52≈2.22×10−16(double)\varepsilon_{\mathrm{mach}} = 2^{-52} \approx 2.22 \times 10^{-16}\quad (\text{double})εmach=2−52≈2.22×10−16(double)

IEEE 754 默认的舍入到最近偶数模式下,单次运算的相对误差不超过半个 ULP,于是得到舍入单元:

u=εmach2=2−53≈1.11×10−16u = \frac{\varepsilon_{\mathrm{mach}}}{2} = 2^{-53} \approx 1.11 \times 10^{-16}u=2εmach=2−53≈1.11×10−16

推导:任取 x ∈ [2^k, 2^{k+1}),半个 ULP 为 2^{k-53},相对误差最大出现在 x 取区间左端点时,即 2{k-53}/2k = 2⁻⁵³。

对工程师意味着什么:double 「免费」给你约 15--16 位十进制有效数字,一次运算的损失不超过 u。后续所有算法的误差分析都以 u 为基本单位------这是本课程的「计量衡」。

1.5 条件数:问题自身的体质

把「问题」看成函数 f:输入 → 输出。输入的相对误差经过 f 会被放大多少倍?这就是条件数

κ(f;x)=lim⁡δ→0∣δf/f(x)∣∣δx/x∣=∣x f′(x)f(x)∣\kappa(f; x) = \lim_{\delta \to 0} \frac{|\delta f / f(x)|}{|\delta x / x|} = \left| \frac{x\, f'(x)}{f(x)} \right|κ(f;x)=δ→0lim∣δx/x∣∣δf/f(x)∣= f(x)xf′(x)

两个例子:

  • f(x) = x²:κ = |x·2x/x²| = 2。输入误差放大 2 倍,良态
  • f(x) = 1 − x:κ = |x/(1−x)|。当 x = 0.9999 时 κ ≈ 10⁴------输入的第 4 位有效数字会被放大成输出的第 1 位,病态

对工程师意味着什么:条件数是问题的先天体质,与算法无关。病态问题上不存在「精确算法」------输入里根本没有那些信息。正确的姿势是:先判断问题良态与否,再谈算法;算出大误差时,先分清是「问题病态」还是「算法不佳」。

2. 算法设计

2.1 金标准:后向稳定性

衡量一个数值算法有两条路线:

  • 前向误差:算出的答案 f̂(x) 离真值 f(x) 有多远;
  • 后向误差:算出的答案是不是「某个 nearby 问题的精确解」------即是否存在 x̃ 使得 f̂(x) = f(x̃),且 x̃ 离 x 很近。

定义 :算法是后向稳定的,当且仅当对每个输入 x,都存在 x̃ = x(1 + O(u)) 使得算法输出恰为 f(x̃)。直观说法:它「精确地解决了一个几乎一样的问题」。

为什么后向稳定能成为金标准?因为它和条件数组合后给出完整的前向误差界:

前向相对误差≲κ(x)⋅u\text{前向相对误差} \lesssim \kappa(x) \cdot u前向相对误差≲κ(x)⋅u

即:后向稳定算法 × 良态问题 = 可靠结果。这个框架还有一个工程上极好的性质------归因清晰:结果不准,要么问题病态(κ 大,换问题建模),要么算法不后向稳定(换算法)。

为什么不直接用「前向误差小」做标准? 前向误差同时由问题和算法决定,库作者无法对任意用户输入做出承诺;而后向稳定性是算法自身的性质 ,一次证明、处处成立。这就是工业界选择它的原因------不是它更「强」,而是它可认证、可归因

2.2 灾难性抵消:减法不背锅,数据先脏了

两个相近的浮点数相减,有效数字大量丢失,这就是灾难性抵消(catastrophic cancellation)。定量地,设 x̂ = x(1+δ₁)、ŷ = y(1+δ₂),|δ| ≤ u,则:

∣误差∣∣x^−y^∣≤u⋅∣x∣+∣y∣∣x−y∣\frac{|\text{误差}|}{|\hat x - \hat y|} \le u \cdot \frac{|x| + |y|}{|x - y|}∣x^−y^∣∣误差∣≤u⋅∣x−y∣∣x∣+∣y∣

当 x ≈ y 时放大因子 ≈ 2|x|/|x−y|,可以任意大。

一个关键澄清:减法本身是精确的 。Sterbenz 引理保证:当 x/2 ≤ y ≤ 2x 时,x − y 没有舍入误差。抵消之所以「灾难」,是因为相减的两个数在更早的运算里已经被污染,减法只是把污染暴露出来。

常见场景与稳定化改写(本模块实现其中三个):

场景 抵消来源 稳定化改写
二次方程求根 −b±√Δ b² ≫ 4ac 时 √Δ ≈ |b| q 公式 + 韦达定理(见 2.3)
方差 E − (Ex 大均值小方差时两项相近 Welford 在线算法(课后练习)
(1+x) − 1,x 很小 1 吞掉 x 的低位 log1p(x) / expm1(x)
√(x+1) − √x x 大时两值相近 有理化:1/(√(x+1)+√x)
(f(x+h)−f(x))/h h 小时分子相近 复步微分/符号微分(毕业项目方向 3 的伏笔)
二次求根的稳定公式

教科书公式 x = (−b ± √Δ)/(2a) 在 b 与 √Δ 同号时必然抵消。稳定做法分两步:

  1. 构造 q = −0.5·(b + sign(b)·√Δ),保证括号内同号相加、绝不相消,得一个远离抵消的根 x₁ = q/a;
  2. 另一个根用韦达定理 x₁x₂ = c/a 求出:x₂ = c/q,同样避开减法。

复杂度仍为 O(1)(一次 sqrt、常数次四则运算),代价几乎为零,误差从「灾难级」降到 O(u)------M01 实验会实测这一差距。

2.3 求和算法:从 O(nu) 到 O(u)

n 个数朴素累加的最坏误差界约为:

∣误差∣≲(n−1) u∑i∣xi∣|\text{误差}| \lesssim (n-1)\, u \sum_i |x_i|∣误差∣≲(n−1)ui∑∣xi∣

误差随 n 线性增长,且与数据顺序有关。两个改进方向:

Kahan / Neumaier 补偿求和 。核心思想:用补偿变量 c 收集每一步被舍入丢掉的部分,最后加回。经典 Kahan 假设「当前和总比新加入的数大」,当数据顺序打破这一假设时会失效;Neumaier 改进版补上了这个角落(matlib 采用的就是它)。伪代码:

text 复制代码
s ← 0;  c ← 0
for x in data:
    t ← s + x
    if |s| ≥ |x|:  c ← c + ((s − t) + x)   # x 的低位被丢进这一项
    else:          c ← c + ((x − t) + s)   # s 的低位被丢进这一项
    s ← t
return s + c

复杂度 O(n),每步多常数次浮点运算;误差界降到约 2u·Σ|x|,与 n 基本无关

备选方案对比(为什么不选它们)

  1. 按绝对值排序后从小到大累加:直觉正确,但需要 O(n log n) 排序、必须拿到全部数据(不适合流式),且最坏误差界仍与 n 有关。弃用,但思想保留;
  2. pairwise(两两分组)求和 :把序列递归二分成树形求和,误差 O(log n · u),无需维护补偿变量、缓存友好、可向量化。这是 NumPy 的工业选择------它是「足够好 + 足够快」的折中,细节见第 4 节与 M12。

matlib 的选择:流式精确场景用 Neumaier(kahan_sum),大规模向量化场景留待 M09 之后再考虑 pairwise。

3. 库级实现

3.1 matlib 骨架

本模块为 matlib 建立目录与构建骨架:

text 复制代码
code/
├── CMakeLists.txt       # C++17,MSVC 下开启 /utf-8 /W4
├── matlib/
│   └── float_info.hpp   # M01 数值底座(header-only)
└── test_m01.cpp         # 数值验证程序(22 项断言)

约定从本模块开始生效:C++17、零第三方依赖、命名向 Eigen 看齐(camelCase)。

3.2 float_info.hpp 关键接口

位级视图bits_of 用 memcpy 做标准允许的位级双关;decompose 按 1|11|52 拆出三段:

cpp 复制代码
inline std::uint64_t bits_of(double x) {
    std::uint64_t b;
    std::memcpy(&b, &x, sizeof(b)); // memcpy 是标准允许的位级双关写法
    return b;
}

机器精度实测。循环对半逼近,直到 1 + eps/2 被舍入回 1:

cpp 复制代码
template <typename T>
T machine_epsilon() {
    T eps = T(1);
    for (int guard = 0; guard < 256; ++guard) { // 防御式上限
        if (T(1) + eps / 2 == T(1)) break;
        eps /= 2;
    }
    return eps;
}

ULP 比较。先把位模式映射成保序整数(负数区间翻转、−0 归一到 +0),再做无符号差------用 unsigned 是为了覆盖 ±DBL_MAX 之间距离超过 int64 范围的极端边界:

cpp 复制代码
inline std::int64_t ordered_bits(double x) {
    const std::int64_t b = static_cast<std::int64_t>(bits_of(x));
    return (b < 0) ? (INT64_MIN - b) : b;
}
inline bool nearly_equal(double a, double b, std::uint64_t max_ulp = 4);

边界探测 。从 1.0 出发倍增直到溢出、倍减直到即将进入非规格化区,实测浮点世界的两堵墙(2^1023 与 2⁻¹⁰²²),实现见 test_m01.cpp8 段。

Neumaier 补偿求和 kahan_sum稳定求根 solve_quadratic(q 公式 + 韦达定理,处理 a=0 退化、Δ<0、二重零根三个边界)、稳定 hypot hypot_stable(按最大值缩放)。完整代码见 code/matlib/float_info.hpp

3.3 数值验证:实测结果

测试环境:Windows x64,MSVC 14.44(VS 2022),/std:c++17 /O2 /EHsc /utf-8。完整验证程序为 test_m01.cpp,22 项断言全部通过。摘录关键输出:

text 复制代码
eps_mach(double) = 2.2204460492503131e-16     # 与理论值 2^-52 一致
eps_mach(float)  = 1.1920929e-07              # 2^-23

bits(0.1) = 0x3FB999999999999A                # 无限循环二进制被截断的证据
0.1+0.2 = 0.30000000000000004
0.3     = 0.29999999999999999, ulp 距离 = 1   # 只差 1 个 ULP

naive_sum = 0, kahan_sum = 1000               # {1e16, 1×1000, -1e16},精确和 1000
sum(0.1 x 1e6): naive |err| = 1.33e-06, kahan |err| = 0

x²+1e8x+1 的小根:
  参考值     = -1.0000000000000002e-08
  naive 求根 = -7.4505805969238281e-09  相对误差 = 0.255      # 错了 25%!
  稳定版求根 = -1e-08                   相对误差 = 1.65e-16   # 回到 u 量级

sqrt(3e200²+4e200²) = inf(溢出)  hypot_stable = 5e+200
((1+x)-1)/x = 0                     log1p(x)/x = 1  (x=1e-16)

边界探测: 最大 2 的幂 = 2^1023,最小正规格化数 = 2^-1022
(2^-1022 之下还有 52 级非规格化数,直到 2^-1074 才归零------gradual underflow)

三个值得盯着看的结果:

  1. 朴素求和得 0:ulp(10¹⁶) = 2,每个 +1 恰好落在舍入的中点上被丢弃------不是「误差小」,是「信息整体蒸发」;
  2. kahan 求和误差为 0:10⁶ × fl(0.1) 与 100000 的差距约 5.55×10⁻¹²,小于该量级的半个 ULP(约 7.3×10⁻¹²),最后的加法舍入恰好把它吸收回 100000.0;
  3. naive 求根错 25%:教科书公式在 b = 10⁸ 时小根已完全不可信------这是「算法不稳定」与「问题病态」的区分样本:问题(求这个根)本身良态,是算法把误差放了进来。

4. 工业级解析

4.1 Eigen:NumTraits 暴露机器参数

Eigen 把本模块手测的所有机器参数收进一个 traits 类:NumTraits<T>(源码坐标 Eigen/src/Core/NumTraits.h,Eigen 3.4.0):

  • epsilon():机器精度,对应 matlib 的 machine_epsilon
  • dummy_precision():经验容差(epsilon 的小倍数),用于近似比较------对应 nearly_equal 的思路,但 Eigen 选了「容差倍数」路线,我们选了「ULP 距离」路线,后者在跨量级比较时更稳;
  • highest() / lowest():取值范围边界。

后续模块中 Eigen 的各类阈值判断(比如秩判定)都从 NumTraits 取数------机器参数集中管理是库级设计的基本功。

4.2 NumPy:finfo 与 pairwise 求和

NumPy 侧的对应物(NumPy 2.x):

  • np.finfo(float) 返回 eps / tiny / max,分别对应机器精度、最小规格化数、最大可表示数;
  • np.spacing(1.0) 返回 1.0 处的 ULP(恰为 2⁻⁵²),np.nextafter 取相邻浮点数------正是 3.2 节 ulp_distance 的原料;
  • np.add.reduce(即 np.sum 的底层)不用朴素累加,而是 pairwise 求和 (NumPy 1.9 起,源自社区对求和精度的长期讨论):不超过 128 个元素的块内做展开直加,超过则递归二分。实现位于源码 numpy/_core/src/umath/loops.c.src(NumPy 2.0 之前为 numpy/core/src/umath/loops.c.src),可在源码中 grep pairwise 定位(M12 会逐行拆)。

对照 Python 生态的另外两个选择:math.fsum 维护全部部分和,精确 但慢;CPython 3.12 起内置 sum() 改用 Neumaier 补偿------与 matlib 的选择同款。

4.3 C 标准库:稳定化改写早就写进了标准

C99 在 <math.h> 中加入 log1pexpm1hypotfma------这些函数存在的唯一理由就是本模块第 2 节的稳定化改写。工业共识:稳定形式是默认形式,朴素写法是调用者的责任。

4.4 差距分析

教学版实现与工业版的差距不在算法思想(本模块的公式与工业界完全同源),而在工程维度:无 SIMD、无多精度回退、无全格式覆盖(float16/bfloat16)、无平台特殊值穷举测试。这些维度分别对应后续模块:SIMD → M09,格式与 dispatch → M10--M12。

5. 随堂实验

目的:在自己的机器上复现本模块全部结论------机器精度不是背下来的 2⁻⁵²,是测出来的。

验收数据要求 :实测 εmach(float/double)并与理论值对照;构造一组数据使朴素求和误差可见而补偿求和精确;复现二次求根的 naive vs 稳定版误差差;给出 hypot 溢出边界的实测值。

结论必须回答:为什么 kahan 求和在「0.1 × 10⁶」实验里误差恰好为 0?(提示:想清楚最终那次加法的舍入。)

详细步骤见 lab.md(将随「生成 M01 实验」指令产出)。

6. 本模块小结

  1. double 只有 2⁶⁴ 个值,误差是表示系统的固有契约,不是 bug;
  2. ULP 随指数翻倍,浮点世界必须用相对误差说话;
  3. εmach = 2⁻⁵²,舍入单元 u = 2⁻⁵³,是后续所有误差分析的计量单位;
  4. 条件数是问题的先天体质:先判良态病态,再谈算法;
  5. 金标准是后向稳定性:前向误差 ≲ κ·u,归因清晰、可认证;
  6. 灾难性抵消中减法本身精确(Sterbenz),锅在上游已被污染的数据;
  7. Neumaier 补偿求和把求和误差从 O(nu) 压到 O(u),NumPy 用 pairwise 走了另一条同样成立的工业路线。

在主线上的位置:本模块四个阶段全部走通,但重心在「数学原理 + 算法设计」------它提供的是贯穿全课程的世界观与误差语言;库级实现刚打下 matlib 的地基,真正的性能与架构主题从 M08 开始。

7. 课后练习

  1. (概念复盘) εmach = 2⁻⁵² 而相对舍入误差界 u = 2⁻⁵³,差一个因子 2。用自己的话解释这两个数各自的定义,以及为什么「测 εmach」和「用 u 做误差分析」都合理。
  2. (实现) 给 matlib 增加 dot_stable:用 Neumaier 补偿的向量点积。构造两组量级悬殊的向量,实测 naive 点积与 dot_stable 的误差差。
  3. (实现) 实现 Welford 在线方差算法(单遍、数值稳定),与「E − (Ex)²」两遍公式在「均值 10⁹、标准差 1」的数据上对比,复现后者算出负方差的翻车现场。
  4. (挑战,选做) 找到 NumPy loops.c.src 中的 pairwise 求和实现,解释块阈值为什么取 128(提示:想想缓存行与展开因子),并复现「pairwise 误差介于 naive 与 Kahan 之间」的曲线。
相关推荐
沫璃染墨1 小时前
《从零入门Linux系统篇(二十八):文件篇·一——Linux为什么“万物皆文件”:从C语言文件流到系统调用》
linux·运维·服务器·开发语言·c++·文件
j7~2 小时前
【C++】《C++STL 笔记:搞懂序列 / 关联容器,聊聊 set 和 map》
c++·set·map·multimap·multiset·键值对pair·序列是容器和关联式容器
郝学胜-神的一滴3 小时前
《C++11 工程级应用01:告别冗长类型,开启简洁高效编码新时代》深度解读
开发语言·c++·算法·软件开发·系统设计
码匠许师傅13 小时前
【C++ 面试真题】30. 聊聊 C++ 的互斥锁与读写锁
java·c++·面试
ShineWinsu13 小时前
对于 C++:C++14中从变量模板、泛型 Lambda 到并发与字面量的解析
c++·算法
一木 之林16 小时前
四、STL容器与数据结构
开发语言·数据结构·c++
程与留16 小时前
07_QMainWindow 与应用程序框架——菜单栏、工具栏、状态栏
c++·qt
_Narcissus_17 小时前
二分算法笔记及例题
数据结构·c++·笔记·算法·蓝桥杯·查找·二分算法
青少儿编程课堂19 小时前
用图形化编程做一个“少年探险闯关”小游戏:方向键控制、碰撞检测与多关卡串起完整项目
c++·python·算法·bfs·信息学竞赛