这个浮点数平方根近似值是如何工作的?

54

我找到了一个相当奇怪但有效的浮点数平方根近似值; 我真的不明白。 有人能解释一下为什么这段代码有效吗?

float sqrt(float f)
{
    const int result = 0x1fbb4000 + (*(int*)&f >> 1);
    return *(float*)&result;   
}

我已经测试了一下,它的值与std::sqrt()输出的值相差约1到3%。我知道Quake III的快速反平方根算法,我猜这里也有类似的东西(没有牛顿迭代),但我真的很希望得到一个关于如何工作的解释。

(注:我将其标记为因为它是有效的-ish(请参阅评论)C和C ++ 代码)


25
这并不是合法的C或C++代码。它违反了别名规则,并假设了特定的浮点值和int值表示方式。这使得它成为黑客风格的代码,有时令人着迷,但通常不值得效仿。 - Pete Becker
7
这是另一个神奇数字0x5f3759df的一种朋友。 - Eugene Sh.
11
简而言之,将f的位表示向右移动一位大致相当于将指数除以二,这等同于取平方根。其他的部分可能是通过魔法来提高尾数范围内的精度。 - Oliver Charlesworth
12
@Fureeish - sqrt(a^b) = (a^b)^0.5 = a^(b/2). - Oliver Charlesworth
5
这段话的意思是,“@PeteBecker: 这是完全合法的 C 和 C++ 代码。然而,它的行为是由实现定义的。不要混淆无效和非可移植性;它们并不相同。” - Jack Aidley
显示剩余10条评论
4个回答

75

(*(int*)&f >> 1) 右移 f 的位表示。这几乎将指数除以二,大约相当于取平方根。1

为什么是“几乎”?在IEEE-754中,实际的指数是 e - 1272 要将其除以二,我们需要 e/2 - 64,但上述近似只给出了 e/2 - 127。因此,我们需要加上63到结果指数中。这是由那个神奇常数(0x1fbb4000)的30-23位贡献的。

我想剩下的神奇常数位被选择为最小化幅度范围内的最大误差或类似的东西。然而,不清楚它是通过分析、迭代还是启发式确定的。


值得指出的是,这种方法有些不可移植。它做出了(至少)以下假设:
  • 平台使用单精度IEEE-754来表示float
  • float表示的字节顺序。
  • 由于此方法违反了C/C++的严格别名规则,因此您不会受到未定义行为的影响。
因此,除非您确定它在您的平台上具有可预测的行为(并且确实比sqrtf提供了有用的加速!),否则应避免使用此方法。
1. sqrt(a^b) = (a^b)^0.5 = a^(b/2) 2. 参见例如https://en.wikipedia.org/wiki/Single-precision_floating-point_format#Exponent_encoding

2
这也可能是Math Goblins的结果 :) - user2261062
6
在我看来,“非可携带性”的发生概率接近于零。IEEE-754标准得到普遍采用,使用不同整型和浮点型字节序的计算机是(曾经)少见的情况。 - user1196549
4
好的回答。具体来说,“浮点数表示的字节顺序”与“整数的字节顺序”有关。如果它们都是大端或小端,那么字节顺序就不是一个问题。 - chux - Reinstate Monica
4
在C和C++中,这确实违反了严格别名规则。"你不会违反C/C++的严格别名规则" 暗示着可能会或可能不会违反。众所周知,现代编译器会积极执行TBAA,历史留下了许多人因为认为 "命中这些非可移植性的概率接近于零" 而失败的案例。我希望清楚地说明它确实违反了规则, OP应该修改代码或使用禁用TBAA的编译器选项(例如gcc和clang)。 - M.M
4
还没有提到的另一个可移植性问题是对负整数进行右移位操作会产生一个实现定义的值。 - M.M
显示剩余14条评论

18

请看Oliver Charlesworth的解释,了解为什么这个方法几乎可行。我在回应评论中提出的问题。

由于有几个人指出这个方法不可移植,下面是一些方法可以使其更具可移植性,或者至少让编译器告诉你它是否可行。

首先,C++允许您在编译时检查std::numeric_limits<float>::is_iec559,例如在static_assert中。您还可以检查sizeof(int) == sizeof(float),如果int是64位,则不成立,但您真正想要做的是使用uint32_t,如果存在,则始终恰好为32位宽,具有明确定义的移位和溢出行为,并且如果您的奇怪架构没有这样的整数类型,将导致编译错误。无论哪种方式,您还应该static_assert()类型具有相同的大小。静态断言没有运行时成本,如果可能的话,您应该始终以这种方式检查前提条件。

很遗憾,将float中的位转换为uint32_t并进行移位的测试是大端、小端还是都不是无法计算为编译时常量表达式。在此,我将运行时检查放在依赖于它的代码部分,但您可能想将其放在初始化中并执行一次。在实践中,gcc和clang都可以在编译时优化掉这个测试。
您不希望使用不安全的指针转换,在我所工作的一些实际系统中,这可能会导致总线错误使程序崩溃。将对象表示转换为最大可移植的方法是使用memcpy()。在我的下面的示例中,我使用union进行类型转换,这适用于任何实际存在的实现。(语言律师反对它,但没有成功的编译器会默默地破坏那么多遗留代码。)如果必须进行指针转换(请参见下文),则有alignas()。但是,无论如何做,结果都将是实现定义的,这就是我们检查转换和移位测试值的结果的原因。
无论如何,您可能不太可能在现代CPU上使用它,这里是一个经过整理的C++14版本,检查那些非可移植的假设。
#include <cassert>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <iomanip>
#include <iostream>
#include <limits>
#include <vector>

using std::cout;
using std::endl;
using std::size_t;
using std::sqrt;
using std::uint32_t;

template <typename T, typename U>
  inline T reinterpret(const U x)
/* Reinterprets the bits of x as a T.  Cannot be constexpr
 * in C++14 because it reads an inactive union member.
 */
{
  static_assert( sizeof(T)==sizeof(U), "" );
  union tu_pun {
    U u = U();
    T t;
  };
  
  const tu_pun pun{x};
  return pun.t;
}

constexpr float source = -0.1F;
constexpr uint32_t target = 0x5ee66666UL;

const uint32_t after_rshift = reinterpret<uint32_t,float>(source) >> 1U;
const bool is_little_endian = after_rshift == target;

float est_sqrt(const float x)
/* A fast approximation of sqrt(x) that works less well for subnormal numbers.
 */
{
  static_assert( std::numeric_limits<float>::is_iec559, "" );
  assert(is_little_endian); // Could provide alternative big-endian code.
  
 /* The algorithm relies on the bit representation of normal IEEE floats, so
  * a subnormal number as input might be considered a domain error as well?
  */
  if ( std::isless(x, 0.0F) || !std::isfinite(x) )
    return std::numeric_limits<float>::signaling_NaN();
  
  constexpr uint32_t magic_number = 0x1fbb4000UL;
  const uint32_t raw_bits = reinterpret<uint32_t,float>(x);
  const uint32_t rejiggered_bits = (raw_bits >> 1U) + magic_number;
  return reinterpret<float,uint32_t>(rejiggered_bits);
}

int main(void)
{  
  static const std::vector<float> test_values{
    4.0F, 0.01F, 0.0F, 5e20F, 5e-20F, 1.262738e-38F };
  
  for ( const float& x : test_values ) {
    const double gold_standard = sqrt((double)x);
    const double estimate = est_sqrt(x);
    const double error = estimate - gold_standard;
    
    cout << "The error for (" << estimate << " - " << gold_standard << ") is "
         << error;

    if ( gold_standard != 0.0 && std::isfinite(gold_standard) ) {
      const double error_pct = error/gold_standard * 100.0;
      cout << " (" << error_pct << "%).";
    } else
      cout << '.';

    cout << endl;
  }

  return EXIT_SUCCESS;
}

更新

这里有一个避免类型转换的reinterpret<T,U>()的替代定义。您还可以在现代C中实现类型转换,并将函数调用为extern "C"。我认为类型转换比memcpy()更加优雅,类型安全并且与该程序的准函数式风格一致。此外,如果存在陷阱表示,则仍然可能具有未定义行为,因此我认为没有太多收益。另外,clang++ 3.9.1 -O -S能够静态分析类型转换版本,将变量is_little_endian优化为常数0x1并消除运行时测试,但它只能将此版本优化为单指令存根。

但更重要的是,这段代码不能保证在每个编译器上都能正常工作。例如,一些旧计算机甚至无法精确地寻址32位内存。但在这些情况下,它应该编译失败并告诉您原因。没有编译器会无缘无故地破坏大量遗留代码。虽然标准技术上允许这样做并仍然声称符合C++14,但只会发生在我们预期的架构非常不同的情况下。如果我们的假设是如此无效,以至于某个编译器将float和32位无符号整数之间的类型转换变成危险的错误,那么我真的怀疑这段代码背后的逻辑是否能够通过使用memcpy()来保持稳定。我们希望这段代码在编译时失败,并告诉我们原因。
#include <cassert>
#include <cstdint>
#include <cstring>

using std::memcpy;
using std::uint32_t;

template <typename T, typename U> inline T reinterpret(const U &x)
/* Reinterprets the bits of x as a T.  Cannot be constexpr
 * in C++14 because it modifies a variable.
 */
{
  static_assert( sizeof(T)==sizeof(U), "" );
  T temp;
  
  memcpy( &temp, &x, sizeof(T) );
  return temp;
}

constexpr float source = -0.1F;
constexpr uint32_t target = 0x5ee66666UL;

const uint32_t after_rshift = reinterpret<uint32_t,float>(source) >> 1U;
extern const bool is_little_endian = after_rshift == target;

然而,在C++核心指南中,Stroustrup等人建议使用reinterpret_cast
#include <cassert>

template <typename T, typename U> inline T reinterpret(const U x)
/* Reinterprets the bits of x as a T.  Cannot be constexpr
 * in C++14 because it uses reinterpret_cast.
 */
{
  static_assert( sizeof(T)==sizeof(U), "" );
  const U temp alignas(T) alignas(U) = x;
  return *reinterpret_cast<const T*>(&temp);
}

我测试过的编译器也可以将此代码优化为一个折叠常量。Stroustrup的推理是:

从声明类型不同的对象中访问 reinterpret_cast 的结果仍然是未定义行为,但至少我们可以看到一些棘手的东西正在发生。

更新

根据评论:C++20 引入了 std::bit_cast,它将对象表示转换为具有未指定而不是未定义的行为的不同类型。这并不保证您的实现将使用与此代码期望的 floatint 相同的格式,但它不会给编译器任意打破您的程序的机会,因为在其中一行中存在技术上未定义的行为。它还可以为您提供一个 constexpr 转换。


1
在C++中,如果读取的联合成员与最后写入的不同,则行为未定义(请参见此答案,特别是最后一段)。 - M.M
@M.M 添加了一个使用 memcpy() 的版本,并解释了您提出的问题。 - Davislor
1
关于联合体问题,我想指出未定义行为使得实现可以自由地做任何他们想做的事情;通过联合体进行类型转换的实现保证工作在“任何他们想要的”范围内,因此如果你对你所使用的编译器有保证,那么你就可以放心使用。 - Matthieu M.
@Davislor:这正是我的观点;如果你的编译器为你提供了这个保证,那么它可能不可移植......但对你很有用:D 是的,大多数编译器确实允许直接使用它(避免将成员指针传递给不透明函数)。 - Matthieu M.
@MatthieuM。您会注意到,我程序中的所有变量都是常量(从技术上讲,除了STL容器的内容),这完全消除了由于优化器在更改变量时未更新不同别名对变量的视图而引起的错误。虽然并非总是可行,但我总是尽力而为。 - Davislor
显示剩余7条评论

8

令y = sqrt(x),

根据对数的性质可知,log(y) = 0.5 * log(x) (1)

将一个普通的 float 解释为整数,可得 INT(x) = Ix = L * (log(x) + B - σ) (2)

其中,L = 2^N,N 是有效数字的位数,B 是指数偏置,σ 是一个自由因子用于调整近似值。

将(1)和(2)结合起来得到:Iy = 0.5 * (Ix + (L * (B - σ)))

在代码中表示为:(*(int*)&x >> 1) + 0x1fbb4000;

找到 σ 使得常量等于0x1fbb4000,并确定它是否最优。


1
请注意,使用普通的float时,尾数的MSbit未编码,仅在正常的float中假定为1。这会影响OP的float sqrt(float f),但在INT(x)中没有考虑到。 - chux - Reinstate Monica
是的,正如您在帖子中指出的那样,这种近似仅适用于普通的“float”。 - Michael Foukarakis

6

添加一个维基测试工具来测试所有float

对于许多float,该近似值在4%以内,但对于次正常数则非常差。结果可能因人而异

Worst:1.401298e-45 211749.20%
Average:0.63%
Worst:1.262738e-38 3.52%
Average:0.02%

请注意,如果使用+/-0.0作为参数,则结果不会是零。
printf("% e % e\n", sqrtf(+0.0), sqrt_apx(0.0));  //  0.000000e+00  7.930346e-20
printf("% e % e\n", sqrtf(-0.0), sqrt_apx(-0.0)); // -0.000000e+00 -2.698557e+19

测试代码

#include <float.h>
#include <limits.h>
#include <math.h>
#include <stddef.h>
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>

float sqrt_apx(float f) {
  const int result = 0x1fbb4000 + (*(int*) &f >> 1);
  return *(float*) &result;
}

double error_value = 0.0;
double error_worst = 0.0;
double error_sum = 0.0;
unsigned long error_count = 0;

void sqrt_test(float f) {
  if (f == 0) return;
  volatile float y0 = sqrtf(f);
  volatile float y1 = sqrt_apx(f);
  double error = (1.0 * y1 - y0) / y0;
  error = fabs(error);
  if (error > error_worst) {
    error_worst = error;
    error_value = f;
  }
  error_sum += error;
  error_count++;
}

void sqrt_tests(float f0, float f1) {
  error_value = error_worst = error_sum = 0.0;
  error_count = 0;
  for (;;) {
    sqrt_test(f0);
    if (f0 == f1) break;
    f0 = nextafterf(f0, f1);
  }
  printf("Worst:%e %.2f%%\n", error_value, error_worst*100.0);
  printf("Average:%.2f%%\n", error_sum / error_count);
  fflush(stdout);
}

int main() {
  sqrt_tests(FLT_TRUE_MIN, FLT_MIN);
  sqrt_tests(FLT_MIN, FLT_MAX);
  return 0;
}

真正的问题是,假设我们可以在“更大的数字”部分工作,它在时间上与sqrtf()相比如何?它可能是一个快速的近似值吗?如果我们需要“还算可以”的近似值,但平均偏差为0.02%的平方根是可以接受的,那么在实时物理模拟中就会有价值。 - Delioth
@Delioth 这可能是一个快速近似吗?当然可能,也可能更慢。对于没有浮点数运算的处理器,sqrt_apx() 显然更快。对于先进的处理器,可能会更快,由于优化代码和并行处理。需要设置特定的情况。请记住,sqrt_apx(0.0) 不是 0,这可能会造成实际问题。这一切都非常依赖于具体情况。也许您可以尝试模拟并发布您的结果? - chux - Reinstate Monica
1
你无需测试所有浮点数!对于标准化数字,您只需要测试两个连续的二进制数 B_1 和 B_2。在二进制数 B_(n+2) 中发生的情况与在二进制数 B_n 中发生的情况是同构的(请注意,当将 f 向上移动两个二进制数时,*(int*)&f >> 1 向上移动一个二进制数)。 - Pascal Cuoq
@PascalCuoq 当然需要测试少量的float,但测试所有内容并不太耗时。使用double,你的想法更有优势,因为它具有2^64个组合。虽然B_1B_2很重要,但是还需要评估与0x1fbb4000相加后导致幂指数变化的选择FP值。 - chux - Reinstate Monica
@PascalCuoq 欢迎对答案进行改进。 - chux - Reinstate Monica
嗯...经过一些研究,似乎许多硬件实现都有一个实际的sqrt分支,所以这可能不太可能快速完成。虽然我不确定GPU是否有类似的分支,但这可能是值得的。如果我真的很无聊并开始使用CUDA之类的东西,那么有时候我可能会回来看看这个问题,但目前我同意它是特定于实现的。 - Delioth

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接