ACM中常常遇到卡精度的问题,卡精度可能因为取整、比较相等、高精度等多种原因。
这里通过一个例子试图探讨两个编译器的浮点数运算实现机制以及类型转换的机制,以及使用不同编译器和使用不同指令集在取整上的卡精度的问题。
问题描述
江科大的nomasp同学在刷Leetcode(171:Excel Colmun Number)的时候发现了一个问题。他的代码简化后如下:
1 |
|
使用Mingw32 GCC/G++(4.7.2)编译(g++.exe cpp_path -o exepath -std=c++11 -g3 -static-libstdc++ -static-libgcc -g3)后,输出如下:
使用MSVC140(VS2015)编译后,输出如下:
稍有常识的人都会看出,GCC的结果是错误的。
问题分析
有同学认为pow函数是个浮点函数,因此应当转成int之后再相加减,但实际试验之后发现这个问题仍然存在,结果是:
经过分析,如果手动实现pow函数则GCC的结果是对的。因此定位到调用pow函数这边出了问题。因此取一下测试代码分析。
1 |
|
在GCC下编译运行得:
在VS2015下编译运行得:
可以发现对GCC,当使用float变量直接初始化int时发生窄化,不过不影响结果。但是将这个float变量换成pow函数后就会影响结果。谦谦同学指出在GCC520版本下源代码也能正常运行。他进而提出pow函数返回的是double,在转成int的时候需要先转成float再转成int。根据这个意见,我测试了下面的代码,如果说会先转换成float,那么yy
的值应当为55,因为从double到float的过程中精度丢失了。
1 | int main(){ |
不过结果是55 54 54
,看来从double到int并不会经过float。不过在他的提示下,我把pow函数改成powf函数,发现返回值正常了。既然如此,我又测试了下面的代码
1 |
|
按照上面的理论,这里应该输出675,但结果却是正确的676。因此现在的差别实际上就来自pow函数,我倾向于是一个RVO问题。
为了进一步研究原因,需要比较对pow函数的实现以及类型转换机制。
MSVC对pow的实现
MSVC中我们实际上使用的是double pow<int, int>(int _Left, int _Right);
这个重载版本。
首先定位到头文件xtgmath.h
:
1 | //xtgmath.h |
可以看到最后调用一个参数是_Common_float_type
的pow函数,那这个函数在哪里呢,我们首先来看一下_Common_float_type
这个类型。
1 | //xtr1common.h |
这边说明一下这段代码:std::is_integral
用来判断一个类型是否是整数std::conditional
有三个参数,其作用相当于_Test? _Ty1: _Ty2
,其中_Ty1
和_Ty2
都是类型,而std::enable_if
相对std::conditional
省略了假的情况。std::is_same
用来判断_Ty1
和_Ty2
是否相同类型。这个是模板编程里面常用到的元函数。_Promote_to_float
用来将integral(整数)类型扩成double类型,如果是浮点数则不变。说到扩展,也挺有意思的,例如char
在被爆之后是直接变成int
,还有符号扩展和0扩展啥的,可以查看CSAPP P49页。_Common_float_type
意思就是
1 | if (_Ty1 is long double || _Ty2 is long double) |
用来给浮点数之间运算结果选择适合的精度
下面我们开启调试,跟踪pow函数执行。在调试中,在断点_CSTD pow(type(_Left), type(_Right))
处发现type(_Left)
和type(_Right)
都已经通过_Common_float_type
变成了double
,继续跟踪发现直接调用了math.h中的pow。
特别地,对于c mode,pow
实际直接调用了pow(double, double)
。
此外还注意到<cmath>
中有一个的_Check_return_ inline double pow(_In_ double _Xx, _In_ int _Yx)
函数,而这个函数实际上调用了<cmath>
中的_Pow_int
函数,该函数如下:
1 | template<class _Ty> |
这段代码主要就是处理指数为int的情况,其使用的是类似于快速幂的方法得到的结果。不过经过测试,这段代码始终没有被调用的情况,为什么标准库不使用这个重载版本,我想附录里面的一段答案应该能够给我们启发。
对于以上代码,我想最重要的就是_Common_float_type
,它避免了GCC472版本的类型二次转换的错误,直接调用对应类型的重载版本。
MSVC对类型转换的实现
为了分析_pow
函数,需要先了解一下MSVC对于类型转换的实现方案
double -> int
1 | ; int i = pow(26, 2); |
这里我们发现如果pow参数都为int则是调用pow<int, int>,而这个pow<int, int>是通过xtgmath.h中的pow函数实现的:
1 | _C_STD_BEGIN |
cvtsi2sd
来自SSE2,负责取出最低位的64位整型,并将其转换为一个浮点值,存放到xmm0
浮点寄存器中。mmword
负责将xmm0
内的浮点移到[esp]
所以pow<int, int>
实现也是先转换成浮点再调用_pow
对于__ftol2_sse
函数底层是调用cvtsi2sd
函数的,相比之下由于double是有符号而且表示范围要大于int,因此需要额外加一些处理。所以实际上通过调用cvttsd2si
函数进行了类型转换。
float -> int
1 | ; int i = powf(26, 2); |
MSVC对_pow函数的实现
下面分析_pow
函数
首先win7并没有装MSVC 140的库,编译成静态IDA导入不了PDB,都是天书。于是再在win10下面使用ollydbg来调试,然而并找不到_main
,只看到_cinit
函数包含的_initterm
,只好靠调用关系和参数传递硬找。
过程 | 图片 | 解释 |
---|---|---|
进入main函数 | ||
main函数 | ||
pow函数 | ||
pow函数 | 跳转使用SSE指令集计算pow | |
pow函数返回 | ||
pow函数返回后准备调用类型转换函数 | ||
使用cvttsd2si的类型转换函数 | 第一行的cmp指令由于不等于0,所以使用cvttsd2si而不是fistp,注意和后面gcc使用fistp进行比较 | |
类型转换的结果 | od_win10_vs_after_main_exit | |
退出main函数 | 可以看到exit和_cexit函数 |
GCC对pow的实现
GCC版本之间差别比较大,我们这里还以GCC 472(Mingw32)分析。并简化了函数
1 |
|
跟踪pow函数,发现实际调用了/MinGW32/lib/gcc/mingw32/5.7.2/include/c++/cmath中的函数
1 | ... |
的重载std::pow<int, int>(__x = 26, __y = 2)
汇编代码如下
1 | ... |
其中fildl
表示往st(浮点数操作堆栈)栈顶放入一个长整数,fstpl
是取出一个长整型数
然后调用了crt中的pow
函数的汇编代码
1 | 0x74cd34b0 <+0>: cmpl $0x0,0x74cf6d84 |
其中stmxcsr
将MXCSR存储到32位寄存器,modf
将数分解为整数部分和小数部分。
在0x74cd34b7 <+7>处程序进入msvcrt!_CrtDbgReportWV+84
。然后在其中某一个modf方法中卡死。于是很奇怪为什么要用到msvcrt!_CrtDbgReportWV
这个函数
后来我试图用IDA来调试,不过win10 把我的安装程序杀掉了,所以我试图在win7里面用IDA调试,首先装完DevCPP之后进行调试,发现了不一样的光景。
crt中pow
函数的代码变成了这样!
1 | => 0x7613608f <+0>: cmpl $0x0,0x761b50c0 |
反汇编居然不一样了!那到底哪个结果是对的呢?
用IDA调试发现GCC编译结果连main函数都不知道在哪里,到处都是sub_xxxx的无名函数,于是使用OD进行调试,至少能看清楚先后调用关系
下面是调试过程
过程 | 图片 | 解释 |
---|---|---|
进入main函数 | 前面要先经过两个jmp | |
进入pow<int, int>函数 | ||
进入CRT中的pow函数的跳转 | 这里是一个jmp跳转 | |
进入CRT中真正的pow函数 | 可以和WIN10和WIN7下的DEVCPP的反汇编进行对比 | |
CRT中pow函数返回 | 注意右边寄存器表中的ST0的值是正确的26**2==676.00 |
|
printf 前的结果 |
注意到eax实际上存放着printf的参数int i2 ,但是变成了整数0x02A3==675 而不是676 |
所以应该是在浮点数变成整数这块出了问题
先解释几个汇编指令
汇编 | 解释 |
---|---|
fstcw | 存储FPU控制字到一个内存区域 |
fldcw | 逆运算 |
fistp | 存储ST(0)到整数并弹出寄存器堆栈 |
下面来看main函数中是如何将FPU中的运算结果拿到eax中并提供给printf输出的
过程 | 图片 | 解释 |
---|---|---|
从pow<int, int>函数跳出 | 发现此时运算结果还在ST(0)中,并且fstcw指令从[esp+1e]处存取浮点控制字,fisttp指令将ST(0)存放到[esp+1c]==0x0028ff1c处 | |
运行完fistp检查内存块0x0028ff1c | 发现此时结果变为2a3==675,于是应该是fistp指令出现了问题 |
总结
MSVC 140和GCC472(MinGW)使用了不同的汇编指令进行类型转换,导致出现问题。GCC使用了x87 FPU指令而MSVC 140使用了SSE指令集。
如果改成这样的方式
1 |
|
输出结果在GCC472版本上也是正确的了
Postscript
本文参考了在 cplusplus.com 上guestgulkan给出的这样的解答:
This is actually quite interesting and works differently on Microsoft Visual Studio 2008 and Dev C++(using mingw);
- Microsoft Visual Studio 2008 cmath is basically a wrapper that calls math.h.
In math.h if running in C mode you only get one power function pow(double, double).
In C++ mode (which we are using) you get the c++ overloaded functions:
long double pow(long double,int), float pow(float,int), double pow(double,int) and a few others.
So calling pow(int, int) for example pow(3,2) will always fail due to ambiguity whether you include cmath or math.h - DEV C++ with MINGW
With this set up, math.h just contains the the usual C function
pow(double, double) - so all the functions work because with pow(int, int) both ints get promoted to double by compiler and all is OK
cmath in more than a wrapper for math.h. First it includes math.h and then undefines a whole lot of stuff that math.h defined, and substitutes the c++ versions.
This includes the pow function declaration.
As the c++ overloaded functions (same as any other c++ compiler), you will get the ambiguity problem - when using pow(int, int).
P.S The ambiguity occurs with pow(int, int) because integers can be promoted to floats or doubles, which means that pow(int, int) can fit any of the 6 or so overloaded c++ pow function - so the compiler gets confused.
对于标准库对pow函数的处理,stackoverflow上的enigmaticPhysicist给出了这样的回答:
A specialisation of pow(x, n) to where n is a natural number is often useful for time performance. But the standard library’s generic pow() still works pretty (surprisingly!) well for this purpose and it is absolutely critical to include as little as possible in the standard C library so it can be made as portable and as easy to implement as possible. On the other hand, that doesn’t stop it at all from being in the C++ standard library or the STL, which I’m pretty sure nobody is planning on using in some kind of embedded platform.