最快的整数平方根[指令数量最少]

时间:2015-06-29 13:52:49

标签: algorithm math sqrt

我需要快速的整数平方根,不涉及任何明确的除法。目标RISC架构可以在一个周期内执行add,mul,sub,shift等操作(好的 - 操作的结果是在第三个周期写的,真的 - 但是有交错),所以任何使用这些操作并且速度很快的Integer算法都会非常赞赏。

这就是我现在所拥有的,我认为二进制搜索应该更快,因为以下循环每次执行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)。缓存中可能没有完全展开的空间(但这将是部分展开的主要候选者[例如A循环4而不是16],当然)。因此,成本是16 * 5 = 80指令(加上循环开销,如果没有展开)。如果完全交错,则仅花费80(对于最后一条指令为+2)周期。

我可以在82个周期内获得一些其他sqrt实现(仅使用add,mul,bitshift,store / cmp)吗?

常见问题:

为什么不依靠编译器来生成一个好的快速代码?

该平台没有可用的C-&gt; RISC编译器。我会 将当前的参考C代码移植到手写的RISC ASM中。

您是否对代码进行了分析,以确定sqrt是否真的成为瓶颈?

不,没有必要。目标RISC芯片即将推出 20 MHz,因此每条指令都会计数。核心循环 (计算射手和射手之间的能量传递形状因子) 接收器补丁),使用此sqrt,将运行约1,000次 每个渲染帧(假设它足够快,当然),向上 每秒60,000,整个演示大约1,000,000次。

您是否尝试优化算法以删除sqrt

是的,我已经这样做了。事实上,我已经摆脱了2 sqrt s  和许多分裂(移除或替换为移位)。我能看到一个  巨大的性能提升(与参考浮动版相比)甚至  在我的千兆赫笔记本上。

申请是什么?

这是一个实时渐进式细化光能传递渲染器 compo演示。这个想法是每帧有一个射击周期,所以 对于每个渲染帧,它会明显地收敛并且看起来更好 (例如,每秒上升60次,尽管SW光栅化器不太可能 那么快[但至少它可以在另一个芯片上并行运行 使用RISC - 所以如果渲染场景需要2-3帧,那么  RISC将通过2-3帧的光能传递数据进行处理  平行])。

为什么不直接在目标ASM中工作?

因为光能传递是一个略微涉及的算法,我需要  即时编辑和继续Visual Studio的调试功能。什么  我已经在周末完成了VS(几百个代码更改为  将浮点数学转换为仅整数)将需要6  在目标平台上只有几个月打印“调试”。

为什么不能使用分部?

因为它在目标RISC上的速度比任何一个慢16倍  以下:mul,add,sub,shift,compare,load / store(只需1  周期)。因此,它仅在绝对需要时使用(几次  不幸的是,当转移不能使用时。)

您可以使用查找表吗?

引擎已经需要其他LUT并从主RAM复制到  RISC的小缓存非常昂贵(绝对不是  每一帧)。但是,如果可以的话,我可能会节省128-256字节  给了我至少100-200%的sqrt。

sqrt的值范围是什么?

我设法将它减少到仅仅是无符号的32位int  (4,294,967,295)

6 个答案:

答案 0 :(得分:7)

看看here

例如,在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条件指令上可以工作)。

通常,this page列出了计算平方根的方法。如果您碰巧想要生成一个快速的平方根(即x**(-0.5)),或者只是想要优化代码的惊人方法,请查看this,{ {3}}和this

答案 1 :(得分:5)

这与您的相同,但操作次数较少。 (我在代码的循环中计算了9个操作,包括for循环和3个赋值中的测试和增量i,但是当在ASM中编码时,其中一些可能会消失?下面的代码中有6个操作,如果你把g*g>n算作两个(没有任务))。

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;
  }
}

我得到了它here。如果您展开循环并根据输入中最高的非零位跳转到适当的位置,您可以消除比较。

<强>更新

我一直在考虑使用牛顿方法。理论上,每次迭代的精度位数应该加倍。这意味着当答案中的正确位数很少时,牛顿的方法比任何其他建议都要糟糕得多;但是,在答案中存在大量正确位的情况下,情况会发生变化。考虑到大多数建议似乎每位需要4个周期,这意味着牛顿方法的一次迭代(16次循环用于除法+ 1用于加法+ 1用于移位= 18次循环)是不值得的,除非它给出超过4位。

所以,我的建议是通过一种建议的方法(8 * 4 = 32个周期)建立8位答案,然后执行牛顿方法的一次迭代(18个周期),将位数加倍到16。这总共是50个周期(加上可能额外的4个周期,在应用牛顿方法之前得到9个比特,加上可能有2个周期来克服牛顿方法偶尔遇到的过冲)。这是最多56个周期,据我所知,可以看到任何其他建议。

第二次更新

我编写了混合算法的想法。牛顿方法本身没有开销;你只需申请并加倍有效数字。在应用牛顿方法之前,问题是要有可预测的有效位数。为此,我们需要弄清楚答案中最重要的部分出现在哪里。使用另一张海报给出的快速DeBruijn序列方法的修改,我可以在估计中在大约12个周期中执行该计算。另一方面,知道答案msb的位置可以加速所有方法(平均而非最坏的情况),所以无论如何它似乎都值得。

在计算答案的msb之后,我运行了上面建议的几轮算法,然后用一两轮Newton方法完成它。我们通过以下计算决定何时运行牛顿方法:根据评论中的计算,答案的一位需要大约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
 * http://stackoverflow.com/questions/23856596/counting-leading-zeros-in-a-32-bit-unsigned-integer-with-best-algorithm-in-c-pro
 */
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%。

答案 2 :(得分:2)

如果乘法是相同的速度(或快于!)加法和移位,或者如果缺少快速按容量移位的寄存器指令,则以下内容将没有用处。否则:

您在每个循环周期重新计算temp*temp,但temp = res | addres + add相同,因为它们的位不重叠,并且(a )你已经在前一个循环周期中计算了res*res,并且(b)add具有特殊结构(它总是只有一个位)。因此,使用(a+b)^2 = a^2 + 2ab + b^2,您已经拥有a^2b^2的事实只是向左移动了两位,与单位b相同,2aba向左移位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标准,所有算术都需要提升(转换)较窄的整数类型到执行算术运算之前涉及的最宽类型,然后在必要时进行后续的反向转换。 (这当然可以通过足够智能的编译器进行优化,但为什么要承担风险呢?)

答案 3 :(得分:1)

从评论追踪中,似乎RISC处理器仅提供32x32-> 32位乘法和16x16-> 32位乘法。不提供32x-32-> 64位加宽乘法或返回64位乘积的高32位的MULHI指令。

这似乎排除了基于Newton-Raphson迭代的方法,这可能是低效的,因为它们通常需要MULHI指令或加宽乘法用于中间定点算术。

下面的C99代码使用不同的迭代方法,仅需要16x16-> 32位乘法,但稍微线性收敛,需要多达六次迭代。此方法需要CLZ功能来快速确定迭代的起始猜测。 Asker在评论中指出,所使用的RISC处理器不提供CLZ功能。因此,需要仿真CLZ,并且由于仿真会增加存储和指令计数,因此这可能会使这种方法失去竞争力。我进行了强力搜索,以确定具有最小乘数的deBruijn查找表。

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

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

请注意,对于大多数处理器,最后的更正步骤不应涉及任何分支。产品y*y可以通过结转(或借出)从x中减去,然后y通过减值进位(或借入)进行更正。因此,每个步骤都应该是序列MULSUBccSUBC

因为循环实现迭代会产生大量开销,所以我选择完全展开循环,但提供两个早期检查。手动统计操作我计算了最快案例的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;
}

另一种可能性是使用牛顿迭代作为平方根,尽管划分成本很高。对于小输入,仅需要一次迭代。虽然提问者没有说明这一点,但基于DIV操作的16个周期的执行时间,我强烈怀疑这实际上是32/16->16比特除法,需要额外的保护代码以避免溢出,定义为一个不符合16位的商。基于这个假设,我已经为我的代码添加了适当的安全措施。

由于Newton迭代每次应用时都会将好位数加倍,因此我们只需要一个低精度的初始猜测,可以根据参数的五个前导位从表中轻松检索。为了获取这些,我们首先将参数标准化为2.30定点格式,并附加隐式比例因子2 32-(lz&amp; ~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)
}

答案 4 :(得分:0)

这里是@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优化,它将完全展开循环并折叠常量。 The result is still only about 100 instructions

答案 5 :(得分:-1)

我不知道如何将其变成一种有效的算法但是当我在80年代对此进行调查时,出现了一个有趣的模式。当舍入平方根时,平方根有两个整数而不是前一个整数(零之后)。

因此,一个数字(零)的平方根为零,两个的平方根为1(1和2),4的平方根为2(3,4,5和6),依此类推。可能不是一个有用的答案,但有趣的是。