算法篇 — Scalar Quantization

Posted by Xun on Friday, February 2, 2024

标量量化(Scalar Quantization)是数字信号处理中一个基本而重要的概念,它在数据压缩和信号表示方面发挥着关键作用。

简介

  • 游戏开发过程中,数据读取和存储必不可少的环节,通常会要求读取的速度要快。然而,对于数据量较大的情况,还会对数据大小进行考量。尤其是在移动设备上,数据文件大小不仅影响安装包的大小,同时还会影响内存的占用大小。因此,开发者通常会对数据进行一定程度的调整,增加量化的过程,将多个数据合并成一个,读取时再对其进行解码,从而实现对数据的压缩。
  • 将一组连续的实数映射到一组整数,这个过程称为量化。通过一组间隔将数据进行划分,并为每个间隔分配一个整数值来进行量化。而当需要解码时,将每个整数映射回表示间隔的标量值。

整数量化

  • 整型数据在内存中的表示为: ScalarQuantization_3.png
  • 对整数进行量化往往比较简单,通常会以二进制或十进制格式进行处理。例如:
    • 有个大小为 1024 x 1024 的地图,每个格子有 2 种加成效果,每种加成效果有不同的数值。
      • 1-攻击加成 :0 ~ 99
      • 2-防御加成 :0 ~ 99
      • 3-速度加成 :0 ~ 99
      • 4-生命加成 :0 ~ 99
  • 为了存储整个地图的每种加成数据,需要一个二维数组,即:
    uint[][] buff1 = new uint[1024][1024];
    uint[][] buff2 = new uint[1024][1024];
    uint[][] buff3 = new uint[1024][1024];
    uint[][] buff4 = new uint[1024][1024];
    for(int y = 0; y < 1024; y++)
    {
        for(int x = 0; x < 1024; x++)
        {
            buff1[x][y] = 90;
            buff2[x][y] = 80;
            buff3[x][y] = 70;
            buff4[x][y] = 60;
        }
    }
  • 可以看到,整个地图的加成数据,使用的 uint 大小为 1024 * 1024 * 4 * 4 / 1024 / 1024 = 16 MB 。为了对数据进行压缩,可以进行量化,量化方式可以以十进制或者二进制方式进行。

十进制

  • 以十进制量化,相对比较直观,由于加成值为 0 ~ 99 ,可以使用每 2 位作为保存,即 ScalarQuantization_1.png
    uint[][] buff = new uint[1024][1024];
    for(int y = 0; y < 1024; y++)
    {
        for(int x = 0; x < 1024; x++)
        {
            buff[x][y] = buff1[x][y] * 100 ^ 0 + buff2[x][y] * 100 ^ 1 + buff3[x][y] * 100 ^ 2 + buff4[x][y] * 100 ^ 3
        }
    }
  • 十进制的每两位用于保存一个 buff 加成数据,读取的时候再根据 buff 所在的位进行读取,例如,buff2 使用百位和千位记录,则读取方式为:
    int x = 100;
    int y = 100;
    uint buff2_100_100 = buff[100][100] % 10000 / 100;

二进制

  • 二进制量化,和十进制量化类似,也是按位进行划分,使用每 8 位保存一个数据,即 ScalarQuantization_2.png
    uint[][] buff = new uint[1024][1024];
    for(int y = 0; y < 1024; y++)
    {
        for(int x = 0; x < 1024; x++)
        {
            buff[x][y] = buff1[x][y] | (buff2[x][y] << 8) | (buff3[x][y] << 16) | (buff4[x][y] << 24)
        }
    }
  • 二进制的每八位用于保存一个 buff 加成数据,范围为 0 ~ 127,读取方式为:
    int x = 100;
    int y = 100;
    uint buff2_100_100 = buff[100][100] & (127 << 8);

浮点数量化

内存表示

  • 浮点型数据的在内存中的表示为: ScalarQuantization_4.png ScalarQuantization_5.png
  • 相比与整型,浮点型将值分为指数部分和有效数字部分。
    • 指数有正负,采用 The Biased exponent(有偏指数),根据 IEEE754 规定,2 ^ (e - 1) - 1 对应的的值为 0 ,其中 e 为指数的位数。大于这个值的为正数(即小数点左移),小于这个值的为负数(即小数点右移)。
      • 对于单精度浮点数,2 ^ (8 - 1) - 1 = 127 为 0 。
      • 对于双精度浮点数,2 ^ (11 - 1) - 1 = 1023 为 0 。
    • 有效数字(significand)部分,IEEE754 规定,在二进制数中,将小数点前面的值固定为 1,因此可以省略不存,只存小数点后的数,而可以多存 1 位,这种形式的浮点数为规范化浮点数。例如:
      • 十进制数 : 0.625
      • 二进制数 : 0.101
        • 十进制小数部分 * 2,大于 1 则当前位为 1 ,否则为 0 。继续取小数部分计算,直到小数部分为 0 。
        • 转为十进制时,即每一位使用 2 ^ n 表示后进行累加,其中小数点后的 n 为负数。
      • 内存中 : 0 0111 1110 0100 0000 0000 0000 0000 000
        • 正数,符号位为 0。
        • 小数点右移 1 位,小数点前为 1 ,指数部分为 127 - 1 = 126 ,即 0111 1110 。
        • 有效数字部分,即小数点移动后,小数点后的数,即 0100 0000 0000 0000 0000 000 。
  • 当数字较小时,小数部分的后面几位对整体的影响较大,随着数字的逐渐增大,影响会越来越小,有效数字中代表小数部分的位数也会越来越小,这种情况下的精度误差通常也符合实际使用需求,也能被接受。

误差

  • 根据浮点数的二进制转化规则,可以发现,一个简单的十进制数,在内存中不一定能准确表示,如:
    • 十进制数 : 0.1
    • 二进制数 : 0.0001 1001 1001 1001 1001 1001 1001 1001 …
    • 内存中 : 0 0111 1011 1001 1001 1001 1001 1001 101 (即 0.1000 0000 1490 1161 19)
  • 可以看到,0.1 在二进制中的表示,是一个无限循环的数,而内存中的位数是有限的,所以超出的位数只能舍去。这也是浮点数计算中总会出现误差的根本原因。
  • 假设小数部分的存储方式和整数部分一样,n 也为非负数,小数点后有限位数为 3 ,则对应二进制位数需要为 10 ,其中最后一位为 0 ,则:
    • 十进制数 : 0.1
    • 二进制数 : 0.0001 1001 00
    • 内存中 : 0 0111 1011 0000 0000 0000 0000 0000 000
  • 可以发现,在这种方式下,大部分小数都能够没有误差地表示,因为使用了乘法替代了除法,从而使得有限位数的小数部分得以连续表示,某种程度上来说,更加便于开发者理解和使用。
  • 然而,随着数字的增大,当有效数字部分仅剩最后一位代表小数部分时,则有:
    • 内存中 : 0 1001 0101 0000 0000 0000 0000 0000 000
      • 二进制范围 :1000 0000 0000 0000 0000 000.0000 0000 00 ~ 1000 0000 0000 0000 0000 000.0111 1111 11
      • 十进制范围 : 4194304.0 ~ 4194304.511
    • 内存中 : 0 1001 0101 0000 0000 0000 0000 0000 001
      • 二进制范围 :1000 0000 0000 0000 0000 000.0111 1111 11 ~ 1000 0000 0000 0000 0000 000.1111 1001 11
      • 十进制范围 : 4194304.512 ~ 4194304.999
  • 此时,内存中连续的两个值,代表了不同范围的区间,所以这种方式可能会导致其他更糟糕的误差情况出现。然而,如果使用浮点数的方式,则
    • 内存中 : 0 1001 0101 0000 0000 0000 0000 0000 000
      • 二进制范围 :1000 0000 0000 0000 0000 000.0000 0000 … ~ 1000 0000 0000 0000 0000 000.0111 1111 …
      • 十进制范围 : 4194304.0 ~ 4194304.499…
    • 内存中 : 0 1001 0101 0000 0000 0000 0000 0000 001
      • 二进制范围 :1000 0000 0000 0000 0000 000.1000 0000 … ~ 1000 0000 0000 0000 0000 000.1111 1111 …
      • 十进制范围 : 4194304.5 ~ 4194304.999…
  • 因此,对于单精度浮点数来说,有效数字最大能表示 2 ^ (23 + 1) - 1 = 16777215 ,即只能保证 7 位有效数字,双精度浮点数最大能 2 ^ (52 + 1) - 1 = 9007199254740991 ,即能保证 15 位有效数字。

量化方式

  • 浮点数量化的编码方式有:
    • T :截断,即把输入值乘以缩放因子,截断结果的小数部分,得到整数值。
    • R :四舍五入,即把输入值乘以缩放因子,对结果四舍五入,得到整数值。
  • 解码方式有:
    • L :左重建,即把编码后的整数值,除以缩放因子,得到浮点数值。
    • C :中心重建,即把编码后的整数值,加上 0.5 后,再除以缩放因子,得到浮点数值。

TL

  • TL 的实现大致如下:
    const int NSTEPS = 64;
    int encoded = (int)(input * NSTEPS);
    if (encoded > NSTEPS - 1)
    {
        encoded = NSTEPS - 1;
    }
    const float STEP_SIZE = (1.0f / NSTEPS);
    float decoded = encoded * STEP_SIZE;
  • 其中,NSTEPS 为缩放因子,input 为 [0 , 1] 区间的输入值,encoded 为编码后的值,decoded 为解码后的值。
  • 示例数据如下:
输入值(input) 编码值(encoded) 解码值(decoded) 偏差值
0.0 0 0.0 0.0
0.1 6 0.09375 -0.00625
0.2 12 0.1875 -0.0125
0.3 19 0.296875 -0.003125
0.4 25 0.390625 -0.009375
0.5 32 0.5 0
0.6 38 0.59375 -0.00625
0.7 44 0.6875 -0.0125
0.8 51 0.796875 -0.003125
0.9 57 0.890625 -0.009375
1.0 63 0.984375 -0.015625
  • 从表中数据可以看到,对输入值执行 TL 过程的最终结果是将它们分流到原始值的左侧。然而,这种向左偏移在大多数应用场景中都是不好的,主要有两个原因:
    • 结果通常会向 0 偏移,代表这个过程会偏向于能量损失。
    • 最终浪费了存储空间(或带宽)。

ScalarQuantization_6.png

  • TL 的量化图示如上图所示,其中白色点代表量化编码的整数结果值,蓝色点代表量化解码后的浮点数结果值,红色箭头表示两个相邻结果值之间的所有数的编码或解码映射方向。

TC

  • TC 的实现大致如下:
    const int NSTEPS = 64;
    int encoded = (int)(input * NSTEPS);
    if (encoded > NSTEPS - 1)
    {
        encoded = NSTEPS - 1;
    }
    const float STEP_SIZE = (1.0f / NSTEPS);
    float decoded = (encoded + 0.5f) * STEP_SIZE;
  • 其中,NSTEPS 为缩放因子,input 为 [0 , 1] 区间的输入值,encoded 为编码后的值,decoded 为解码后的值。
  • 示例数据如下:
输入值(input) 编码值(encoded) 解码值(decoded) 偏差值
0.0 0 0.0078125 0.0078125
0.1 6 0.1015625 0.0015625
0.2 12 0.1953125 -0.0046875
0.3 19 0.3046875 0.0046875
0.4 25 0.3984375 -0.0015625
0.5 32 0.5078125 0.0078125
0.6 38 0.6015625 0.0015625
0.7 44 0.6953125 -0.0046875
0.8 51 0.8046875 0.0046875
0.9 57 0.8984375 -0.0015625
1.0 63 0.9921875 -0.0078125
  • 可以看到,TC 会增大一部分输入值,也会减小一部分输入值,但如果对很多随机值进行编码,这些编码值解码后的平均值,会趋于输入值的平均值,即总能量基本不变。
  • 然而,TC 有一个明显的问题,可能会比 TC 更糟糕,即无法表示 0 。也就是说,当输入值为 0 时,会得到一个很小的正数。如果用于表示速度,当对静止的对象的数据进行保存和加载时,静止的对象就变成在慢慢移动,会造成错误的结果。 ScalarQuantization_7.png
  • TC 的量化图示如上图所示。

RL

  • RL 的实现大致如下:
    const int NSTEPS = 64;
    int encoded = (int)(input * (NSTEPS - 1) + 0.5f);
    if (encoded > NSTEPS - 1)
    {
        encoded = NSTEPS - 1;
    }
    const float STEP_SIZE = (1.0f / (NSTEPS - 1));
    float decoded = encoded * STEP_SIZE;
  • 其中,NSTEPS - 1 为缩放因子,input 为 [0 , 1] 区间的输入值,encoded 为编码后的值,decoded 为解码后的值。
  • 示例数据如下:
输入值(input) 编码值(encoded) 解码值(decoded) 偏差值
0.0 0 0.0 0.0
0.1 6 0.09524 -0.00476
0.2 13 0.20635 0.00635
0.3 19 0.30159 0.00159
0.4 25 0.39683 -0.00317
0.5 32 0.50794 0.00794
0.6 38 0.60317 0.00317
0.7 44 0.69841 -0.00159
0.8 51 0.80952 0.00952
0.9 57 0.90476 0.00476
1.0 63 1.0 0.0
  • RL 具有和 TC 一样的输出结果,但对应的输入值不一样。RL 可以表示 0 ,通过将缩放因子设置为 NSTEPS - 1 ,还可以表示 1 。
  • 如果缩放因子仍为 NSTEPS ,接近 1 的值将得到比输入范围中的其他数字更向下映射,因此会引入比更多的错误,并且会出现能量减小的轻微趋势。
  • 然而,为了达到与 TC 相同的误差目标,即整数代表的间隔相同,则 NSTEPS 需要加 1 ,使得带宽增大。如果 NSTEPS 很大,此时加 1 带来的额外代价很小,但如果 NSTEPS 很小,带宽增量就会比较明显,此时需要在 RL 和 TC 之间做选择。当不想考虑太多情况时,RL 是默认最安全、最稳定的方法。 ScalarQuantization_8.png
  • RL 的量化图示如上图所示,其中虚线部分即为额外需要的 1 个区间范围。

RC

  • RC 的实现大致如下:
    const int NSTEPS = 64;
    int encoded = (int)(input * (NSTEPS - 1) + 0.5f);
    if (encoded > NSTEPS - 1)
    {
        encoded = NSTEPS - 1;
    }
    const float STEP_SIZE = (1.0f / (NSTEPS - 1));
    float decoded = (encoded + 0.5f) * STEP_SIZE;
  • 其中,NSTEPS - 1 为缩放因子,input 为 [0 , 1] 区间的输入值,encoded 为编码后的值,decoded 为解码后的值。
  • 示例数据如下:
输入值(input) 编码值(encoded) 解码值(decoded) 偏差值
0.0 0 0.00794 0.00794
0.1 6 0.10317 0.00317
0.2 13 0.21429 0.01429
0.3 19 0.30952 0.00952
0.4 25 0.40476 0.00476
0.5 32 0.51587 0.01587
0.6 38 0.61111 0.01111
0.7 44 0.70635 0.00635
0.8 51 0.81746 0.01746
0.9 57 0.91270 0.01270
1.0 63 1.00794 0.00794
  • 和 TL 类似,最终的结果都比输入值大,然而,这样的结果会比 TL 更加糟糕,因为会出现能量增加。能量损失能保证系统存在上限,而能量增加则往往会出现系统爆炸,所以通常不会考虑使用这种方式。 ScalarQuantization_9.png
  • RC 的量化图示如上图所示。

浮点数组合

  • 整数量化中,通过减小整数的取值范围,可以将几个整数组合成一个整数值进行存储,使用时再根据每个数据使用的位进行解析。同样,浮点数也可以采用这种方式进行数据压缩。
  • 对于两个浮点数,最容易考虑的方式,就是使用十进制的方式进行,即将浮点数放大,保留整数部分,将两个整数部分进行整合。然而,浮点数的有效数字位数只有 23 + 1 位,即每个浮点数编码后只能使用 12 位保留有效数字,当小数点后最多保留 3 位,对于 [0, 4] 区间内的值则能够进行表示。实现代码如下:
    float a = 0.123f;
    float b = 0.456f;
    float c = 0.123 * 1000000 + 0.456 * 1000;
    float a = Mathf.Floor(c / 1000) / 1000;
    float b = Mathf.Floor(c % 1000) / 1000;
  • 如果想要表示更大的范围,则需要使用二进制方式进行处理。前面提到,0.1 在内存中的表示为 0 0111 1011 1001 1001 1001 1001 1001 101 ,则
基础值 放大值 内存表示
0.1 2 ^ 0 0 0111 1011 1001 1001 1001 1001 1001 101
0.1 2 ^ 1 0 0111 1100 1001 1001 1001 1001 1001 101
0.1 2 ^ 10 0 1000 0101 1001 1001 1001 1001 1001 101
0.1 2 ^ 20 0 1000 1111 1001 1001 1001 1001 1001 101
  • 可以看到,浮点数乘上 2 的次幂时,就是指数部分发生了变化,有效数字部分保持不变,因此在二进制下的处理方式为:
    • 对输入值乘上 2 的次幂,进行放大,调整小数点的位置。
    • 对浮点数进行取整,将小数点后的有效数字清 0 。
    • 将一个浮点数乘上 2 ^ 12 ,进行移位操作,再与另一个浮点数相加,得到最终结果。
  • 编码和解码的示例代码如下:
    private float Encode(float x, float y)
    {
        double x0 = (int)(x * 512);
        double y0 = (int)(y * 512);
        return (float)((x0 * 4096) + y0);
    }

    private (float, float) Decode(float v)
    {
        float x = Mathf.Floor(v / 4096);
        float y = v - 4096 * x;
        float scale = 0.001953125f; // 1.0f / 512
        return (x * scale, y * scale);
    }
  • 以 0.1 为例,编码过程如下:
    • 进行放大后,在内存中为 0 1000 0100 1001 1001 1001 1001 1001 101 ,此时指数部分为 132 ,即小数点需要右移 132 - 127 = 5 位。
    • 进行取整后,在内存中为 0 1000 0100 1001 1000 1000 0000 0000 000 ,此时有效数字部分为 1 1001 1000 1000 0000 0000 000 (首位 1 为浮点数省略表示的)。
    • 进行移位后,在内存中为 0 1001 0000 1001 1000 1000 0000 0000 000 。
    • 对两个浮点数进行相加,即对有效数字部分进行相加:
      • 1 | 1001 1000 1000 0000 0000 000 | 0 0000 0000 000
      • 0 | 0000 0000 0001 1001 1000 100 | 0 0000 0000 000
      • 1 | 1001 1000 1001 1001 1000 100 | 0 0000 0000 000
    • 最终结果为 0 1001 0000 1001 1000 1001 1001 1000 100 。
  • 解码过程如下:
    • 将编码值进行移位,得到 0 1000 0100 1001 1000 1001 1001 1000 100 ,此时指数部分为 132 ,即小数点需要右移 132 - 127 = 5 位。
    • 向下取整,得到放大后的第一个浮点数,为 0 1000 0100 1001 1000 1000 0000 0000 000 。
    • 计算另一个放大后的浮点数,指数部分为 1001 0000 ,即 144 ,同样使用有效数字部分进行相减:
      • 1 | 1001 1000 1001 1001 1000 100
      • 1 | 1001 1000 1000 0000 0000 000
      • 0 | 0000 0000 0001 1001 1000 100
    • 有效数字首位设置为 1 ,则指数部分为 144 - 12 = 132 ,因此另一个浮点数为 0 1000 0100 1001 1000 1000 0000 0000 000 。
    • 将两个浮点数进行缩小,得到最终值,为 0 0111 1011 1001 1000 1000 0000 0000 000 ,即 0.0997314453125 。
  • 从上面的过程可以看到,两个浮点数分别使用 12 个有效数字位。假设输入的浮点数,二进制下整数部分占 3 位,小数部分占 21 位,则按照编码过程放大 512 (2 ^ 9),则整数部分占 12 位,小数部分占 12 位,整数部分刚好能完全保留,第 13 ~ 24 位则全部置为 0 。而如果输入的浮点数整数部分占 4 位,则第 13 位不为 0 ,进行有效数字部分相加时,就会出现第一个浮点数的第 13 位的值加到第二个浮点数的第 1 位上,出现进位时,还会影响第一个浮点数的第 12 位,即两个浮点数的数据都受到影响,尤其是第二个浮点数,如:
    • 1 | 1001 1000 1001 0000 0000 000 | 0 0000 0000 000
    • 0 | 0000 0000 0001 1001 1000 100 | 1 0000 0000 000
    • 1 | 1001 1000 1010 1001 1000 100 | 1 0000 0000 000
  • 此时,两个有效数字为 1 1001 1000 101 和 0 1001 1000 100 ,可以看到第二个有效数字已经出现了很大的偏差,无法解码出正确的结果。
  • 因此,编码过程的放大值,需要根据浮点数的大小进行调整。假设浮点数的整数部分最大值为 2 ^ n ,则放大值最大为 2 ^ (12 - n) ,其中 n <= 12 。需要注意,随着放大值减小,小数部分舍弃的有效值位数越多,则最终解码得到的值偏差会越来越大,需要根据具体使用环境选择合适的 n 值。
  • 使用 RL 方式进行调整,则代码实现为:
    private float Encode(float x, float y)
    {
        double x0 = (int)(x * 511);
        double y0 = (int)(y * 511);
        return (float)((x0 * 4096) + y0);
    }

    private (float, float) Decode(float v)
    {
        float x = Mathf.Floor(v / 4096);
        float y = v - 4096 * x;
        float scale = 1.0f / 511;
        return (x * scale, y * scale);
    }

参考