定点数优化:性能成倍提升

定点数这玩意儿并不是什么新东西,早年 CPU 浮点性能不够,定点数技巧大量活跃于各类图形图像处理的热点路径中。今天 CPU 浮点上来了,但很多情况下整数仍然快于浮点,因此比如:libcario (gnome/quartz 后端)及 pixman 之类的很多库里你仍然找得到定点数的身影。那么今天我们就来看看使用定点数到底能快多少。

简单用一下的话,下面这几行宏就够了:

#define cfixed_from_int(i)      (((cfixed)(i)) << 16)
#define cfixed_from_float(x)    ((cfixed)((x) * 65536.0f))
#define cfixed_from_double(d)   ((cfixed)((d) * 65536.0))
#define cfixed_to_int(f)        ((f) >> 16)
#define cfixed_to_float(x)      ((float)((x) / 65536.0f))
#define cfixed_to_double(f)     ((double)((f) / 65536.0))
#define cfixed_const_1          (cfixed_from_int(1))
#define cfixed_const_half       (cfixed_const_1 >> 1)
#define cfixed_const_e          ((cfixed)(1))
#define cfixed_const_1_m_e      (cfixed_const_1 - cfixed_const_e)
#define cfixed_frac(f)          ((f) & cfixed_const_1_m_e)
#define cfixed_floor(f)         ((f) & (~cfixed_const_1_m_e))
#define cfixed_ceil(f)          (cfixed_floor((f) + 0xffff))
#define cfixed_mul(x, y)        ((cfixed)((((int64_t)(x)) * (y)) >> 16))
#define cfixed_div(x, y)        ((cfixed)((((int64_t)(x)) << 16) / (y)))
#define cfixed_const_max        ((int64_t)0x7fffffff)
#define cfixed_const_min        (-((((int64_t)1) << 31)))
typedef int32_t cfixed;

类型狂可以写成 inline 函数,封装狂可以封装成一系列 operator xx,如果需要更高的精度,可以将上面用 int32_t 表示的 16.16 定点数改为用 int64_t 表示的 32.32 定点数。

那么我们找个浮点数的例子优化一下吧,比如 libyuv 中的 ARGBAffineRow_C 函数:

void ARGBAffineRow_C(const uint8_t* src_argb,
                     int src_argb_stride,
                     uint8_t* dst_argb,
                     const float* uv_dudv,
                     int width) {
  int i;
  // Render a row of pixels from source into a buffer.
  float uv[2];
  uv[0] = uv_dudv[0];
  uv[1] = uv_dudv[1];
  for (i = 0; i < width; ++i) {
    int x = (int)(uv[0]);
    int y = (int)(uv[1]);
    *(uint32_t*)(dst_argb) = *(const uint32_t*)(src_argb + y * src_argb_stride + x * 4);
    dst_argb += 4;
    uv[0] += uv_dudv[2];
    uv[1] += uv_dudv[3];
  }
}

这个函数是干什么用的呢?给图像做 仿射变换(affine transformation) 用的,比如 2D 图像库或者 ActionScript 中可以给 Bitmap 设置一个 3×3 的矩阵,然后让 Bitmap 按照该矩阵进行变换绘制:

基本上二维图像所有:缩放,旋转,扭曲都是通过仿射变换完成,这个函数就是从图像的起点(u, v)开始按照步长(du, dv)进行采样,放入临时缓存中,方便下一步一次性整行写入 frame buffer。

这个采样函数有几个特点:

  • 运算简单:没有复杂的运算,计算无越界,不需要求什么 log/exp 之类的复杂函数。
  • 范围可控:大部分图像长宽尺寸都在 32768 范围内,用 16.16 的定点数即可。
  • 转换频繁:每个点的坐标都需要从浮点转换成整数,这个操作很费事。

适合用定点数简单重写一下:(点击 Read more 展开)

void ARGBAffineRow_Fixed(const uint8_t* src_argb,
                     int src_argb_stride,
                     uint8_t* dst_argb,
                     const float* uv_dudv,
                     int width) {
  int32_t u = (int32_t)(uv_dudv[0] * 65536);  // 浮点数转定点数
  int32_t v = (int32_t)(uv_dudv[1] * 65536);
  int32_t du = (int32_t)(uv_dudv[2] * 65536);
  int32_t dv = (int32_t)(uv_dudv[3] * 65536);
  for (; width > 0; width--) {
    int x = (int)(u >> 16);  // 定点数坐标转整数坐标
    int y = (int)(v >> 16);
    *(uint32_t*)(dst_argb) = *(const uint32_t*)(src_argb + y * src_argb_stride + x * 4);
    dst_argb += 4;
    u += du;   // 定点数加法
    v += dv;
  }
}

局部用一下定点数都不需要定义前面那一堆宏,按相关原理直接写就是了。

我们用 llvm-mca 分析一下,浮点数版本 gcc 9.0 的循环主体部分用 -O3 的代码生成:

Iterations:        100
Instructions:      1300
Total Cycles:      458
Total uOps:        1500

Dispatch Width:    6
uOps Per Cycle:    3.28
IPC:               2.84
Block RThroughput: 3.0

Instruction Info:
[1]: #uOps
[2]: Latency
[3]: RThroughput
[4]: MayLoad
[5]: MayStore
[6]: HasSideEffects (U)

[1]    [2]    [3]    [4]    [5]    [6]    Instructions:
 2      6     1.00                        cvttss2si     eax, xmm1
 1      1     0.25                        add   rsi, 4
 1      4     0.50                        addss xmm1, xmm2
 2      6     1.00                        cvttss2si     edx, xmm0
 1      4     0.50                        addss xmm0, xmm3
 1      3     1.00                        imul  eax, r9d
 1      1     0.50                        shl   edx, 2
 1      1     0.25                        cdqe
 1      1     0.25                        add   rax, rdi
 1      5     0.50    *                   mov   eax, dword ptr [rax + rdx]
 1      1     1.00           *            mov   dword ptr [rsi - 4], eax
 1      1     0.25                        cmp   rsi, rcx
 1      1     0.50                        jne   .L3

链接:https://godbolt.org/z/HGPCHA

可以看到,虽然编译器自动生成了 sse 代码,但性能消耗的大户,cvttss2si(浮点数转整数指令),虽然只有一条命令,但会生成两个微指令(uOP),延迟 6 个周期,rthroughput 很高 1.0 代表每周期只能同时运行一条该指令,其次是加法指令 addss, 延迟是 4 个周期,吞吐量 0.5 代表每周期可以并行执行 2 条,该代码块模拟运行 100 次,总消耗 458 个周期。

再看定点数版本:

Iterations:        100
Instructions:      1500
Total Cycles:      337
Total uOps:        1500

Dispatch Width:    6
uOps Per Cycle:    4.45
IPC:               4.45
Block RThroughput: 2.5

Instruction Info:
[1]: #uOps
[2]: Latency
[3]: RThroughput
[4]: MayLoad
[5]: MayStore
[6]: HasSideEffects (U)

[1]    [2]    [3]    [4]    [5]    [6]    Instructions:
 1      1     0.25                        mov   eax, edi
 1      1     0.25                        mov   edx, ecx
 1      1     0.25                        add   rsi, 4
 1      1     0.25                        add   ecx, r11d
 1      1     0.50                        sar   eax, 16
 1      1     0.50                        sar   edx, 16
 1      1     0.25                        add   edi, ebx
 1      3     1.00                        imul  eax, r10d
 1      1     0.50                        shl   edx, 2
 1      1     0.25                        cdqe
 1      1     0.25                        add   rax, r9
 1      5     0.50    *                   mov   eax, dword ptr [rax + rdx]
 1      1     1.00           *            mov   dword ptr [rsi - 4], eax
 1      1     0.25                        cmp   rsi, r8
 1      1     0.50                        jne   .L8

链接:https://godbolt.org/z/Adj9gQ

指令虽然多了两条,但是整数指令 latency 比浮点要低,并且吞吐量(并行性)比浮点更好,大部分指令都是 0.25,代表每周期可以并行执行四条,模拟运行 100 次,总消耗 337 个周期。

这里你可能要问,整数版本,第二列 latency 加起来有 21 个周期啊,为什么平均运行一次才 3.37 个周期呢?这就是多级流水线中很多指令可以并行运行,只要没有运算结果依赖,以及没有执行单元的资源冲突,很多运算都是可以并行的,所以优化里,运算解依赖很有用。

我们给 llmv-mca 加一个 -timeline 参数,能得到流水线分析报表,这里截取两次循环:

[0,0] DeER . . . . . . . . . mov eax, edi
[0,1] DeER . . . . . . . . . mov edx, ecx
[0,2] DeER . . . . . . . . . add rsi, 4
[0,3] DeER . . . . . . . . . add ecx, r11d
[0,4] D=eER. . . . . . . . . sar eax, 16
[0,5] D=eER. . . . . . . . . sar edx, 16
[0,6] .DeER. . . . . . . . . add edi, ebx
[0,7] .D=eeeER . . . . . . . . imul eax, r10d
[0,8] .D=eE--R . . . . . . . . shl edx, 2
[0,9] .D====eER . . . . . . . . cdqe
[0,10] .D=====eER. . . . . . . . add rax, r9
[0,11] .D======eeeeeER. . . . . . . mov eax, dword ptr [rax + rdx]
[0,12] . D==========eER . . . . . . mov dword ptr [rsi - 4], eax
[0,13] . DeE----------R . . . . . . cmp rsi, r8
[0,14] . D=eE---------R . . . . . . jne .L8
[1,0] . DeE----------R . . . . . . mov eax, edi
[1,1] . D=eE---------R . . . . . . mov edx, ecx
[1,2] . D=eE---------R . . . . . . add rsi, 4
[1,3] . DeE---------R . . . . . . add ecx, r11d
[1,4] . D=eE--------R . . . . . . sar eax, 16
[1,5] . D=eE--------R . . . . . . sar edx, 16
[1,6] . D=eE--------R . . . . . . add edi, ebx
[1,7] . D==eeeE-----R . . . . . . imul eax, r10d
[1,8] . D==eE-------R . . . . . . shl edx, 2
[1,9] . D====eE----R . . . . . . cdqe
[1,10] . D=====eE---R . . . . . . add rax, r9
[1,11] . D======eeeeeER . . . . . . mov eax, dword ptr [rax + rdx]
[1,12] . D===========eER . . . . . . mov dword ptr [rsi - 4], eax
[1,13] . DeE-----------R . . . . . . cmp rsi, r8
[1,14] . D==eE---------R . . . . . . jne .L8

每条指令有下面几个生存周期:

D : Instruction dispatched.
e : Instruction executing.
E : Instruction executed.
R : Instruction retired.
= : Instruction already dispatched, waiting to be executed.
- : Instruction executed, waiting to be retired.
`````

可以看到无依赖的头四条指令:

```bash
[0,0] DeER . . . . . . . . . mov eax, edi
[0,1] DeER . . . . . . . . . mov edx, ecx
[0,2] DeER . . . . . . . . . add rsi, 4
[0,3] DeER . . . . . . . . . add ecx, r11d

从分发(D),执行(e),完成执行(E)到退休(R),基本都是同时进行的。后面两条指令虽然也是和前面四条一起分发(D),但由于依赖头两条指令的运算结果,所以产生了一个周期的等待(=):

[0,4] D=eER. . . . . . . . . sar eax, 16
[0,5] D=eER. . . . . . . . . sar edx, 16

等到头两个指令执行成功(e 结束),他们才能开始执行,第二次迭代 [1,0] 类似,多次迭代虽然用到了同样的寄存器,但是在 CPU 里 eax 只是个名字,CPU 对无关运算的寄存器进行重命名后,其实背后对应到了不同的寄存器地址,第二次迭代又有很多地方可以和第一次迭代并行执行,所以我们会发现两次迭代的最后一条指令 [0,14] 和 [1,14] 处理的退休时间 R 都差不多,两次循环几乎是并行执行的,如此多次循环平摊下来每次只要 3.37 个周期。

可以发现比浮点数版本的 4.58 个周期快了 35% 左右,注意一点,实际 I/O 操作会占用更多时间,所以在 mca 的分析里都标注了 MayLoad / MayStore,所以算上 I/O,两边的周期数都会略有增加,但是优势还在那里。

使用 mca 进行分析的时候,你把编译结果贴过去时,只能贴循环的主体部分,因为本身就要进行多次运行的流水线模拟,所以你贴了循环外的初始化部分就会干扰分析结果。

可能有人会说,妈呀,静态性能分析还要计算流水线么?其实大部分时候不需要,没有 llvm-mca 的时候我们做静态性能分析一般就是查指令手册:

大部分时候,看一下 uops 数量(越少越好),看一下周期 latency(越少越好),以及 throughput (决定并行效率,越低越好),心中对各类指令的占用消耗有一个基本概念,再细致一点的话还可以看看占用哪些硬件资源,p0156 代表可以再 0/1/5/6 几个硬件单元里任意一个执行,然后对着纸面代码进行一个大概评估。

后面有了 Intel IACA 以及 llvm-mca 后,自动化静态分析可以更加简单和准确。到这里,我们对仿射纹理映射做了一次性能静态分析,可以看得出定点数版本确实快,于是我们得到了一个性能更好的仿射纹理映射函数:

那么这样的静态分析准不准确呢?我们接着对两个函数进行动态性能评测:

链接:http://quick-bench.com/FqOYuExcXoyHe_r6Bl1oSm0wUPE

在 gcc 9.2 下面,定点数比浮点数快了 30%,比我们之前静态分析的结论类似(4.58 比 3.37),但差距略稍许偏低没有纯周期计算出来的 35% 性能差距那么高,因为两边都平摊了 I/O 操作引入的延迟(这部分 mca 没法计算进去)。

那么我们继续切换编译器,换成 clang 9.0 :

链接:http://quick-bench.com/RoUdH66MayHq6exmQ99mhODUN2w

可以看到性能提升了 2.2 倍,看代码生成,因为在 clang 下进行了矢量展开,定点数和浮点数都进行了循环展开,每轮循环一次性计算两个点,导致了更大的性能提升。

C 版本的定点数还可以继续用 SIMD 一次性算四个点的定点数坐标,性能应该还能提升一级。

定点数除了能在特定地方让你的代码性能提升数倍外,在很多对结果严格要求一致的地方,也会比浮点数更好,比如帧间同步的游戏,需要在 arm/x86 下面不同客户端保证同样的计算结果,这样浮点数就挂了,不同手机运算结果不同,只能用定点数来处理。

(PS:对于一些复杂运算比如 sin/cos 之类的三角函数,定点数一般用查表+插值进行)

最后,定点数是一个值得收藏到你编程百宝箱里的好工具,必要的时候能够帮到你。

Loading

About skywind

Putty 本无树,MinGW 亦非台
This entry was posted in 编程技术 and tagged . Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *