KOTET'S PERSONAL BLOG

#assembly gccにおけるモジュラ逆数を用いた(x % m == 0)の最適化

Created: , Last modified:
#assembly #tech #cpplang

これは1年以上前の記事です

ここに書かれている情報、見解は現在のものとは異なっている場合があります。

gccの最適化

ある数が定数で割り切れるかを%を使って判定するコードが

#include "stdint.h"

uint32_t is_div_7(uint32_t k)
{
  return (k % 7)==0;
}

こんな感じに最適化されるようです。

is_div_7(unsigned int):
        imul    edi, edi, -1227133513
        xor     eax, eax
        cmp     edi, 613566756
        setbe   al
        ret

わかりやすくCに再翻訳するとこんな感じでしょうか。

#include "stdint.h"

uint32_t is_div_7(uint32_t k)
{
  return (k * -1227133513) <= 613566756;
}

剰余演算(x86では除算と同じ処理です)がなくなり、乗算と比較だけになっているのがわかります。 除算は他の演算と比べてコストが大きいので、なくすことができると嬉しいです。 しかし-1227133513613566756などという数字はどこから出てきたのでしょうか?

モジュラ逆数

自然数mで割ったときの余りが同じ整数を同一視することを「法mで考える」といいます。 このとき、mと互いに素な整数aに対して

a1xmodm

となるような整数x、つまりa逆数が存在します。 このxオイラー関数 φ(n)(=nと互いに素である1以上n以下の自然数の数)を用いて求めることができます。

a1aφ(m)1

通常このφ(n)を求めるのは素因数分解などのめんどくさい計算が必要になりますが、 素数pのべき乗の場合は単純な式になります。

φ(pk)=pkpk1

p=2の場合は以下のようにもっと単純になります。

φ(2k)=2k2k1 =2k1(21) =2k1

べき乗は簡単な工夫O(logn)で求めることができます。 というわけで法2kのときの奇数aの逆数(逆数が存在するのはa2kと互いに素のとき、 つまり奇数のときです)はそれなりに速く求めることができて、それは以下のような式になります。

a1a2k11mod2k

オーバーフローは剰余算

ここでuint32_tの性質について考えてみます。 uint32_tは32ビット整数です。 32ビットで表せる範囲を超えた数値はオーバーフロー(アンダーフロー)を起こし、 32ビットで表せる範囲に戻ってきます。

つまり!uint32_tは!勝手に232を法とする剰余類環になっています!

ということはuint32_tの範囲で任意の奇数の逆数を求めることができて、uint32_tの範囲で除算ができます。

uint32_t inv = 3067833783; // 7の逆数
return 63 * inv; // 9

除算の性質

わかりやすく数字を小さくして考えてみましょう。 法8=23において3の逆元は3です。 0から7までの整数に3をかけてみます。

xx*3%8
00
13
26
31
44
57
62
75

結果に重複はなく、0から7までの数字が並べ替えられていることがわかります。 ここで右側の列をキーにしてソートし直してみましょう。

x*3%8x
00
13
26
31
44
57
62
75

3で割ったあまりが0のもの、1のもの、2のものと纏まっていることがわかるでしょうか。 法である8を3で割った数(切り上げ)をcとおくと、余りがkとなる数の結果の値域は[ck,c(k+1))になります。 つまり結果がどの範囲にあるか見れば余りがいくつになるかわかるということです。

完成

これらをすべて組み合わせると最初のコードになります。 任意の奇数で同じ手順での最適化が可能です。

is_div_7(unsigned int):
        imul    edi, edi, -1227133513 ; == 3067833783 == 7^(2^31 - 1) mod 2^32 == 7の逆数
        xor     eax, eax
        cmp     edi, 613566756 ; 2^32 / 7
        setbe   al ; (k * 7^(2^31 - 1) mod 2^32) <= 2^32 / 7
        ret

偶数は?

偶数も少し工夫すると簡単に最適化できます。 まずは実際に最適化されたコードを見てみましょう。

uint32_t is_div_14(uint32_t k)
{
  return (k % 14)==0;
}
is_div_14(unsigned int):
        imul    edi, edi, -1227133513
        xor     eax, eax
        ror     edi
        cmp     edi, 306783378
        setbe   al
        ret

偶数cは奇数nと指数mを使って以下のように表せます。

c=n2m

まず奇数部分nの逆数を作って判定対象kを割ります。 あとはk/n2mで割り切れるかを判定すればいいです。

ある数が2mで割り切れるということは、下位mビットがゼロであるということです。 これは2m1とのANDがゼロかどうかで判定することもできますが、 右ローテート を使うとnで割り切れるかどうかの判定を一度に行うことができます。

k2mで割り切れる=下位mビットがゼロであるときにk/nm回右ローテートすると、 これは右シフト と同じであり値は単に2mで割られます。 このときnで割った余りが0になる値域を2m分の1にした範囲に値があれば、kn2mの両方で割り切れるためcで割り切れます。

そうでないときにk/nm回右ローテートすると、下位ビットの1がぐるっとまわって上位ビットに付加されます。 すると値は非常に大きくなるためnで割った余りが0になる値域を2m分の1にした範囲から外れます。

以上のようにして偶数で割り切れるかどうかの判定を除算なしに行うことができました。

is_div_14(unsigned int):
        imul    edi, edi, -1227133513 ; == 3067833783 == 7^(2^31 - 1) mod 2^32 == 7の逆数
        xor     eax, eax
        ror     edi ; 1ビット右ローテート
        cmp     edi, 306783378 ; (2^32 / 7) / 2
        setbe   al
        ret

感想

符号なし32ビット整数を232で割った余りと捉えてオーバーフローを活用するという発想がなかったので勉強になりました。 そのうちなにかに使えるかもしれない……

参考