[英]Fastest Integer Square Root in the least amount of instructions
我需要不涉及任何显式除法的快速整数平方根。 目标 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)的循环。 缓存中可能没有空间可以完全展开(但这将是部分展开的主要候选对象 [例如,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)
),或者只是对优化代码的惊人方法感兴趣,请查看this 、 this和this 。
这与您的相同,但操作次数较少。 (我在您的代码中计算循环中的 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.