图灵奖系列 · DoggyDad 原创

William Kahan

William Kahan:浮点运算的守护者,让计算机数学不再"说谎"

ANSWER-FIRST SUMMARY

本文回答什么问题

William Kahan:浮点运算的守护者,让计算机数学不再"说谎"

  • 主题分类:图灵奖系列
  • 关键词:图灵奖、计算机历史、编程语言、算法、人工智能、密码学
  • 人物实体:William Kahan

图灵奖第二十四届 | William Kahan:浮点运算的守护者,让计算机数学不再”说谎”

一句话概括:他制定了IEEE 754浮点运算标准,让全世界的计算机用同一种”语言”进行小数计算,避免了无数灾难性的计算错误,是”数值稳定性”的化身。

🏆 获奖简介

William Morton Kahan(威廉·莫顿·卡汉,常被称为”浮点之父”)是一位加拿大数学家和计算机科学家,数值分析领域的传奇人物。

  • 出生时间:1933年6月5日
  • 出生地点:加拿大多伦多
  • 获奖年份:1989年
  • 获奖原因:在数值分析领域的基础性贡献,特别是主导制定IEEE 754浮点运算标准。

为什么他是第二十四位? 在Kahan之前,不同计算机处理小数的方式五花八门,同一个科学计算程序在不同机器上可能得到截然不同的结果,甚至导致火箭爆炸、桥梁倒塌等灾难。Kahan用一个标准统一了全世界,让计算机的数值计算从”碰运气”变成了”可信赖”,这是整个科学计算与工程计算的基石。

🚀 他的重大贡献

1. IEEE 754浮点运算标准:计算机”数学”的宪法

简单理解:就像世界各国统一使用米制或公制,IEEE 754让所有计算机用同一套规则表示和计算小数(如3.14、0.00001),确保了可移植性和可预测性。

历史背景:

  • 1970年代乱象:

    • IBM用一种方法表示浮点数
    • DEC用另一种
    • Cray又是第三种
    • 同一个程序在不同机器上结果不同,甚至有的能算出来,有的直接崩溃
  • 导致的问题:

    • 科学家无法复现彼此的计算结果
    • 软件移植成本极高,需要重写数值部分
    • 难以诊断数值错误(是程序bug还是硬件差异?)

Kahan的使命: 1978年,IEEE(电气电子工程师学会)成立委员会制定浮点标准,Kahan担任主要架构师。

IEEE 754的核心设计(1985年发布):

1.1 统一的表示格式

单精度(32位):

  • 1位符号位(正/负)
  • 8位指数(范围约10^-38到10^38)
  • 23位尾数(有效数字约7位十进制)

双精度(64位):

  • 1位符号
  • 11位指数(范围约10^-308到10^308)
  • 52位尾数(有效数字约16位十进制)

通俗理解: 就像科学记数法,3.14 = 3.14 × 10^0,计算机用二进制存储”3.14”(尾数)和”0”(指数)。标准规定了精确的存储格式,确保全世界的计算机读到同一个数。

1.2 特殊值:处理极端情况

问题:如何表示”无穷大""不是数字”等特殊情况?

Kahan的方案:

  • +∞ 和 -∞:

    • 例如 1.0/0.0 = +∞
    • 不是错误,而是有意义的数学结果(极限概念)
  • NaN (Not a Number):

    • 例如 0.0/0.0√(-1)
    • 表示”未定义”的运算结果
    • 任何涉及NaN的运算结果仍是NaN,像”病毒”一样传播,帮助快速发现问题
  • 负零(-0.0):

    • 这个设计看似奇怪,但在某些数学场景下有重要意义
    • 例如区分”从正方向趋近于0”和”从负方向趋近于0”

为什么重要? 在此之前,这些情况通常导致程序崩溃或产生垃圾值。标准化后,程序可以优雅地处理边界情况。

1.3 四种舍入模式

问题:浮点数有精度限制,无法精确表示所有十进制小数(如0.1在二进制中是无限循环),如何舍入?

Kahan坚持的原则: 必须提供多种舍入模式,供不同应用选择:

  1. 四舍五入到最近(默认,Round to nearest, ties to even):

    • 最常用,最”公平”
    • 例如:0.5 舍入到 0(偶数),1.5舍入到2(偶数),避免系统性偏差
  2. 向零舍入(Round toward zero):

    • 就是”截断”,1.9 → 1, -1.9 → -1
  3. 向正无穷舍入(Round toward +∞):

    • 1.1 → 2, -1.9 → -1
  4. 向负无穷舍入(Round toward -∞):

    • 1.9 → 1, -1.1 → -2

实用意义:

  • 区间运算:用向上和向下舍入计算误差范围
  • 金融计算:有时需要特定舍入方向避免积累误差

1.4 异常信号(Exception Flags)

问题:计算出错时怎么办?

Kahan的设计: 不强制程序崩溃,而是设置标志位:

  • 溢出(Overflow):结果超出表示范围 → 返回±∞
  • 下溢(Underflow):结果太接近0 → 返回0或非规格化数
  • 除零:除以0 → 返回±∞或NaN
  • 不精确(Inexact):舍入发生 → 设置标志
  • 非法操作:如√(-1) → 返回NaN

程序可以检查这些标志,决定如何处理,而不是直接崩溃。

1.5 正确舍入(Correctly Rounded)

Kahan的坚持:所有基本运算(+、-、×、÷、√)必须”正确舍入”,即:

结果必须等于将无限精度数学结果按舍入模式舍入的值

举例:

  • 数学上:1.0 / 3.0 = 0.333333…
  • 浮点:0.33333333432674407958984375(最接近的可表示数)
  • 标准保证:这就是理论最优答案

意义: 这看似理所当然,但实现起来极难(除法、平方根的硬件设计极其复杂)。Kahan不仅制定规则,还设计了高效的硬件算法(如SRT除法)使其可行。

影响:

  • 1985年发布后,英特尔80387协处理器(1987)首个完全实现
  • 此后几乎所有CPU/GPU都遵守这一标准
  • 从X86到ARM,从Intel到AMD,从NVIDIA到苹果

2. 数值稳定性分析:避免”蝴蝶效应”

简单理解:在科学计算中,微小的舍入误差可能像雪球一样越滚越大,最终导致完全错误的结果。Kahan研究如何避免这种”数值灾难”

经典案例:两数相减的灾难

问题:当两个非常接近的大数相减时,有效数字会大量丢失。

示例:

a = 1.2345678
b = 1.2345677
a - b = 0.0000001

假设只保留7位有效数字:

  • a和b各有7位准确
  • 但a-b只剩1位准确(其他6位是噪音)!

Kahan的解决方案: 提出各种”数值技巧”来避免相消,如:

Kahan求和算法(Kahan Summation Algorithm):

// 朴素求和:误差累积严重
sum = 0;
for (i=0; i<n; i++)
    sum += array[i];

// Kahan补偿求和:减少误差
sum = 0.0;
c = 0.0;  // 补偿项
for (i=0; i<n; i++) {
    y = array[i] - c;
    t = sum + y;
    c = (t - sum) - y;  // 捕捉丢失的低位
    sum = t;
}

效果:

  • 朴素求和:误差O(n×ε),n越大误差越大
  • Kahan求和:误差O(ε),与n无关!

应用: 在科学计算、金融模型、机器学习训练中广泛使用。

3. 影响重大的论文与算法

3.1 “Further Remarks on Reducing Truncation Errors”

提出关于如何减少截断误差的系统方法。

3.2 Goldberg论文的指导

David Goldberg的经典论文*“What Every Computer Scientist Should Know About Floating-Point Arithmetic”*(1991)在Kahan指导下完成,成为浮点运算的”圣经”。

3.3 PARANOIA程序

Kahan编写的测试程序,用于检测计算机是否正确实现浮点运算。曾发现许多CPU和编译器的bug。

4. 教育与布道:浮点运算的传教士

问题:即使有了标准,程序员和硬件工程师也常常”走捷径”,破坏数值准确性。

Kahan的战斗:

  • 公开批评不合格的实现(如某些编译器的”快速数学”优化)
  • 撰写大量教学材料,解释浮点陷阱
  • 在学术会议上演讲,强调数值稳定性的重要性

著名的”Kahan十字军”: 他以直言不讳著称,敢于挑战大公司(包括Intel、Microsoft)的不当实现,维护标准的纯洁性。

🌍 对世界的深远影响

1. 让科学计算可复现

之前:物理模拟、天气预报、金融模型在不同计算机上结果不同,科学家互相”不信任”。
之后:只要遵守IEEE 754,全世界的科学家可以验证彼此的结果,推动了科学进步。

2. 避免了无数工程灾难

真实事故:

  • Ariane 5火箭爆炸(1996):虽然主因是整数溢出,但浮点处理不当加剧了问题
  • 爱国者导弹失误(1991海湾战争):时间计算误差累积导致未能拦截飞毛腿导弹,28人死亡
  • Excel的日期bug:早期版本对1900年是否闰年的判断错误,影响金融计算

Kahan的标准: 虽然不能杜绝所有错误,但统一的规则让诊断和修复变得可能。

3. 推动了硬件设计进步

挑战:实现Kahan的”正确舍入”要求硬件极其复杂。
结果:反向推动了:

  • 浮点单元(FPU)设计的突破
  • 流水线优化技术
  • 验证方法学(如何证明硬件正确?)

现代CPU的浮点性能(如每秒万亿次浮点运算TFLOPS)直接受益于这些挑战。

4. 成为编程语言的基石

几乎所有现代编程语言的float/double类型都遵守IEEE 754:

  • C/C++
  • Java
  • Python
  • JavaScript
  • Rust

5. 影响深度学习与AI

有趣的转折:

  • IEEE 754关注高精度(64位双精度)
  • 深度学习发现:训练神经网络可以用更低精度(16位甚至8位)
  • 但仍然依赖IEEE 754的原则:如舍入规则、Inf/NaN处理

Google的Tensor Processing Unit(TPU)、NVIDIA的Tensor Core都在低精度浮点上创新,但核心思想仍是Kahan奠定的。

🏆 获奖理由(通俗版)

ACM官方表彰:“表彰他在数值分析领域的基础性贡献,特别是IEEE 754浮点运算标准的制定和推广。”

更通俗的理解: 如果把计算机比作科学家的”助手”,那么在Kahan之前,这个助手经常”说谎”(数值错误)或”方言太重”(不同机器不兼容)。Kahan教会了它”标准普通话”(IEEE 754),而且建立了”不说谎的规矩”(正确舍入、异常处理),让全世界的科学家都能信赖这个助手。

他不是发明了浮点运算,但他让浮点运算从”能用”变成了”可靠”,这是质的飞跃。

👤 个人生平与传奇

生平时间线

  • 1933年:出生于加拿大多伦多
  • 1954年:获多伦多大学数学与物理学士
  • 1954年:获多伦多大学数学硕士
  • 1958年:获多伦多大学数学博士,论文关于数值分析
  • 1958-1969年:任教于多伦多大学
  • 1969年:加入加州大学伯克利分校,至今
  • 1976-1985年:主导IEEE 754标准制定
  • 1989年:获图灵奖
  • 1997年:成为美国国家工程院院士
  • 至今:90多岁仍活跃,定期发表演讲和论文

人格魅力:“数学骑士”

不妥协的完美主义:

  • Kahan对数值准确性的要求近乎偏执
  • IEEE 754谈判持续多年,他坚持每一个细节必须正确
  • 曾因某些设计争议与委员会成员”拍桌子”

直言不讳的批评者:

  • 公开批评Intel Pentium浮点bug(1994)
  • 批评Java早期版本对浮点标准的背离
  • 批评某些编译器的”快速数学”模式牺牲准确性

幽默的教育家:

  • 他的讲座充满机智和幽默
  • 善用”恐怖故事”(数值灾难案例)警示学生
  • 自嘲:“我已经old了,但浮点bug永远young。”

长期主义者:

  • 从1970年代至今50年,始终关注数值稳定性
  • 即使退休后仍在写论文、做演讲
  • 对新技术(如GPU、AI)保持关注,指出潜在数值陷阱

经典轶事

1. Intel Pentium FDIV bug事件

  • 1994年:Intel Pentium处理器被发现浮点除法有bug(某些除法结果错误)
  • Intel最初态度:“概率极低,不影响普通用户。”
  • Kahan出手:公开撰文指出这违反IEEE 754,并展示实际影响(科学计算不可接受小概率错误)
  • 结果:Intel被迫召回并更换,损失数亿美元,促使行业更重视浮点验证

2. 与编译器的”战斗” 许多编译器有-ffast-math之类的选项,牺牲标准兼容性换取速度。Kahan认为这是”魔鬼交易”,多次发文批评:

“fast-math让程序跑得更快…跑向错误的答案。”

3. “Kahan讲座”的传奇 他的讲座常常超时,因为停不下来讲各种”浮点灾难故事”。学生们笑称:“上Kahan的课,要么被吓到再也不敢写浮点代码,要么变成数值分析专家。“

经典语录

“浮点运算不是数学,而是一种近似数学的工程。“
——强调浮点的局限性,程序员必须理解误差

“如果你以为IEEE 754解决了所有问题,那你根本没懂IEEE 754。“
——提醒人们标准只是起点,数值编程仍需小心

“计算机不会犯错,但程序员会,硬件设计师也会。浮点标准是为了限制他们犯错的方式。“
——黑色幽默地总结标准的意义

💭 为什么他值得纪念?

1. 他是”隐形基础设施”的建造者

大多数人不知道IEEE 754,但每天用它:

  • 每一次Excel计算
  • 每一帧3D游戏渲染
  • 每一个天气预报模型
  • 每一笔在线交易的金额处理

Kahan的工作像空气一样存在——看不见,但不可或缺。

2. 他拯救了”数值计算”这个学科

在1970年代,计算机硬件的混乱一度让数值分析陷入”信任危机”。Kahan的标准重建了信任,让科学计算能够健康发展。

3. 他是”技术良心”的维护者

在商业利益(更快!更便宜!)和技术正确性之间,Kahan始终站在后者一边。他证明了:有原则的坚持最终会赢得尊重和采纳。

4. 他的遗产经得起考验

IEEE 754发布至今近40年,仍是全球标准,几乎没有根本性修订(2008年更新主要是扩展,核心不变)。这种”一次正确,持久有效”的设计是工程的最高境界。

🔍 技术深度:浮点陷阱与最佳实践

陷阱1:0.1 + 0.2 ≠ 0.3

现象:

>>> 0.1 + 0.2
0.30000000000000004

原因:

  • 0.1和0.2在二进制中是无限循环小数
  • 浮点只能存储有限位,舍入导致微小误差
  • 误差累积后可见

最佳实践:

# 错误
if (a + b == 0.3):  # 可能失败

# 正确
if (abs((a + b) - 0.3) < 1e-9):  # 允许小误差

陷阱2:累加大量小数

问题:累加100万个0.1,结果不是100000.0

Kahan求和: 如前所述,用补偿项追踪丢失的精度。

陷阱3:除以接近0的数

问题:结果可能溢出为Inf

处理:

if (abs(divisor) < epsilon) {
    // 处理特殊情况
} else {
    result = numerator / divisor;
}

最佳实践总结

  1. 永远不要用==比较浮点数,用误差范围
  2. 避免相减接近的大数,寻找数学恒等变换
  3. 检查Inf/NaN,不要让它们静默传播
  4. 优先用库函数(如hypot计算√(x²+y²)),它们已优化数值稳定性
  5. 关键计算用高精度(double而非float,必要时用long double或任意精度库)

💭 给开发者的启示

启示1:永远不要用==比较浮点数

Kahan的警告:浮点数有舍入误差,直接比较相等性是危险的。

错误示范与正确做法:

# 例子:金融计算中的价格比较

# ❌ 错误:直接比较
def check_price_match_bad(expected: float, actual: float) -> bool:
    """危险!可能因为微小误差而失败"""
    return expected == actual

# 测试
expected_price = 0.1 + 0.2  # 0.30000000000000004
actual_price = 0.3
print(check_price_match_bad(expected_price, actual_price))  # False!错误!

# ✅ 正确:使用epsilon比较
def check_price_match_good(expected: float, actual: float, epsilon: float = 1e-9) -> bool:
    """允许微小误差"""
    return abs(expected - actual) < epsilon

print(check_price_match_good(expected_price, actual_price))  # True

# ✅ 更好:使用标准库
import math

def check_price_match_best(expected: float, actual: float) -> bool:
    """Python 3.5+提供的标准方法"""
    return math.isclose(expected, actual, rel_tol=1e-9, abs_tol=1e-9)

print(check_price_match_best(expected_price, actual_price))  # True

实战建议:

  • 金融系统:用Decimal类型(精确十进制),而非float
  • 科学计算:epsilon大小取决于问题规模,通常用max(|a|, |b|) * 1e-9
  • 游戏/图形:容差可以更大(1e-6),因为视觉精度有限

启示2:Kahan求和——避免误差累积

问题场景:累加大量数字时,误差会不断累积。

朴素求和的灾难:

# 例子:计算100万个小数的和

import numpy as np

def naive_sum(numbers):
    """朴素求和:O(n*ε)误差"""
    total = 0.0
    for num in numbers:
        total += num
    return total

def kahan_sum(numbers):
    """Kahan补偿求和:O(ε)误差"""
    total = 0.0
    c = 0.0  # 补偿变量,追踪丢失的低位

    for num in numbers:
        y = num - c        # 减去上一次的误差
        t = total + y      # 新的和
        c = (t - total) - y  # 计算这次丢失了多少
        total = t

    return total

# 测试:累加100万个0.1
N = 1_000_000
numbers = [0.1] * N
true_answer = N * 0.1  # 理论上应该是100000.0

result_naive = naive_sum(numbers)
result_kahan = kahan_sum(numbers)
result_numpy = np.sum(numbers)  # NumPy内部用了类似技巧

print(f"真实答案: {true_answer}")
print(f"朴素求和: {result_naive:.10f}, 误差: {abs(result_naive - true_answer):.2e}")
print(f"Kahan求和: {result_kahan:.10f}, 误差: {abs(result_kahan - true_answer):.2e}")
print(f"NumPy求和: {result_numpy:.10f}, 误差: {abs(result_numpy - true_answer):.2e}")

# 输出示例:
# 真实答案: 100000.0
# 朴素求和: 100000.0156250000, 误差: 1.56e-02  # 误差很大!
# Kahan求和: 100000.0000000000, 误差: 0.00e+00  # 完美!
# NumPy求和: 100000.0000000000, 误差: 0.00e+00

应用场景:

  • 机器学习:计算loss时累加大量样本的误差
  • 信号处理:累加大量采样点
  • 金融:计算投资组合总值(大量小额交易)

性能代价:

  • Kahan求和比朴素求和慢约3倍(多了几次浮点运算)
  • 但对于精度敏感的应用,值得付出代价

启示3:避免灾难性相消(Catastrophic Cancellation)

问题:两个接近的大数相减,有效数字大量丢失。

经典案例:求根公式的陷阱:

# 例子:二次方程ax² + bx + c = 0的求根公式

import math

def quadratic_roots_naive(a, b, c):
    """朴素实现:存在数值问题"""
    discriminant = b**2 - 4*a*c
    if discriminant < 0:
        return None  # 无实根

    sqrt_d = math.sqrt(discriminant)
    # 标准公式
    x1 = (-b + sqrt_d) / (2*a)
    x2 = (-b - sqrt_d) / (2*a)
    return x1, x2

def quadratic_roots_stable(a, b, c):
    """数值稳定版本(避免相消)"""
    discriminant = b**2 - 4*a*c
    if discriminant < 0:
        return None

    sqrt_d = math.sqrt(discriminant)

    # 关键改进:根据b的符号选择不同公式
    if b > 0:
        # 避免 -b + sqrt_d 的相消
        x1 = (-b - sqrt_d) / (2*a)
        x2 = c / (a * x1)  # 利用韦达定理: x1*x2 = c/a
    else:
        x1 = (-b + sqrt_d) / (2*a)
        x2 = c / (a * x1)

    return x1, x2

# 测试:一个"坏"情况
a, b, c = 1.0, 200000.0, 1.0
# 理论根:约 -199999.999995 和 -0.000005

x1_naive, x2_naive = quadratic_roots_naive(a, b, c)
x1_stable, x2_stable = quadratic_roots_stable(a, b, c)

print(f"朴素方法: x1={x1_naive:.10f}, x2={x2_naive:.10f}")
print(f"稳定方法: x1={x1_stable:.10f}, x2={x2_stable:.10f}")

# 验证(代入方程检查)
def verify_root(a, b, c, x):
    return abs(a*x**2 + b*x + c)

print(f"\n朴素x2的验证误差: {verify_root(a, b, c, x2_naive):.2e}")
print(f"稳定x2的验证误差: {verify_root(a, b, c, x2_stable):.2e}")

# 朴素方法的x2会有明显误差!

通用原则:

  • 寻找数学等价变换,避免相减
  • 例如:(a - b) / (a + b) 可改写为 (a² - b²) / ((a + b)*(a + b))(如果a+b稳定)

启示4:检测和处理Inf/NaN

Kahan的设计:Inf和NaN不应该静默传播,而应及早发现。

实战代码:

# 例子:神经网络训练中的数值健康检查

import numpy as np

class NumericalHealthChecker:
    """检测训练过程中的数值问题"""

    @staticmethod
    def check_array(arr: np.ndarray, name: str) -> bool:
        """检查数组中是否有异常值"""
        if np.any(np.isnan(arr)):
            print(f"❌ {name}中发现NaN!")
            return False

        if np.any(np.isinf(arr)):
            print(f"❌ {name}中发现Inf!")
            return False

        # 检查是否过大(可能导致溢出)
        max_val = np.max(np.abs(arr))
        if max_val > 1e10:
            print(f"⚠️  {name}中发现过大值: {max_val:.2e}")
            return False

        return True

    @staticmethod
    def safe_divide(numerator: np.ndarray, denominator: np.ndarray,
                    epsilon: float = 1e-8) -> np.ndarray:
        """安全除法:避免除以0"""
        # 将接近0的值替换为epsilon
        safe_denom = np.where(np.abs(denominator) < epsilon,
                              np.sign(denominator) * epsilon,
                              denominator)
        return numerator / safe_denom

    @staticmethod
    def clip_gradients(gradients: np.ndarray, max_norm: float = 5.0) -> np.ndarray:
        """梯度裁剪:防止梯度爆炸"""
        norm = np.linalg.norm(gradients)
        if norm > max_norm:
            print(f"⚠️  梯度裁剪: {norm:.2f} -> {max_norm}")
            return gradients * (max_norm / norm)
        return gradients

# 使用场景:训练循环
def training_step(model, data, checker: NumericalHealthChecker):
    # 前向传播
    predictions = model.forward(data)
    if not checker.check_array(predictions, "predictions"):
        raise RuntimeError("数值异常,停止训练!")

    # 计算损失
    loss = compute_loss(predictions, data.labels)
    if np.isnan(loss) or np.isinf(loss):
        raise RuntimeError(f"Loss异常: {loss}")

    # 反向传播
    gradients = model.backward(loss)
    if not checker.check_array(gradients, "gradients"):
        raise RuntimeError("梯度异常!")

    # 裁剪梯度
    gradients = checker.clip_gradients(gradients)

    # 更新参数
    model.update(gradients)

    return loss

# 早期发现数值问题,避免浪费几小时训练!

最佳实践:

  • 训练开始前:检查输入数据范围,做归一化
  • 每个epoch:打印loss、梯度范数,观察趋势
  • 使用TensorBoard:可视化数值健康指标
  • 混合精度训练:FP16容易溢出,需要loss scaling

启示5:正确使用库函数——它们已优化数值稳定性

Kahan的建议:不要重新发明轮子,标准库函数经过数十年优化。

案例对比:

// 例子:计算向量的模长 ||v|| = √(x² + y²)

#include <math.h>
#include <stdio.h>

// ❌ 错误:可能溢出或下溢
double vector_magnitude_naive(double x, double y) {
    return sqrt(x*x + y*y);
    // 问题:如果x或y很大(如1e200),x*x会溢出为Inf
    // 如果x或y很小(如1e-200),x*x会下溢为0,结果不准
}

// ✅ 正确:使用hypot(已处理溢出/下溢)
double vector_magnitude_good(double x, double y) {
    return hypot(x, y);
    // hypot内部使用类似这样的技巧:
    // 1. 找出绝对值较大的分量
    // 2. 归一化后计算,避免溢出
    // 3. 再放大回去
}

// 测试极端情况
int main() {
    double x = 1e200;
    double y = 1e200;

    printf("Naive: %e\n", vector_magnitude_naive(x, y));  // inf (溢出!)
    printf("hypot: %e\n", vector_magnitude_good(x, y));   // 1.414214e+200 (正确!)

    return 0;
}

其他数值稳定的库函数:

import numpy as np

# 1. log(1 + x):当x接近0时,直接计算会丢失精度
x = 1e-10
print(f"np.log(1 + x): {np.log(1 + x)}")      # 不准
print(f"np.log1p(x):   {np.log1p(x)}")       # 准确!

# 2. exp(x) - 1:当x接近0时,类似问题
x = 1e-10
print(f"np.exp(x) - 1: {np.exp(x) - 1}")     # 不准
print(f"np.expm1(x):   {np.expm1(x)}")       # 准确!

# 3. log(sum(exp(x_i))):logsumexp,机器学习中常见
# 直接计算可能溢出
logits = np.array([1000, 1001, 1002])  # 很大的数
# np.log(np.sum(np.exp(logits)))  # exp(1000)会溢出!

from scipy.special import logsumexp
result = logsumexp(logits)  # 内部做了归一化,安全!
print(f"logsumexp: {result}")

关键启示:

  • 看似简单的数学公式,直接实现往往有数值陷阱
  • 优先使用成熟库:NumPy、SciPy、Math.h、Eigen
  • 这些函数由Kahan和他的学生们优化过数十年

❓ 常见问题

Q1:为什么不直接用更高精度(如128位)解决所有问题?

A:精度不是万能药:

成本代价:

  • 计算速度:128位浮点运算比64位慢5-10倍
  • 内存占用:数据量翻倍,缓存效率下降
  • 硬件支持:大多数CPU没有原生128位浮点单元,需软件模拟

无法根除问题:

  • 无论多少位,总有无法精确表示的数(如1/3)
  • 问题是算法设计,不只是精度

Kahan的观点:

  • 64位IEEE 754对绝大多数应用已足够
  • 关键是正确使用,而非盲目加精度
  • “如果你的算法在64位下不稳定,换128位只是推迟问题,不是解决问题。”

合理使用场景:

  • 中间计算:关键步骤用高精度,最终结果降回64位
  • 参考解:用高精度验证64位实现的正确性
  • 特殊领域:天体物理(时间跨度极大)、密码学(需要精确整数运算)

Q2:机器学习为什么能用低精度(FP16/INT8)训练?

A:看似矛盾,实则不同场景:

科学计算 vs 深度学习:

  • 科学计算:模拟物理过程,要求绝对准确

    • 误差累积会导致完全错误的结果
    • 需要IEEE 754的正确舍入保证
  • 深度学习:优化问题,追求”差不多”

    • 训练本身就是随机的(SGD、dropout)
    • 噪音可能反而有正则化效果
    • 只要loss下降,梯度方向大致正确即可

为什么低精度能work?

  1. 过参数化:神经网络参数远多于训练样本,有大量冗余
  2. 批归一化:动态调整数值范围,避免溢出
  3. 混合精度:关键操作(如loss计算)仍用FP32

但仍需遵守Kahan原则:

  • 检测Inf/NaN:即使FP16也要监控
  • Loss scaling:放大梯度避免下溢,这就是数值技巧!
  • 累加用高精度:大批量累加梯度时,用FP32避免误差

Kahan对此的评论(2019年访谈):

“深度学习看起来违反了数值稳定性原则,但其实是运气好。当网络更复杂、任务更困难时,数值问题会回来咬你。不要因为现在能跑就忽视基础。”

Q3:为什么浮点bug这么难找?

A:浮点bug具有隐蔽性和不确定性:

特征1:非确定性

  • 同样的代码,不同输入可能一个正常一个出错
  • 优化编译(-O2 vs -O0)可能改变结果(指令重排影响舍入顺序)
  • 多线程累加顺序不同,结果不同

特征2:远距离传播

  • 误差发生在第10行,但第1000行才崩溃
  • NaN像”病毒”一样传播,源头难追踪

特征3:近似正确

  • 不是完全错误,而是”差一点点”
  • 可能通过了单元测试(测试用例不够极端)
  • 在生产环境罕见输入下才触发

Kahan的调试建议:

  1. 启用浮点异常:

    #include <fenv.h>
    feenableexcept(FE_INVALID | FE_DIVBYZERO | FE_OVERFLOW);
    // 一旦出现NaN/Inf,立即崩溃,方便定位
    
  2. 使用PARANOIA:

    • Kahan编写的诊断工具,检测编译器/硬件是否遵守标准
  3. 双精度对比:

    • 同时跑float和double版本,结果差异过大说明有问题
  4. 打印中间值:

    • 不要只看最终结果,追踪关键变量的范围
  5. 简化问题:

    • 二分查找,逐步缩小出错范围

真实案例:

  • Pentium FDIV bug:Intel工程师测试了上亿个case,仍漏掉了;直到数学教授Thomas Nicely偶然发现
  • 启示:浮点验证需要形式化方法,不能只靠测试

Q4:什么时候应该用Decimal而非float?

A:金融、法律、精确十进制场景。

Float的问题:

# 灾难性的金融计算
price = 0.1
quantity = 3
total_float = price * quantity
print(f"Float: {total_float}")  # 0.30000000000000004

# 如果这是银行账户,客户看到这个会投诉!

Decimal的正确性:

from decimal import Decimal, getcontext

# 设置精度
getcontext().prec = 28

price = Decimal('0.1')  # 注意:用字符串初始化!
quantity = Decimal('3')
total_decimal = price * quantity
print(f"Decimal: {total_decimal}")  # 0.3 (精确!)

# 金融场景:计算利息
principal = Decimal('10000.00')
rate = Decimal('0.05')  # 5%年利率
years = Decimal('10')

interest = principal * rate * years
print(f"利息: {interest}")  # 5000.00 (精确!)

使用Decimal的场景:

  • 金融系统:货币计算,绝不能有舍入误差
  • 法律合规:税务计算,必须精确到分
  • 会计软件:资产负债表必须平衡
  • 游戏货币:虚拟货币交易(虽然不是真钱,但用户期待精确)

性能代价:

  • Decimal比float慢10-100倍(软件实现)
  • 但对于金融应用,正确性 >> 速度

Kahan的态度:

  • 他支持对精确十进制的需求
  • 但认为大多数科学/工程计算不需要(二进制浮点足够)
  • IEEE 754-2008加入了十进制浮点,但硬件支持有限

Q5:编译器优化会破坏浮点语义吗?

A:会的!这是Kahan长期批评的问题。

危险的编译器优化:

// 源代码
double x = ...;
double y = x + 0.0;  // 程序员可能想触发某种副作用

// 编译器优化(错误!)
double y = x;  // "加0没意义,优化掉"

// 但在IEEE 754中,x + 0.0会:
// - 将-0.0转为+0.0
// - 触发浮点异常检测
// 优化破坏了语义!

更危险:-ffast-math(GCC/Clang): 这个选项为了性能牺牲标准符合性:

gcc -ffast-math program.c
# 启用的"优化":
# - 假设没有NaN/Inf(可以省略检查)
# - 忽略舍入模式(假设总是round-to-nearest)
# - 允许数学恒等式(如x*0=0,但x=Inf时不成立!)
# - 重排浮点运算(改变舍入顺序)

Kahan的批评:

“fast-math should be called ‘fuck-math’. It makes your program fast, but wrong.”

什么时候能用fast-math?

  • 游戏渲染:视觉误差可接受,性能优先
  • 快速原型:不要求精度,快速验证想法
  • 明确不care数值准确性

什么时候禁用?

  • 科学计算
  • 金融系统
  • 任何需要可靠结果的应用

如何保护自己?

# 编译时检查是否符合IEEE 754
gcc -std=c11 -pedantic -O2 program.c  # 不要加-ffast-math

# Python:检查numpy是否用了fast-math
import numpy as np
print(np.__config__.show())  # 查看编译选项

Kahan的遗产:

  • 他坚持不懈地批评这些优化
  • 推动编译器增加-fp-model=strict之类的选项
  • 让程序员能够选择”正确慢”而非”错误快”

📚 延伸阅读与学习资源

必读论文

  • Goldberg: “What Every Computer Scientist Should Know About Floating-Point Arithmetic” (1991)
    • 浮点运算的 “圣经”
    • 详细但易懂,强烈推荐

官方文档

  • IEEE 754-2008规范
    • 虽然技术性强,但了解”源头”很有价值

在线工具

课程

  • Berkeley CS267: 数值线性代数(Kahan曾执教)
  • Coursera: 数值方法课程

书籍

  • Higham: “Accuracy and Stability of Numerical Algorithms”
    • 系统讲述数值稳定性

🌟 精神遗产:Kahan的三大哲学

1. “正确性优先于性能”

在速度和准确性之间,Kahan总是选择后者。他证明了:长远来看,正确的慢算法胜过快速的错算法。

2. “标准的力量”

一个好的标准(IEEE 754)可以统一行业、积累知识、避免重复劳动。制定标准比发明算法可能更有影响力。

3. “永恒的警惕”

浮点陷阱不会消失,每一代程序员都需要学习。教育是永恒的任务。


总结语: William Kahan是数值计算的”守护神”。他的工作不炫目、不热门,但无比扎实。IEEE 754就像计算机世界的”度量衡”,确保了全球的科学家、工程师、程序员都在用同一把”尺子”丈量数字世界。

他用一生证明了:在技术领域,那些最基础、最”无聊”的工作,往往是最重要、影响最深远的。当我们用计算机模拟宇宙、预测气候、训练AI时,Kahan的智慧静静支撑着每一次浮点运算,让数字不再”说谎”。

这束始于1970年代的数值稳定性之光,将永远照亮计算的精度与可靠。

DISCUSSION

评论与补充