この記事はDeNA Advent Calendar 2020の20日目の記事です。
大竹悠人(@Trapezoid)です。 DeNAではゲーム用のライブラリ/SDK開発やセキュリティ対策、開発効率化などを横断的に行いつつ、トラブルシューターとして活動しています。
今日はUnityエンジニア向けに、死ぬほどニッチな小ネタを書こうと思います。
TL;DR
- IL2CPPでは、高負荷なロジックをunsafeにしたらiOSのデバッグビルドで激烈に遅くなることがある
- IL2CPPがinline化を前提とした癖のあるコードを生成するが、それがデバッグビルドで最適化が無効になることでinline化が阻害されるのが原因
- 一般的な最適解としてはそもそも最適化を有効にするか、Burstやネイティブプラグイン化で対策した方が望ましい
- 様々な事情により、局所解として手動inline化とも言える力技を行って解決した
経緯
パフォーマンス的にナイーブな計算量の多い場面で、C#のunsafeを使った最適化を行うことは稀にあるかと思います。
僕も特に暗号化やハッシュ化などのために稀によく頻繁に使うのですが、ある時利用者から iOSでの実行時だけ該当ロジックが異常に重いので調査してほしい
という報告を受けました。
いくつか原因切り分けをしてもらったところ、 (Xcode上の)DebugビルドでiOSビルドを行った場合のみ この現象が起こることが分かってきました。
Debug設定時のみとはいえ、続行を諦めたくなるほど遅くなっていたので、速やかな対処が必要です。
発生していた問題
Debugビルドのみで起こるというと何かしら最適化と相性の悪い何かがあるのかな...とは思いつつ、再現させてプロファイリングを行ったところ、unsafeコードとして記述している区間がボトルネックになっていることが分かりました。
問題になったのは、stackallocで固定長の小さなバッファを用意して、そのバッファをひたすらこねるといった、暗号化などでよくあるワークロードでした。
起こっていたことを、次のようなコードを使って説明します。
(今回は問題を単純化するために、ただ引数をstackallocしたバッファに添字指定でコピーするだけのmemcpyすれば?って感じの意味のないコードにしています。実際はバッファへのコピーも単純なコピーではないです)
public unsafe void Test1(byte* x)
{
var buffer = stackalloc int[16];
buffer[0] = x[0];
buffer[1] = x[1];
buffer[2] = x[2];
// ...
buffer[15] = x[15];
//bufferをこね回す重い処理が以降に入る
}
このコードにIL2CPPをかけると、次のようなC++コードに変換されます。
// var buffer = stackalloc int[16];
int8_t* L_0 = (int8_t*) alloca((((uintptr_t)((int32_t)64))));
memset(L_0, 0, (((uintptr_t)((int32_t)64))));
// buffer[0] = x[0];
int8_t* L_1 = (int8_t*)(L_0);
int32_t* L_2 = ___x0;
int32_t L_3 = *((int32_t*)L_2);
*((int32_t*)L_1) = (int32_t)L_3;
// buffer[1] = x[1];
int8_t* L_4 = (int8_t*)L_1;
int32_t* L_5 = ___x0;
int32_t L_6 = *((int32_t*)((int32_t*)il2cpp_codegen_add((intptr_t)L_5, (int32_t)4)));
*((int32_t*)((int8_t*)il2cpp_codegen_add((intptr_t)L_4, (int32_t)4))) = (int32_t)L_6;
// buffer[2] = x[2];
int8_t* L_7 = (int8_t*)L_4;
int32_t* L_8 = ___x0;
int32_t L_9 = *((int32_t*)((int32_t*)il2cpp_codegen_add((intptr_t)L_8, (intptr_t)((intptr_t)il2cpp_codegen_multiply((intptr_t)(((intptr_t)2)), (int32_t)4)))));
*((int32_t*)((int8_t*)il2cpp_codegen_add((intptr_t)L_7, (intptr_t)((intptr_t)il2cpp_codegen_multiply((intptr_t)(((intptr_t)2)), (int32_t)4))))) = (int32_t)L_9;
// buffer[3] = x[3];
int8_t* L_10 = (int8_t*)L_7;
int32_t* L_11 = ___x0;
int32_t L_12 = *((int32_t*)((int32_t*)il2cpp_codegen_add((intptr_t)L_11, (intptr_t)((intptr_t)il2cpp_codegen_multiply((intptr_t)(((intptr_t)3)), (int32_t)4)))));
*((int32_t*)((int8_t*)il2cpp_codegen_add((intptr_t)L_10, (intptr_t)((intptr_t)il2cpp_codegen_multiply((intptr_t)(((intptr_t)3)), (int32_t)4))))) = (int32_t)L_12;
//...
単なる代入だけの単純な処理だったはずが、大量の il2cpp_codegen_add
及び il2cpp_codegen_multiply
の呼び出しが発生しており、
IL2CPPを通ったunsafeなメモリアクセスが、intptr_tにキャストした上でアドレスを計算しなおすように変換されていることが分かります。
il2cpp_codegen_add
及び il2cpp_codegen_multiply
の定義を見てみると、次のようなinline指定されたtemplateになっています。
template<typename T, typename U>
inline typename pick_bigger<T, U>::type il2cpp_codegen_multiply(T left, U right)
{
return left * right;
}
template<typename T, typename U>
inline typename pick_bigger<T, U>::type il2cpp_codegen_add(T left, U right)
{
return left + right;
}
inlineキーワードのみのinline関数は-O0
指定時はインライン展開されないため、これは-O0
で最適化が無効化された場合にはunsafeコード中でのアドレス演算の度に複数回の関数呼び出しが行われるということを意味します。unsafeコード中のアドレス演算が手計算される関係で、この回数は想像よりも更に多くなります
今回の問題の原因はこのように、XcodeでBuild ConfigurationをDebugにしてビルドした場合、-O0
で最適化が無効化されるようになっているため、呼出回数の多いポインタへのインデックスアクセスのコストが爆増したことにありました。
また、そもそもプリミティブ型の四則演算に関しては、unsafeでない場合にも概ね同じルールで変換されるため、unsafeに限らず純粋なプリミティブ型の演算を大量に行う場合はこの問題にあたることが多くなりそうです。
対策
対策は複数考えられます。
Debugビルド時の最適化レベルを-O2
以上にする
-O2
以上であればinline関数は概ねinline化される為、関数呼出によるオーバーヘッドはなくなり、根本的な対策にはなります。
また、問題になるような負荷の高いロジック以外でも、四則演算全般の速度がある程度向上すると思われます。
ただし、デバッガビリティはある程度犠牲になります。今回は共通基盤となるライブラリのコード内での問題でしたので、これを利用者側への強制するのは流石に避けたいという思いがありました。また、元々パフォーマンスに振り切ったかなりナイーブな実装をメソッドに閉じてしていた為、ある程度ナイーブな対策を追加で行っても相対的には問題ないという判断をしました。
Burst / ネイティブプラグインで書く
そもそも負荷の高いナイーブな処理であれば、Burstを使ってIL2CPPを避けて最適化されたLLVM Bitcodeを出力させたり、直接C/C++でネイティブプラグインとして書く、というのも手です。
そもそもの最適化という意味では非常に良い選択肢ですし、そもそも問題が起こり得る領域を考えると大抵の場合の最適解となり得ると思っています。
ですが、今回はUnityに依存しない、.NET Coreからも使われるコードベースであった為、Burst化やネイティブプラグイン化はUnity特化として追加する形で行う必要がありました。このため、検討はしつつも今回の解決策としては見送りました。
アドレス演算の回数を少なくする
問題となったコードでは、stackallocした領域にかなり回数アクセスする一方で、アクセスするアドレスの範囲は非常に限られており、数も固定されていました。
このため、今回はアクセスする全てのアドレスのポインタをそれぞれ1つだけスタック上に確保しておき、それらのポインタを経由して値を読み書きするという死ぬほど泥臭い対応を行うことで解決しました。
サンプルとして出した例で書き直すなら、以下のような形になります。
public unsafe void Test1(int* x)
{
var buffer = stackalloc int[16];
var bp0 = &buffer[0];
var bp1 = &buffer[1];
var bp2 = &buffer[2];
//...
var bp15 = &buffer[15];
*bp0 = x[0];
*bp1 = x[1];
*bp2 = x[2];
//...
*bp15 = x[15];
//bp~経由でbufferをこね回す重い処理が以降に入る
}
IL2CPPを通してみると、以下のようなコードになります。
int32_t* V_0 = NULL;
int32_t* V_1 = NULL;
int32_t* V_2 = NULL;
int32_t* V_3 = NULL;
//...
{
// var buffer = stackalloc int[16];
int8_t* L_0 = (int8_t*) alloca((((uintptr_t)((int32_t)64))));
memset(L_0, 0, (((uintptr_t)((int32_t)64))));
// var bp0 = &buffer[0] ;
int8_t* L_1 = (int8_t*)(L_0);
V_0 = (int32_t*)(((uintptr_t)L_1));
// var bp1 = &buffer[1] ;
int8_t* L_2 = (int8_t*)L_1;
V_1 = (int32_t*)(((uintptr_t)((int8_t*)il2cpp_codegen_add((intptr_t)L_2, (int32_t)4))));
// var bp2 = &buffer[2] ;
int8_t* L_3 = (int8_t*)L_2;
V_2 = (int32_t*)(((uintptr_t)((int8_t*)il2cpp_codegen_add((intptr_t)L_3, (intptr_t)((intptr_t)il2cpp_codegen_multiply((intptr_t)(((intptr_t)2)), (int32_t)4))))));
// var bp3 = &buffer[3] ;
int8_t* L_4 = (int8_t*)L_3;
V_3 = (int32_t*)(((uintptr_t)((int8_t*)il2cpp_codegen_add((intptr_t)L_4, (intptr_t)((intptr_t)il2cpp_codegen_multiply((intptr_t)(((intptr_t)3)), (int32_t)4))))));
//...
// *bp0 = x[0];
int32_t* L_17 = V_0;
int32_t* L_18 = ___x0;
int32_t L_19 = *((int32_t*)L_18);
*((int32_t*)L_17) = (int32_t)L_19;
// *bp1 = x[1];
int32_t* L_20 = V_1;
int32_t* L_21 = ___x0;
int32_t L_22 = *((int32_t*)((int32_t*)il2cpp_codegen_add((intptr_t)L_21, (int32_t)4)));
*((int32_t*)L_20) = (int32_t)L_22;
// *bp2 = x[2];
int32_t* L_23 = V_2;
int32_t* L_24 = ___x0;
int32_t L_25 = *((int32_t*)((int32_t*)il2cpp_codegen_add((intptr_t)L_24, (intptr_t)((intptr_t)il2cpp_codegen_multiply((intptr_t)(((intptr_t)2)), (int32_t)4)))));
*((int32_t*)L_23) = (int32_t)L_25;
// *bp3 = x[3];
int32_t* L_26 = V_3;
int32_t* L_27 = ___x0;
int32_t L_28 = *((int32_t*)((int32_t*)il2cpp_codegen_add((intptr_t)L_27, (intptr_t)((intptr_t)il2cpp_codegen_multiply((intptr_t)(((intptr_t)3)), (int32_t)4)))));
*((int32_t*)L_26) = (int32_t)L_28;
//...
}
stackallocした領域のポインタが全てスタック上に確保されて、使い回されていることがわかります。
これはもちろん、sizeof(IntPtr) * 要素数
byteのスタックを追加で消費することになるので、要素数が少ないときにしか使えない、場面が非常に限定される対策ではあります。
ですが、暗号学的なアルゴリズムでは比較的サイズが固定的で小さいステートに対して流し込むデータのサイズに比例する回数の操作を行う
というものが多くありますし、それらを実装する場面(どちらにせよニッチですが...)では同様に使えることもあるのではないか...と思います。
最後に
ここまで読んでくれた皆様、ありがとうございました。
ニッチな事例のための特殊な対応を紹介していきましたが、いかがでしたでしょうか。
僕はBurst最高だと思います。皆さんBurstを使いましょう。
DeNA 公式 Twitter アカウント @DeNAxTech では、Blog記事だけでなく色々な勉強会での登壇資料も発信しています。
この記事でDeNAの技術的な取り組みに興味を持った方は、是非フォローお願いします。