[英]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.