もっと高速な完全精度 expf 関数の作り方

何年か前に、高速な完全精度(すべての入力に対して最近接丸めを行う) expf 関数の作り方を明らかにしました(高速な完全精度 expf 関数の作り方 - よーる)。

その中で、 t = x - s\log2を求める際に使う \log2は、倍精度浮動小数点数の53bit精度ですら精度が不足しているので、二つの倍精度浮動小数点数ln2hln2lの和で表す必要がある、と書きました。

これ自体は正しいのですが、ここの誤差に由来してexpf(x)が正しく求められないxは二つしかありません。 よって、他の部分のアルゴリズム部分にわずかな(数学的な正当化ができないような姑息な)変更を加えることで、全体として返す結果が正しくなるようにできる可能性があります。

実際、いくつか実験してみると、tの誤差を補償することで完全精度とできる実装を手に入れることができました。

以下、試したことを書いてみます。

多項式近似をいじってみる

この方針はうまくいきませんでした。 というのも、多項式近似はあらゆる入力で使われるため、tの誤差を補償しようとすると、他の入力の時に正しくない値を返してしまうことにつながるからです。

テーブルをいじってみる

この方針が正しかったです。

tの誤差に由来してexpf(x)が正しく求められないxは、x1 = -0x1.d2259ap+3fx2 = 0x1.112856p+6fだけです。 x1ではb1table[31]b2table[16]を、x2ではb1table[16]b2table[21]を使います。 これらのテーブルの値を、expf(x)が目的の値になるようにわずかにずらすことを考えます。 ずらす量 \varepsilonを二つのテーブルに分散させれば、

  • ほとんどの入力に対しては、たかだか \varepsilon/2しかずれない
  • 運悪く同じテーブルの組み合わせを使う入力に対しては \varepsilonずれてしまうけれど、そういう入力は全体の1/1024しかないので、運が悪くなければ完全精度自体は達成可能

とできます。 なんだかブルームフィルタみたいです。

実際にやってみると、x3 = -0x1.e1dbe2p-8fで問題が発生します。 この入力は元々運よくぎりぎり正しい丸め方向になっていた入力で、b1table[31]b2table[21]を使っています。

x1に対しては5ULP増やす必要があり、x2に対しては8ULP減らす必要があります。 つまり、x1x2では、動かすべき方向が逆で、x2の方がたくさんうごかす必要があります。 よって、ずらす量を二つのテーブルに均等に分散させると、x3の計算結果がずれてしまい、今回の場合は丸め境界をまたいでしまうようです。 そこで、x2の調整のためには、b2table[21]はあまりずらさず、かわりにb1table[16]をたくさんずらす、とする必要があるようです。

実際のコード

#include <cmath>
#include <cstdint>
#include <cstring>
#include <bit>

namespace {
    double expm1_taylor3( double t1 ) noexcept {
        constexpr double C2 = 1.0 / 2.0;
        constexpr double C3 = 1.0 / 6.0;
        const double s1 = std::fma( C3, t1, C2 );
        const double t2 = t1 * t1;
        return std::fma( s1, t2, t1 );
    }

    double exp_table( uint64_t s ) noexcept {
        constexpr double b1table[32] {
            0x1.0000000000000p+0,
            0x1.059b0d3158574p+0,
            0x1.0b5586cf9890fp+0,
            0x1.11301d0125b51p+0,
            0x1.172b83c7d517bp+0,
            0x1.1d4873168b9aap+0,
            0x1.2387a6e756238p+0,
            0x1.29e9df51fdee1p+0,
            0x1.306fe0a31b715p+0,
            0x1.371a7373aa9cbp+0,
            0x1.3dea64c123422p+0,
            0x1.44e086061892dp+0,
            0x1.4bfdad5362a27p+0,
            0x1.5342b569d4f82p+0,
            0x1.5ab07dd485429p+0,
            0x1.6247eb03a5585p+0,
            0x1.6a09e667f3bc7p+0, // pow(2, 16./32) = 0x1.6a09e667f3bcdp+0 から6ULPずらした
            0x1.71f75e8ec5f74p+0,
            0x1.7a11473eb0187p+0,
            0x1.82589994cce13p+0,
            0x1.8ace5422aa0dbp+0,
            0x1.93737b0cdc5e5p+0,
            0x1.9c49182a3f090p+0,
            0x1.a5503b23e255dp+0,
            0x1.ae89f995ad3adp+0,
            0x1.b7f76f2fb5e47p+0,
            0x1.c199bdd85529cp+0,
            0x1.cb720dcef9069p+0,
            0x1.d5818dcfba487p+0,
            0x1.dfc97337b9b5fp+0,
            0x1.ea4afa2a490dap+0,
            0x1.f50765b6e4542p+0, // pow(2, 31./32) = 0x1.f50765b6e4540p+0 から2ULPずらした
        };

        constexpr double b2table[32] {
            0x1.0000000000000p+0,
            0x1.002c605e2e8cfp+0,
            0x1.0058c86da1c0ap+0,
            0x1.0085382faef83p+0,
            0x1.00b1afa5abcbfp+0,
            0x1.00de2ed0ee0f5p+0,
            0x1.010ab5b2cbd11p+0,
            0x1.0137444c9b5b5p+0,
            0x1.0163da9fb3335p+0,
            0x1.019078ad6a19fp+0,
            0x1.01bd1e77170b4p+0,
            0x1.01e9cbfe113efp+0,
            0x1.02168143b0281p+0,
            0x1.02433e494b755p+0,
            0x1.027003103b10ep+0,
            0x1.029ccf99d720ap+0,
            0x1.02c9a3e778063p+0, // pow(2, 16./1024) = 0x1.02c9a3e778061p+0 から2ULPずらした
            0x1.02f67ffa765e6p+0,
            0x1.032363d42b027p+0,
            0x1.03504f75ef071p+0,
            0x1.037d42e11bbccp+0,
            0x1.03aa3e170aafcp+0, // pow(2, 21./1024) = 0x1.03aa3e170aafep+0 から2ULPずらした
            0x1.03d7411915a8ap+0,
            0x1.04044be896ab6p+0,
            0x1.04315e86e7f85p+0,
            0x1.045e78f5640b9p+0,
            0x1.048b9b35659d8p+0,
            0x1.04b8c54847a28p+0,
            0x1.04e5f72f654b1p+0,
            0x1.051330ec1a03fp+0,
            0x1.0540727fc1762p+0,
            0x1.056dbbebb786bp+0,
        };

        const double b1 = b1table[s>>5&31];
        const double b2 = b2table[s&31];
        const uint64_t exponent = (s >> 10) << 52;
        return std::bit_cast<double>( std::bit_cast<uint64_t>( b1 * b2 ) + exponent );
    }
}

float exact_expf( float x ) noexcept {
    if( x < -104.0f ) { return 0.0f; }
    if( x > 0x1.62e42ep+6f ) { return HUGE_VALF; }

    constexpr double R    =  0x3.p+51f;
    constexpr double iln2 =  0x1.71547652b82fep+10;
    constexpr double ln2  =  0x1.62e42fefa39efp-11;

    const double k_R     = std::fma( static_cast<double>(x), iln2, R );
    const double k       = k_R - R;
    const double t       = std::fma( k, -ln2, static_cast<double>(x) );
    const double exp_s   = exp_table( std::bit_cast<uint64_t>(k_R) );
    const double expm1_t = expm1_taylor3( t );
    const double exp_x   = std::fma( exp_s, expm1_t, exp_s );
    return static_cast<float>( exp_x );
}

wslttyのスクロールバックを以前と同じ量にする

wslttyという、WSL用の端末エミュレータがあります。

GitHub - mintty/wsltty: Mintty as a terminal for Bash on Ubuntu on Windows / WSL

wslttyは、以前はスクロールバックが事実上無制限だったのですが、最近のバージョンだとたったの25万行しか保持できなくなっていました。 これは(あまり普通の使い方ではなさそうですが)私の使い方では大問題です。

他の端末エミュレータ

調べた範囲ではまともな端末エミュレータは他には存在しませんでした。

Windowsで使えるターミナルとシェルのまとめ - Qiitaを参考に以下を導入して調べましたが、全く話になりません。

端末エミュレータ 最大スクロールバック 備考
ConEmu 32766行 これを超える値を設定すると強制的にこの値になる
ConsoleZ 32766行 これを超える値を設定するとエラーとなる
Hyper 無限? yesコマンドに対するCtrl+Cの効きが悪い
(7秒くらいかかる)
PowerShell 実測で9031行 最大スクロールバックの量をいくら大きくしても無言で
この量になって邪悪。また、yesコマンドに対する
Ctrl+Cの効きが悪い(2秒くらいかかる)

wslttyの設定で直せる

wslttyはオープンソースですから、解決策を自力で探すことができます。 wslttyはminttyの薄いラッパーなので、minttyのソースを読む必要があります。

GitHubでscrollbackなどと検索してみると、以下のコードが怪しそうであることがわかります。

https://github.com/mintty/mintty/blob/eb8c5c81e561598704ff7f3384917debaaa4f529/src/config.c#L160

どうやらこのコミットでスクロールバックが25万行に制限されてしまったようです

github.com

MaxScrollbackLinesはGUIからは設定できませんが、configファイルを直接編集することで設定できます。 configファイルは、C:\Users\lpha\AppData\Roaming\wslttyにありました。 これに、MaxScrollbackLines=999999999を書き加えれば、スクロールバックが以前と同様に十分多く保持されるようになりました。

よく見るcarry-lookahead adderはBrent-Kung adder?

Nビット加算器の基本

Nビット整数二つを加算した結果を出力する回路を、Nビット加算器と呼びます。 Nビット加算器は、もっとも単純には、全加算器(full adder, FA)をN個直列につなげば作ることができます。 この構成法のことを、リプルキャリー加算器(ripple-carry adder, RCA)と呼びます。 リプルキャリー加算器は、Θ(N)の遅延を持ち遅いため、使われることは稀です。 ただし、最も小さな加算器構成法であるため、遅延が気にならない場合には優れた構成法です。

キャリー先読み加算器

リプルキャリー加算器が遅いのは、下の桁で生じたキャリーがわからないことには上の桁で生じるキャリーがわからない、という依存関係が直列に並んでいるためです。 キャリー先読み加算器(carry-lookahead adder, CLA)は、上の方の桁で生じるキャリーを何らかの方法で算出する回路を備えた、高速な加算器です。 上の方の桁で生じるキャリーを一足飛びに算出することで、高速な加算を実現します。 上の方の桁で生じるキャリーは、下の方の桁で生じるキャリーを使わずに*1、加算器の入力だけを用いて計算します。 上の方の桁で生じるキャリーを直接計算することは、原理的には可能ですが、指数的に多くの回路素子が必要となるため現実的ではありません。 実際には、以下のようなΘ(log N)段で計算できる方法を使うことが多いようです。

よく紹介されるキャリー先読み加算器では「4ビットをひとまとめのブロックとして、そのブロックから外にキャリーが出るか」を算出しています。 これだけだとキャリーの伝搬が4倍速くなっただけですが、この手順を再帰的に繰り返すことで、指数的な高速化を図ります。 このブロックが16進の全加算器のようなものになっていることに着目します。 このブロックを4つまとめてスーパーブロックとして、「スーパーブロックから外にキャリーが出るか」を算出することで、16ビット先のキャリーを算出することができます。 以下、64ビット先、256ビット先、……と指数的に上のビットでのキャリーを算出することができます。

キャリー先読みにより、より小さなビット幅の高速な加算器を作る問題に帰着されることになります。 したがって、これを再帰的に行えばΘ(log N)段の遅延で結果が出る加算器が完成します。

並列プリフィクス加算器

並列プリフィクス加算器(parallel prefix adder, PPA)は、高速加算器の構成体系です。 並列プリフィクス加算器に分類される加算器は無数にありますが、いずれも各桁のキャリーをΟ(log N)段で算出します。

したがって、広い意味でのキャリー先読み加算器の一種になります。

並列プリフィクス加算器は、キャリーの生成g(二つの入力のこの桁がどちらも1で、下の桁のキャリーにかかわらずこの桁でキャリーが出る)とキャリーの伝搬p(二つの入力のこの桁が片方だけ1*2で、下の桁のキャリーがそのままこの桁のキャリーとなる)のペアに関する特定の二項演算がモノイド(単位元があり、結合則が成り立つ)になることを利用した加算器です。 具体的には、(g0,p0)⊕(g1,p1)=(g0&p1|g1,p0&p1)という二項演算です。 結合則が成り立つということはどこから計算し始めてもよいということで、特に二分木状にマージすることを並列に行うことでΘ(log N)段の遅延で“総和”が求まります。 実際には、“総和”だけでなく、各部分和を求めることもΟ(log N)段でできます(prefix scan)。 並列プリフィクス加算器という名前は、これに由来します。

並列プリフィクス加算器として有名なものに、Sklansky加算器(Sklansky adder, 1960*3)、Kogge-Stone加算器(Kogge-Stone adder, 1973)、Brent-Kung加算器(Brent-Kung adder, 1982)があります。 また、Sklansky加算器とBrent-Kung加算器の中間的特性を持つLandner-Fischer加算器(1980)、Kogge-Stone加算器とBrent-Kung加算器の中間的特性を持つHan-Carlson加算器(1987)、Sklansky加算器とKogge-Stone加算器の中間的特性を持つKnowles加算器(1999)もあります。 これらの関係をまとめた論文が"A Taxonomy of Parallel Prefix Networks"であり、その際に三者すべての中間的特性を持つ謎の加算器も発見されました(Harris, 2003)。

以下では、最も極端な特性を持つ三種類の並列プリフィクス加算器(Sklansky加算器、Kogge-Stone加算器、Brent-Kung加算器)の特性をまとめてみます。

Sklansky加算器

  • ☹️回路素子量がΘ(N log N)
  • 😊回路面積がΘ(N log N)
  • ☹️最大ファンアウトがΘ(N)
    • ファンアウト16などは非現実的
  • 😊回路の段数が最短(モノイドの二項演算がlog N回)

Kogge-Stone加算器

  • ☹️回路素子量がΘ(N log N)
  • ☹️回路面積がΘ(N2)
    • 長さΘ(N)の長い配線がΘ(N)本必要なため
  • 😊最大ファンアウトが2
    • Sklansky加算器よりも高速に動作する
  • 😊回路の段数が最短(モノイドの二項演算がlog N回)

Brent-Kung加算器

  • 😊回路素子量がΘ(N)
    • 高速加算器の中では最も消費電力が少ないとされる
  • 😊回路面積がΘ(N log N)
    • 回路素子量がΘ(N)なのにΘ(N log N)の面積が必要なのは、配線の長さの合計がΘ(N log N)になるため
  • 😊最大ファンアウトが2
  • ☹️回路の段数が他のものと比べて約二倍(モノイドの二項演算が2 log N - 1回)

よく紹介されるキャリー先読み加算器はどれ?

4つごとにグループ化するタイプのキャリー先読み加算器は、これらのうちどの方式なのでしょうか。 そもそも、並列パラレルプリフィクス加算器と呼んでよいのでしょうか?

Fast Adder Architectures: Modeling and Experimental Evaluation(http://web.tecnico.ulisboa.pt/~ist14359/wordpress/nfvr_pubs/dcis03.pdf)という論文*4のFig. 1には、2つごとにグループ化するタイプのキャリー先読み加算器の構成法が載っています。 上から下に情報が流れていくのではなく、上から入力された情報がいったん下に集められてから上に戻され、上から加算結果が出力される、という方式になっており、ぱっと見ではどの入力とどの出力がつながっているのかや段数などが良くわかりません。

上から下に流れるタイプで書き直してみたのが以下の図1になります。

図1: 2つごとにグループ化するキャリー先読み加算器のプリフィクス演算ネットワーク

これはほぼBrent-Kung加算器です(後半部分についてボックスを置く位置がずれていますが、受信側に置くか送信側に置くかの違いであり、回路としては同じはずです)。 これが明示されている文献が見つからなかったので、回路の構成を展開して考えて初めて分かりました。 冷静に考えてみると、情報が下に集められた後に上に行くので、2 log N段くらいになりそうで、つまりSklansky型やKogge-Stone型ではないはずと予想できたかもしれません。

4つごとにグループ化するタイプのキャリー先読み加算器も、おそらく、4入力のプリフィクスボックスを使い、Brent-Kung型のネットワークを組んで動かしているのでしょう。

*1:実際には、回路規模削減のため、遅延が増えない範囲で下の方の桁で生じるキャリーの情報を再利用することが多いです。

*2:片方だけ1、ではなく少なくとも片方1、としたものをpの定義とすることが結構あります(実際、図1で使っているpの定義もそれです)。その場合、真の意味での「伝搬」を意味する情報ではなくなり、解釈がやや直観的ではなくなります。一方、特定の二項演算がモノイドであるという議論には影響がなく、またpをXORゲートより簡単なORゲートで作れるようになります。このため、「少なくとも片方1」をpの定義とすることがよく行われるようです。

*3:1960年に提案されたのは桁上げ選択加算器であって、並列プリフィクス加算器としてのSklansky加算器ではなさそうです。conditional sum adder (COSA)の選択信号を作るネットワークがSklansky型になっている、というのが命名の由来のようです。

*4:N. Roma, T. Dias and L. Sousa, "Fast Adder Architectures: Modeling and Experimental Evaluation", Proceedings of the XVIII Conference on Design of Circuits and Integrated Systems, 367–372 (2003).

Leading Zero Anticipation (LZA) の勉強

Leading Zero Count (LZC) は、二進法で表された符号なし整数の上位に0が何個連続しているかを数えることです。 例えば、32ビット符号なし整数で考えると、LZC(0x80000000)は0、LZC(0x7fffffff)は1、LZC(5)は29、LZC(2)は30、LZC(1)は31、LZC(0)は32、などです。

これに対し、Leading Zero Anticipation (LZA) は、二進法で表された符号つき整数A, Bを受け取り、A+Bを直に計算することなしにLZC(A+B+1)を(多少の誤差の範囲で)予測することです*1。 A+Bの計算なんて非常に軽量(普通のCPUなら1 cycleで可能)なのだから省略する意味はないのでは、と思うかもしれません。 しかし、 Nビットの加算器のハードウェアは \Theta(\log N)段のゲートからなるのが普通*2なので、意外とレイテンシが長いです。 A+Bの計算を省略したいのはこのためであり、つまりハードウェアを作る場合にのみ利用価値のあるアルゴリズムだということです。

実際にどのような場合に役立つのかを見てみると、低レイテンシの浮動小数点数加算器を作るときに使われるようです。 普通に浮動小数点数加算器を作ろうと思うと、1. 入力を桁合わせする、2. それらを加算器に入力する、3. その結果のLZCを求める、4. 加算結果をLZC分だけシフトする、5. 丸める、という手順で仮数部を算出します。 ここで、「2. 加算器に入力する」→「3. その結果のLZCを求める」という部分が直列になっています。 LZAを使えば、加算の計算と並列してLZAの計算ができるので、その分だけ4. の手順に早くたどり着くことができます。 図1は、これを表した図です。

図1: (a)LZAを用いた場合 (b)LZCを用いた場合(参考文献[1]より引用)

以下、参考文献[1]で紹介されているアルゴリズムを紹介します。 このLZAアルゴリズムは、LZC(A+B+1)またはLZC(A+B+1)-1を予測します。

アルゴリズム

仮定

LZAの計算では、A+B+1の計算でオーバーフローが起きないことを仮定します。 浮動小数点数加算器を作ることにLZAを利用する場合、これを仮定することは妥当です。

  • AとBが同符号(足し算)の場合:繰り上がりすることを見越してAやBよりも1ビット広い加算器を使うはずなので、オーバーフローは発生しない。
  • AとBが異符号(引き算)の場合:適宜左右オペランドを交換するはずなので、LZAの入力は A\ge-Bと仮定でき、オーバーフローは発生しない。

なお、LZAの出力は、使う加算器のビット幅を Nビットとして、 \log Nビットの整数(0~N-1)です。A+B+1は0になることがないため、LZCと違い Nを出力することがありません。

概略

LZAのアルゴリズムは、以下の手順によります。

  1. まず、A+B+1の近似値Eを計算する。
  2. 次に、LZA(A, B)をLZC(E)で求める。

非常にシンプルでわかりやすいアルゴリズムです。 もちろん、重要なのは、どのようにEを計算するかです。 Eの具体的な計算手順は後で示しますが、重要なのはEが以下の条件を満たすように計算されることです。

A+B+1 ≦ E ≦ 2A+2B+1

この時、LZA(A, B) ≦ LZC(A+B+1) ≦ LZA(A, B)+1となります。 なぜなら、以下が成り立つからです。

  • A+B+1 ≦ Eなので、LZC(A+B+1) ≧ LZC(E) = LZA(A, B)が成り立つ。
  • E ≦ 2A+2B+1なので、LZC(A+B+1) = LZC(2A+2B+1)+1であることに注意して、LZC(A+B+1) = LZC(2A+2B+1)+1 ≦ LZC(E)+1 = LZA(A, B)+1が成り立つ。

Eの計算

具体的なEの計算方法の説明をします。 重要なのは、 O(1)段の回路で計算しないと意味がないということです。

足し算の場合

足し算の場合、Eの計算は適当で大丈夫です。 例えば、E = (i|j)<<1|1とかで十分です。 ほかにもいろいろ解があります。

実際には、引き算の時と回路を共有したいです。 引き算の時用の回路の方が複雑になるので、まずは引き算の時用の回路を作ってみて、それが足し算の時でも正しく動くかを確認することにしましょう。

引き算の場合

変数Xの2iの位を X_iと表すことにします。  E_iは、 A_i, B_i, A_{i-1}, B_{i-1}のみを用いて、ビット毎に計算します。作戦としては、

  • A+B+1の計算結果で、上位の0が連続するビットには、1を立てない
  • A+B+1の計算結果で、最も上位の1が立つビットかその一つ左に、1を立てる
  • 下位は適当でよい(0でも1でもよい)ので、上の二条件を満たす簡単な回路を流用する

という感じです。

さて、どのようにA+Bを計算しないままに、A+B+1の計算結果が0になるところを見分けるのかということになります。 ここで、Aと-Bが打ち消しあって(浮動小数点数演算の用語でいうところの桁落ちが発生して)上位に0に並ぶパターンは、以下の二つしかないことに注目します( A\ge-Bが満たされていることに注意します)。

  • Aの上位が100000...となっていて、-Bの上位が011111...となっている
    • AとBは上位が100000...となり、XOR(A,B)は上位に0がならぶ
  • Aと-Bの上位ビットが完全に同じ
    • XOR(A,B)は上位に1がならぶ

そこで、次のようにすれば、「A+B+1の計算結果で、上位の0が連続するビットには、1を立てない」を満たせそうです。

  •  A_i B_iが同じで、 A_{i-1} B_{i-1}がどちらも0なら、 E_iは0
  •  A_i B_iが違うなら、 E_iは0

「 A+B+1の計算結果で、最も上位の1が立つビットかその一つ左に、1を立てる」を満たすには、次のようにすればよいです。

  •  A_i B_iが同じで、 A_{i-1} B_{i-1}のどちらかが1であれば、 E_iは1

本当にこれでよいのでしょうか。別の視点から考えてみます。

Aのiビット目以上とBのiビット目以上を足すと0になるとします。 ここで、Aの 2^{i-1}の位かBの 2^{i-1}の位のどちらかが1であるパターンを考えます。 この時、 2^{i-1} \lt A+B+1 \lt 2^{i+1}となるので、Eの 2^iの位を1にすればよいです。 なぜなら「 2^iの位に繰り上がりが発生して、A+B+1の最上位の1は 2^iの位(LZCはLZAに等しい)」「 2^iの位に繰り上がりが発生せず、A+B+1の最上位の1は 2^{i-1}の位(LZCはLZAより1大きい)」のどちらかが発生し、いずれの場合でも出力の要求を満たすからです。 もう一方のパターン、つまりAの 2^{i-1}の位とBの 2^{i-1}の位のどちらもが0である場合は、Aのi-1ビット目以上とBのi-1ビット目以上を足しても0になるので、iを一つ減らして同じことを考えればよいです。

よって、

  •  E_i = \lnot (A_i \oplus B_i) \land (A_{i-1} \lor B_{i-1})

とすればよいです。先と同じ結論が得られました。なお E_0は範囲外アクセスですが、1としておけば要求を満たせます( E_0はLZAが Nになってしまうのを防ぐためにしか使われません)。

足し算の場合の確認

Eは、Aの最上位ビットかBの最上位ビットのどちらか上にあるほうの一つ上の位に1が立つので、これで大丈夫です。

C言語ソースコード

#include <stdio.h>
#include <assert.h>

unsigned lza(unsigned A, unsigned B) {
        unsigned E = ~(A^B) & (A|B)<<1 | 1;
        assert( A+B+1 <= E && E <= A+A+B+B+1 );

        // calculate LZC(E) without addition
        unsigned Q2  = (E  & 0xaaaaaaaa) | (E <<1 & 0xaaaaaaaa) | (~E  & 0xaaaaaaaa)>>(2-1);
        unsigned Q4  = (Q2 & 0x88888888) | (Q2<<2 & 0x88888888) | (~Q2 & 0x88888888)>>(4-2)
     | (((Q2 & 0x88888888)>> 3)*1 & Q2>>2 | ((~Q2 & 0x88888888)>> 3)*1 & Q2) & 0x11111111;
        unsigned Q8  = (Q4 & 0x80808080) | (Q4<<4 & 0x80808080) | (~Q4 & 0x80808080)>>(8-3)
     | (((Q4 & 0x80808080)>> 7)*3 & Q4>>4 | ((~Q4 & 0x80808080)>> 7)*3 & Q4) & 0x03030303;
        unsigned Q16 = (Q8 & 0x80008000) | (Q8<<8 & 0x80008000) | (~Q8 & 0x80008000)>>(16-4)
     | (((Q8 & 0x80008000)>>15)*7 & Q8>>8 | ((~Q8 & 0x80008000)>>15)*7 & Q8) & 0x00070007;
        return Q16 & 15;
}

unsigned lzc(unsigned x) {
        if( x & 1<<15 ) { return  0; }
        if( x & 1<<14 ) { return  1; }
        if( x & 1<<13 ) { return  2; }
        if( x & 1<<12 ) { return  3; }
        if( x & 1<<11 ) { return  4; }
        if( x & 1<<10 ) { return  5; }
        if( x & 1<< 9 ) { return  6; }
        if( x & 1<< 8 ) { return  7; }
        if( x & 1<< 7 ) { return  8; }
        if( x & 1<< 6 ) { return  9; }
        if( x & 1<< 5 ) { return 10; }
        if( x & 1<< 4 ) { return 11; }
        if( x & 1<< 3 ) { return 12; }
        if( x & 1<< 2 ) { return 13; }
        if( x & 1<< 1 ) { return 14; }
        if( x & 1<< 0 ) { return 15; }
        return 16;
}

int main() {
        // Add
        for( unsigned i = 0; i < (1<<15); ++i ) {
                for( unsigned j = 0; j < (1<<15); ++j ) {
                        assert( lzc(i+j+1) == lza(i,j) || lzc(i+j+1) == lza(i,j)+1 );
                }
        }
        // Sub
        for( unsigned i = 0; i < (1<<15); ++i ) {
                for( unsigned j = 0; j <= i; ++j ) {
                        assert( lzc(i+~j+1) == lza(i,~j) || lzc(i+~j+1) == lza(i,~j)+1 );
                }
        }
}

参考文献

  • [1] Hiroaki Suzuki, Hiroyuki Morinaka, Hiroshi Makino, Yasunobu Nakase, Koichiro Mashiko, Tadashi Sumi, "Leading-Zero Anticipatory Logic for High-Speed Floating Point Addition", IEEE Journal of Solid-State Circuits 31(8), 1996.

*1:「+1」部分を不思議に思うかもしれませんが、LZC(X-Y)を予測するためにLZA(X, ~Y)のように入力したいからです。コーナーケースであるLZC(0)を回避する意味合いもあるかもしれません。

*2:最上位ビットの計算は入力のすべてのビットの影響を受けるため、入力数が定数個以内のゲートを使う限り、明らかにo(log N)段は無理です。また、プリフィックス加算器と呼ばれる構成を使えば、O(log N)段は達成できます。

128bit除算も定数除算最適化したい

一般に整数除算(ここでは剰余演算も含むこととします)を行う命令は遅いです。 そのため、除算はなるべく回避したいものです。

除数がコンパイル時定数の場合、コンパイラの最適化により、除算が取り除かれることがあります。 これを定数除算最適化と呼びます。

定数除算最適化では、除算はいくつかの軽量な命令に分解されます。 二のべき乗で割る場合、単にシフト命令が使われます。 それ以外の数で割る場合、乗算とシフト命令が使われます。 符号付きの除算の場合、零への丸めの部分の取り扱いが面倒ですが、追加のシフト命令と加算命令だけで(分岐命令を使わずに)なんとかできるようです。

さて、この定数除算最適化は、128bit整数の除算の場合は常には行われないようです。 これを何とかしてみたいと思います。 もちろん、手で最適化されたコードを書けばできますが、コンパイル時計算により除数が決まる場合などに対応するのは困難という問題があるなど、柔軟性に欠けます。 そこで、128bit除算を行う、64bit除算のみを用いたC言語ソースコードを作ることにします。 こうすれば、マジックナンバーの導出はコンパイラにまかせることができます。

前提:コンパイラが最適化する場合としない場合

clangは、二のべき乗の時にシフトにする最適化だけ行うようです。 gccも、二のべき乗の時にシフトにする最適化を行います。 それに加えて、除数が小さい時は最適化してくれることが多いようです。 66以下の自然数で割る場合はすべて最適化してくれました。 67,83,101,……で割る場合は__udivti3 (128bit÷128bitを計算する外部ルーチン)の呼び出しになりました。 大きな数でも最適化してくれる数はいくつか存在するようです。 また、二のべき乗以外の時に最適化で出力されるコードは、25~40命令程度です。

実装

基本的な考え方は、64bit÷64bitを基本演算とした長除算を書けばいい、というものです。 これを実装したのが以下のコードになります。

void partial_divide( __uint128_t* R, __uint128_t* Q, unsigned long long D, int shamt ) {
    assert((unsigned long long)(*R >> shamt) == (*R >> shamt));
    *Q += (__uint128_t)((unsigned long long)(*R >> shamt) / D) << shamt;
    *R = *R - (*R >> shamt << shamt) + ((__uint128_t)((unsigned long long)(*R >> shamt) % D) << shamt);
}

void my_divrem( __uint128_t* R, __uint128_t* Q, __uint128_t X, unsigned D ) {
    *R = X;
    *Q = 0;
    partial_divide(R, Q, D, 64);
    partial_divide(R, Q, D, 32);
    partial_divide(R, Q, D, 0);
}

このコードは、Dが32bit整数しか受け付けないという制約がありますが、かなり高速に動作します。

性能測定

実験に使ったコードは以下です。

__uint128_t S = (__uint128_t)1 << 125; // ※グローバル変数にしないと最適化されてしまうので注意
__uint128_t E = S + 1000 * 1000 * 1000;

int main() {
    auto start = std::chrono::system_clock::now();
    __uint128_t sum = 0;
    for( __uint128_t t = S; t < E; ++t ) {
#ifdef G
        sum += t / DD;
#else
        __uint128_t R, Q;
        my_divrem(&R, &Q, t, DD);
        sum += Q;
#endif
    }
    auto end = std::chrono::system_clock::now();
    std::cout << (unsigned long long)sum << " " << std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count() << std::endl;
}

私の書いたコードのコンパイルには、clang++12.0.1をおよびg++12.1.0を使いました。コンパイルオプションは-std=c++20 -O3 -march=nativeです。 コンパイルは、AMD EPYC 7702上で行いました(-march=nativeをつけても、以下で使うどのCPU上でも動くコードができました)。

速度の比較相手は、コンパイラの自動最適化が出力した機械語コードです。 これを作るためには、g++12.1.0を使いました。コンパイルオプションは-std=c++20 -O3 -march=native -DGです。

実行にかかった時間は以下のようになりました。

DD=3の場合↓

使ったCPU 自動最適化 my_divrem(clang) my_divrem(gcc)
AMD EPYC 7702 3.0s 3.5s 6.2s
Intel Xeon E5-2643 v3 3.1s 3.4s 5.9s
Intel Core i9-12900KF 1.3s 1.6s 1.9s

DD=67の場合↓

使ったCPU 自動最適化 my_divrem(clang) my_divrem(gcc)
AMD EPYC 7702 27s 3.7s 6.3s
Intel Xeon E5-2643 v3 33s 3.5s 6.3s
Intel Core i9-12900KF 3.9s 1.7s 1.9s

DD=3の場合、my_divrem(clang)は自動最適化よりやや遅い程度となりました。 「自動最適化」はさすがにコンパイラが出力したコードだけあって速いです。 とはいえ、私の強引な実装もそれほど遅くはないようです。 my_divrem(gcc)はかなり遅いですが、これはスピルが大量に発生してしまったのが原因です。 __uint128_tを取り扱うコードはレジスタ圧が高いため、レジスタ割り付けが難しいようです。

DD=67の場合、my_divremはclangもgccも両方とも自動最適化より速くなりました。 「自動最適化」は定数除算最適化をしていないため、除算命令を実行する必要があり低速です。 最近のIntelのCPUでは除算命令が高速化されたのか、除算命令にコンパイルされてもある程度の速さですが、依然として定数除算最適化をした場合よりも二倍以上遅いです。

もっと大きな数での剰余

Dが32bitよりも少し大きいくらいの場合であれば、partial_divideを呼ぶ回数を増やすことで対処可能です。 例えば、Dが42bitに収まる整数であれば、次にようにすればよいです。

void my_divrem( __uint128_t* R, __uint128_t* Q, __uint128_t X, unsigned long long D ) {
    *R = X;
    *Q = 0;
    partial_divide(R, Q, D, 66);
    partial_divide(R, Q, D, 44);
    partial_divide(R, Q, D, 22);
    partial_divide(R, Q, D, 0);
}

つまり、Dがkビットであれば、(64-k)ビットずつずらしながら除算を行えばよいです。 どこまでやればいいかというと、(64-k)*Nが64以上になるところまでです。

剰余環における演算がしたい場合

剰余環における乗算を実装するために128bit整数が必要となっている場合、さらに最適化することができます。 法をMとして、Mが42bitに収まる整数であれば、次のようにして問題ありません。

unsigned long long mul_with_mod( unsigned long long x, unsigned long long y, unsigned long long M ) { // x,yはM未満とする
    __uint128_t Q, R = (__uint128_t)x * y; // Rは84bitに収まる
    partial_divide(&R, &Q, M, 22); // 84-22=64なので64bit除算でOK
    partial_divide(&R, &Q, M, 0); // 上の除算の余りRは42+22=64で64bitに収まっている
    return R;
}

Mがkビットである時、(64-k)ビットずつずらしながら除算をするという点は先ほどと同じです。 一方、どこまでやるかが違っていて、(64-k)*Nが2k-64以上になるところまでです。

例えば、法Mが48bitに収まる整数であれば、partial_divideを三回に増やします。このとき、shamtは32, 16, 0となります。 法Mが51bitに収まる整数であれば、partial_divideを四回に増やします。このとき、shamtは39, 26, 13, 0となります。

まとめ

128bit整数を、32bitに収まる整数定数で割るプログラムを最適化しました。 具体的には、コンパイラが最適化しやすいようなコード(partial_divide)から構成される除算関数(my_divrem)を作りました。 その結果、gccが最適化できないような定数除算(例えば、67で割る)プログラムにおいて除算命令を回避し、大幅に高速化できることが分かりました。 gccが最適化する定数除算(例えば、3で割る)プログラムでも、速度低下はわずかでした。 定数除算最適化を行わないclang向けにコンパイルする場合に利用価値が高そうです。

また、32bitより少し大きいくらい(例えば、42bit)の整数で割る場合にも応用できることを示しました。 さらに、重要な応用として、剰余環における乗算の実装の場合、一般の場合より演算回数を少なくできることを示しました。

cat a.out を防ぐ(その2)

また実行形式ファイルをcatしてしまってコンソールが大変なことになったので、対策を強化しました。

今回はa.outという名前ではなかったので、前回(cat a.out を防ぐ - よーる)の対策が働きませんでした。

どういう文字列がコンソールを破壊するのかを調べてみると、[ESC]cという文字列が原因のようです(こんなに短い文字列で発生すると思っていませんでした。実行ファイルをcatするとほぼ毎回、コンソールが大変なことになるのは、標準ライブラリ由来の文字列が原因だと思っていました)。 参考:ANSI escape code - Wikipedia いろいろ実験してみると、[ESC]cの間に印字可能文字でないものが挟まっていても同様の動作をするようです。

cat a.outだけに対処するのではなく、根本的にこういった問題に対処するため、.catを以下のように変更しました。

cat $args | sed "s/\x1b//g"

コンソールが壊れる原因は[ESC]0x1b)なので、それを取り除きます。 こうすることで、実行形式ファイルだけでなく、圧縮ファイルなどに含まれる問題のある文字列にも対処することができるようになりました。