在小数部分,和十进制一样,二进制数也可以表示小数部分。
对于一个任意的二进制数 $N$,如果它有 $n$ 位整数和 $m$ 位小数,可以写成:
\[(d_{n-1}d_{n-2}\dots d_1d_0 . d_{-1}d_{-2}\dots d_{-m})_2\]其对应的十进制数值 $V$ 的求和形式为:
\[V = \sum_{i=-m}^{n-1} d_i \cdot 2^i\]
For example, 0.11111111 = 1.0 - $\epsilon$
#include <stdio.h>
void paradox() {
double x = 0.1f;
double y = 0.2f;
double z = 0.3f;
printf("The sum of 0.1 + 0.2:\t%.20f\n", x + y);
printf("The value of 0.3:\t%.20f\n", z);
printf("Are they the same? ");
printf((x + y == z ? "True" : "False"));
printf("\n");
}
int main(){
paradox();
}
The sum of 0.1 + 0.2: 0.30000000447034835815
The value of 0.3: 0.30000001192092895508
Are they the same? False
符号位,指数部分和尾数部分:
\[(-1)^s \times M \times 2^E\]
在上述的计数标准中,指数部分 $E$ 为一个有符号整数,假设一个浮点数中分配了 $M$ 位的字长给指数部分,则此时指数部分可以表示的大小为:$E \in [-2^{M-1} + 2, 2^{M-1} - 1]$
$-2^{M-1}$ 和 $-2^{M-1} + 1$ 用于表示特殊的类型
此时指数部分 $E$ 的存储方式是补码形式存储有符号整数,此时因为首位符号位的权重为负数,不方便比较大小,因此选择将有符号整数加上指数偏置变成无符号整数。加上的项为 $2^{M-1} -1$ 后 $E’ \in [1, 2^{M} - 2]$
归一化后 $E’ = 0$ 和 $E’ = 2^M - 1$(-1 转化为无符号整数会自动到最大值),专门用于处理不正常的情况。
Condition: exp = 0000...00 (All zeros, $E’ = 0$)
Exponent Value: E = 1 - Bias
如果去掉归一化的项 $2^{M-1} -1$,对应的指数退回到有符号表示就是 $-2^{M-1} +1$,这代表这最小的指数位的情况(一个非常小的小数)。如果需要表示的数比这个数小,浮点数的值就会突越到 0,导致数值误差。
因此,为了实现极小数的平滑过渡,我们引入非规格化数 (Denormalized Values),对应的条件是 exp = 0000...00 并且尾数位的首位是 0,来表示更小的数字。
For normalized values, the actual floating pointing is:
\[V = (-1)^s \times (\mathbf{1}.f) \times 2^{E-2^{M-1}+1}\]For denormalized values, the actual floating point is:
\[V = (-1)^s \times (\mathbf{0}.f) \times 2^{-2^{M-1}+1}\]可以做一个非常粗浅的计算,计算如果不使用非规格化数最终计算绝对值最小的非零浮点数值:
为什么需要使用
E = 1 - Bias? 同样的,为了防止出现突然的断层:
- 如果使用
E = 1 - Bias
- 最大的非规格化数(尾数全为 1):$(0.111\dots1)_2 \times 2^{-126}$这个值无限接近于 $1.0 \times 2^{-126}$。
- 最小的规格化数:$(1.000\dots0)_2 \times 2^{-126}$
- 如果使用
E = 0 - Bias
- 最大的非规格化数(尾数全为 1):$(0.111\dots1)_2 \times 2^{-127} \approx 1.0 \times 2^{-1} \times 2^{-127} = \mathbf{1.0 \times 2^{-128}}$
- 最小的规格化数($E’=1$):$(1.000\dots0)_2 \times 2^{1-127} = \mathbf{1.0 \times 2^{-126}}$
- 两者存在 4 倍的偏差,此时浮点数的表示存在真空地带。
因此,IEEE-754 标准下的浮点数 ($M$ 位指数, $N$ 位尾数),可以近似连续的表示 $[2^{-2^{M-1} + 2 - N}, (2 - 2^{-N}) \times 2^{2^{M-1} - 1}]$ 范围内的浮点数值。
考虑符号位都是 0
在 IEEE 754 规格化数中,要取到最大正值,各部分的取值如下:
根据规格化数的求和公式 $V = (1 + f) \times 2^{E’ - Bias}$:
实际指数 (E):\(E = E'_{max} - Bias = (2^M - 2) - (2^{M-1} - 1)\)\(E = 2^{M-1} - 1\)
尾数 (1+f):当 $N$ 位尾数全为 1 时,其值为:\(1 + \sum_{i=1}^{N} 2^{-i} = 1 + (1 - 2^{-N}) = 2 - 2^{-N}\)
最终最大值 $V_{max}$:\(V_{max} = (2 - 2^{-N}) \times 2^{2^{M-1} - 1}\)
Condition: exp = 1111...11 (All ones, $E’ = 2^M - 1$)
exp = 111...1, frac=000...0: $\infin$
exp = 111...1, frac=000...0: NaN (Not a Number)

假设指数位为 4 位,尾数位为 3 位,则简易的浮点数可以表示为:
frac舍入是一个非常深入的哲学命题,对于有限字长的计算机而言,舍入操作是必要的,也必定会带来微小误差。舍入存在不同的方式:
IEEE 754 的默认模式。
对于二进制,需要找到两个数的中点并进行判断:
例如,10.00011 需要舍入到小数点后两位 (10.00 & 10.01):
10.00110.00011 is smaller than 10.00011, thus round down to 10.0010.00110 is bigger than 10.00011, thus round up to 10.0110.11100: compare with 10.11 and 11.00, the medium number is 10.111. 此时需要选择末尾是偶数(0) 的那一边,即 11.00 (round up)10.10100: compare with 10.10 and 10.11, the medium number is 10.101, 此时需要选择末尾是偶数(0) 的那一边,即 10.10 (round down)从统计学上,如此的舍入方式可以很好的减少舍入误差,我们也可以做一个模拟实验展示 Round Up, Round Down, Nearest Even Rounding 三种不同的舍入方式:


对齐阶码是为了保证指数位是相同的,此时指数部分更小的数的尾数部分会发生右移位操作(移位的目标是对齐到两个指数到其最大值),此时会发生截断误差。
对齐阶码后,尾数部分进行求和,具体的求和过程本质上就是无符号整数的求和过程。
接下来,对浮点数的运算结果进行正规化操作(保证其符合浮点数的形式),此时也会发生因为移位而导致的截断误差。
最终会进行舍入操作,将超过位数的部分通过 Round 的方式进行舍入。(为了保证精度,当 CPU 的算术逻辑单元(ALU)处理尾数相加时,它不会只用 23 位(float)来算。它会在右侧增加三个额外的位,这就是 GRS 位)
Mathematical Properties for Floating Point Additions
- 满足交换律
- 不满足结合律(考虑舍入误差)
float and double
Casting between int, float, and double changes bit representation.
double/float into int:
int into double:
int has less than 53 bit word size. (e.g. int64 will fail)int into float:
int has less than 23 bit word size. (int32 and int64 will fail)#include <stdio.h>
#include <limits.h>
#include <math.h>
void show_casting(){
// Casting between `int`, `float`, and `double` changes bit representation.
// 1. double/float into int: truncate fractional part (rounding towards zero)
printf("1. double/float to int (truncate towards zero):\n");
double d1 = 3.7;
double d2 = -3.7;
float f1 = 2.9f;
float f2 = -2.9f;
printf(" double 3.7 -> int: %d\n", (int)d1);
printf(" double -3.7 -> int: %d\n", (int)d2);
printf(" float 2.9 -> int: %d\n", (int)f1);
printf(" float -2.9 -> int: %d\n", (int)f2);
// Overflow case: out of range sets to INT_MIN (undefined behavior)
double d_overflow = 1.0e100;
int i_overflow = (int)d_overflow;
printf(" double 1e100 -> int: %d (overflow, UB)\n", i_overflow);
// NaN case: NaN sets to INT_MIN (undefined behavior)
double d_nan = NAN;
int i_nan = (int)d_nan;
printf(" double NaN -> int: %d (0, UB)\n\n", i_nan);
// 2. int into double: works as long as int has less than 53 bit word size
printf("2. int to double (works for < 53 bits):\n");
int i1 = 123456789;
long long ll_large = 9007199254740992LL; // 2^53, boundary case
double d_from_int = (double)i1;
double d_from_ll = (double)ll_large;
printf(" int 123456789 -> double: %.0f (exact)\n", d_from_int);
printf(" int64 2^53 -> double: %.0f\n", d_from_ll);
long long ll_very_large = 9007199254740993LL; // 2^53 + 1
double d_very_large = (double)ll_very_large;
printf(" int64 (2^53+1) -> double: %.0f\n\n", d_very_large);
// 3. int into float: works as long as int has less than 23 bit word size
printf("3. int to float (works for < 23 bits):\n");
int i2 = 1000000; // < 2^23, should work exactly
int i_large = 16777216; // 2^24, exceeds precision
float f_from_int = (float)i2;
float f_from_large = (float)i_large;
printf(" int 1000000 -> float: %.0f (exact)\n", f_from_int);
printf(" int 16777216 (2^24) -> float: %.0f\n", f_from_large);
int i_very_large = 16777217; // 2^24 + 1
float f_very_large = (float)i_very_large;
printf(" int 16777217 (2^24+1) -> float: %.0f\n", f_very_large);
}
int main() {
show_casting();
return 0;
}
1. double/float to int (truncate towards zero):
double 3.7 -> int: 3
double -3.7 -> int: -3
float 2.9 -> int: 2
float -2.9 -> int: -2
double 1e100 -> int: 2147483647 (overflow, UB)
double NaN -> int: 0 (0, UB)
2. int to double (works for < 53 bits):
int 123456789 -> double: 123456789 (exact)
int64 2^53 -> double: 9007199254740992
int64 (2^53+1) -> double: 9007199254740992
3. int to float (works for < 23 bits):
int 1000000 -> float: 1000000 (exact)
int 16777216 (2^24) -> float: 16777216
int 16777217 (2^24+1) -> float: 16777216
>>> torch.finfo(torch.float16)
finfo(resolution=0.001, min=-65504, max=65504, eps=0.000976562, smallest_normal=6.10352e-05, tiny=6.10352e-05, dtype=float16)
>>> torch.finfo(torch.float32)
finfo(resolution=1e-06, min=-3.40282e+38, max=3.40282e+38, eps=1.19209e-07, smallest_normal=1.17549e-38, tiny=1.17549e-38, dtype=float32)
>>> torch.finfo(torch.float64)
finfo(resolution=1e-15, min=-1.79769e+308, max=1.79769e+308, eps=2.22045e-16, smallest_normal=2.22507e-308, tiny=2.22507e-308, dtype=float64)
>>> torch.finfo(torch.bfloat16)
finfo(resolution=0.01, min=-3.38953e+38, max=3.38953e+38, eps=0.0078125, smallest_normal=1.17549e-38, tiny=1.17549e-38, dtype=bfloat16)
混合精度训练:使用FP16计算,使用FP32做模型权重更新
这样有两个好处:
IEEE-754 标准下的浮点数 ($M$ 位指数, $N$ 位尾数),可以近似连续的表示 $[2^{-2^{M-1} + 2 - N}, (2 - 2^{-N}) \times 2^{2^{M-1} - 1}]$ 范围内的浮点数值。
例如,BF16 的指数位比 FP16 的指数位多 3 个 bit,保证在整体维持低显存的条件下进一步降低精度,减少数值上溢和下溢的问题。
在实际运算中,对于一些需要高精度的操作内部(算子)还是需要高精度计算,例如 CrossEntropyLoss/LayerNorm/RMSNorm 等等。