sqrt
函数的实现,它结合使用IEEE 754,压缩位域和Newton-Raphson算法:decompose.h
:#ifndef DECOMPOSE_H
#define DECOMPOSE_H 1
#ifdef __cplusplus
extern "C" {
#endif
#include <float.h>
#define MANTISSA_SIZE 52
#define EXPONENT_SIZE 11
#define EXPONENT_BIAS 0x3ff
#define TYPE double
#define TYPE_MIN DBL_MIN
#define TYPE_MAX DBL_MAX
#define NAME_SFX(name) name
typedef unsigned long long mantissa;
typedef unsigned short exponent;
typedef unsigned char sign;
#include "decompose_generic.h"
#ifdef __cplusplus
}
#endif
#endif
decompose_generic.h
:#ifndef DECOMPOSE_GENERIC_H
#define DECOMPOSE_GENERIC_H 1
#ifdef __cplusplus
extern "C" {
#endif
#define SIGN_SIZE 1
union decompose {
TYPE f;
struct decompose_s {
mantissa m:MANTISSA_SIZE;
exponent e:EXPONENT_SIZE;
sign s:SIGN_SIZE;
} __attribute__((packed)) o;
};
static inline union decompose decompose(TYPE f)
{
union decompose result;
result.f = f;
return result;
}
#define is_nan(x) NAME_SFX(isnan)(x)
#ifdef __cplusplus
}
#endif
#endif
sqrt.c
:#include <math.h>
#include <errno.h>
#include "decompose.h"
TYPE NAME_SFX(sqrt)(TYPE a)
{
TYPE result_old2 = 0;
unsigned short c = 0;
union decompose r = decompose(a);
if(a < -(TYPE)0)
{
errno = EDOM;
return NAME_SFX(nan)("");
}
if(a == (TYPE)0 || is_nan(a) || a > TYPE_MAX)
{
return a;
}
/* Divide the exponent by 2 */
r.o.e -= EXPONENT_BIAS;
r.o.e = (r.o.e & 1) | (r.o.e >> 1);
r.o.e += EXPONENT_BIAS;
/* Newton-Raphson */
while(result_old2 != r.f)
{
if(c % 2 == 0) result_old2 = r.f;
r.f -= (r.f*r.f-a)/(2*r.f);
c++;
}
return r.f;
}
我在寻找什么/>
是否存在效率问题?
是否存在精度问题?
还有其他需要改进的杂项吗?
据我测试,这是尽可能准确的。它甚至与GNU C库中的结果匹配。
一个注意事项:这是模块化系统的一部分。每个浮点类型(
decompose.h
,float
,double
)都会有一个long double
,并且它们都包含decompose_generic.h
。不是那么新,以至于我要添加Beginner标签。#1 楼
该答案使用指针广播进行类型优化,以节省空间。实际上,请继续使用联合(在ISO C99中安全,在C ++中作为GNU和MSVC扩展安全)或memcpy(在C和C ++中安全)。仅在MSVC或具有-fno-strict-aliasing
的GNU兼容编译器中,这种指针广播才是安全的。初始逼近打包位字段不仅在这里不是必需的,而且会使结果更糟。您可以执行以下操作:
uint64_t i = *(uint64_t*)&f; // double to int
i = i - (1023 << 52); // remove IEEE754 bias
i = i >> 1; // divide exponent by 2
i = i + (1023 << 52); // add IEEE754 bias
如果更改操作顺序,则可以在单个常量中处理偏差:
uint64_t i = *(uint64_t*)&f; // double to int
i = i >> 1; // divide exponent by 2
i = i + (1023 << 52) - (1023 << 51); // remove/add IEEE754 bias
常量可以简化为:
uint64_t i = *(uint64_t*)&f; // double to int
i = (i >> 1) + (1023 << 51); // all of the above
请注意,移位时我并没有遮掩,所以指数是最右边的位落入尾数,尾数也会移动。这是一个功能,而不是错误。解释为整数时,故意将IEEE754格式选择为单调的,因此明确允许指数和尾数之间的残留。
按我的近似,映射如下(区间内的线性映射):
original number is exponent manitssa orig interval
exponent in interval in sqrt first bit maps to
0 [ 1.0, 2.0 ) 0 0 [ 1.0, 1.5 )
1 [ 2.0, 4.0 ) 0 1 [ 1.5, 2.0 )
2 [ 4.0, 8.0 ) 1 0 [ 2.0, 3.0 )
3 [ 8.0, 16.0 ) 1 1 [ 3.0, 4.0 )
4 [ 16.0, 32.0 ) 2 0 [ 4.0, 6.0 )
使用您的代码,映射是(在间隔内也是线性的):
original number is exponent orig interval
exponent in interval in sqrt maps to
0 [ 1.0, 2.0 ) 0 [ 1.0, 2.0 )
1 [ 2.0, 4.0 ) 0 [ 1.0, 2.0 )
2 [ 4.0, 8.0 ) 1 [ 2.0, 4.0 )
3 [ 8.0, 16.0 ) 1 [ 2.0, 4.0 )
4 [ 16.0, 32.0 ) 2 [ 4.0, 8.0 )
Newton- Raphson
给出一个很好的近似值,Newton-Raphson将每次迭代的有效位数翻倍(二次收敛)。上面的近似值提供了大约4位精度(最大误差:6%或〜1/16),因此单精度需要3个Newton-Raphson迭代,而双精度则需要4个迭代。
整个代码可能看起来像这样:
float sqrtf(float x)
{
float y = x;
// Approximation
uint32_t* i = (uint32_t*)&x;
*i = (*i >> 1) + (127 << 22);
// Newton-Raphson
x = (x + y/x) / 2;
x = (x + y/x) / 2;
x = (x + y/x) / 2;
return x;
}
double sqrt(double x)
{
double y = x;
// Approximation
uint64_t* i = (uint64_t*)&x;
*i = (*i >> 1) + (1023 << 51);
// Newton-Raphson
x = (x + y/x) / 2;
x = (x + y/x) / 2;
x = (x + y/x) / 2;
x = (x + y/x) / 2;
return x;
}
次常态和其他危害
您已经对
nan
,inf
,0
和x < 0
进行了特殊处理,这很好,但是我们也需要处理非正规数。您和我的代码在遇到这样的数字时都会失败。作为补救措施,我建议您通过2^200
缩放次正规数,并通过2^-100
缩放结果。我之所以选择2^200
,是因为它可以安全地将所有次法线恢复到所有精度(包括四精度)的正常范围内,但其他数字也能很好地工作(但只有两个的幂才能保持全精度)。还要注意我的代码依赖于IEEE754属性。它不适用于非IEEE754数字,尤其是x86的80位
extended double
格式。如果要处理此类数字,可以将它们转换为float
或double
进行近似计算,然后将其转换为extended double
进行牛顿-拉夫森计算。评论
\ $ \ begingroup \ $
这不适用于80位长的double,后者不能表示为整数。
\ $ \ endgroup \ $
–S.S. Anne
19-09-24在17:59
\ $ \ begingroup \ $
还请注意,这违反了严格的别名,是未定义的行为。
\ $ \ endgroup \ $
–S.S. Anne
19-09-24在19:39
\ $ \ begingroup \ $
最坏情况下,您的近似值降低了41%,大约是1位。 (大约是6%)-@ JL2210
\ $ \ endgroup \ $
– Rainer P.
19 Sep 24 '19:48
\ $ \ begingroup \ $
*(uint64_t *)&f在ISO C中是未定义的行为。它仅在MSVC或gcc -fno-strict-aliasing中定义良好。 ,@ JL2210使用联合来进行类型双关,因为这在ISO C99中已定义明确。 (但在C ++中仅作为GNU扩展。他们可能应该使用memcpy,因为如果它们被编译为C ++,它们会费心地包含extern“ C” {}东西)
\ $ \ endgroup \ $
– Peter Cordes
19-09-25在0:22
\ $ \ begingroup \ $
@ JL2210和Rainer ::具有80位长双精度数的另一个问题是有效位数(aka尾数)中的前导1或0是显式的,而不是指数字段非零所暗示。 en.wikipedia.org/wiki/…因此,将指数从尾数移到尾数可能是个大问题,可能会创建一个非标准化的非标准化值。我忘了在那种情况下x86 / x87硬件会做什么。如果您使用的是C ++,则可以使用std :: bitset
\ $ \ endgroup \ $
– Peter Cordes
19-09-25在0:32
#2 楼
仅供参考,在IEEE754sqrt
中是“基本”操作,需要正确舍入(舍入误差<= 0.5ulp),与+-* /相同。硬件FPU(我认为)总是提供sqrt,如果它们提供其他操作,尤其是通常以类似方式实现(并且具有类似性能)的除法。每个涉及除法的NR迭代都不会更快。比硬件sqrt。我认为这就是为什么x86的近似指令用于倒数和rsqrt而不是sqrt的部分原因:1/sqrt(x)
的牛顿迭代公式不涉及除法运算,因此在与mulps相比sqrtps非常慢的旧CPU上,值得使用sqrt(x) ~= x * approx_rsqrt(x)
,还可以选择使用对rox_rsqrt结果进行牛顿迭代。也许有时仍然值得在不执行其他任何操作的循环中进行此操作,但通常您应该争取更多的计算强度(在寄存器中热数据时对数据进行更多处理;不要编写仅加载/ sqrt /存储的循环)。 br /> IDK,如果代码可能会收敛到不是正确舍入结果的值,但是您应该通过检查实现的
sqrt
函数来进行测试。(以其他方式调用您的函数,例如在GCC中,除非您使用
sqrt
,否则-fno-builtin-sqrt
是一个特殊名称。调用者将在具有x86 sqrtsd
之类的目标上内嵌sqrt指令,仅对需要设置errno的输入调用libc函数。或永远不要使用-fno-math-errno
进行编译,这总是一个好主意。尽管GNU C确实支持Math-errno,但并非所有libcs都支持Math-errno。这是一个非常糟糕的过时设计,没有人可以使用;这就是为什么我们需要fenv.h
来检查线程粘性FP异常标志。)输入错误的初始猜测<1.0
/* Divide the exponent by 2 */
r.o.e -= EXPONENT_BIAS;
r.o.e = (r.o.e & 1) | (r.o.e >> 1);
r.o.e += EXPONENT_BIAS;
您的指数位域类型是
unsigned short
1,所以这是合乎逻辑的右移。但是无论如何,逻辑右移是您初始近似值中的一个错误。
指数字段是2的补码整数(使用偏差编码,但这只是您已经处理过的编码/解码细节) 。小指数的逻辑右移(例如
0.0001
)将导致大的正指数从零移入。您最初对0.0001
的猜测将接近DBL_MAX
,远高于1.0
。您需要对指数进行算术右移,以使其更接近中间指数值(无偏)
0
或(有偏见)127
。因此,使表示值的大小更接近1.0
。大多数C实现都明智地选择了带符号整数类型的
>>
是算术右移。 ISO C要求实现定义它是什么,但是不幸的是,它们让他们选择逻辑还是算术。 (不同于有符号的左移,它不能是UB)。但是幸运的是,您的代码是用GNU C(不是ISO C)编写的,例如在结构上使用__attribute__((packed))
。 GNU C保证有符号整数上的>>
是算术的。 (而且带符号的整数类型是2的补码!)因此,唯一可以编译代码的实现是>>
上的int
可以满足您的要求。(GCC手册,整数实现)定义的行为):
带符号的“ >>”通过符号扩展作用于负数。但是您可以放心使用
int32_t
(保证是2的补码整数类型),也可以仅使用int
临时保留用于偏差/无偏计算的字段值。偏差/无偏差可能会越过零(无符号环绕),而不是有符号溢出。或者更好,请使用@Rainer P的方法:首先不要无偏心,只有无符号偏移,然后再加上一半的偏置常数。 (请参阅他的答案,以了解如何简化到这一点)。在无偏之前进行移位意味着指数是无符号的,所以移位为零是好的。 (我认为即使对于偏零指数接近零(即接近最小可能的指数编码)的微小值,此方法也适用。 net / FloatConverter / IEEE754.html,手动移动指数,然后将1(带进位)加到第二高的指数位。例如
2.3509887E-38
(有偏指数= 0b0000'0010
)变为2.1684043E-19(有偏指数= 0b0100'0001
)。但是当移位整个位模式时,请注意不要从q的符号位中移入
1
-0.0
。您正在将a == 0.0
作为特殊情况进行处理,因此您可以在这里很好,因为-0.0 == 0.0
为true。我猜你不能让0.0
穿过主NR循环,因为你将被零除。脚注1:
IDK为什么你请为您的指数位字段类型选择
unsigned short
而不是unsigned
。位字段的基础类型可以比字段宽,而不会损害任何内容,并且某些编译器会在该类型的宽度上进行数学运算。因此,如果unsigned int
足够宽,则是一个好主意。 int
至少为16位。使用
uint64_t
作为位域类型会导致某些编译器(尤其是MSVC)的32位代码运行缓慢,但我认为unsigned int
对于指数总是很好的。对于有偏的指数,无符号确实有意义。有趣的事实:位域类型只有
int
,而签名则留给实现。您可以使用signed int
强制签名。 https://stackoverflow.com/questions/42527387/signed-bit-field-represetation 展开而不是检查
c % 2 == 0
编译器仍然可以执行此操作,但是将循环展开2而不是使用计数器进行其他每次迭代的特殊操作可能更有意义。
实际的Newton迭代表达式足够紧凑,以至于与通过
c++
和if (c%2 == 0)
逻辑进行操作相比,对人类重复读取一次可能更简单。展开时,您可以将
while(foo)
条件作为if(!foo) break;
处理,也可以简单地将其保留,只检查是否保留了每2个牛顿迭代循环一次,除非您需要检查两种可能性以捕获两个值之间的交替。(初始估计很差,无论如何都要进行多次迭代,这可能是值得的。否则,可能不值得,特别是在超标量/乱序CPU上,它可以与没有很多ILP(指令级并行性);主要是一个长的依赖链的主依赖链并行地检查分支条件。 br />
拉快速路径上的一些检查
您的版本在进行实际工作之前对
a
进行了4次单独检查。 if(a < -(TYPE)0)
{
errno = EDOM;
return NAME_SFX(nan)("");
}
if(a == (TYPE)0 || is_nan(a) || a > TYPE_MAX)
{
return a;
}
带有一个或两个比较的特殊情况,并在快速路径之外的那个块中将它们分类。
sqrt(NaN)
的性能远不如sqrt(1.234)
的性能重要。 if(! a > (TYPE)0)
{
if(a == (TYPE)0 || is_nan(a))
return a;
// else a < 0
errno = EDOM;
return NAME_SFX(nan)("");
}
if( a > TYPE_MAX)
{
return a;
}
如果您需要专门处理次态(至少是为了生成初始近似值?),您可以可能使用
if (! a>=DBL_MIN)
。或者,您可以决定您的软浮点实现根本不处理次态。IDK此代码的目的是什么;如果它是软浮动库的一部分,则仅使用FP add,mul和div似乎效率低下。您想一起优化多个操作,等等。
但是如果是用于硬件FPU,那么我认为实际上所有FPU都将内置sqrt。
所以我认为这只是一个学习练习。因此,我们将不会针对针对任何特定硬件或编译器进行优化进行详细介绍。
评论
\ $ \ begingroup \ $
@ JL2210:我知道您不能使用int a:64,这就是为什么我说基础类型可以更宽(比字段大)而不会引起问题,反之亦然。显然我的措辞很容易被误解,会考虑如何解决。
\ $ \ endgroup \ $
– Peter Cordes
19/09/25'2:05
\ $ \ begingroup \ $
@ JL2210:哦。对于您以前使用unsigned short的指数,我的意思总是很好。因为这两种类型在ISO C中具有相同的最小宽度。我的意思是,使用uint64_t e:8可能会为指数提供比所需的慢的代码。特别是对于MSVC,我记得当时写了这个答案:关闭优化时未解析的外部符号__aullshr
\ $ \ endgroup \ $
– Peter Cordes
19-09-25在2:09
\ $ \ begingroup \ $
@ JL2210:哦,是的,我想念您是否单独检查== 0.0。我假设您只需要在快速路径上使用一项检查,即可通过要求x> = 0.0来排除两个负的NaN输入。对于NaN而言,这是错误的,因此您可以将isnan检查移到if快速路径无法运行的地方。因此,您可以执行if(!x> 0.0){进一步检查以检测+ = 0.0,NaN或负数}
\ $ \ endgroup \ $
– Peter Cordes
19-09-25在23:28
\ $ \ begingroup \ $
@ JL2210:更新了我的答案以修正您指出的错误,并讨论我在评论中说的一些内容。和更多。
\ $ \ endgroup \ $
– Peter Cordes
19/09/26'0:08
\ $ \ begingroup \ $
@ JL22:就像我在回答顶部说的那样,IEEE要求sqrt正确取整,就像+-*和/一样。所有x86 sqrt指令都可以这样做。 (当然rsqrtps除外,它有意给出1 / sqrt(x)的快速近似值)。英特尔的编译器默认情况下启用快速运算(与GCC不同),但是英特尔的硬件兼容IEEE。也许您正在考虑使用诸如fsin之类的指令,这些指令不需要正确取整,并且实际上在Pi / 2附近具有较大的相对误差。 randomascii.wordpress.com/2014/10/09/…
\ $ \ endgroup \ $
– Peter Cordes
19-09-26在0:27
#3 楼
我不明白为什么要自己定义此常量:#define MANTISSA_SIZE 52
鉴于我们已经假设
FLT_RADIX
为2,我们可以使用来自<float.h>
的适当宏(用于DBL_MANT_DIG
的double
等)。我认为这里存在整数溢出的危险:
/* Divide the exponent by 2 */
r.o.e -= EXPONENT_BIAS;
r.o.e = (r.o.e & 1) | (r.o.e >> 1);
r.o.e += EXPONENT_BIAS;
我们最好将其提取到足够大的临时区域中,并对其施加指数偏差:
int e = r.o.e - EXPONENT_BIAS;
e = (e & 1) | (e >> 1);
r.o.e = e + EXPONENT_BIAS;
转移到位,然后使用一半的偏差进行校正;我还没有检查是否等效。
评论
\ $ \ begingroup \ $
没有明确定义带符号的移位。我认为答案的后半部分无效。
\ $ \ endgroup \ $
–S.S. Anne
19年9月24日在17:10
\ $ \ begingroup \ $
我同意最重要的一点,但是应该是DBL_MANT_DIG-1,而不是DBL_MANT_DIG。
\ $ \ endgroup \ $
–S.S. Anne
19-09-24在17:33
\ $ \ begingroup \ $
@ JL2210:右移带符号的位域比带符号的int定义得更好吗?您当前的代码是否还不依赖>>算术右移,负指数以1s移位?是的,它是实现定义的,是算术的还是逻辑的(不是未定义的)。但是,您的代码使用GNU C __attribute __((packed)),因此,如果您有意使用GNU C而不是ISO C编写,则实际上可以依靠算术右移。gnu.huihoo.org/gcc/gcc-4.9.4/gcc /Integers-implementation.html指定“带符号扩展名的带符号的>>应用于负数”。
\ $ \ endgroup \ $
– Peter Cordes
19-09-25在1:04
\ $ \ begingroup \ $
@ JL2210:是的,只是注意到了这一点。我认为它们将是有符号的,因为2的补数指数的无符号右移(无偏后)没有意义,对吗?在对指数字段进行逻辑右移后,负指数(小浮点)将变为非常大的正指数(与DBL_MAX近似)。还是我错过了一些东西,而您的函数对于诸如0.0001之类的输入却能正常工作?
\ $ \ endgroup \ $
– Peter Cordes
19-09-25在1:07
\ $ \ begingroup \ $
@ JL2210:读取浮点数的硬件将有效消除偏差。这只是一种编码方法。实际值是2的补码整数。这就是您消除偏差(解码)后要使用的功能。是的,您必须重做编码才能使其再次成为IEEE浮动,但是那又如何呢?顺便说一句,如果要在不先消除偏见的情况下进行逻辑右移,则将使值接近最小可能的指数值的两倍,而不是接近中间值的两倍(偏倚后为0)。因此,如果FLT_MIN约为1e-34f,则1.0f将变为1e-17f或类似的值。
\ $ \ endgroup \ $
– Peter Cordes
19-09-25的1:22
#4 楼
模块化奇数校验编译器仍然可以执行此操作,但是不能
if(c % 2 == 0)
if (!(c & 1))
?
因素
不是
r.f -= (r.f*r.f-a)/(2*r.f);
等同于:
r.f = (r.f + a/r.f)/2?
优化
根据所使用的编译器以及传递的标志,某些尝试进行的数学运算可能会被优化程序,并且在与硬件紧密耦合的实现中,这可能很重要。您检查过生产的组件了吗?您可以向我们展示您的构建标志吗?
评论
\ $ \ begingroup \ $
在“分解”下:这就是Newton-Raphson,它可以检测并减少错误量。它与(1-a)/ 2不同。
\ $ \ endgroup \ $
–S.S. Anne
19-09-24在15:39
\ $ \ begingroup \ $
Soooo。问题不在于我的答案(而且我认为它不应该收到您的编辑);这是您发布的代码。对CR政策的严格解释表明,由于已经有了答案,因此您无法编辑代码。但是,我认为您应该编辑问题以显示实际的(未经预处理的)代码,然后由我自己修改此答案。
\ $ \ endgroup \ $
– Reinderien
19-09-24在15:47
\ $ \ begingroup \ $
@Reinderien谢谢。我添加了一个说明。
\ $ \ endgroup \ $
–S.S. Anne
19年9月24日15:50
\ $ \ begingroup \ $
重新。分解:所以我对数学的第一次尝试是错误的,因此我对其进行了修改以反映现实。
\ $ \ endgroup \ $
– Reinderien
19-09-24的16:04
\ $ \ begingroup \ $
我立即知道if(c%2 == 0)会做什么,但是我必须多次思考才能确定if(!(c&1))会做什么。那是过早的优化,恕我直言违反了干净代码规则(KISS)。
\ $ \ endgroup \ $
–托马斯·韦勒(Thomas Weller)
19-09-26在11:57
评论
遗憾的是这不是C ++,您可以使用std :: numeric_limits@TobySpeight __STDC_IEC_559__