簡體   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