繁体   English   中英

以最少的指令获得最快的整数平方根

[英]Fastest Integer Square Root in the least amount of instructions

我需要不涉及任何显式除法的快速整数平方根。 目标 RISC 架构可以在一个周期内执行addmulsubshift操作(好吧 - 操作的结果是在第三个周期中写入的,真的 - 但存在交错),因此任何使用这些操作并且速度很快的 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)的循环。 缓存中可能没有空间可以完全展开(但这将是部分展开的主要候选对象 [例如,4 个循环而不是 16 个循环],当然)。 所以,成本是 16*5 = 80 条指令(加上循环开销,如果没有展开的话)。 如果完全交错,则只需 80 个(最后一条指令为 +2)个周期。

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

常问问题:

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

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

  • 您是否分析了代码以查看sqrt是否实际上是瓶颈?

    不,没有必要那样做。 目标 RISC 芯片大约为 20 MHz,因此每条指令都很重要。 使用此sqrt的核心循环(计算发射器和接收器补丁之间的能量传输形状因子)将在每个渲染帧运行约 1,000 次(当然,假设它足够快),每秒最多 60,000 次,整个演示大约 1,000,000 次。

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

    是的,我已经这样做了。 事实上,我已经摆脱了 2 sqrt和很多部门(删除或替换为移位)。 即使在我的千兆赫兹笔记本上,我也可以看到巨大的性能提升(与参考浮动版本相比)。

  • 应用程序是什么?

    这是用于组合演示的实时渐进式细化光能传递渲染器。 这个想法是每帧有一个拍摄周期,所以它会在每个渲染帧上明显收敛并看起来更好(例如每秒上升 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 的小缓存非常昂贵(而且绝对不是每一帧)。 但是,如果sqrt至少给我 100-200% 的提升,我也许可以节省 128-256 字节。

  • sqrt的值范围是多少?

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

看看这里

例如,在 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) ),或者只是对优化代码的惊人方法感兴趣,请查看thisthisthis

这与您的相同,但操作次数较少。 (我在您的代码中计算循环中的 9 个操作,包括 for 循环中的测试和增量i以及 3 个赋值,但也许其中一些在 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;
  }
}

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

更新

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

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

第二次更新

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

在计算出答案的 msb 之后,我运行了多轮上述建议的算法,然后用一两轮牛顿法完成它。 我们通过以下计算来决定何时运行牛顿法:根据评论中的计算,答案的一位大约需要 8 个周期; 一轮 Newton 方法需要大约 18 个循环(除法、加法和移位,可能还有赋值),所以我们应该只运行 Newton 方法,如果我们要从中得到至少三位。 所以对于 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%。

如果乘法与加法和移位的速度相同(或快于!),或者如果您缺少寄存器中包含的快速移位指令,那么以下内容将无济于事。 除此以外:

您在每个循环周期重新计算temp*temp ,但temp = res | add 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 标准,所有算术都需要在算术运算之前将较窄的整数类型提升(转换)为最宽的类型执行,然后在必要时进行后续的反向转换。 (这当然可以被足够智能的编译器优化掉,但为什么要冒险呢?)

从评论线索来看,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 位变量。 对于生产代码,应该删除volatile

请注意,对于大多数处理器,最后的校正步骤不应涉及任何分支。 乘积y*y可以通过进位(或借位)从x减去,然后通过进位(或借位)的减法来修正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;
}

另一种可能性是对平方根使用牛顿迭代,尽管除法成本很高。 对于小输入,只需要一次迭代。 虽然提问者没有说明这一点,但基于DIV操作 16 个周期的执行时间,我强烈怀疑这实际上是一个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)
}

这是@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 条指令

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

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

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM