CSAPP - 02.Floating Point Numbers
一、小数的二进制表示 (Fractional Binary Numbers)
1. 表示方法
计算机使用二进制来表示一切信息。对于小数,其表示方法与整数类似,只是在小数点右侧的每一位代表 2 的负数次幂。
一个二进制小数 的值可以表示为:
- 小数点左边的位权是
- 小数点右边的位权是
示例:
- 的值趋近于 1.0 ()。
2. 表示的局限性
- 精确表示:只有形如 的有理数才能被精确地表示。
- 循环表示:其他许多有理数在二进制下是无限循环小数。
二、IEEE 754 浮点数标准
为了统一浮点数的算术标准,IEEE 在 1985 年推出了 IEEE 754 标准。它被所有主流 CPU 支持,并为舍入、溢出等问题提供了良好的规范。该标准的主要设计者是 William Kahan,他因此获得了 1989 年的图灵奖。
1. 数值形式 (Numerical Form)
所有浮点数都被表示为科学记数法的形式:
- (Sign): 符号位,决定正负 (0 为正, 1 为负)。
- (Significand): 尾数,通常是一个范围在
[1.0, 2.0)之间的小数。 - (Exponent): 阶码,用于对数值进行加权。
2. 编码 (Encoding)
在计算机中,一个浮点数被编码为三个部分:
s (符号位) |
exp (阶码字段) |
frac (小数部分) |
|---|
- s: 直接编码符号 。
- exp: 编码阶码 (但
exp不等于 )。 - frac: 编码尾数 (但
frac不等于 )。
3. 精度 (Precisions)
- 单精度 (Single Precision /
float): 32 bits 4 bytes。s: 1 位,exp: 8 位,frac: 23 位。
- 双精度 (Double Precision /
double): 64 bits 8 bytes。s: 1 位,exp: 11 位,frac: 52 位。
4. 三种编码类型
a. 规格化值 (Normalized Values)
这是最常见的情况。
- 条件:
exp字段既不全为 0,也不全为 1。 - 阶码 (Exponent): 采用 偏置表示法 (Biased Value)。
- 。
Exp是exp字段的无符号整数值。Bias是一个偏置常数(用于表示大于和小于1的数),值为 ( 是exp的位数)。- 单精度
k=8Bias= 127,能表达 (阶码不全为0也不全为1) - 双精度
Bias = 1023。
- 单精度
- 尾数 (Significand): 采用 隐含的头一位 (Implied Leading 1)。
- ,其中
xxx...x是frac字段的值。 - 因为规格化数的尾数第一位总是 1,所以可以省略不存,从而 “免费” 获得一位额外的精度。
- ,其中
我们以单精度(Single Precision) 为例来详细解释:
exp字段的位数: 对于单精度浮点数,阶码字段exp共有 k=8 位 1。exp的原始范围: 作为一个8位的无符号整数,exp字段可以表示的十进制值范围是从00000000(0) 到11111111(255)。- 保留的特殊值: IEEE 754 标准规定,
exp字段的两个极端值是保留作特殊用途的:
exp全为 0 (00000000, 即十进制的 0) 被保留用于表示非规格化数 (Denormalized Values) 和 02222。exp全为 1 (11111111, 即十进制的 255) 被保留用于表示无穷大 (Infinity) 和 NaN (Not-a-Number) 3。
- 规格化数的
exp范围: 因为最大和最小的值被保留了,所以用于表示常规规格化数(Normalized Values)的exp字段的有效范围就变成了从 1 到 254。 - 计算实际的阶码 E:
- 偏置值 (Bias): 单精度的 Bias 是。
- 最小阶码 Emin: 使用 exp 的有效最小值 1 来计算。
- 最大阶码 Emax: 使用 exp 的有效最大值 254 来计算。
简单来说,引入偏置值(Bias)是为了在 exp 字段中只使用无符号整数,就能方便地表示正负范围的阶码,这简化了硬件在比较浮点数大小时的设计。而去掉头尾两个特殊值,则是为了能够表示像0和无穷大这样的特殊情况。
[!tip] 规格化浮点数换算
数值 =
- 符号 (s): 直接取二进制的最高位。0代表正数,1代表负数。
- 阶码 (E):
- 读取
exp字段的二进制,将其转换为无符号整数Exp。 - 用
Exp减去一个固定的偏置值 (Bias) 得到E。 - 公式:
E = Exp - Bias(例如,单精度Bias为127)。
- 尾数 (M):
- 在
frac字段的二进制小数前,加上一个隐含的 “1.”。 - 公式:
M = 1.frac(二进制形式)。
将计算出的 s, E, 和 M 代入核心公式,即可得到最终的十进制数。
b. 非规格化值 (Denormalized Values)
用于表示非常接近 0 的数。
- 条件:
exp字段全为 0。 - 阶码 (Exponent): 。这是是为了实现平滑过渡(Seamless Transition)。
- 最小的规格化数(4字节)
exp=1,frac=00...0: 。 - 最大的非规格化数 (
exp=0,frac=11...1): 如果 是 :。这与上面的 之间会出现断层。 如果 是 :。
- 最小的规格化数(4字节)
- 尾数 (Significand): 隐含的头一位是 0。
- ,其中
xxx...x是frac字段的值。
- ,其中
- 特殊情况:
- 如果
frac也全为 0,则表示 0 值(区分+0和-0)。 - 如果
frac不全为 0,则表示非常接近 0 的数,实现了向 0 的 渐进下溢 (Gradual Underflow)。
- 如果
c. 特殊值 (Special Values)
- 条件:
exp字段全为 1。 - 情况一:无穷大 (Infinity)
- 当
frac字段全为 0 时。 - 表示运算溢出(如
1.0 / 0.0)的结果,有+∞和-∞。
- 当
- 情况二:非数值 (Not-a-Number / NaN)
- 当
frac字段不全为 0 时。 - 表示一个无法确定数值结果的操作(如
sqrt(-1),∞ - ∞)。
- 当
- 位置: 非规格化数填充了最小的正规格化数与零之间的空隙(同样适用于负数)。
- 密度: 非规格化与规格化数在数轴上越靠近零的区域分布越密集。
- 间距: 与数值越大间距越大的规格化数不同,所有的非规格化数之间是等距 (Equispaced) 的,即它们均匀地分布在零附近。相同阶数的规格化数的间距是相等的,并且阶数越大间距越大。
- 在二进制里,尾数 每次变化的最小单位是最后一位加 1。对于 32 位浮点数(23 位尾数),这个最小变化量是 。所以,相邻两个数的间距(Gap)是:
- 结论:只要 不变, 就是一个定值。所以在 到 这一段里,刻度是完全均匀的。
- 每当跨越一个 2 的幂次边界(比如从 2 到 4,或者从 1024 到 2048),浮点数的间距就会精确地翻倍。
- 作用: 这种分布方式实现了“渐进下溢” (Gradual Underflow),避免了从一个很小的数直接跳变为零,从而保留了对极小数值的处理能力,但代价是牺牲了精度。
三、浮点数运算 (Floating Point Operations)
1. 基本思想
精确结果所含的有效位数,超过了目标浮点格式的尾数(frac)字段所能容纳的位数时,就需要进行舍入 。以下是讲义中提到的几种情况:
- 浮点数乘法 (Floating Point Multiplication): 两个浮点数的尾数相乘后,其结果的有效位数可能会翻倍,超出了
frac字段的长度,因此需要舍入 。 - 浮点数加法 (Floating Point Addition): 在加法运算中,为了对齐小数点,阶码较小的数的尾数需要右移,这可能产生额外的低位。两个尾数相加后的结果也可能需要舍入才能存入
frac字段 。 - 类型转换 (Casting): 当一个位数更多的整数(例如,一个超过24位的
int)转换为float时,由于float的尾数只有23位(加上隐含的1位),无法精确表示该整数,因此需要进行舍入 。同样,从double(52位尾数)转换到float也常常需要舍入
公式表示:
2. 舍入模式 (Rounding Modes)
核心原则:默认模式总是优先选择精度最高(最接近)的值,仅在无法区分远近时使用仲裁规则。
1. 默认模式:向偶数舍入 (Round-to-nearest, ties to even)
这是几乎所有现代计算机系统的默认行为。判断逻辑分为两步:
- 第一步:找最近 (Nearest)
- 绝对优先原则。选择距离原数值最近的可表示值,不需要看奇偶。
- 例:
1.11.0;1.92.0。
- 第二步:中间值仲裁 (Ties to Even)
- 触发条件:仅当原数值恰好位于两个可表示值的正中间(即 )时。
- 规则:选择最低有效位 (LSB) 为 0(偶数)的那个值。
- 例 (十进制):
1.52(偶);2.52(偶)。 - 例 (二进制):
10.10 100(中间值)10.10(末尾0, 偶)。10.11 100(中间值)11.00(末尾0, 偶)。
- 优势:统计无偏 (Statistically Unbiased)。避免了传统“四舍五入”在大量运算中造成结果持续偏大的系统误差(50%几率向上,50%几率向下)。
2. 其他非默认模式 (Directed Rounding)
这些模式不考虑“谁更近”,直接按方向强制取整,常用于区间算术或特定边界计算。
- 向零舍入 (Round toward zero): 直接截断 (Truncate)。C 语言
(int)强制转换时的行为。 - 向正无穷舍入 (Round toward +Inf): 向上取整 (Ceiling)。始终向数轴右侧靠近(
1.12.0,-1.5-1.0)。 - 向负无穷舍入 (Round toward -Inf): 向下取整 (Floor)。始终向数轴左侧靠近(
1.11.0,-1.5-2.0)。
3. 浮点数加法和乘法详解
浮点数运算比整数复杂,本质上是“科学计数法”的运算。
1. 浮点数乘法 (Floating-point Multiplication)
乘法的逻辑相对直观,符号、阶码和尾数可以分开处理。
-
Step 1: 生成符号位 (Compute Sign)
- 操作:
- 原理:同号相乘为正,异号相乘为负。
-
Step 2: 阶码运算 (Compute Exponent)
- 操作:
- 原理:阶码存储为移码 ()。若直接相加,Bias 会被加两次,因此必须减去一次偏置值。
-
Step 3: 尾数相乘 (Multiply Significands)
- 操作:
- 范围:两个规格化数 相乘,结果 的范围是 。
-
Step 4: 规格化与舍入 (Normalization & Rounding)
- 情况 A ():结果已规格化,无需调整。
- 情况 B ():即结果在 之间。
- 动作:尾数 右移 1 位,阶码 加 1。
- 舍入:截断多余位并按规则(如向偶数舍入)处理。
- 检查:检查阶码是否发生上溢 (Overflow) 或下溢 (Underflow)。
2. 浮点数加法 (Floating-point Addition)
加法的核心难点在于必须先对齐小数点(对阶)。
-
Step 1: 对阶 (Alignment)
- 原则:小阶向大阶看齐。
- 理由:小阶变大需要右移尾数,丢失的是低位精度(可控);大阶变小需要左移尾数,会丢失高位有效数字(灾难性错误)。
- 操作:保持大阶码不变,将小阶码对应的尾数 右移,直到阶码相同。
-
Step 2: 尾数加减 (Significand Addition)
- 操作: (其中 是对阶后的尾数)。
- 根据符号位决定实际执行加法还是减法。
-
Step 3: 规格化 (Normalization)
- 情况 A (进位, ):例如 。
- 动作:尾数 右移 1 位,阶码 加 1。
- 情况 B (抵消, ):例如 。
- 动作:尾数 左移若干位,直到最高位恢复为 1。
- 补偿:阶码相应 减小。
- 特殊情况:结果为 0,阶码和尾数全置 0。
- 情况 A (进位, ):例如 。
-
Step 4: 舍入 (Rounding)
- 处理对阶或计算中移出的位。
- 注意:舍入可能导致再次进位(如 进位成 ),此时需再次执行“右移1位、阶码加1”。
1. 可行性 (Feasibility)
- 可以运算: 非规格化数之间、以及与规格化数之间完全可以进行加减乘除。
- 机制: 硬件 (FPU) 或软件库会自动处理阶码对齐和尾数计算,实现从规格化数到 0 的平滑过渡。
2. 潜在风险 (Risks)
- 性能陷阱 (Performance Penalty): 许多硬件处理非规格化数效率极低(可能触发微代码或软件模拟),导致计算速度下降几十倍甚至上百倍。
- 精度损失 (Precision Loss): 数值越小,尾数的前导零越多,有效位数递减,导致计算精度严重下降。
3. 工程对策 (Handling)
- 归零模式 (Flush-to-Zero, FTZ): 在游戏开发或高性能计算中,为了保住性能,通常配置 CPU 将非规格化数直接视为 0 处理,但这会牺牲 IEEE 754 的标准兼容性。
4. 数学属性 (Mathematical Properties)
浮点数运算不完全满足实数算术的数学定律。
- 结合律不成立 (Associativity is violated):
(3.14 + 1e10) - 1e10的结果可能是0.0。3.14 + (1e10 - 1e10)的结果是3.14。- 原因是舍入误差。
- 分配律不成立 (Distributivity is violated):
1e20 * (1e20 - 1e20)结果是0.0。1e20 * 1e20 - 1e20 * 1e20结果可能是NaN(因为中间结果溢出为∞)。
这对编译器优化和严肃的数值计算程序员来说是一个巨大的挑战。
四、C 语言中的浮点数
- 数据类型:C 语言保证了两种精度的浮点数:
float(单精度) 和double(双精度)。 - 类型转换 (Casting):
double/float→int: 截断 (Truncate) 小数部分,类似于向零舍入。int→double: 只要int的位数不多于 53 位(double的尾数位数),转换是精确的(满足double的步长小于1)。对于一般的int(4 bytes)转换是精确的,对于long long可能发生舍入。int→float: 可能会发生舍入。尾数 (Mantissa):只剩下 23 位(加上隐含的 1,共 24 位有效数字),不一定能存下int的最多32位有效数字。
- 比较问题:
- 表达式
(f+d)-f == d可能为false,因为f+d的计算可能导致d的低位精度丢失。大数+小数导致精度丢失。
- 表达式
浮点数谜题(Floating Point Puzzles)
预设条件: int x (32位), float f (32位), double d (64位)。假设无 NaN。
1. 类型转换与精度
x == (int)(float) xFalse- 原因:
int(31位有效值) >float(24位有效精度)。大整数转 float 会发生舍入,丢失精度。
- 原因:
x == (int)(double) xTrue- 原因:
double(53位有效精度) >int(31位)。double 能像大箱子一样无损装下 int。
- 原因:
f == (float)(double) fTrue- 原因: float 转 double 是无损的(尾数补零),转回来数值不变。
d == (float) dFalse- 原因: double 转 float 会发生舍入(丢弃低位)或溢出(变无穷大)。
2. 运算规则
2/3 == 2/3.0False- 原因: 左边是整数除法 (结果为 0),右边是浮点除法 (结果为 0.66…)。
(d+f)-d == fFalse- 原因: 大数吃小数。当
d极大f极小时,d+f可能因精度限制仍等于d,导致最终结果为0(不等于f)。
- 原因: 大数吃小数。当
3. 符号与不等式
f == -(-f)True- 原因: 两次翻转符号位,还原回原值。
d < 0.0 => ((d*2) < 0.0)True- 原因: 浮点数乘 2 仅增加指数,不改变符号位。即使溢出为 也依然小于 0。
d > f => -f > -dTrue- 原因: 数学不等式性质,同乘 -1 变号。
d * d >= 0.0True- 原因: 实数平方非负。自乘时符号位异或 (),结果永远为正。
核心记忆原则
- 大转小必有失:
doublefloatint容易丢失精度或溢出。 - 小转大很安全:
intdouble是绝对精确的。 - 大数吃小数:浮点加法运算中,极大数加极小数,小数的精度会被忽略。
五、前沿热点:FP8
近年来,随着 AI 的发展,低精度浮点数格式成为研究热点。
- 背景:为了在 AI 芯片中实现更高的计算吞吐量和更低的功耗,业界提出了多种比 FP32/FP16 更低精度的格式,如 FP8。
- FP8 格式:FP8 有多种变体,主要通过调整 阶码 (Exponent) 和 尾数 (Mantissa/Fraction) 的位数来权衡动态范围 (Range) 和 精度 (Precision)。
- E5M2: 5 位阶码,2 位尾数。提供更大的动态范围,适合梯度等数值范围大的场景。
- E4M3: 4 位阶码,3 位尾数。提供更高的精度,适合权重等场景。
- 优势:在专用硬件(如 NVIDIA H100 GPU)上,使用 FP8 进行张量运算可以比 FP16 带来数倍的性能提升。
总结
- IEEE 754 标准为浮点数提供了一套清晰的数学属性和运算规则。
- 浮点数的表示形式为 。
- 我们可以独立于具体硬件实现来推理浮点运算,因为其行为就像是先用无限精度计算再进行舍入。
- 核心要点:浮点数算术 不等于 实数算术,它不满足结合律和分配律。理解这一点对于编写健壮、可靠的数值计算代码至关重要。