« x64(64bit)対応JITアセンブラXbyakリリース | メイン | google ChromeでSHA-1のベンチマーク »

fast strlen and memchr by SSE2

strlen()とmemchr()のSIMD版を作ってみました.

今回は最速よりもお手軽さを重視したのでアセンブリ言語ではなくintrinsic関数を使っています.そのためVisual Studio 2008, gcc 4.xの両方でコンパイルでき32-bit, 64-bit OS上で動作します.
WindowsとLinuxでのみ確認していますが恐らくIntel Mac OS X上でも動作するでしょう(sample source).

ベンチマークはランダムな長さの文字列の平均長(average length)を変化させつつ取りました.数値は1byteあたりにかかった処理時間比で小さいほど速いことを表します.
strlenが3種類(ANSI, BLOG, SSE2)とmemchrが2種類(ANSI, SSE2)あります.BLOGというのは今回試してみようというきっかけになったCounting Characters in UTF-8 Strings Is Fastの中のCバージョンです.

結果は極端に短いところではSIMDが使えず,かつそのチェックのためのオーバーヘッドがありそれほど速くなりませんが,32文字以上ではは2~5倍高速であることが分かります.
なお,gccのstrlen()は今となっては遅い命令を使っているためよくありません(なぜこうなっているのかちょっと理解に苦しみます).

一つ細かい注意点としては,高速化のために与えられた範囲外の空間を少しアクセスします(readはするがその値は捨てます).16byteアライメントされたところを超えることはないのでバグで16byte超えることがあったのですが修正しましたのでページングエラーにはなりませんが,purifyなどのツールを使うと警告がでるかもしれません.


intrinsic関数はVCとgccとで共通化され,しかも32bit, 64bit両対応なため汎用性も高いです.strlenSSE2のように30行未満の関数でも十分は効果はでます.利用しない手はないと思われます.

size_t strlenSSE2(const char *p)
{
    const char *const top = p;
    __m128i c16 = _mm_set1_epi8(0);
    /* 16 byte alignment */
    size_t ip = reinterpret_cast(p);
    size_t n = ip & 15;
    if (n > 0) {
        ip &= ~15;
        __m128i x = *(const __m128i*)ip;
        __m128i a = _mm_cmpeq_epi8(x, c16);
        unsigned long mask = _mm_movemask_epi8(a);
        mask &= 0xffffffffUL << n;
        if (mask) {
            return bsf(mask) - n;
        }
        p += 16 - n;
    }
    /*
        thanks to egtra-san
    */
    assert((reinterpret_cast(p) & 15) == 0);
    if (reinterpret_cast(p) & 31) {
        __m128i x = *(const __m128i*)&p[0];
        __m128i a = _mm_cmpeq_epi8(x, c16);
        unsigned long mask = _mm_movemask_epi8(a);
        if (mask) {
            return p + bsf(mask) - top;
        }
        p += 16;
    }
    assert((reinterpret_cast(p) & 31) == 0);
    for (;;) {
        __m128i x = *(const __m128i*)&p[0];
        __m128i y = *(const __m128i*)&p[16];
        __m128i a = _mm_cmpeq_epi8(x, c16);
        __m128i b = _mm_cmpeq_epi8(y, c16);
        unsigned long mask = (_mm_movemask_epi8(b) << 16) | _mm_movemask_epi8(a);
        if (mask) {
            return p + bsf(mask) - top;
        }
        p += 32;
    }
}
Core2Duo 1.8GHz Xp SP3 + Visual Studio 2008(32bit)
average length257 10 12 16 20 32 64128256512 1024
strlenANSI683.8406.3308.8234.5203.3168.0148.3113.3 86.0 70.5 62.5 58.5 54.5
strlenBLOG835.8449.3347.5269.5234.3195.3175.8140.5109.389.882.078.382.3
strlenSSE2765.8355.5269.5199.3172.0133.0109.5 78.3 47.0 27.3 19.8 15.57.8
memchrANSI1046.8648.5515.8390.5347.8273.3226.5164.0105.5 74.3 62.5 50.8 50.8
memchrSSE2773.5375.0285.0214.8179.5144.8121.3 82.0 54.5 31.3 19.5 15.8 11.8
Core2Duo 1.8GHz Linux 2.4 on VMware + gcc 4.3.0(32bit)
average length2571012162032641282565121024
strlenANSI2019.6933.9728.7576.8512.3430.5386.6316.5261.2235.6219.4214.0212.9
strlenBLOG692.6397.4331.0242.5216.7194.3152.2124.7110.381.576.382.270.0
strlenSSE2560.0275.6214.3159.2135.7104.487.565.141.525.316.614.09.3
memchrANSI1152.4609.5487.4375.0325.6260.5229.9152.495.572.856.849.348.0
memchrSSE2574.6282.1224.3161.0139.1108.590.063.345.123.915.810.011.3
Core2Duo 1.8GHz Linux 2.6.18-53 on VMware + gcc 4.1.2(64bit)
average length257 10 12 16 20 32 64128256512 1024
strlenANSI1039.4568.0460.9353.7306.9254.9212.2145.2 82.0 50.3 35.3 26.4 24.5
strlenBLOG795.4474.9366.4291.0263.9227.6201.7178.3144.5125.9117.2112.0110.5
strlenSSE2703.0340.0267.6196.3167.2131.6108.9 78.7 47.4 30.2 20.4 15.0 14.5
memchrANSI1280.7736.4594.2472.2423.8338.0294.3211.0127.2 90.4 67.3 55.4 55.1
memchrSSE21001.4456.3336.4241.3206.4159.5138.5 93.0 57.5 35.6 23.3 17.0 15.7

追記(2008/6/13)
64bit Windowsで_BitScanForwardの引数の型を勘違いしていたので修正.
ソースにライセンスを修正BSDライセンスと明記.

追記(2008/7/16)
strlenSSE2でループアンロールのため32byte単位で読み込んでいたため,ページングの境界を超えてしまうことがあったバグを修正(thanks to egtraさん).

コメント (10)

shinh:

一応、 32bit/64bit MacOSX でも動作して、同様に速い感じみたいでした、と報告させてもらいます。

こんにちは。
SSE2を用いたstrlenのコードをRubyにとりこみたいのですが、
ライセンスはどのようになるのでしょうか。
よろしくお願いいたします。

へるみ:

>ライセンスはどのようになるのでしょうか。

ソースに書いたつもりがアップし忘れていました.ご指摘ありがとうございます.修正BSDライセンスでお願いします.
とは言え単なるコード片ですのでもっと緩くしても構いません.何かご希望のライセンスはありますでしょうか.

RubyはGPLなので、修正BSDLならば十分です、ありがとうございました。

egtra:

こんばんは。手元のプログラムに適用したところ、運悪く境界に引っかかったようで落っこちてしまいました。こうすると再現できるのですがどうでしょうか。
int main()
{
DWORD old;
char* p = (char*)VirtualAlloc(0, 8192, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
VirtualProtect(p + 4096, 4096, PAGE_NOACCESS, &old);
memset(p, 1, 4095);
p[4095] = 0;
strlenSSE2(p + 16);
}

へるみ:

そっか,ループアンロールしたので最大32byte読んでしまうため,そういうケースでは落ちてしまいますね.うかつでした.
ご指摘ありがとうございます.
明日修正します.
=>修正しました.

初投稿失礼します。
実はSSE2を使った実装は私も以前(5月末)書いて某サイトに投稿したことがあったのですが、pcmpeqb→pextrw→判定→bsfって言う流れがそのまんま同じで思わず苦笑してしまいました。
やっぱり速さを追求すると誰が組んでもそうなるもんなんですね(こちらは若干アンロールが甘いですが)

私のほうは
http://smallcode.weblogs.us/2006/08/22/fast-strlen-function/
から引っ張ってきたコードをベースに自分の書いたコードを入れて公開しておりました。

テスト用に改造したソース一式(VC2008+インラインアセンブラ)
http://download.kousaku.in/trip/fast_strlen_mod2.zip
(※なお、このソースで私が改変を入れた部分について私は一切の著作権・著作者人格権を行使しません)


こちらはE8500(Wolfdale)ですが、MSVCRTの2.5倍程度にはなっています。
ソースの通り、SSE4.1でpcmpeqb→ptestで判定する実装も用意しておりますが、なぜかSSE2と比べても速くならないです(むしろ若干遅い)。
アンロールして並列化してみれば多少は速くなるかもしれません。あと、極端に長い文字列ならmovntdqaを使うのもよさそうですね。

へるみ:

お,既にされていましたか.存じませんでした.
ptestなんて便利な命令もあるんですね.
SSE 4.1使ってみたいのですが,家のメインマシンがいまだにPentiumDでして….

こちらでもいろいろ試してみましたがSSE4.1はそんなに速くなりませんでした。Intel C++だと若干優位なんですが。


恐れながら数点だけパフォーマンス面で指摘を。
おそらくどのコンパイラでも共通だと思いますが_mm_set1_epi8(0)は_mm_setzero_si128()のほうが速いです。アセンブリコードを吐いてみると一目瞭然です。
それとmemchrのほう、Core 2(SSSE3)以降前提になりますが、_mm_set1_epi8(n)は
_mm_shuffle_epi8(_mm_setzero_si128(), _mm_cvtsi32_si128(n))
に置き換えたほうが速いです。

へるみ:

>_mm_set1_epi8(0)は_mm_setzero_si128()のほうが速いです
アドバイスありがとうございます.なるほどそのあたり,よきに払ってくれるわけではないのですね.
実は私は仕事では殆どNASMやXbyakで書いていたので,intrinsic関数は慣れてなかったりします(^^;
#大抵Win/Linuxどちらでも動かす必要があったのでインラインアセンブラ不可だし,intrinsicはもどかしいし...

今回の件では一応ループ内は出力コードの確認はしてたのですが,外側は抜かりました.

コメントを投稿

(いままで、ここでコメントしたことがないときは、コメントを表示する前にこのブログのオーナーの承認が必要になることがあります。承認されるまではコメントは表示されません。そのときはしばらく待ってください。)