探究 gcc 浮点数精度转换所使用的指令 —— use_vector_fp_converts 优化

在学习 浮点数精度转换指令CS:APP 里提到 gcc 并不使用 vcvtss2sd/vcvtsd2ss而是使用 vunpcklps & vcvtps2pd / vmovddup & vcvtpd2psx但是我使用 gcc 12.2.0 编译得到的结果就是 vcvtss2sd/vcvtsd2ss对此CS:APP 没有深究

It is unclear why GCC generates this code. There is neither benefit nor need to have the value duplicated within the XMM register.

但是 CS:APP 选择放弃反而会激发读者的斗志啊

虽然但是我确实没想到这玩意会让我断断续续搞了三天ddl 又要寄了 🌚

问题描述

这一问题可以由下面的代码所展示

double f2d(float x)
{
    return x;
}

float d2f(double x)
{
    return x;
}

double fp2d(float *x)
{
    return *x;
}

float dp2f(double *x)
{
    return *x;
}

它编译成汇编的结果有如下几种

函数 A B A with AVX2 B with AVX2
f2d cvtss2sd unpcklps & cvtps2pd vcvtss2sd vunpcklps & vcvtps2pd
d2f cvtsd2ss unpcklpd & cvtpd2ps vcvtsd2ss vmovddup & vcvtpd2psx
fp2d pxor & cvtss2sd movss & cvtps2pd vxorps & vcvtss2sd vmovss & vcvtps2pd
dp2f pxor & cvtsd2ss movq & cvtpd2ps vxorps & vcvtsd2ss vmovq & vcvtpd2psx

问题在于gcc 为什么/在什么情况下会生成如 B 所示的指令其中使用两条理解起来都不太容易的指令unpcklpscvtps2pd来代替指令集中自带的 cvtss2sd/cvtsd2ss 最令人费解而 source 为指针的另外两个函数是受 performance - Why don't GCC and Clang use cvtss2sd [memory]? - Stack Overflow 启发

我先尝试了各种关键词用搜索引擎进行搜索均未找到答案

gcc 版本的定位

使用 Compiler Explorer 进行尝试很快就能确认是在 gcc 4.8.5 / 4.9.0 之间行为出现了变化

于是我先看了 gcc 4.9 changes又在 commit log 里大力搜索 cvtss2sd cvtsd2ss unpcklp floating point convert……都没找到相关内容

具体 commit 的定位git bisect / 编译 gcc

在各种搜索都失败后我决定使用 git bisect 找到行为出现变化的 commit

要 bisect就得编译 gcc 4.8.5 ~ 4.9.0步骤大致如下

  1. 将 gcc 代码 clone 下来: git clone https://github.com/gcc-mirror/gcc --branch releases/gcc-4.9 --depth 50000
  2. 创建 build 目录
  3. build 目录下运行 gcc 仓库根目录的 configure 脚本
  4. build 目录下运行 make

其中configure 的配置可以参考 Installing GCC: Configuration - GNU Project但我只是 bisect 一下就没仔细研究

过程中走的弯路就不一一细说了只说一下最终的解决方案中遇到的几个主要问题

最终的编译命令为:

CC=gcc-4.8
CXX=g++-4.8
make distclean
../gcc/configure --enable-languages=c --disable-multilib --disable-libsanitizer
make -j$(nproc)

语言标准问题

因为编译的是多年前的 gcc 4.8~4.9用现在的编译器会遇到一些语言标准不同的问题修改编译选项大概能解决问题但改起来麻烦也不见得能解决所有问题不如直接用旧版 gcc 来编译

但是现在目标就是编译旧版 gcc你哪来的旧版 gcc 用来编译?

用旧版 gcc 解决编译旧版 gcc 遇到的问题看起来很矛盾但我们要解决的是编译两个版本之间的一堆 commit而获得一个用来编译的旧版 gcc 只需要一个 gcc release 的可执行文件这个用来编译的旧版 gcc 可以用各种方式下载一个而我用的是 Arch Linux就装了 AUR 里的 gcc48

获得了一个旧版 gcc 后运行 configure 时修改环境变量 CCCXX 即可使用

P.S. 我本来想用 AUR 的 PKGBUILD 来编译但能编译 release 不代表能编译各个 commit然后踩各种坑踩了半天..

struct ucontext

还会遇到一个编译错误: md-unwind-support.h:65:47: error: dereferencing pointer to incomplete type 'struct ucontext'

将相应代码中的 ucontext 修改为 ucontext_t 即可

reference: How to compile gcc 6.4.0 with gcc 7.2 in Archlinux - Stack Overflow

libsanitizer

还会遇到一些编译错误在搜索其中一个的解决方案时我找到了 一次令人吐血的 ubuntu 源码安装 gcc-5.4.0 经历_亿零贰肆的博客-CSDN 博客_ubuntu 安装 gcc5.4.0

这篇博客也提到了上面说的 struct ucontext 的问题而仔细一看就会发现剩下其他问题全都是 libsanitizer 里的而我研究这个问题不需要 libsanitizer直接 --disable-libsanitizer 就能解决这一堆编译错误还能缩短编译用时

make distclean

修改各种选项或者更换 commit 后如果直接 make 容易出问题可以先 make distclean 来重置

git bisect

在处理完上面几个问题之后编译就很顺利了在我 8C16T 的 AMD Ryzen 7 4800H 上编译一次大约需要 9min

需要注意的是因为原问题可以看成是在更老的版本出现即新版本 good 老版本 badgit bisect 默认是老版本 good 新版本 bad这里容易搞反需要注意git bisect 可以把 good/bad 改成 old/new但我懒得研究怎么改了

bisect 找到的 commit 是 915e8e6e

从这个 commit 的内容可以得知那些看起来有些奇怪的编译结果是一个叫做 use_vector_fp_converts 的优化的结果这个 commit 使得这个优化只对 amdfam10 架构生效在新版 gcc 中仍可使用 -mtune=amdfam10 选项观察到这一行为也可以使用 -mtune-ctrl=use_vector_fp_converts 来启用这个优化代码中对这一优化给出的理由是 avoids partial write to the destination也就是说 cvtss2sd/cvtsd2ss 只修改目标寄存器的低位可能导致效率低

优化最终被关闭的原因

锁定了问题出现的 commit接下来就是搜索这个 commit 修改的原因了

gcc-patches 里搜索 use_vector_fp_converts然后再顺着邮件内容找就可以找到相关邮件

令我自闭的是..直接搜索 cvtss2sd/cvtsd2ss 就能搜到这些邮件 😵 为什么 bisect 出来才想着在邮件列表里搜呢...低情商wssb高情商还是 mailing list 使用经验不足 😢

总结一下这些邮件的内容就是

  1. 在一些 Intel CPU 上某些 test case 上启用 use_vector_fp_converts 更快另一些 test case 上不启用更快
  2. 在启用 use_vector_fp_converts 更快的 test case 上可以通过在 cvtss2sd/cvtsd2ss 之前将 XMM 寄存器清空pxor %xmm0, %xmm0以避免只更新低位带来的性能损失从而达到和启用 use_vector_fp_converts 差不多的性能所以 157ca3e9 就对 m_CORE_ALL 关闭了这个优化并且在需要时先将 XMM 寄存器清空
  3. Re: [PATCH] disable use_vector_fp_converts for m_CORE_ALLHonza 表示可以先把上面那个 patch commit 了他测试一下再决定是否对 m_GENERIC 也关闭这个优化所以 157ca3e9m_CORE_ALL 关闭优化并在需要时清空 XMM和 915e8e6em_GENERIC 关闭优化分成了两个 commit

优化最初被添加的原因

继续追根溯源下去通过 git blame 来找到一开始添加这个优化的原因

首先找到 54723b46这个 commit 将 TARGET_USE_VECTOR_FP_CONVERTSTARGET_USE_VECTOR_CONVERTS 中抽离出来成为单独的优化开关

然后找到 4845dbb5这个 commit 添加了 X86_USE_VECTOR_CONVERTSX86_TUNE_USE_VECTOR_FP_CONVERTS 的前身

4845dbb5 的邮件是 SSE conversion optimization里面写的很简略就是 Amdfam10 preffers doing packed conversions destinating SSE register rather than scalar

只不过 performance - Why don't GCC and Clang use cvtss2sd [memory]? - Stack Overflow 里还是有一些解释的反正简单来说就是 partial regisiter update 会有性能损失

对 m_CORE_ALLm_GENERIC 启用优化的原因

可以发现一开始有这个优化时是只对 m_AMDFAM10 启用的这和现在是一样的为什么中间绕了一圈又回到最开始的选择呢?继续寻找对 m_CORE_ALLm_GENERIC 启用这个优化的原因

首先找到 3ad20bd4这个 commit 把相关代码挪了个位置

然后找到 3a579e09这个 commit 把 m_CORE2I7 改成了 m_CORE_ALL

然后找到 3a4ffde6这个 commit 修改了一堆处理器架构的 bitmask然后..把 m_AMDFAM10m_CORE2I7 换了个位置 🤔

然后找到 ab247762这个 commit 新增了 m_CORE2I7 架构并且为它启用了 X86_TUNE_USE_VECTOR_FP_CONVERTS这个 patch 的邮件是 0005-Switch-Core-2-to-new-tuning给出的原因是 Core 2/i7 比较适合使用 generic tuning而此时 X86_TUNE_USE_VECTOR_FP_CONVERTS 是对 m_GENERIC 启用的就也对 m_CORE2I7 启用了

这时再回头看上面找到的 54723b46这个 commit 将 TARGET_USE_VECTOR_CONVERTS || TARGET_GENERIC 改成了 TARGET_USE_VECTOR_FP_CONVERTS所以要 blame 这个 || TARGET_GENERIC

最后找到是 bf019a1f 添加了 || TARGET_GENERIC这个 patch 的邮件是 PR target/33396时间上紧跟着最初的 4845dbb5而 changelog 和邮件里只提到了添加 TARGET_SSE_MATH 而没有提到添加 TARGET_GENERIC 的原因并且这封邮件还没人回复

至此我已经不知道能如何继续探究下去了我感觉可能是

  1. SSE conversion optimization 中提到 We are now testing if the patch is good for generic可能他自己测试之后因为某些原因得到了这个优化 good for generic 的结论就 commit 了也没再解释
  2. 或者是PR target/33396 里提到了 failure with 32bit generic可能是测试的时候加上了 TARGET_GENERIC后来忘记删掉了 🤔
  3. 或者是有什么其他原因但忘记写在 log / 邮件里了或者写在某个隐秘的角落我没找到

反正不管是什么原因bf019a1f 都是一个 commit 做了两件事还只写了一件事的 changelog导致这成了一个谜

与其他编译器的对比

使用 Compiler Explorer 看一下其他编译器是怎么做的

clang: 无论是否 -mtune=amdfam10 都没有 use_vector_fp_converts

MSVC: 无论是否 -mtune=amdfam10 都是 source in register 则 cvtss2sd/cvtsd2sssource in memory 则表现出 use_vector_fp_converts 的行为

但是我并不知道如何研究其他编译器为什么做出这样的选择 😢

性能测试

最后来实际测试一下这个优化的性能

测试使用的代码为 [PATCH] disable use_vector_fp_converts for m_CORE_ALL 中的 1.c2.c但原来的 2.c 用时太短所以把循环范围改成了 1ll << 32

测试使用的编译选项有

  1. -O2 -mtune-ctrl=^use_vector_fp_converts
  2. -O2 -mtune-ctrl=^use_vector_fp_converts -mavx2
  3. -O2 -mtune-ctrl=use_vector_fp_converts
  4. -O2 -mtune-ctrl=use_vector_fp_converts -mavx2

其中值得注意的是使用 2 号编译选项编译 2.c 时会通过将 vcvtsd2ss 指令的第二个 operand 设为 %xmm1 来代替 pxor %xmm0, %xmm0 以达到清空 %xmm0 的效果

为了测试清空 XMM 寄存器的效果在这 4 种编译选项之外还对 2.c 增设了手动删掉用于清空 %xmm0 的指令的两份汇编代码

所以总共有 10 份汇编代码用于测试

  • 1-1: 1.c-O2 -mtune-ctrl=^use_vector_fp_converts
  • 1-2: 1.c-O2 -mtune-ctrl=^use_vector_fp_converts -mavx2
  • 1-3: 1.c-O2 -mtune-ctrl=use_vector_fp_converts
  • 1-4: 1.c-O2 -mtune-ctrl=use_vector_fp_converts -mavx2
  • 2-1: 2.c-O2 -mtune-ctrl=^use_vector_fp_converts
  • 2-2: 2.c-O2 -mtune-ctrl=^use_vector_fp_converts -mavx2
  • 2-3: 2.c-O2 -mtune-ctrl=use_vector_fp_converts
  • 2-4: 2.c-O2 -mtune-ctrl=use_vector_fp_converts -mavx2
  • 2-5: 2.c-O2 -mtune-ctrl=^use_vector_fp_converts然后删掉 pxor %xmm0, %xmm0
  • 2-6: 2.c-O2 -mtune-ctrl=^use_vector_fp_converts -mavx2然后将 vcvtsd2ss 的第二个 operand 改为 %xmm0

用于测试的机器有五台CPU 型号分别为

  • A: AMD Ryzen 7 4800H with Radeon Graphics 笔记本
  • B: Intel(R) Xeon(R) CPU E5-2670 v2 @ 2.50GHz Hostwinds
  • C: Intel(R) Xeon(R) CPU E5-4610 v2 @ 2.30GHz THU 校内服务器
  • D: Intel(R) Xeon(R) Platinum 8255C CPU @ 2.50GHz 腾讯云
  • E: Intel(R) Xeon(R) Platinum 8269CY CPU @ 2.50GHz 阿里云

测试时将程序运行 10 遍记录其中第 3 短的用时

测试结果为

代码/用时(s) A B C D E
1-1 2.10 3.15 3.96 4.62 4.41
1-2 2.10 3.11 3.95 4.61 4.42
1-3 3.50 3.82 4.52 5.30 5.04
1-4 3.50 3.77 4.90 5.30 5.04
2-1 1.62 6.47 7.22 3.59 3.43
2-2 1.41 6.26 7.65 4.31 4.08
2-3 1.61 4.66 5.31 3.24 3.11
2-4 1.61 4.76 5.69 3.59 3.43
2-5 1.40 9.06 10.41 7.12 6.77
2-6 1.41 9.03 10.98 7.11 6.77

这数据真的非常让人怀疑是不是测错了

只能说是大千世界无奇不有性能优化实在是太玄学了

但测试似乎表明source in register 时关闭 use_vector_fp_convertssource in memory 时开启 use_vector_fp_converts也就是 MSVC 的选择在总体上是比较优的