图灵奖系列 · 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坚持的原则: 必须提供多种舍入模式,供不同应用选择:
-
四舍五入到最近(默认,Round to nearest, ties to even):
- 最常用,最”公平”
- 例如:0.5 舍入到 0(偶数),1.5舍入到2(偶数),避免系统性偏差
-
向零舍入(Round toward zero):
- 就是”截断”,1.9 → 1, -1.9 → -1
-
向正无穷舍入(Round toward +∞):
- 1.1 → 2, -1.9 → -1
-
向负无穷舍入(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;
}
最佳实践总结
- 永远不要用==比较浮点数,用误差范围
- 避免相减接近的大数,寻找数学恒等变换
- 检查Inf/NaN,不要让它们静默传播
- 优先用库函数(如
hypot计算√(x²+y²)),它们已优化数值稳定性 - 关键计算用高精度(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?
- 过参数化:神经网络参数远多于训练样本,有大量冗余
- 批归一化:动态调整数值范围,避免溢出
- 混合精度:关键操作(如loss计算)仍用FP32
但仍需遵守Kahan原则:
- 检测Inf/NaN:即使FP16也要监控
- Loss scaling:放大梯度避免下溢,这就是数值技巧!
- 累加用高精度:大批量累加梯度时,用FP32避免误差
Kahan对此的评论(2019年访谈):
“深度学习看起来违反了数值稳定性原则,但其实是运气好。当网络更复杂、任务更困难时,数值问题会回来咬你。不要因为现在能跑就忽视基础。”
Q3:为什么浮点bug这么难找?
A:浮点bug具有隐蔽性和不确定性:
特征1:非确定性
- 同样的代码,不同输入可能一个正常一个出错
- 优化编译(
-O2vs-O0)可能改变结果(指令重排影响舍入顺序) - 多线程累加顺序不同,结果不同
特征2:远距离传播
- 误差发生在第10行,但第1000行才崩溃
- NaN像”病毒”一样传播,源头难追踪
特征3:近似正确
- 不是完全错误,而是”差一点点”
- 可能通过了单元测试(测试用例不够极端)
- 在生产环境罕见输入下才触发
Kahan的调试建议:
-
启用浮点异常:
#include <fenv.h> feenableexcept(FE_INVALID | FE_DIVBYZERO | FE_OVERFLOW); // 一旦出现NaN/Inf,立即崩溃,方便定位 -
使用PARANOIA:
- Kahan编写的诊断工具,检测编译器/硬件是否遵守标准
-
双精度对比:
- 同时跑float和double版本,结果差异过大说明有问题
-
打印中间值:
- 不要只看最终结果,追踪关键变量的范围
-
简化问题:
- 二分查找,逐步缩小出错范围
真实案例:
- 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规范
- 虽然技术性强,但了解”源头”很有价值
在线工具
- Float Toy: 可视化浮点表示(https://evanw.github.io/float-toy/)
- Herbie: 自动改进浮点表达式数值稳定性的工具
课程
- 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
评论与补充