计算机小数存储原理

在写程序时,我们经常会处理小数这种数据。这节课,将会给同学们讲解下,在计算机中小数存储的两种方式:浮点小数和定点小数。学习目标:

  1. 了解浮点数的存储原理,以及其精度、缺点;
  2. 了解定点数的存储原理,以及与浮点数的转换规则、优缺点。

1. 浮点小数

浮点小数是程序中经常使用的一种数据类型,其使用非常广泛,特别是在科学计算、工程领域以及计算机图形学、人工智能等领域。

2.1 原理

在计算机中,浮点数常常采用 IEEE 754 标准来表示。这个标准定义了两种浮点数格式:单精度(32位)和双精度(64位)。

  1. 在单精度浮点数中,通常使用1位表示符号、8位表示指数和23位表示尾数;
  2. 在双精度浮点数中,通常使用1位表示符号、11位表示指数和52位表示尾数。

那么,当我们拿到一个小数时,是如何存储到这 32 个二进制位中?

我们以 3.75 为例,来说明下存储过程。

  1. 将 3.75 转换为二进制表示:\(11.11\)
  2. 二进制表示转换为科学计数法:\(1.111*2^1\)
  3. 我们这个数是正数,所以最高位符号位放 0,反之如果是负数,则最高位符号位放 1
  4. 中间 8 个比特位存储指数,即:\(2^1\)中的1,但是并不是直接把 1 放进去,这 8 个比特位是不包含符号位的,其只能表示正数,但是指数是可能存在负数的,所以,将其值加上 127 转换为正数即可,处理的时候再减去 127 就行。所以,这 8 个比特位实际存储的是:1+127=128,对应的二进制为:10000000
  5. 尾数部分是:1.111,这部分的值都是 1.xxx,所以为了节省了一个二进制位,这里只存储 111,处理的时候加上前面的 1 即可。
  6. 所以,3.75 实际在内存中存储为:0 10000000 11100000000000000000000

除二取余,倒序排列,高位补零

  1. 将要转换的整数不断除以2,直到商为0。
  2. 每次除法得到的余数(0或1)就是二进制数的一个位数,从下往上排列得到最终的二进制表示。

举个例子,将整数27转换为二进制:

步骤1:27 ÷ 2 = 13 余 1
步骤2:13 ÷ 2 = 6 余 1
步骤3:6 ÷ 2 = 3 余 0
步骤4:3 ÷ 2 = 1 余 1
步骤5:1 ÷ 2 = 0 余 1

将余数从最后一步开始排列,得到二进制数为 11011。

乘二取整

  1. 将小数乘以2,取结果的整数部分作为二进制数的一个位数。
  2. 将小数部分保留,再次乘以2,重复以上步骤直到小数部分为0或者达到所需的精度。

举个例子,将小数0.625转换为二进制:

步骤1:0.625 × 2 = 1.25,取整数部分1,小数部分保留为0.25
步骤2:0.25 × 2 = 0.5,取整数部分0,小数部分保留为0.5
步骤3:0.5 × 2 = 1,取整数部分1,小数部分为0,转换结束。

因此,0.625 的二进制表示为0.101。

我们通过一个程序例子来获得 3.75 在内存中真实的二进制位是不是和我们分析的一样,如下代码:

#include <iostream>
#include <stack>

void test()
{
    float value = 3.75;

    // 初始化 p 指针指向 value 的低地址位置(也是低位字节位置)
    unsigned char *p = (unsigned char *)&value;
    // 输出的二进制位是由低到高,通过栈翻转下输出
    std::stack<unsigned int> bit_values;

    // 遍历每一个字节
    for (unsigned int i = 0; i < sizeof(value); ++i)
    {
        // 从右向左获得每一个字节(8bit)的数据
        unsigned char temp = p[i];
        // 用于位与运算提取每个位的值
        unsigned char mask = 0x1;

        for (unsigned int j = 0; j < 8; ++j)
        {
            // 提取最低位的二进制位值
            unsigned int bit_value = temp & mask;
            // 存储二进制位置到栈容器中
            bit_values.push(bit_value);
            // 当前字节数据向右移动一个位置
            temp >>= 1;
        }
    }

    while (!bit_values.empty())
    {
        printf("%u", bit_values.top());
        bit_values.pop();
    }
}

int main()
{
    test();
}

程序输出结果:

# 输出结果
01000000011100000000000000000000
# 手算结果
01000000011100000000000000000000

[/expand]

2.2 浮动

浮点数中的 “浮点” 指的是小数点可以 “浮动”,即可以在数字中的不同位置。例如

  1. 浮点数 1234.5 表示为 1.2345 × 10^3。在这个表示中,1.2345 是尾数,而 10^3 是指数。这个指数表示了小数点向右移动了3 位;
  2. 浮点数 12.345 表示为 1.2345 × 10^1。在这个表示中,1.2345 是尾数,而 10^1 是指数。这个指数表示了小数点向右移动了 1 位。

2.3 精度

浮点数这种存储原理使其能够表示非常大或非常小的数值范围,但也引入了一些精度问题,因为在有限的位数内表示无限的实数范围是不可行的,这可能导致浮点数的舍入误差。

指数位可以为正负值,如果是一个很小的负数,例如:-127,则表示小数点向左移动了 127 位,这将会是一个非常小的数值。反之,如果是一个较大的正数,例如:127,则表示小数点向右移动了 127 位,这将会是一个非常大的数字。

尾数部分存储了数据中真实的各位的值,但是由于其位数有限,能表示的数值范围有限,故而又存在精度的问题。尾数的位数越多,则能表示的精度越高。

2.4 缺点

  1. 运算复杂性:浮点数的计算通常由专门的浮点数处理单元(FPU)或浮点数部件执行。这些部件需要复杂的电路设计和逻辑,以支持浮点数的加法、减法、乘法、除法等操作,这就导致浮点数运算通常需要更多的指令和周期来完成,与整数运算相比,这增加了计算的开销。
  2. 浮点数精度:浮点数通常使用有限的位数来表示,例如单精度浮点数使用32位表示,双精度浮点数使用64位表示。这种有限精度可能会导致舍入误差,并降低计算的准确性。
  3. 数据存储开销:浮点数通常需要 4 或 8 个字节存储,对于存储海量浮点数场景,开销是非常昂贵。

2. 定点小数(Fixed Point)

定点小数是一种用固定位数表示小数部分的数值表示方法。在定点小数中,小数点的位置是固定的,不像浮点数那样可以在数字中的不同位置浮动。

2.1 存储原理

使用定点数存储小数,我们首先得先确定:

  1. 使用多少字节内存空间进行存储,1个字节、2个字节、还是其他多少个字节
  2. 这些二进制位中,符号位占多少位,整数位占多少位,小数位占多少位

假设:使用单字节(8个二进制位)存储定点小数,其中:

  1. 符号位:1
  2. 整数位:4
  3. 小数位:3

如下图所示:

此时,小数点的位置就固定了。其能够表示的数值范围为:-15 ~ 15,小数位表示的数值范围为:-0.875 ~ 0.875,所以其能表示的小数范围为:-15.875 ~ 15.875。

需要注意的是:

  1. 小数点并不占二进制位
  2. 小数点位置并没有标准规定
  3. 整数位多了,小数位则就会变少

接下来,通过一个例子来了解浮点小数 3.75 如何存储到定点数空间中:

  1. 符号位: 0
  2. 整数位:0011
  3. 小数位:110

即:浮点小数 3.75 使用定点小数存储后位:0 0011 110

定点数的存储方式更为简单,为什么计算机不默认使用这种方式?
这是由于定点数表示的数值范围太小了,进行小数运算时很容易溢出。

2.2 浮点数转换

定点小数和浮点小数之间可以进行相互转换,计算公式如下:

其中:

  1. (int) 表示取整操作,可以向下取整,当然也可以向上取整,当然也可以四舍五入;
  2. Q 是定点数表示中的小数位数。

例如:浮点小数 3.75 转换为 8 位(单字节)的定点小数,其中1个符号位,4个整数位,3个小数位,计算如下:

  1. \((int)3.75*2^{3}=30\),即:使用 8 位定点数表示为:30,二进制表示为:0 0011 110
  2. \((float)30*2^{-3}=3.75\),即: 使用浮点数(float32)表示为:3.75,二进制表示为:0 10000000 11100000000000000000000

需要注意:浮点数在转换为定点数的过程中会产生误差。比如:0.78 的按照前面设定的 8 位定点数表示为:6.24≈6,再将定点数转换为浮点数为:0.75,存在 0.03 的误差。

2.3 相较浮点数

  1. 内存占用优势:定点数通常需要更少的内存空间来存储。
  2. 数据传输优势:定点数需要更少的位数来表示相同的数值范围和精度,这意味着在数据传输过程中需要传输的数据量更少。
  3. 计算速度优势:定点数的计算通常比浮点数更快。因为定点数的计算通常可以在硬件层面更高效地执行,而且不涉及额外的指数部分的计算。定点数的计算速度可能会更快,并且在一些硬件架构中,可能会有专门的指令集来优化定点数的计算。

我们现在对于大模型使用的量化技术,其原理也是将模型中的 float32 类型参数转换为 int8、int16 的定点小数,从而实现更小的空间占用、更快的模型推理。

3. 小节

最后,我们来总结下这两种存储方式:

  1. 定点小数:
    • 定点小数是一种简单而直观的表示方法,其中小数点的位置是固定的。
    • 通过将固定数量的位分配给整数部分和小数部分,可以实现定点小数的表示。
    • 定点小数通常用于对小数部分精度要求不高的场景,例如在货币计算或传感器数据处理中。
  2. 浮点小数:
    • 浮点小数采用科学计数法表示实数,由尾数和指数两部分组成。
    • 尾数表示数字的有效位数,而指数表示基数的放大因子。
    • 浮点数能够表示非常大或非常小的数,并且在保持一定精度的同时,能够覆盖较广的数值范围。
    • 浮点数通常用于需要高精度和大范围数值计算的领域,如科学计算和工程领域。