IEEE浮動小数点数における平方根演算の精度に関する覚書

IEEE浮動小数点数における演算では、丸め誤差が不可避です。特に、複数回の演算を繰り返すと丸め誤差が積もっていき、正確な値と大きく離れた答えを得てしまうことがあります。しかし、次の演算については、(数学的に)正確な値を求めた後、一回だけの丸めが発生することが、IEEE標準で規定されています。

浮動小数点数演算のできるCPUであれば、普通、四則演算や積和演算を行う命令を持っています。 しかし、平方根を正確に計算する命令を持たない命令セットも存在します。

そのような場合、平方根関数はライブラリ実装となるわけですが、どのように実装すれば要求を満たせるのでしょうか?

C++std::sqrtは正確に計算しているのか?

結論

しています。

標準の丸めモード、つまり最近接丸め(ぴったり半分ならば偶数へ丸める)を用いた時、単精度浮動小数点数floatについては全ての値で正しく丸められていることを確認しました。

確認は、Wandboxのclang7.0.0で行いました。

その他の性質

std::fmaとの関係

また、任意の単精度浮動小数点数xに対して、次のことが成り立つようです。

std::sqrt(x)は、std::abs(std::fma(s,s,-x))を最も小さくする正のsである。

これは自明ではありません。丸め誤差が0.499999999ulpであるほうに丸めるより、-0.500000001ulpであるほうに丸める方がstd::abs(std::fma(s,s,-x))が小さくなる、という場合があり得るわけです。そのようなぎりぎりの位置に平方根が存在することがないことは証明できるのでしょうか。四則演算の場合はそんな感じの定理があったような……?

これは実験的に確かめたことであり、少し怪しいです。特に、xが非正規化数である場合はそのようなsが複数個あることがあります(s*s-xを無限精度で計算できる場合には、sが正かつfloatで表現可能、という条件のもとそのようなsは一意に定まるはずですが、std::fma(s,s,x)がほぼ0(絶対値が最小の正の非正規化数よりも小さく、丸めると0.0f-0.0fになる値)になり、精度が不足することが原因のようです)。

丸め誤差の最大値

丸め誤差が0.5ulp(丸める桁の0.5倍)にかなり近い状況は発生することがあります。 たとえばx = 0x1.000002p+0f1.0f + std::numeric_limits<float>::epsilon()とかstd::nextafter(1.0f,2.0f)などとも表せます。1+2^-23です)な場合にstd::sqrt(x) == 1.0fになりますが、その丸め誤差は0.499999985ulpになります。

平方根を取ってから二乗するとどうなるか

平方根を計算してから、その値を二乗すると元に戻らないことがあります。 たとえば、x = 0x1.ffd508p+0fに対して、std::sqrt(x) == 0x1.69fab4p+0f(真の値は大体0x1.69fab4fffe986p+0くらい)であり、std::sqrt(x)*std::sqrt(x) == 0x1.ffd506p+0fになりますが、左辺の結果を丸める前は0x1.ffd5052c0e90p+0であり、真の値から1.413951ulpもずれた値になっています。

しかし、このずれはほぼ√2 ulpに収まります。つまり、1.5ulpを超えることはないので、xstd::sqrt(x)*std::sqrt(x)の間の違いは最大でも1ulpに収まります。

二乗してから平方根を取るとどうなるか

なんと必ず元の値に戻ることが証明されているようです。

https://fixedpoint.jp/2016/03/18/sqrt-of-square-of-fp-number.html

もちろん、二乗した値が0になったり、INFINITYになった場合は元に戻ることはありません。

どういったアルゴリズムで計算できるのか

超越関数の場合、意地悪な引数を与えられると丸め誤差が0.5ulpに非常に近くなり、どちらに丸めてよいかを判断するために追加で計算する桁数が多くなる例があるようです。 平方根関数の場合、そのようなことはなく、仮数部の桁数から計算可能な定数桁を計算すれば丸める方向が決まるのでしょう(調べられませんでしたが、IEEE平方根関数の丸めは正確に行うと決められているのですから、何かしらの裏付けがあるのだと思います)。

単純な方法としては、整数上で十分な精度で計算してそれを浮動小数点数に変換するというものがあります。 しかし、この方法は明らかに遅く、実用に耐えるものとは言えません。 なんとか、浮動小数点演算をうまく活用して平方根関数を実現できないものでしょうか。

以下のような実装をしてみたところ、全ての浮動小数点数の値でstd::sqrtと値が一致しました。ただし、std::sqrtと違い、エラー報告をしない点は動作が違いますので注意してください。 負の数を入れると-nanが返ってくるんですね。

float apploxInvSqrt( float x ) {    
    static_assert( sizeof(float) == sizeof(uint32_t) );
    uint32_t i;
    std::memcpy( &i, &x, sizeof i );
    i = 0x5f3759df - ( i >> 1 );
    float f;
    std::memcpy( &f, &i, sizeof f );
    
    return f;
}

float invSqrt( float x ) {
    // overflows if x <= 0x1.d1cc6p-129 
    assert( isnormal(x) && x > 0 );

    float f = apploxInvSqrt(x);
    f *= 1.5f - 0.5f * x * ( f * f );
    f *= 1.5f - 0.5f * x * ( f * f );
    f *= 1.5f - 0.5f * x * ( f * f );    
    return f;
}

float mySqrt( float x ) {
    if( x == 0 ) { 
        // same sign
        return x;
    }
    if( x < 0 ) {
        return std::copysign( std::nanf(""), -1.0f );
    }
    if( std::isinf( x ) ) {
        return x;
    }
    if( x < std::numeric_limits<float>::min() ) {
        return std::ldexp( mySqrt( std::ldexp( x, 32 ) ), -16 );
    }
    const float q = invSqrt(x);
    const float t = std::fma( x*q, x*q, -x );
    const float X = x*q - std::ldexp( q*t, -1 );
    
    return X;
}

apploxInvSqrt関数は、Fast Inverse Square Rootと呼ばれるビット演算テクニックを使っています(C++ではビット列を読み替えるためにキャストやunionを使うのはstrict alias rulesに反しますので、std::memcpyを使います。C++20ではstd::bit_castというそのものずばりの関数が導入されるのでそのような間違いは減ってゆくでしょう)。

invSqrt関数では、apploxInvSqrtで得られる初期値をもとに、ニュートン・ラフソン法を用いて逆数平方根の精度を上げています。もともと4bitは精度があるため、3回加速すれば8bit, 16bit, 32bitと精度が上がり、24bit精度のfloatでは十分な精度になります。 逆数平方根の精度が重要な場合は、最後の加速は丁寧に行う必要がありますが(参考:逆数平方根の収束公式と丸め誤差)、今回は平方根を求めることができればいいため、細かいことは気にしない実装になっています。

mySqrt関数では、基本的にはmySqrt(x) = x * invSqrt(x)の式によって計算を行います(これは、低速な除算を避けるためです。平方根自体をニュートン・ラフソン法で求めるのではなく、逆数平方根ニュートン・ラフソン法で求めたのも除算を避けるためです。平方根ニュートン・ラフソン法で求めたい場合、除算が必要になってしまいます)。 これだけだと最後の一桁が正しくならないことがあるので、(一次の)ニュートン法で補正します。微分係数は1/2√xであり、二次収束が必要な場合は1/2(x*q)の計算が必要になりますが、一次収束で十分なのでq/2で代用できます。 なお、非正規化数の平方根を求める場合、逆数平方根を介すと大きくなりすぎて表現できなくなる危険性があります。そのため、非正規化数であった場合は232をかけた値の平方根を求め、それを216で割ることにより、この問題を回避しています。

標準でない丸めモードを使うとどうなるのか

最近接丸め(丸め誤差がぴったり0.5ulpになることは平方根演算では発生しないので、偶数丸めでも無限大への丸めでもどちらでもよい)の場合は上記の方法で求められることが分かりました。

方向丸め(正の無限大への丸め、負の無限大への丸め、零への丸め)の場合もうまくいくのでしょうか?

結論から言うと、上記の方法では正しく求められません。

平方根関数の丸めモードを指定する場合、以下のようなコードになるでしょう。

std::fesetround(FE_TOWARDZERO);
std::cout << std::sqrt(2.0f) << std::endl;

このように書いた場合、std::sqrt関数がライブラリ実装されており、その中で浮動小数点演算を行っている場合、その浮動小数点演算の丸めモードが意図せず変わってしまうことになります。

たとえば、零への丸めの場合、上記の方法ではx = 1.0fの時、q = invSqrt(x)についてq == 0x0.fffffep+0となってしまい、t = std::fma(x*q,x*q,-x)についてt == -1e-23になり、mySqrt(x) == 0x0.ffffffp+0となり、正しくありません。

tは正負両方の値を取りうるため、三つの丸めをすべて異なる方法で取り扱う必要が出てきます。いったいどうやったらよいのでしょう……?分岐してよいのなら、近似値を計算した後にstd::fmaで検算をして、ずれていれば1ulp増減させるという方法がありますが……。

やはりライブラリ実装をするのならば、整数上で計算するのが無難だということでしょうか。

しかしstd::sqrtは正しく計算するようです。しかも高速です。SSE命令のsqrtss命令を使っているからのようです。

そういえば先ほどの実装ではstd::fmaはちゃんと精度よく求まることを前提としていましたが、std::sqrtがライブラリ実装のアーキテクチャstd::fmaだけはハードウェア命令で実現されるなんてことはあるのかと思われるかもしれません。実際、Intel Itanium (IA-64)では、FMA命令はあるものの、FDIVやFSQRT命令は存在せず、そのようなアーキテクチャの一例になっています。

*1:ただし、-0に平方根演算を適用すると-0が返るらしい。

bit列のパターンマッチング分解を行う方法

先週の話題

bit列のマッチングを行う方法 - よーる

に引き続き、bit列操作の可読性を上げようという試みです。

パターンマッチを行い、特定のパターンに合致したと判明した時、残りの部分を(そのパターン特有の方法で)分解することは、特に関数型言語においては頻出の操作です。

bit列操作において頻出の操作であるかについてはやや疑問が残るところですが、使いたくなったので作ってみました。

まず、指定されたbitを抽出する、extract_bits関数を作ります。

#include <cstdint>
#include <utility>

constexpr uint64_t make_mask( const char* str, char C, uint64_t acc = 0 ) {
    return *str ? make_mask( str+1, C, acc<<1 | *str==C ) : acc;
}

template<uint64_t Mask, std::size_t... Indeces>
constexpr uint64_t extract_bits_impl( uint64_t bits, std::index_sequence<Indeces...> ) {
    uint64_t result = 0;
    int i = 0;

    ( (result |= Mask&1ull<<Indeces ? !!(bits&1ull<<Indeces)<<i++ : 0), ... );
    return result;
}

template<char C, class Str>
constexpr uint64_t extract_bits( Str pattern, uint64_t bits ) {
    return extract_bits_impl<make_mask( pattern(), C )>( bits, std::make_index_sequence<64>() );
}

#include <iostream>
int main() {
    uint64_t x;
    std::cin >> x;
    std::cout << extract_bits<'a'>( []{return"aaaabbbb";}, x ) << std::endl;
    std::cout << extract_bits<'b'>( []{return"aaaabbbb";}, x ) << std::endl;
    std::cout << extract_bits<'a'>( []{return"aaaaaaaabbbbbbbb";}, x ) << std::endl;
    std::cout << extract_bits<'b'>( []{return"aaaabbbbaaaabbbb";}, x ) << std::endl;
}

このコードは、実行時の効率にも注意を払っています。たとえば、for文で64回ループを作るのではなく、コンマ演算子の畳み込みを用いることで、ループアンローリングを強制します。 このようにすることで、コンパイラの定数伝搬最適化を助けることができます。

実際、このコードをclang++ -std=c++1z -O2 -S -masm=intel(バージョンは4.0.1、古い……)でコンパイルすると、以下のようなコードになります。

# extract_bits<'a'>( []{return"aaaabbbb";}, x )
mov   edx, dword ptr [rbp - 8]
shr   edx, 4
and   edx, 15

# extract_bits<'b'>( []{return"aaaabbbb";}, x )
mov   rdx, qword ptr [rbp - 8]
and   edx, 15

# extract_bits<'a'>( []{return"aaaaaaaabbbbbbbb";}, x )
mov   eax, dword ptr [rbp - 8]
movzx edx, ah  # NOREX

# extract_bits<'b'>( []{return"aaaabbbbaaaabbbb";}, x )
mov   rdx, qword ptr [rbp - 8]
mov   eax, edx
and   eax, 15
shr   rdx, 4
mov   ecx, edx
and   ecx, 16
or    rcx, rax
mov   eax, edx
and   eax, 32
or    rax, rcx
mov   ecx, edx
and   ecx, 64
or    rcx, rax
and   edx, 128
or    rdx, rcx

上三つのコードは最適コードになっています。最近のコンパイラはさすがの最適化性能です。最後のものは上位4bitの計算部分が最適とは言えなくなっていますが、抽出すべきbit部分が分離している時点で相当難しいことを要求しているため、これくらいは仕方がないでしょう(後半のor命令では不要に64bitレジスタを使っているところからもコンパイラが"見抜け"ていないことがうかがえます)。

パターンマッチを行うところまで実装すると、以下のようになります。

#include <cstdint>
#include <utility>
#include <array>

constexpr uint64_t make_mask( const char* str, char C, uint64_t acc = 0 ) {
    return *str ? make_mask( str+1, C, acc<<1 | *str==C ) : acc;
}

template<uint64_t Mask, std::size_t... Indeces>
constexpr uint64_t extract_bits_impl( uint64_t bits, std::index_sequence<Indeces...> ) {
    uint64_t result = 0;
    int i = 0;

    ( (result |= Mask&1ull<<Indeces ? !!(bits&1ull<<Indeces)<<i++ : 0), ... );
    return result;
}

template<char C, class Str>
constexpr uint64_t extract_bits( Str pattern, uint64_t bits ) {
    return extract_bits_impl<make_mask( pattern(), C )>( bits, std::make_index_sequence<64>() );
}



constexpr uint64_t make_mask( const char* str, uint64_t acc = 0 ) {
    return *str ? make_mask( str+1, acc<<1 | *str=='0' | *str=='1' ) : acc;
}

constexpr uint64_t make_bits( const char* str, uint64_t acc = 0 ) {
    return *str ? make_bits( str+1, acc<<1 | *str=='1' ) : acc;
}

template<class Str>
constexpr bool match( Str pattern, uint64_t test_bits ) {
    constexpr uint64_t mask = make_mask( pattern() );
    constexpr uint64_t bits = make_bits( pattern() );
    
    return (test_bits&mask) == bits;
}

template<char... Chars>
class Match {
    bool success;
    std::array<uint64_t, sizeof...(Chars)> decomp;
public:
    template<class Str>
    constexpr Match( Str pattern, uint64_t test_bits )
        : success( match( pattern, test_bits ) )
        , decomp { extract_bits<Chars>( pattern, test_bits )... }
        {}
        

    template<std::size_t N>
    constexpr auto get() const {
        if constexpr( N == 0 ) { return success; }
        else { return std::get<N-1>( decomp ); }
    }    
};

namespace std {
    template<char... Chars>
    class tuple_size<Match<Chars...>> : public std::integral_constant<std::size_t, sizeof...(Chars) + 1> {};
    
    template<std::size_t N, char... Chars>
    class tuple_element<N, Match<Chars...>> {
    public:
        using type = decltype( std::declval<Match<Chars...>>().template get<N>() );
    };
}

#include <iostream>
int main() {
    const uint64_t x = 0b00000000101000000000010110010011;
    auto [ADDI, rd, rs1, imm] = Match<'d','a','i'>( []{return"iiiiiiiiiiiiaaaaa000ddddd0010011";}, x );
    if( ADDI ) {
        std::cout << "ADDi x" << rd << ", x" << rs1 << ", " << imm << std::endl;
    }
}

[Wandbox]三へ( へ՞ਊ ՞)へ ハッハッ

ビット列のパターンマッチング分解なんて何に使うのかと思った方もいらっしゃったかもしれませんが、このように機械語のビット列をパースするのに使えます。 main関数では、RISC-VというISAのADDi x11, x0, 10という命令の機械語をパースしています(本来は符号拡張を実装しないといけないですが……)。

bit列のマッチングを行う方法

bit列操作のような低級なことをやっていると、bit列に対してマッチングを行いたいことがあります。 たとえば、以下のような例です。

t=0b11011010pattern=0b11xx1010にマッチするか?→マッチする。

このようなことを行うためには、pattern中に出現するx0に、それ以外を1に置き換えたmask=0b11001111pattern中に出現するx0に置き換えたbits=0b11001010を作成し、 (t&mask)==bitsであるかを確かめれば十分であり、一般的には最高速です(pattern=0b0000xxxx*1などの特殊な場合には、符号なし比較だけで判定できるため、常に最高速ではありません)。

さて、このようなmaskbitsを手作業で作成するのは発見しづらいミスのもとであり、ソースコードの意図も分かりづらくなります。 ソースコード中にpatternを文字列として記述できれば、そのような問題を解決できます。

しかし、そのような方法を用いた場合、実行時に処理されるため、低速になることが予想されます。 bit列操作のような低級なことをやっている場合、高速化のために行っていることが多く、いくらソースコードが見やすくなっても低速なのでは意味がないという状況も考えられます。

こういったときに便利なのがconstexprです。以下のように少しコードを書くだけで、上記の問題を解決することができます。以下のコードはC++17以降で動作します *2

#include <cstdint>
#include <cassert>

constexpr uint64_t make_mask( const char* str, uint64_t acc = 0 ) {
    assert( *str == '\0' || *str == '0' || *str == '1' || *str == 'x' );
    return *str ? make_mask( str+1, acc<<1 | *str!='x' ) : acc;
}

constexpr uint64_t make_bits( const char* str, uint64_t acc = 0 ) {
    assert( *str == '\0' || *str == '0' || *str == '1' || *str == 'x' );
    return *str ? make_bits( str+1, acc<<1 | *str=='1' ) : acc;
}


template<class Str>
constexpr bool match( Str pattern, uint64_t test_bits ) {
    constexpr uint64_t mask = make_mask( pattern() );
    constexpr uint64_t bits = make_bits( pattern() );
    
    return (test_bits&mask) == bits;
}

#include <iostream>
int main() {
    std::cout << match( []{return"xxxxxxxx1111xxxx";}, 0xacf9 ) << std::endl;
}

ところで、match関数の第一引数として、patternの文字列そのものではなく、ラムダ式を使っているのは、あまり普通の見た目ではないと言えるかもしれません。

マクロレス型安全printf - よーるでも使ったテクニックですが、文字列を返すラムダ式を使うことで、各々の文字列に異なる型を与えるといったことを行っています。

    constexpr const char* pattern = "xxxxxxxx1111xxxx";
    constexpr uint64_t mask = make_mask( pattern );
    constexpr uint64_t bits = make_bits( pattern );

のように自分でmaskbitsを計算してconstexpr変数に入れる場合は、ラムダ式を使った型付けテクニックを使わなくてもよいのですが、match関数のようにpatternも受け取る関数を作る場合はこのテクニックを使う必要があります。

パターンを文字列で書くのではなく、ユーザー定義リテラルで書く方法も考えられます。しかし、ユーザー定義リテラル(整数)は実際の整数リテラルとして有効な表現しか使えないという弱点があります。 今回の目的である可読性向上の目的には、今一歩使い勝手が悪いです。また、異なる型を与えないといけないという制約はユーザー定義リテラルを用いた場合でも満たさないといけないため、ユーザー定義リテラル(文字列)ではうまくいきません。ユーザー定義リテラル(文字列・テンプレート)があったらこんなラムダ式で囲むなんていう方法を取らなくてすむようになります。

*1:10/07訂正:もともとは0bxxxx1111と書いていましたが、誤りでした

*2:時代はC++17だというのに、いまだにループで回すみたいなコードではなく実質リターン文ひとつみたいなコードを書いてしまっています(別に悪いというわけではないはずですが)。あと-Wparenthesesに怒られますが、演算子の優先順位は把握している(し、スペーシングもそれを反映したものになっている)のになぁって思ってしまいます。まぁ優先順位を把握していないプログラマーに不親切だから、常識的にわかる優先順位以外はかっこでくくろうという意見も分からないではないですが。特にC++は暗黙変換で型検査を通ってしまう怖い言語なので、用心しようという姿勢は大切ですね(このコードでは暗黙変換を使っているわけですが)。

std::pairをmemcpyすることについて

g++-8で追加された-Wclass-memaccessの警告を有効にしている場合、以下のコードはコンパイル時に警告が出ます。-Wclass-memaccessの警告は、-Wallに含まれるため、単にコンパイラをg++-8に変更しただけでこの警告に出くわすこともあると思います。

#include <utility>
#include <cstring>

std::pair<double, double> p, q;

int main() {
    ::memcpy(&q,&p,sizeof q);
}

これは、どのような警告かというと、「std::pair<T, U>には非自明なコピー代入演算子がある。本当にmemcpyでバイト列をコピーするだけで大丈夫?」という警告です。

ここで、非自明なコピー代入演算子とは、何も書かなかった時の自動定義と= defaultでの定義を除く、ソースコード中に明示的に書かれたコピー代入演算子のことです。

普通に考えるとstd::pair<T, U>のコピー代入演算子は非自明なことをするはずはないのですが、定義されているので仕方がないです。

以下、TUはPlain Old Data (POD)であることを前提として話を進めます。

LLVMコンパイルするとこの警告が出た

どうもg++-8でLLVMコンパイルするとこの警告が出ます。 LLVMC++で書かれており、コンパイル速度が速いことを売りにしているようですが、スピード狂がいるのでしょうか。

警告メッセージによれば、llvm::SmallVector<T, N>Tとしてstd::pairを使ったことが原因のようです。llvm::SmallVector<T, N>TがPODのようなものである場合、具体的にはllvm::isPodLike<T>::valuetrueの時、特殊化されており、memcpyを使った高速実装を用いるようです。

問題のllvm::isPodLike<T>ですが、大体以下のような感じに定義されています。

template<typename T>
struct isPodLike {
  static const bool value = std::is_trivially_copyable<T>::value;
};

llvm/type_traits.h at 425788d8b599aef1e77092228423e0bb641df359 · llvm-mirror/llvm · GitHub

これならばmemcpyを用いても問題は発生しそうにありません。しかし、その下に定義されている特殊化がまずいです。

// std::pair's are pod-like if their elements are. 
template<typename T, typename U> 
struct isPodLike<std::pair<T, U>> { 
  static const bool value = isPodLike<T>::value && isPodLike<U>::value; 
}; 

ちなみに、現在LLVMをg++-8でコンパイルしてもこの警告は出なくなっています。どうやって解決したのかというと、

Fix few g++ 8 warning with non obvious copy object operations · llvm-mirror/llvm@425788d · GitHub

reinterpret_cast<void*>を使って、単に警告を黙らせるという姑息な解決法だったようです。

正しい方法

正しい方法は、以下の二通りです。

1 自明にコピー可能なstruct Pair<T, U>を自分で定義する。

2 std::copyなどを用いてコピーする。

1 の方法を使う場合、std::pairとの互換性のため、std::pair<T1, U1>からの変換コンストラクタ等を定義した方が良いでしょう。= defaultによる定義を用いることで、自明にコピー可能であることと両立可能です。

2 の方法を使う場合、memcpyよりも遅くなってしまうという欠点があります。

std::copymemcpyよりおそいのか

現代の最適化コンパイラ、clang++やg++をもってしても、std::copymemcpyよりも遅いです(x86のように幅広なレジスタを持たないアーキテクチャなら互角になるかもしれません)。

memcpyを使った場合

memcpyを使った場合は限界までチューニングされた関数が呼び出されます。たぶん、一番幅広なレジスタ(私の環境では256bit幅のymmレジスタ)に乗っけてコピーしているのだと思われます。

std::copyを使った場合

以下はstd::pair<int, double>[10000000]std::copyするコードを、clang5.0.1で-O3コンパイルした時のアセンブリです。注意すべき点として、この構造体にはパディングが含まれているという点があげられます。パディングが含まれている構造体をmemcmpで比較するのはまずいですが、memcpyでコピーすること自体は問題ありません(実際はstd::pairは自明にコピー可能なわけではないのでこの議論は根本がまちがっているわけですが)。

.LBB0_3:
  mov eax, [rbx+src]
  mov [rbx+dst], eax
  mov rax, [rbx+src+8]
  mov [rbx+dst+8], rax
  mov eax, [rbx+src+16]
  mov [rbx+dst+16], eax
  mov rax, [rbx+src+24]
  mov [rbx+dst+24], rax
  mov eax, [rbx+src+32]
  mov [rbx+dst+32], eax
  mov rax, [rbx+src+40]
  mov [rbx+dst+40], rax
  mov eax, [rbx+src+48]
  mov [rbx+dst+48], eax
  mov rax, [rbx+src+56]
  mov [rbx+dst+56], rax
  mov eax, [rbx+src+64]
  mov [rbx+dst+64], eax
  mov rax, [rbx+src+72]
  mov [rbx+dst+72], rax
  lea rax, [r15+5]
  and r15, -2
  add rbx, 80
  cmp r15, 4
  mov r15, rax
  jne .LBB0_3

単に五倍ループアンローリングされること以外はごく普通のコードになっています。パディングのところに触らないようにeax(4Byteレジスタ)やrax(8Byteレジスタ)を使っているため、命令数がかさみ、コピーに時間がかかるようになってしまうようです。

パディングがあるのは意地悪な例のようにも思えますが、std::pair<double, double>のようなものの場合でも、movsd命令(16Byteレジスタの下半分だけ使う命令)を使っているため、結局コピーに時間がかかる点は同じです。

g++の場合はもう少し速いコードを生成するようですが、実行速度はmemcpyより有意に一割ほど遅かったです(wandboxを使ったためアセンブリは確認していません)。

おわりに

最適化はコンパイラに任せろと言われて久しいですが、現代のコンパイラをもってしてもstd::copyを自動でmemcpyに変換するような高級なことはできず、いまだにこの手の最適化はプログラマの責任の範囲となってしまっています。 一方で最近のコンパイラは想像を絶するような最適化も行うため、うっかり未定義挙動を踏まないようにするのも、最適化プログラマの責任となってしまっています。

現代のスピード狂プログラマは、速いコードを書くための知識だけではなく、言語仕様の熟知も必要条件となりつつあります。 コンパイラを黙らせるのではなく、コンパイラの有用な指摘をありがたく受け取れるようにしたいものです。

そもそも、今回の問題はstd::pairの設計に問題があることが原因のような気がしますが。

パック展開でリスト操作

先週はパック展開を使うとconstexpr関数が高速化するみたいなことを書きましたが、具体的な使い方をあまり書けなかったので、具体例を書いておきます。

map

template<class T, std::size_t N, std::size_t... Indeces>
constexpr auto map_f_impl( const T (&arr)[N], std::index_sequence<Indeces...> ) {
    return std::array { f( arr[Indeces] )... };
}
template<class T, std::size_t N>
constexpr auto map_f( const T (&arr)[N] ) {
    return map_f_impl( arr, std::make_index_sequence<N>() );
}

パック展開をそのまま使えばよいです。 C++17では推論ガイドのおかげでstd::arrayのテンプレート実引数を指定しなくてよくなりました。

fold

template<class T, std::size_t N, std::size_t... Indeces>
constexpr auto fold_impl( const T (&arr)[N], std::index_sequence<Indeces...> ) {
    return (arr[Indeces] + ...);
}
template<class T, std::size_t N>
constexpr auto fold( const T (&arr)[N] ) {
    return fold_impl( arr, std::make_index_sequence<N>() );
}

畳み込み式をそのまま使えばよいです。

scan (prefix sum)

template<class T>
constexpr auto sum( T& acc, T val ) {
    return acc += val;
}

template<class T, std::size_t N, std::size_t... Indeces>
constexpr auto prefix_sum_impl( const T (&arr)[N], T init, std::index_sequence<Indeces...> ) {
    return std::array { sum( init, arr[Indeces] )... };
}
template<class T, std::size_t N>
constexpr auto prefix_sum( const T (&arr)[N], T init = T() ) {
    return prefix_sum_impl( arr, init, std::make_index_sequence<N>() );
}

副作用を持つ関数を使ってパック展開すると、scanが作れます。あまり知られていないような気がします。

実践例

最小値の検索

template<class T, std::size_t N, std::size_t... Indeces>
constexpr auto min_impl( const T (&arr)[N], T init, std::index_sequence<Indeces...> ) {
    return ((init = std::min( init, arr[Indeces]), ...);
}
template<class T, std::size_t N>
constexpr auto min( const T (&arr)[N], T init ) {
    return min_impl( arr, init, std::make_index_sequence<N>() );
}

最小値の探索は一種の畳み込みであると考えることができます。 コンマ演算子で畳み込みすることでfor文の代わりにすることができます。 畳み込み式が使えないC++14の場合でも、Swallowの技法を使っても同じことができます。

行列乗算

template<class T, std::size_t N>
struct Matrix { 
    T data[N*N];
};

template<class T, std::size_t N, std::size_t... Indeces>
constexpr auto inner_product( const T (&lhs)[N*N], const T (&rhs)[N*N], std::size_t i, std::size_t k, std::index_sequence<Indeces...> ) {
    return ((lhs[i*N + Indeces] * rhs[Indeces*N + k]) + ...);
}

template<class T, std::size_t N, std::size_t... Indeces>
constexpr auto product_impl( const Matrix<T, N>& lhs, const Matrix<T, N>& rhs, std::index_sequence<Indeces...> ) {
    return Matrix<T, N> { inner_product<T, N>( lhs.data, rhs.data, Indeces / N, Indeces % N, std::make_index_sequence<N>() )... };
}

template<class T, std::size_t N>
constexpr auto product( const Matrix<T, N>& lhs, const Matrix<T, N>& rhs ) {
    return product_impl( lhs, rhs, std::make_index_sequence<4>() );
}

同時に複数のパック展開を行うことはできないので、関数ごとに分けて行います。 constexpr でニューラルネットワークを実装したというのが最近(6月)ありましたが、そこで使われていた行列乗算をこれに置き換えたところ8倍程度高速化した記憶があります。 前回説明した、

  • パック展開で初期化する
  • 生配列を使って関数呼び出しを減らす
  • 畳み込み式を使って変数の書き換えを減らす

をすべて使っています。

constexpr関数を高速化する方法

constexpr関数ではほとんど何でもできてしまうので、可読性を損なうことなく多くの仕事をコンパイル時にこなすことができます。しかし多くのC++コンパイラでは、インタプリタ上で実行する実装となっており、非常に低速です。constexprで多くのことをやりたい場合、速度がネックになることがあります。

そこで、constexpr関数の(コンパイル時の)実行速度を向上させるテクニックを書いてみます。

constexpr関数の良いところとして、実行時にも同じ関数が使えるという点があげられます。しかし、紹介するテクニックの中には実行時の効率を犠牲にしているものがあるので注意が必要です。

コンパイラは、clang++またはcl.exe(MSVC)を使うものとします。g++はconstexpr関数をメモ化するという性質があるため、竹内関数やフィボナッチ数列を計算するのは速いですが、大規模な計算をするためには膨大なメモリが必要になり、不適です。

変数をなるべく書き換えない

C++14からはconstexpr関数内での変数書き換えが可能になりましたが、変数の書き換えは極力しない方が高速になります。

初期化は統一初期化構文とパック展開を使う

constexpr関数内では未初期化変数は使えないので、必ず初期化する必要があります。そのため、以下のようにfor文を使って値を書き込む場合、「最初の無駄な初期化」「値の書き込み」の二回変数を書き換えていることになります(ついでに、ループ変数を書き換えているのも時間がかかる原因になります)。

constexpr auto f() {
    std::array<std::size_t, 100> arr {};
    for( std::size_t i = 0; i < 100; ++i ) {
        arr[i] = i;
    }
    return arr;
}

こうではなく、index_tupleイディオムとパック展開を用いて、必要な値を初期化時にいきなり書き込むことで高速化することができます。

template<std::size_t... Indeces>
constexpr auto f_impl( std::index_sequence<Indeces...> ) {
    return std::array<std::size_t, 100> arr { Indeces... };
}
constexpr auto f() {
    return f_impl( std::make_index_sequence<100>() );
}

とてもC++11らしいコードになりますが、C++14の新機能を使っても速くならないのでしょうがないです。

このようにした場合、コンパイル時の実行速度は稼げますが、実行時のコードはループアンローリングしたものになるため、プログラムサイズが肥大化します。今回の例はそれほどひどいことにはなりませんが、複雑なコードをアンローリングすることは性能低下につながります。

constexprで行いたい重たい処理のうち、大部分は配列的処理だと思います。関数型言語的に言ってmap関数にあたる処理を行う場合はindex_tupleイディオムとパック展開で一気に処理するのが最も高速です。Indexを扱えるので、ランダムアクセス的な処理も一部可能です。たとえば、並列ソートであるバイトニックソートはこの方法で実装できます。

関数呼び出しを避ける

関数呼び出しには一定のオーバーヘッドがあるため、関数呼び出しを減らすほど高速になります(constexpr関数を使うという点では本末転倒ですが)。可読性を保つこととはトレードオフとなりますが、プログラム高速化の原則「一番実行されるところだけ最適化する」を守ってやればそれほどひどいことにはならないでしょう。

畳み込み式を活用する

map関数の適用のような、完全に並列な処理は先ほど書いたパック展開のテクニックによって高速化が望めますが、「全部足す」のような処理はfor文を使うか、再帰関数呼び出しを行うかのどちらかとなり、いずれも低速です。ここはC++17で導入された畳み込み式を活用する以外ありません。

注意点として、パック展開と同様ループアンローリングをしたことになるので、実行時のコードは肥大化します。

生配列を使う

C++で固定長配列を扱いたいときは、std::array<T, N>を使うのが良いとされ、C言語由来の生配列を使うことは時代遅れとされます。実際、最適化をかければ実行時のパフォーマンスには全く差が出ないのですが、constexpr関数のコンパイル時実行の際には生配列を使った方が高速となります。これは、配列アクセスをする際、std::array<T, N>::operator[](std::size_t)関数(またはat関数)を呼び出していることによるオーバーヘッドが主要因です。一方生配列の場合は単純なポインタ演算により配列アクセスが可能であり、高速になります。

とはいっても配列を関数の返り値として返却することはできないので、返り値の部分にはstd::array<T, N>のような、配列をメンバに持つ構造体を返す必要があります。そこから生配列を取り出すことを一回だけ行えば、それ以降は高速な配列アクセスを利用できるようになります。ただし、std::array<T, N>::data()関数はなぜかconstexpr指定されていないので、自作のArrayクラスなどを利用する必要があります。

ノートパソコンが二台いっぺんに起動しなくなってびっくりした

WindowsUpdateしたからなのか使っているノートパソコンが二台いっぺんに起動しなくなってびっくりしました。

他の同じ症状で困っているのが助かればと思って症状と直した手順を書いておきます。

Lenovo Miix 720

症状

電源ボタンを押すといつも通りLenovoのロゴが出る。しかしその後●がくるくる回るアニメーションが出てこない。そのまま待ってても一時間くらいそのまま。

BIOSを出すためにFn+F2キーを押すとLenovoのロゴが消えて画面遷移する。でもそのあと何も起こらない。 Fn+F12キーの場合も同様。

ブートメディアから起動できないかと思って試してみたけれど、そもそもBIOS画面にたどり着けないので意味がなかった。

直し方

サポートに問い合わせたら、「音量アップボタンを押しながら電源キーを押す」とNovoメニューが出てくるとのことで、実際にやってみるとNovoメニューが出てくる。

4種類あるメニューのうち、「System Recovery」をタッチパッドか方向キーで選択し、エンターキー(左クリックではダメ)を押すと、Windows10の復元画面が出てくる。 その画面で「Windowsを通常起動する」を選ぶと普通に起動した。再起動してみたりしても普通に起動できるので、おそらくこれで解決?何が原因だったのだろう。

「System Recovery」以外のメニューを選ぶとやっぱり画面が真っ暗になってそのあと何も起こらない。

HP Spectre x360

症状

パスワードを入力すると、「User Profile Serviceサービスによるサインインの処理に失敗しました」と出てくる。何回か試しても同じ。 パスワードが間違っている場合は、普通に間違っていますって出る。

直し方

とりあえず再起動したら直った。WindowsUpdateでどっかのファイルのロックがかかりっぱなしになったとかかな?