c - 如何指示 MSVC 编译器使用 64 位/32 位除法而不是较慢的 128 位/64 位分法



如何告诉 MSVC 编译器使用 64 位/32 位除法运算来计算 x86-64 目标的以下函数的结果:

#include <stdint.h> 
uint32_t ScaledDiv(uint32_t a, uint32_t b) 
{
if (a > b)
return ((uint64_t)b<<32) / a;   //Yes, this must be casted because the result of b<<32 is undefined
else
return uint32_t(-1);
}

我希望当if语句为真时,代码编译为 64 位/32 位除法运算,例如:

; Assume arguments on entry are: Dividend in EDX, Divisor in ECX
mov edx, edx  ;A dummy instruction to indicate that the dividend is already where it is supposed to be
xor eax,eax
div ecx   ; EAX = EDX:EAX / ECX

。但是,x64 MSVC 编译器坚持使用 128 位/64 位div指令,例如:

mov     eax, edx
xor     edx, edx
shl     rax, 32                             ; Scale up the dividend
mov     ecx, ecx
div rcx   ;RAX = RDX:RAX / RCX

请参阅: https://www.godbolt.org/z/VBK4R71

根据这个问题的答案,128位/64位div指令并不比64位/32位div指令

这是一个问题,因为它不必要地减慢了我的DSP算法,该算法使数百万个这样的缩放除法。

我通过修补可执行文件以使用 64 位/32 位div 指令来测试此优化:根据rdtsc指令生成的两个时间戳,性能提高了 28%。

(编者注:大概是在最近的一些英特尔CPU上。 AMD CPU不需要这种微优化,如链接的问答中所述。

当前没有编译器(gcc/clang/ICC/MSVC)会从可移植的ISO C源进行此优化,即使您让它们证明b < a商将适合32位。 (例如 GNU Cif(b>=a) __builtin_unreachable();在 Godbolt 上)。 这是一个错过的优化;在修复之前,您必须使用内部函数或内联 ASM 来解决它。

(或者改用 GPU 或 SIMD;如果许多元素具有相同的除数,请参阅 SIMD https://libdivide.com/计算一次乘法逆运算并重复应用。


_udiv64从Visual Studio 2019 RTM开始提供。

在C模式(-TC)中,它显然总是被定义的。 在C++模式下,您需要#include <immintrin.h>,按照Microsoft文档。 或intrin.h.

https://godbolt.org/z/vVZ25L(或者在 Godbolt.ms 上,因为最近在 Godbolt 主站点上的 MSVC 不起作用1.)

#include <stdint.h>
#include <immintrin.h>       // defines the prototype
// pre-condition: a > b else 64/32-bit division overflows
uint32_t ScaledDiv(uint32_t a, uint32_t b) 
{
uint32_t remainder;
uint64_t d = ((uint64_t) b) << 32;
return _udiv64(d, a, &remainder);
}
int main() {
uint32_t c = ScaledDiv(5, 4);
return c;
}

_udiv64将产生 64/32 格。左右两个移位是错过的优化。

;; MSVC 19.20 -O2 -TC
a$ = 8
b$ = 16
ScaledDiv PROC                                      ; COMDAT
mov     edx, edx
shl     rdx, 32                             ; 00000020H
mov     rax, rdx
shr     rdx, 32                             ; 00000020H
div     ecx
ret     0
ScaledDiv ENDP
main    PROC                                            ; COMDAT
xor     eax, eax
mov     edx, 4
mov     ecx, 5
div     ecx
ret     0
main    ENDP

所以我们可以看到 MSVC 不做 通过_udiv64进行常量传播 ,即使在这种情况下它不会溢出并且它可以main编译为仅mov eax, 0ccccccccH/ret


更新 #2https://godbolt.org/z/n3Dyp- 添加了带有英特尔C++编译器的解决方案,但这效率较低,并且会破坏常量传播,因为它是内联 asm。

#include <stdio.h>
#include <stdint.h>
__declspec(regcall, naked) uint32_t ScaledDiv(uint32_t a, uint32_t b) 
{
__asm mov edx, eax
__asm xor eax, eax
__asm div ecx
__asm ret
// implicit return of EAX is supported by MSVC, and hopefully ICC
// even when inlining + optimizing
}
int main()
{
uint32_t a = 3 , b = 4, c = ScaledDiv(a, b);
printf( "(%u << 32) / %u = %un", a, b, c);
uint32_t d = ((uint64_t)a << 32) / b;
printf( "(%u << 32) / %u = %un", a, b, d);
return c != d;
}

脚注1:Matt Godbolt的主站的非WINE MSVC编译器暂时(?)消失了。 Microsoft运行 https://www.godbolt.ms/以在真正的Windows上托管最新的MSVC编译器,通常主 Godbolt.org 站点中继到MSVC站点。

似乎 godbolt.ms 会产生短链接,但不会再次扩展它们! 无论如何,完整链接对链接腐烂的抵抗力更好。

@Alex Lopatin的答案显示了如何使用_udiv64来获取不可怕的标量代码(尽管MSVC愚蠢地错过了向左/向右移动的优化)。

对于支持 GNU C 内联 asm(包括 ICC)的编译器,您可以使用它来代替低效的 MSVC 内联 asm 语法,该语法在包装单个指令方面有很多开销。 请参阅"asm"、"__asm"和"__asm__"之间有什么区别?以包装 64 位/32 位 => 32 位idiv为例。 (只需将助记符和类型更改为 unsigned,即可将其用于div。 GNU C 没有 64/32 或 128/64 除法的内在;它应该优化纯 C。 但不幸的是,GCC/Clang/ICC甚至使用if(a<=b) __builtin_unreachable();来承诺a>b也错过了这种情况的优化。


但这仍然是标量除法,吞吐量相当差。

也许您可以使用 GPU 来完成您的 DSP 任务? 如果您有足够大的工作批次(并且算法的其余部分对 GPU 友好),那么与 GPU 的通信往返开销可能是值得的。

如果您使用的是 CPU,那么我们可以建议的任何内容都将受益于跨多个内核的并行化,因此这样做可以提高吞吐量。


x86 SIMD (SSE4/AVX2/AVX512*) 在硬件中没有 SIMD 整数除法。 英特尔 SVML 函数_mm_div_epu64_mm256_div_epu64不是实际指令的内在函数,它们是慢速函数,可能会解压缩为标量或计算乘法逆函数。 或者他们使用的任何其他技巧;32位除法函数可能转换为double的SIMD向量,特别是如果AVX512可用。 (英特尔仍然称它们为"内联函数",也许是因为它们就像内置函数,它理解并且可以进行恒定传播。 它们可能尽可能高效,但这"不是很",它们需要处理一般情况,而不仅仅是一个除数的低半部分全部为零且商拟合为 32 位的特殊情况。

如果许多元素具有相同的除数,请参阅SIMD https://libdivide.com/计算乘法逆运算一次并重复应用。 (你应该调整这种技术来烘焙股息的转移,而不是实际这样做,留下全零的低半隐含。

如果您的除数总是变化的,并且这不是某些较大的 SIMD 友好算法中的中间步骤,那么如果您需要确切的结果,标量除法可能是您最好的选择。


如果 24 位尾数精度足够,使用 SIMDfloat可以获得很大的加速

uint32_t ScaledDiv(uint32_t a, uint32_t b) 
{
return ((1ULL<<32) * (float)b) / a;
}

(float)(1ULL<<32)是一个编译时常量4294967296.0f

这确实在数组上自动矢量化,即使没有-ffast-mathgcc 和 clang(但不是 MSVC),也可以使用 gcc 和 clang。 在Godbolt上看到它。 您可以将 gcc 或 clang 的 asm 移植回 MSVC 的内在;他们使用一些 FP 技巧将无符号整数打包转换为没有 AVX512 的浮点数。 在 MSVC 上,非矢量化标量 FP 可能比普通整数慢,并且准确性较低。

例如,Skylake 的div r32吞吐量为每 6 个周期 1 个。 但它的AVXvdivps ymm吞吐量是每5个周期一条指令(8float秒)。 或者对于 128 位 SSE2,divps xmm每 3 个周期有一个吞吐量。因此,您可以在Skylake上从AVX获得大约10倍的除法吞吐量。(8 * 6/5 = 9.6) 较旧的微架构具有慢得多的 SIMD FP 除法,但整数除法也稍慢一些。 一般来说,这个比率较小,因为较旧的 CPU 没有那么宽的 SIMD 分频器,因此 256 位vdivps必须分别运行 128 位的一半。 但是仍然有很多收获,比如比哈斯韦尔的4倍更好。 锐龙的吞吐量vdivps ymm6c,但div 3214-30 个循环的吞吐量。 所以这是一个比Skylake更大的加速。

如果您的其余 DSP 任务可以从 SIMD 中受益,则整体加速应该非常好。float操作具有更高的延迟,因此无序执行必须更加努力地隐藏独立循环迭代的延迟和重叠执行。 因此,IDK 是否只转换为浮点数并返回此操作,或者更改算法以在任何地方使用float会更好。 这取决于您还需要如何处理您的数字。


如果您的无符号数字实际上适合有符号的 32 位整数,则可以使用直接硬件支持进行打包的 SIMD int32 -> 浮点转换。 否则,您需要AVX512F打包uint32-> 使用单个指令进行浮点运算,但这可以通过一些效率损失来模拟。 这就是 gcc/clang 在使用 AVX2 自动矢量化时所做的,也是 MSVC自动矢量化的原因。

MSVC 确实使用int32_t而不是uint32_t进行自动矢量化(gcc/clang 可以制作更高效的代码),因此如果无法设置整数输入和/或输出的最高位,则更喜欢这样做。 (即 2 对其位模式的补码解释将是非负的。

特别是对于 AVX,vdivps足够慢,可以在很大程度上隐藏从整数转换和转换回来的吞吐量成本,除非有其他有用的工作可以重叠。


浮点精度:

float将数字存储为有效位数在[1.0, 2.0)范围内significand * 2^exp。 (或[0, 1.0)次正常值)。 单精度float具有 24 位的有效和精度,包括 1 位隐式位。

https://en.wikipedia.org/wiki/Single-precision_floating-point_format

因此,可以表示整数的 24 个最高有效数字,其余数字因舍入误差而丢失。 像(uint64_t)b << 32这样的整数对float来说没有问题;这只是意味着一个更大的指数。 低位均为零。

例如,b = 123105810为我们提供了b64 << 32528735427897589760。 直接从 64 位整数转换为float得到我们528735419307655168,舍入误差为 0.0000016%,或大约 2^-25.8。 这并不奇怪:最大舍入误差是 0.5ulp(最后一位的单位),或 2^-25,而这个数字是偶数,无论如何它都有 1 个尾随零。 这与我们从转换123105810中获得的相对误差相同;生成的float也相同,除了其指数字段(高 32)。

(我用 https://www.h-schmidt.net/FloatConverter/IEEE754.html 来检查这个。

float的最大指数足够大,可以容纳INT64_MININT64_MAX范围之外的整数。float可以表示的大整数的低位都是零,但这正是你对b<<32所拥有的。 因此,在最坏的情况下,您只会丢失低 9 位b,因为它是全频和奇数。

如果结果的重要部分是最高有效位,并且在转换回整数后具有低~9整数位=舍入误差是可以的,那么float非常适合您。

如果float不起作用,double可能是一种选择。

在许多CPU上,divpd的速度大约是divps的两倍,并且只执行一半的工作(2个double元素而不是4个float)。 因此,您以这种方式损失了 4 倍的吞吐量。

但是每个 32 位整数都可以精确地表示为double通过转换回截断到零,我认为您可以获得所有输入对的精确整数除法,除非双舍五入是一个问题(首先到最接近的double,然后截断)。 你可以用

// exactly correct for most inputs at least, maybe all.
uint32_t quotient = ((1ULL<<32) * (double)b) / a;

无符号长长常数(1ULL<<32)转换为double,因此你有 2x u32 -> 双转换(ab)、双乘、双除和双 -> u32 转换。 x86-64 可以通过标量转换有效地完成所有这些工作(通过将uint32_t扩展到int64_t中,或忽略双>int64_t转换的高位),但它可能仍然比div r32慢。

转换 u32 -> 双精度和返回(没有 AVX512)可能比转换 u32 ->浮点更昂贵,但 clang确实会自动矢量化它。 (只需在上面的 godbolt 链接中将float更改为double)。 同样,如果您的输入都是<= INT32_MAX的,这将有很大帮助,因此它们可以被视为 FP 转换的有符号整数。

如果双重舍入是一个问题,如果您不将 FP 用于运行 DSP 代码的线程中的其他任何内容,则可以将 FP 舍入模式设置为截断,而不是默认的舍入到最接近。

最新更新