最快的整数平方根算法(指仅需最少的指令数量)

34

我需要一个快速的整数平方根算法,不涉及任何显式除法。目标RISC体系结构可以在一个周期内执行诸如addmulsubshift等操作(好吧——操作的结果实际上是在第三个周期写入的,但有交错),因此,任何使用这些操作并且速度快的整数算法将受到极大的赞赏。

这就是我现在所拥有的,我认为二分查找应该更快,因为以下循环每次都要执行16次(无论值如何)。我还没有对其进行详细的调试(但很快会),所以可能有一个提前退出的方法:

unsigned short int int_sqrt32(unsigned int x)
{
    unsigned short int res=0;
    unsigned short int add= 0x8000;   
    int i;
    for(i=0;i<16;i++)
    {
        unsigned short int temp=res | add;
        unsigned int g2=temp*temp;      
        if (x>=g2)
        {
            res=temp;           
        }
        add>>=1;
    }
    return res;
}

在目标RISC上,上述内容的当前性能成本似乎是一个5个指令的循环(bitset、mul、compare、store、shift)。可能没有足够的缓存空间来完全展开(但肯定会成为部分展开[例如4次循环而不是16次循环]的主要候选项)。因此,成本为16 * 5 = 80条指令(加上循环开销,如果未展开)。如果完全交错,将只需80(加上最后一条指令的2)个周期。

我能否得到某些其他sqrt实现(仅使用add,mul,bitshift,store/cmp)低于82个周期?

常见问题解答:

  • 为什么不依赖编译器生成良好快速的代码?

该平台不存在可用的C→RISC编译器。我将把当前的参考C代码转换为手写的RISC ASM。

  • 您是否对代码进行了剖析以查看sqrt是否实际上成为瓶颈?

不需要这样做。目标RISC芯片约为20 MHz,因此每个单独的指令都很重要。核心循环(计算射线枪和接收器补丁之间的能量传递形式因子)中使用了这个sqrt,每个渲染帧将运行约1,000次(如果快到足够快),最多每秒60,000次,在整个演示程序中大约运行了1,000,000次。

  • 您是否尝试过优化算法以消除sqrt

是的,我已经这样做了。实际上,我已经去掉了两个sqrt,并且还去掉了许多除法(被移位替换或删除)。即使在我的千兆赫笔记本电脑上(与参考浮点版本相比),我也可以看到巨大的性能提升。

  • 应用程序是什么?

这是一个实时的渐进式辐射度渲染器,用于竞赛演示。想法是每帧进行一次射线追踪,因此每隔一段时间会可见地收敛,并随着每帧渲染而变得更好(例如每秒上涨60次,尽管软件光栅化器可能不会那么快[但至少可以与RISC并行运行-因此如果需要2-3帧来渲染场景,则RISC将在并行中处理2-3帧辐射度数据])。

  • 为什么不直接使用目标ASM工作?

因为辐射度是一个稍微有些复杂的算法,我需要Visual Studio的即时编辑和继续调试能力。如果只打印调试信息,在目标平台上我在VS中所做的几百个代码更改(将浮点数学转换为仅限整数)需要6个月的时间。

  • 为什么不能使用除法?

因为在目标 RISC 上,与以下任何操作相比(mul、add、sub、shift、compare、load/store 只需1个周期),除法速度慢16倍。因此,只有在绝对必要的情况下才使用它(不幸的是已经出现了几次情况无法使用移位)。

  • 能否使用查找表?

引擎已经需要其他 LUT,从主内存复制到 RISC 的小缓存开销太大了(肯定不是每一帧都需要)。但是,如果可以让 sqrt 获得至少100-200% 的提升,我可以把128-256字节的空间留出来。

  • sqrt 值的范围是多少?

我设法将其降至仅为无符号32位int(4,294,967,295)。

编辑1:我已经将两个版本移植到目标RISC ASM中,因此我现在可以在执行期间获得确切的 ASM 指令计数(针对测试场景)。
sqrt 调用数:2,800。
方法1:本帖中的相同方法(循环执行16次)
方法2:fred_sqrt(来自http://www.azillionmonkeys.com/qed/sqroot.html,只需3个周期)

方法1:每个sqrt的指令数为152.98。
方法2:每个sqrt的指令数为39.48(具有最终舍入和2个牛顿迭代)。
方法2:每个sqrt的指令数为21.01(不带最终舍入和2个牛顿迭代)。

方法2 使用了256个值的 LUT,但由于目标 RISC 在其4 KB 的缓存中只能使用32位访问,因此实际上需要 256*4 = 1 KB。但是考虑到其性能,我想我必须留出 1 KB 的空间(在总共 4 KB 的情况下)。

此外,我发现在禁用Method2的最后舍入和两个牛顿迭代时,没有任何可见的视觉瑕疵。也就是说,那个LUT的精度显然是足够好的。谁知道呢……
最终成本为每个平方根21.01条指令,几乎比第一个解决方案快了一个数量级。还有可能通过牺牲32个可用寄存器中的一些用于条件和跳转标签的常数来进一步降低它(每个条件必须填充2个寄存器——一个用于实际比较的常数(CMPQ指令只允许小于16的值,大于16的值必须放入寄存器),另一个用于跳转到else标签(then跳转是fall-through),因为直接相对跳转仅能在约10个指令内实现(除了最内层的两个条件外,对于如此大的if-then-else链来说是不可能的)。

编辑2: ASM微优化
在基准测试时,我为26个If.Then.Else代码块中的每一个添加了计数器,以查看是否有任何最常执行的块。 结果显示,0号、10号和11号块在99.57%/99.57%/92.57%的情况下被执行。这意味着我可以为这些比较常数(在这3个块中)牺牲3个寄存器(32个寄存器中的3个),例如r26 = $1.0000 r25 = $100.0000 r24 = $10.0000。
这将使总指令成本从58,812(平均值:21.01)降至50,448(平均值:18.01)。

因此,现在每个sqrt的平均成本只有18.01条ASM指令(且没有除法!),但它必须被内联。

编辑3: ASM微优化
由于我们知道那3个块(0/10/11)最常执行,因此我们可以在这些特定条件中使用局部短跳转(两个方向上的16字节,通常只有几个指令(因此大多无用),尤其是在条件期间使用6字节MOVEI #jump_label,register)。 当然,Else条件将会产生额外的2个操作(否则不会),但这是值得的。块10将不得不交换(Then块与Else块),这将使其更难阅读和调试,但我已经详细记录了原因。

现在,在测试场景中,总指令成本只有42,500,平均每个sqrt的指令数为15.18条ASM指令

EDIT4: ASM微优化
第11个块条件分割为最内层的块12&13。恰好这些块不需要额外的+1数学操作,因此如果我将位移右边界与必要的位移左移#2合并(因为缓存中的所有偏移量都必须是32位),那么本地短跳跃实际上可以到达Else块。这样可以节省填充跳转寄存器的开销,但我需要牺牲另一个寄存器r23来比较$40.000的值。

最终成本是34,724条指令,每个sqrt平均12.40个ASM指令。

我还意识到我可以重新排列条件的顺序(这将使其他范围的操作更加昂贵,但只会在约7%的情况下发生),首选这个特定范围($10,000,$40,000),至少可以节省1甚至2个条件。
如果这样做,则每个sqrt应该会降至~8.40。
我意识到范围直接取决于光线强度和距离墙的距离。这意味着我可以直接控制光的RGB值和距离墙的距离。虽然我希望解决方案尽可能通用,但是鉴于这一认识(每个sqrt平均12个操作令人惊叹),如果我可以获得如此快速的sqrt,我很乐意牺牲一些灯光颜色的灵活性。此外,在整个演示中可能只有10-15种不同的灯,因此我可以简单地找到最终导致相同sqrt范围的颜色组合,但将获得疯狂快速的sqrt。当然,这是值得的。而且我的通用备选方案(涵盖整个int范围)仍然可以很好地工作。真正做到了两全其美。


4
如果你的目标是ARM或类似的处理器,你可能想要查看http://www.finesse.demon.co.uk/steven/sqrt.html获取一些想法 - 我相当确定当我以手写汇编语言编程ARM(很久以前)时,Archimedes手册中有如何完成这个(还有除法)的示例。我怀疑二分查找比牛顿迭代法或类似方法更快。 - abligh
1
很久很久以前,在一个遥远的星系里,当我为80386编写愚蠢的介绍时,我使用了牛顿算法。它仍然需要除法,但如果你选择一个聪明的起始值,就不需要太多。 - biziclop
1
如果您可以访问浮点数,那么如何进行简单的近似计算呢?请参考以下链接:https://en.wikipedia.org/wiki/Methods_of_computing_square_roots#Approximations_that_depend_on_the_floating_point_representation - CaldasGSM
1
我可能有些冒险,但你的数字分布是怎样的呢?如果其中许多数字很小,也许值得先计算结果将有多少位,然后只运行那么多次循环。当然,这仅适用于如果你有 clz 或类似的 1 个周期指令的情况下。 - biziclop
4
常见问题解答(FAQ)中为何答案先于问题呈现,难道只有我一个人会想知道吗? - abligh
显示剩余15条评论
6个回答

9

请看这里

例如,在3(a)处有这种方法,它很容易适应64->32位平方根,并且也很容易转换为汇编语言:

/* by Jim Ulery */
static unsigned julery_isqrt(unsigned long val) {
    unsigned long temp, g=0, b = 0x8000, bshft = 15;
    do {
        if (val >= (temp = (((g << 1) + b)<<bshft--))) {
           g += b;
           val -= temp;
        }
    } while (b >>= 1);
    return g;
}

没有除法,也没有乘法,只有位移。但是时间可能会有些不可预测,特别是如果你使用了分支(在ARM RISC上,条件指令可以工作)。
通常,这个页面列出了计算平方根的方法。如果你想要产生快速的反平方根(即x**(-0.5)),或者只是对优化代码的惊人方法感兴趣,请看一下这篇文章这个这个

找到了,但是它是倒数平方根。链接附加以供娱乐价值。 - abligh
然而,你的第一个链接提供了一种方法(3c节),其中包含一个小的LUT,它有潜力在所有平方根上花费更少的总时间(由于早期退出)。这非常有趣!我可以修改它以保持增加一些名为TotalOps的全局变量(在每个cmp/add/bitshift操作之后),并在处理测试数据集后查看总计。确实,它可能平均花费比80个操作更多的时间,但我们不会知道直到我真正进行基准测试。我可能会很幸运!记住 - 只有操作的总数(等于RISC ASM指令)才是重要的。 - 3D Coder
@3DCoder 没错;我忍不住要发布Quake3的东西 - 没有你平台的知识,我在想如果你可以在几个指令中完成整个运算(假设你想要反向操作),那么返回浮点数可能会更快。 - abligh
所以,我使用了第3c节中带有小LUT(256 B)的方法,并使用全局变量TotalOps进行“分析”估计,试图估计RISC指令的总数 - 例如,它不仅包括数学指令,还包括赋值(1个操作),条件(if为2个操作,else为1个操作)和循环(2个操作)。对于测试数据,我使用了一个由5个墙壁组成的立方体,每个墙壁为32x32 = 1,024个纹素,因此总共有5,120个纹素。我的当前方法使用了607,248个操作(平均118个),而LUT方法仅使用了91,704个操作(平均18个)!这是6.62倍的差距!早期退出真是太神奇了! - 3D Coder
1
@abligh 噢,这个失败的控制台早已死亡和埋葬了。毫无疑问 :-) 然而,我喜欢挑战一个固定的硬件,并且它是我童年最喜欢的品牌,所以我很好奇我能在3D方面推动它到什么程度。 - 3D Coder
显示剩余12条评论

6

这与您的代码相同,但操作更少。(我在您的循环中计算了9个操作,包括测试和增加 i 在for循环中以及3个赋值操作,但也许在编写ASM时有些操作会消失?下面的代码中有6个操作,如果将 g*g>n 视为两个操作(没有赋值)则为6个操作。)

int isqrt(int n) {
  int g = 0x8000;
  int c = 0x8000;
  for (;;) {
    if (g*g > n) {
      g ^= c;
    }
    c >>= 1;
    if (c == 0) {
      return g;
    }
    g |= c;
  }
}

我在这里找到了解决方案如果展开循环并根据输入中最高非零位跳转到适当的位置,可以消除比较操作。

更新

我正在考虑使用牛顿法。理论上,精度位数应该在每次迭代时翻倍。这意味着,当答案中正确的位数很少时,牛顿法比任何其他建议都要糟糕得多;但是,在答案中有许多正确位数时,情况会改变。考虑到大多数建议似乎每位需要4个周期,这意味着一次牛顿迭代(16个周期进行除法+1个周期进行加法+1个周期进行移位=18个周期)仅在给出超过4个位时才值得。

因此,我的建议是通过其中一种建议的方法建立8位答案(8*4 = 32个周期),然后执行一次牛顿法迭代(18个周期)以将位数加倍为16。总共需要50个周期(加上在应用牛顿方法之前获取9位的额外4个周期,加上克服牛顿方法偶尔经历的超调的额外2个周期)。就我所看到的,这最多需要56个周期,可以与任何其他建议相媲美。

第二次更新

我编写了混合算法代码。牛顿法本身没有开销;您只需应用并将有效数字加倍即可。问题是在应用牛顿方法之前,我们需要一个可预测的有效数字数量。为此,我们需要找出答案最高有效位将出现的位置。使用另一篇文章中给出的快速DeBruijn序列方法的修改版,我们可以在我估计的12个周期内完成该计算。另一方面,了解答案msb的位置会加速所有方法(平均值,而不是最坏情况),因此似乎无论如何都值得。

在计算出答案msb后,我运行一些轮上述算法,然后最后进行一两轮牛顿法。我们通过以下计算决定何时运行牛顿法:根据评论中的计算,一位答案大约需要8个周期;一轮牛顿法需要约18个周期(除法、加法和移位,以及可能的赋值),因此只有在我们将得到至少三个位时才应运行牛顿法。因此,对于6位答案,我们可以运行线性方法3次以获得3个有效位,然后运行牛顿法1次以获得另外3个。对于15位答案,我们运行线性方法4次以获得4位,然后通过两次牛顿法获得另外4位和7位。等等。

这些计算的依据是准确知道使用线性方法获取一个比特需要多少个周期,与使用牛顿方法需要多少个周期。如果“经济”发生变化,例如发现一种更快的线性方式来构建比特时,何时调用牛顿方法的决定将会改变。
我展开了循环并将决策实现为开关,我希望这将转化为在汇编中进行快速表查找。我不确定每种情况下是否已经达到了最小数量的周期,因此可能还可以进一步调整。例如,对于s = 10,您可以尝试获取5位,然后仅应用一次牛顿方法而不是两次。
我已经彻底测试了算法以确保正确性。如果您愿意在某些情况下接受稍微不准确的答案,则可能还有一些附加的次要加速。在应用牛顿方法纠正形式为m^2-1的数字时,至少需要使用两个周期来纠正一个偏差。而在开始时测试输入0时会使用一个周期,因为该算法无法处理该输入。如果您知道永远不会对零取平方根,则可以消除该测试。最后,如果您只需要8个有效位数的答案,则可以省略其中一次牛顿方法计算。
#include <inttypes.h>
#include <stdint.h>
#include <stdbool.h>
#include <stdio.h>

uint32_t isqrt1(uint32_t n);

int main() {
  uint32_t n;
  bool it_works = true;
  for (n = 0; n < UINT32_MAX; ++n) {
    uint32_t sr = isqrt1(n);
    if ( sr*sr > n || ( sr < 65535 && (sr+1)*(sr+1) <= n )) {
      it_works = false;
      printf("isqrt(%" PRIu32 ") = %" PRIu32 "\n", n, sr);
    }
  }
  if (it_works) {
    printf("it works\n");
  }
  return 0;
}

/* table modified to return shift s to move 1 to msb of square root of x */
/*
static const uint8_t debruijn32[32] = {
    0, 31, 9, 30, 3,  8, 13, 29,  2,  5,  7, 21, 12, 24, 28, 19,
    1, 10, 4, 14, 6, 22, 25, 20, 11, 15, 23, 26, 16, 27, 17, 18
};
*/

static const uint8_t debruijn32[32] = {
  15,  0, 11, 0, 14, 11, 9, 1, 14, 13, 12, 5, 9, 3, 1, 6,
  15, 10, 13, 8, 12,  4, 3, 5, 10,  8,  4, 2, 7, 2, 7, 6
};

/* based on CLZ emulation for non-zero arguments, from
 * https://dev59.com/rWAg5IYBdhLWcg3wM4r_
 */
uint8_t shift_for_msb_of_sqrt(uint32_t x) {
  x |= x >>  1;
  x |= x >>  2;
  x |= x >>  4;
  x |= x >>  8;
  x |= x >> 16;
  x++;
  return debruijn32 [x * 0x076be629 >> 27];
}

uint32_t isqrt1(uint32_t n) {
  if (n==0) return 0;

  uint32_t s = shift_for_msb_of_sqrt(n);
  uint32_t c = 1 << s;
  uint32_t g = c;

  switch (s) {
  case 9:
  case 5:
    if (g*g > n) {
      g ^= c;
    }
    c >>= 1;
    g |= c;
  case 15:
  case 14:
  case 13:
  case 8:
  case 7:
  case 4:
    if (g*g > n) {
      g ^= c;
    }
    c >>= 1;
    g |= c;
  case 12:
  case 11:
  case 10:
  case 6:
  case 3:
    if (g*g > n) {
      g ^= c;
    }
    c >>= 1;
    g |= c;
  case 2:
    if (g*g > n) {
      g ^= c;
    }
    c >>= 1;
    g |= c;
  case 1:
    if (g*g > n) {
      g ^= c;
    }
    c >>= 1;
    g |= c;
  case 0:
    if (g*g > n) {
      g ^= c;
    }
  }

  /* now apply one or two rounds of Newton's method */
  switch (s) {
  case 15:
  case 14:
  case 13:
  case 12:
  case 11:
  case 10:
    g = (g + n/g) >> 1;
  case 9:
  case 8:
  case 7:
  case 6:
    g = (g + n/g) >> 1;
  }

  /* correct potential error at m^2-1 for Newton's method */
  return (g==65536 || g*g>n) ? g-1 : g;
}

在我的机器上进行了轻微测试(虽然与您的机器完全不同),新的isqrt1例程平均运行速度比我之前提供的isqrt例程快约40%。

有趣的想法。我只是不确定混合两种不同方法的开销会是多少。你似乎假设混合它们不会有任何开销?没有一次操作吗? - 3D Coder
1
我正在检查你的代码。你没有在C语言中明确编写赋值语句(该语言允许您链接多个临时变量),但是在ASM中无法避免它们。每个ASM指令需要2个操作数-有些可以使用第三个小常量(范围为0-16)。因此,你的第一行if (gg > n)*实际上是3个操作:赋值、乘法、比较。下一行是1个操作,然后是1个操作。接下来是条件,实际上可以从ASM中删除,因为位移将设置Z标志,所以我只需执行单操作跳转(到带有rts的标签),然后进行逻辑或的1个操作,最后进行循环的1个操作(跳转到@LoopStart)。这就是8个操作。 - 3D Coder
1
现在,如果我将相同的规则应用于我的初始方法,那么第一行需要2个操作,第二行需要2个操作,第三行需要1个操作,第四行需要1个操作,第五行需要1个操作,循环开销为2个操作(减少循环寄存器,跳转到Z)。总共:9个操作。所以,看起来你节省了1个操作,这很棒!谢谢!当然,如果我们降到那个级别,我们必须将每个比较计为2个操作(比较,跳转),因此这会增加两种解决方案的总指令数。看起来你将循环计数器与sqrt逻辑结合在一起,这在ASM中非常聪明,因为我们可以通过在Z标志上跳转来完全避免条件*if(c==0)*。 - 3D Coder
1
如果你想让isqrt精确,牛顿法本身在最后有两个操作的开销来纠正潜在的误差。然而,你必须从正确的位置开始非牛顿部分,否则对于小数它将生成前导零而不是有效位。这意味着你需要一个CLZ或类似的指令。好处是一旦你知道答案中有多少位,你可以使用这些信息来调整操作数,在平均情况下减少一半步骤。我正在实现...除了CLZ模拟之外,其他都完成了。 - Edward Doolittle

4
如果乘法的速度(或者比加法和移位更快!),或者你没有快速shift-by-amount-contained-in-a-register指令,那么以下内容将无济于事。否则:
在每个循环周期中重新计算temp * temp,但是temp = res | add,这与res + add相同,因为它们的位不重叠,并且(a)你已经在之前的循环周期中计算过res * res,以及(b) add具有特殊结构(它总是一个单一位)。所以,利用 (a+b)^2 = a^2 + 2ab + b^2 这个公式,你已经有了a^2,而b^2仅仅是单比特b向左移动两倍的位置,2ab只是a左移1个比特位超过b的位置,你可以摆脱乘法。
unsigned short int int_sqrt32(unsigned int x)
{
    unsigned short int res = 0;
    unsigned int res2 = 0;
    unsigned short int add = 0x8000;   
    unsigned int add2 = 0x80000000;   
    int i;
    for(i = 0; i < 16; i++)
    {
        unsigned int g2 = res2 + (res << i) + add2;
        if (x >= g2)
        {
            res |= add;
            res2 = g2;
        }
        add >>= 1;
        add2 >>= 2;
    }
    return res;
}

我认为最好使用相同类型(unsigned int)的所有变量,因为根据C标准,所有算术运算在执行之前都需要将较窄的整数类型提升(转换)为涉及的最宽类型,然后进行后续的反向转换(如果必要的话)。 (当然,这可能被足够智能的编译器优化掉,但为什么要冒险呢?)


由于OP将手动将代码移植到ASM,最后一段可能并不重要。 - biziclop
1
@j_random_hacker 是的,RISC架构上的乘法也是单周期指令(请查看我帖子中的第一句话),但还是感谢您的想法,因为它激发了我的不同想法,我会尝试。另外,在将来的某个时间,我计划增加一个额外的实现,仅在Motorola 68000 CPU上运行(没有任何RISC架构),在那里乘法是昂贵的,除法则更是超级昂贵。因此,拥有不涉及它们的版本非常重要!然而,对于RISC架构而言,这个版本具有16*8个操作,这比我现在拥有的还要多。 - 3D Coder
2
@3DCoder 如果您想在M68k上实现整数平方根,可以查看这篇论文:K.C. Johnson,“ALGORITHM 650:68000上高效的平方根实现”,ACM TOMS 13(1987)138-151。论文的程序代码可以从netlib获取。 - njuffa
我刚刚查看了这个链接,真是太棒了!我很快就要尝试将C代码编译成68000,但会用一些手动优化的汇编替换最昂贵的例程(例如sqrt)。所以这个链接很快就会派上用场。非常感谢! - 3D Coder
1
嘿,看起来这与我的相同 如何在一个时钟周期内获取32位输入的平方根? - Spektre

3

从评论中可以看出,RISC处理器只提供32x32->32位乘法和16x16->32位乘法。不提供32x-32->64位扩展乘法或返回64位乘积的高32位的MULHI指令。

这似乎排除了基于牛顿-拉弗森迭代的方法,因为它们通常对中间的定点算术需要MULHI指令或扩展乘法,而此处理器不支持。

下面的C99代码使用了一种不同的迭代方法,只需要16x16->32位乘法,但收敛较缓慢,需要最多六次迭代。这种方法需要CLZ功能来快速确定迭代的起始猜测。由于该处理器不提供CLZ功能,因此需要模拟CLZ,由于模拟增加了存储和指令计数,这可能使此方法不太有竞争力。我进行了暴力搜索以确定具有最小乘数的deBruijn查找表。

这种迭代算法提供了与所需结果非常接近的原始结果,即(int)sqrt(x),但始终略高于实际值,由于整数算术的截断性质。为了得到最终结果,需要有条件地减少结果,直到结果的平方小于或等于原始参数。

代码中使用volatile限定符仅用于确定所有命名变量实际上可以分配为16位数据而不影响功能。我不知道这是否提供了任何优势,但注意到OP在其代码中专门使用了16位变量。对于生产代码,应删除volatile。

请注意,在大多数处理器上,最终的校正步骤不应涉及任何分支。可以用带有Carry-Out(或借出)的产品y*y从x中减去,然后通过带有Carry-In(或借入)的减法来纠正y。因此,每个步骤应该是一个序列MUL,SUBcc,SUBC。

由于用循环实现迭代会带来相当大的开销,因此我选择完全展开循环,并提供两个早期退出检查。手动计算操作时,最快情况下有46个操作,平均情况下有54个操作,最坏情况下有60个操作。

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <math.h>

static const uint8_t clz_tab[32] = {
    31, 22, 30, 21, 18, 10, 29,  2, 20, 17, 15, 13, 9,  6, 28, 1,
    23, 19, 11,  3, 16, 14,  7, 24, 12,  4,  8, 25, 5, 26, 27, 0};

uint8_t clz (uint32_t a)
{
    a |= a >> 16;
    a |= a >> 8;
    a |= a >> 4;
    a |= a >> 2;
    a |= a >> 1;
    return clz_tab [0x07c4acdd * a >> 27];
}
  
/* 16 x 16 -> 32 bit unsigned multiplication; should be single instruction */
uint32_t umul16w (uint16_t a, uint16_t b)
{
    return (uint32_t)a * b;
}

/* Reza Hashemian, "Square Rooting Algorithms for Integer and Floating-Point
   Numbers", IEEE Transactions on Computers, Vol. 39, No. 8, Aug. 1990, p. 1025
*/
uint16_t isqrt (uint32_t x)
{
    volatile uint16_t y, z, lsb, mpo, mmo, lz, t;

    if (x == 0) return x; // early out, code below can't handle zero

    lz = clz (x);         // #leading zeros, 32-lz = #bits of argument
    lsb = lz & 1;
    mpo = 17 - (lz >> 1); // m+1, result has roughly half the #bits of argument
    mmo = mpo - 2;        // m-1
    t = 1 << mmo;         // power of two for two's complement of initial guess
    y = t - (x >> (mpo - lsb)); // initial guess for sqrt
    t = t + t;            // power of two for two's complement of result
    z = y;

    y = (umul16w (y, y) >> mpo) + z;
    y = (umul16w (y, y) >> mpo) + z;
    if (x >= 0x40400) {
        y = (umul16w (y, y) >> mpo) + z;
        y = (umul16w (y, y) >> mpo) + z;
        if (x >= 0x1002000) {
            y = (umul16w (y, y) >> mpo) + z;
            y = (umul16w (y, y) >> mpo) + z;
        }
    }

    y = t - y; // raw result is 2's complement of iterated solution
    y = y - umul16w (lsb, (umul16w (y, 19195) >> 16)); // mult. by sqrt(0.5) 

    if ((int32_t)(x - umul16w (y, y)) < 0) y--; // iteration may overestimate 
    if ((int32_t)(x - umul16w (y, y)) < 0) y--; // result, adjust downward if 
    if ((int32_t)(x - umul16w (y, y)) < 0) y--; // necessary 

    return y; // (int)sqrt(x)
}

int main (void)
{
    uint32_t x = 0;
    uint16_t res, ref;

    do {
        ref = (uint16_t)sqrt((double)x);
        res = isqrt (x);
        if (res != ref) {
            printf ("!!!! x=%08x  res=%08x  ref=%08x\n", x, res, ref);
            return EXIT_FAILURE;
        }
        x++;
    } while (x);
    return EXIT_SUCCESS;
}

另一个可能性是使用牛顿迭代法求平方根,尽管除法的成本很高。对于小输入,只需要一次迭代。虽然提问者没有明确说明,但基于执行16个周期的DIV操作的执行时间,我强烈怀疑这实际上是一个32/16->16位的除法,需要额外的保护代码来避免溢出,即商不适合16位。基于这个假设,我已经在我的代码中添加了适当的保护措施。
由于牛顿迭代每次应用都会将好位数加倍,因此我们只需要一个低精度的初始猜测,可以根据参数的前五位从表中轻松检索到。为了获取这些,我们首先将参数标准化为2.30的定点格式,并具有一个额外的隐式比例因子2^(32-(lz & ~1)),其中lz是参数中前导零的数量。与之前的方法一样,迭代并不能总是提供准确的结果,因此如果初步结果太大,则必须应用纠正。快速路径计算需要49个周期,慢速路径计算需要70个周期(平均60个周期)。
static const uint16_t sqrt_tab[32] = 
{ 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000,
  0x85ff, 0x8cff, 0x94ff, 0x9aff, 0xa1ff, 0xa7ff, 0xadff, 0xb3ff,
  0xb9ff, 0xbeff, 0xc4ff, 0xc9ff, 0xceff, 0xd3ff, 0xd8ff, 0xdcff, 
  0xe1ff, 0xe6ff, 0xeaff, 0xeeff, 0xf3ff, 0xf7ff, 0xfbff, 0xffff
};

/* 32/16->16 bit division. Note: Will overflow if x[31:16] >= y */
uint16_t udiv_32_16 (uint32_t x, uint16_t y)
{
    uint16_t r = x / y;
    return r;
}

/* table lookup for initial guess followed by division-based Newton iteration*/ 
uint16_t isqrt (uint32_t x)
{
    volatile uint16_t q, lz, y, i, xh;

    if (x == 0) return x; // early out, code below can't handle zero

    // initial guess based on leading 5 bits of argument normalized to 2.30
    lz = clz (x);
    i = ((x << (lz & ~1)) >> 27);
    y = sqrt_tab[i] >> (lz >> 1);
    xh = (x >> 16); // needed for overflow check on division

    // first Newton iteration, guard against overflow in division
    q = 0xffff;
    if (xh < y) q = udiv_32_16 (x, y);
    y = (q + y) >> 1;
    if (lz < 10) {
        // second Newton iteration, guard against overflow in division
        q = 0xffff;
        if (xh < y) q = udiv_32_16 (x, y);
        y = (q + y) >> 1;
    }

    if (umul16w (y, y) > x) y--; // adjust quotient if too large

    return y; // (int)sqrt(x)
}

1
我认为CLZ仿真是一个好主意。我也在我的代码中使用过它。但是,请注意,您可以通过修改表而不是对clz(n)执行算术运算来节省一些周期(请参见我的答案)。由于您的初始猜测很复杂,因此您可能希望计算x * 0x076be629 >> 27,然后使用两个不同的表查找m和初始猜测的值,以替换clz表。 - Edward Doolittle
1
@EdwardDoolittle 我当然意识到可以将几个指令的计算拉回到表格中,但我希望保持我的答案足够通用,因为这种方法对于所有具有非常快速乘法的平台都应该是有用的,特别是那些提供 CLZ 类型指令的平台。初始猜测实际上并不复杂,至少在概念上不是:它基本上是一个 1 位预置到适当位位置的右移参数。可能有一种更优雅的实现相同功能的方法,但我还没有找到。 - njuffa
1
回到起点。昨天我注意到我所称赞的LUT解决方案实际上有2个除法 - 它在末尾有2次牛顿迭代。当我在周围添加“基准测试”代码时,我不知道我是如何错过它的,可能缩小了太多,看起来像是逻辑OR。双手掌打脸..所以,它不是18个操作,而是16个操作加上2 * 16 = 32个周期的除法。现在我必须检查这里所有的回复,重新检查是否有一个比这个更好,因为有32个额外的周期,这确实非常可能。对此我深感抱歉。 - 3D Coder
1
我已经实现了上述的平方根近似算法,并且确实将计算完整精度平方根的需求降低到了3%。97%的数据可以使用6个操作的近似值来处理。虽然在80%的情况下误差在5%以下(例如在视觉上并不真正明显),但在20%的情况下误差会超过100%。我知道何时会发生这种情况,所以我只会在需要时逐个更改平方根算法。我会看看是否能想出一个更干净、非混合的解决方案。 - 3D Coder
1
不仅乘数改变了。以前的 CLZ 模拟在乘法之前有一个增量,以确保乘法中的一个因子是二的幂。我首先搜索了该变体的最小乘数,即 0x04653adf。然后我想检查如果我删除增量会发生什么,这样可以节省一个整数 ADD。对于该变体,最小乘数为 0x07c4acdd。我试图找到只有少量 1 位的神奇乘数,但所有有效的乘数都具有 16 个 1 位的种群计数,因此这基本上是“不可能完成的任务”。 - njuffa
显示剩余20条评论

2
我不知道如何将其转化为高效算法,但是当我在80年代调查时,发现了一个有趣的模式。在四舍五入平方根时,与前一个整数相比,会多出两个具有该平方根的整数(在0之后)。
因此,有一个数字(零)的平方根为零,有两个数字的平方根为1(1和2),有4个数字的平方根为2(3、4、5和6),以此类推。可能不是一个有用的答案,但仍然很有趣。

如果你向下取整,这是另一种观察前n个奇数之和为n²的事实的方式。如果你只是四舍五入,我不确定它是否正确。 - Teepeemm
该死!他在第一段说得对,但第二段需要做一些小修正:这个模式不适用于0、1和2的平方根(它只从3开始)。我曾经非常怀疑这一点,但现在在确认了前50亿个数字的平方根后,我开始怀疑自己。非常有趣,谢谢分享。 - Jack G
1
这个真正有趣的地方在于,从导数的角度来看,d/dx[x^2]是2x,这意味着如果我们向下取整,连续平方数之间的跨度将比前一个跨度多2。然而,让我感到奇怪的是,应用普通舍入时总是增加2,没有例外。在进一步的测试中,我发现除了0.0(floor/ceil)或0.5(普通舍入)以外的其他小数舍入点都不会导致此行为。 - Jack G
我只检查了16位以内的数字(在C64上使用6502进行3D图形处理),但每次四舍五入到最近的整数都是准确的。很奇怪。 - Mike

1
这是@j_random_hacker所描述的技术的一种更少增量的版本。在我几年前调整它时,至少在一个处理器上速度更快了一点。我不知道为什么。
// assumes unsigned is 32 bits
unsigned isqrt1(unsigned x) {
  unsigned r = 0, r2 = 0; 
  for (int p = 15; p >= 0; --p) {
    unsigned tr2 = r2 + (r << (p + 1)) + (1u << (p + p));
    if (tr2 <= x) {
      r2 = tr2;
      r |= (1u << p);
    }
  }
  return r;
}

/*
gcc 6.3 -O2
isqrt(unsigned int):
        mov     esi, 15
        xor     r9d, r9d
        xor     eax, eax
        mov     r8d, 1
.L3:
        lea     ecx, [rsi+1]
        mov     edx, eax
        mov     r10d, r8d
        sal     edx, cl
        lea     ecx, [rsi+rsi]
        sal     r10d, cl
        add     edx, r10d
        add     edx, r9d
        cmp     edx, edi
        ja      .L2
        mov     r11d, r8d
        mov     ecx, esi
        mov     r9d, edx
        sal     r11d, cl
        or      eax, r11d
.L2:
        sub     esi, 1
        cmp     esi, -1
        jne     .L3
        rep ret
*/

如果您打开gcc 9 x86优化,它会完全展开循环并折叠常量。结果仅约为100条指令

谢谢,但与我在帖子中的基准算法相比,这太多操作了,基准算法只有4个操作(OR、MUL、SHIFT、CMP)在循环内。你的算法有8个操作(加上条件内部的4个),还需要至少4-5个中间结果(目标ASM没有3操作数指令,因此需要大量MOVE操作)来计算tr2。最少的话,总指令数将是:16 *(2(循环)+ 2(条件)+ 12(tr2))= 16 * 16 = 256,再加上条件(tr2<=x)被调用的次数。 因此,大约为~300,而我的LUT方法(Method2)只有约17。 - 3D Coder
这个算法是在乘法非常昂贵的时候(我认为由 Knuth 设计)设计的。在小型微控制器上,仍然如此。我发布它是因为它很有趣,并且避免乘法可能是一件好事。我没有声明它在你的应用中的速度。 - Gene
是的,尝试在像6502这样的芯片上可能是值得的,虽然你必须以某种方式处理lo/hi字节。因为我的RISC在相同的3个周期内执行MUL,所以对我来说没有意义。 - 3D Coder

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