すべてのCプログラマーが未定義の動作について知っておくべきこと。 パート1/3

パート1

パート2

パート3



最適化がオンになっているときに、LLVMでコンパイルされたコードがSIGTRAPシグナルを生成することがある理由を尋ねられることがあります。 掘り下げた後、Clangがud2命令(X86コードを意味する)を生成したことを発見しました-__builtin_trap()によって生成された命令と同じです。 この記事では、Cコードの未定義の動作とLLVMがそれを処理する方法に関するいくつかの問題について説明します。



画像






この記事(3つのうちの最初の記事)では、これらの質問のいくつかを説明して、それらに関連するトレードオフと困難をよりよく理解し、おそらくCの少し暗い側面を探ろうとします。Cは「ハイレベルアセンブラ」ではないことがわかります何人の経験豊富なCプログラマー(特に低レベルに焦点を合わせている人)が考えることを好み、C ++とObjective-Cが多くのそのような問題を直接受け継いだこと。



未定義の動作の概要



LLVM IRおよびCプログラミング言語には、「不定の動作」という概念があります。 不定の振る舞い、これは多くのニュアンスを伴う幅広いトピックです。 私が見つけたトピックの最良の紹介は、 John Regerのブログ投稿です。 この優れた記事の簡潔な本質は、Cで意味があると思われる多くのものが実際には未定義の動作を持っていることであり、これがプログラムの多くのバグの原因です。 さらに、Cで未定義の動作をする各構造は、ハードドライブをフォーマットし、 さらに悪いことに完全に予想外に実行できるコードを実装(コンパイルおよび実行)するためにライセンスされています。 繰り返しになりますが、 ジョンの記事を読むことを強くお勧めします



C開発者が非常に効率的な低レベルのプログラミング言語にしたいという理由で、Cに似た言語には未定義の動作が存在します。 対照的に、Javaなどの言語(および他の多くの「安全な」プログラミング言語)は、安全で再現可能な実装に依存しない動作を望み、パフォーマンスを犠牲にしてこの目標を達成するため、未定義の動作を回避します。 これはあなたの目標ではありませんが、Cでプログラムを作成する場合、不明確な動作が何であるかを本当に理解する必要があります。



詳細を説明する前に、魔法の弾丸がないにもかかわらず、コンパイラが幅広いCアプリケーションで高いパフォーマンスを実現できることを思い出してください。 最高レベルでは、コンパイラは次の事実により高速コードを生成します。a)レジスタ割り当て、スケジューリングなどの基本的なコンパイルアルゴリズムを実装します。 b)多くのトリック(のぞき穴の最適化、ループ変換など)を使用し、有益な場合にそれらを適用します。 c)冗長な抽象化(マクロの使用などによる)の削除、インライン関数の作成、C ++での一時オブジェクトの削除など。 d)そして、同時に何も損なわない。 最適化はどれも些細に見えるかもしれませんが、重要なループで1回の反復を保存するだけで、たとえばコーデックの作業を10%高速化し、消費電力を10%節約できます。



Cの未定義の動作の利点、例



LLVMをCコンパイラとして使用する場合のLLVMの未定義の動作と動作とポリシーのダークサイドに飛び込む前に、未定義の動作のいくつかの特定のケースを検討し、これらの各ケースでパフォーマンスがどのように達成されるかについて話すのが役立つと思います。 Javaのような安全な言語よりも優れています。 これは、不明確な動作のクラスで可能な最適化、またはこのクラスのケースに特定の動作があった場合に必要な冗長性を排除する方法として見ることができます。 コンパイラは場合によってはこれらの冗長性の一部を削除できますが、一般的な方法(各ケース)でこれを行うには、「シャットダウンの問題」やその他の多くの興味深い問題を解決する必要があります。



ClangとGCCの両方が、C標準では未定義のままになっているいくつかの動作を定義していることにも注意してください。 私が説明するケースは、標準に従って未定義であり、デフォルト設定では両方のコンパイラによって未定義と見なされます。



初期化されていない変数の使用 :Cプログラムの問題のよく知られたソースであり、そのようなエラーをキャッチするための多くのツールがあります:コンパイラー警告から静的および動的アナライザーまで。 これにより、(Javaのように)スコープ内にあるすべての変数をゼロで初期化する必要がないため、パフォーマンスが向上します。 ほとんどのスカラー変数では、これはほとんど冗長性ではありませんが、スタック上の配列とヒープに割り当てられたメモリの初期化は、特にこのメモリが後で完全に上書きされる場合、非常に高価になる可能性があります。



符号文字整数のオーバーフロー :「int」型がオーバーフローした場合(たとえば)、結果は未定義です。 たとえば、「INT_MAX + 1」がINT_MINと等しいことは保証されません。 この動作により、多くの場合に重要なクラス全体の最適化が可能になります。 たとえば、INT_MAX + 1が未定義であることを知っていると、「X + 1> X」を「true」に置き換えることができます。 (定義されていない動作につながるため)乗算がオーバーフローを「できない」ことを知っていると、「X * 2/2」を「X」に置き換えることができます。 これらの例は取るに足らないように見えますが、インライン関数または拡張マクロの後に見られることがよくあります。 このループの「<=」に対して、より重要な最適化が行われます。



for (i = 0; i <= N; ++i) { ... }
      
      





このループでは、コンパイラーは、オーバーフロー中に「i」が決定されない場合、ループの反復回数が正確にN + 1であると想定します。これにより、さまざまな最適化が可能になります。 一方、オーバーフロー中に変数を確実に「ラップ」する必要がある場合、コンパイラはそのようなサイクルが無限であると想定する必要があります(NがINT_MAXの場合に発生します)-多くのループ最適化を許可しません。 これは、ループ変数に「int」がよく使用される64ビットプラットフォームに特に当てはまります。



符号なしの変数の場合、2を法とするオーバーフロー(ラッピング)を保証する費用はかかりません。いつでも使用できます。 シンボリック数の特定のオーバーフローを作成するには、そのような最適化を失う価値があります(たとえば、問題の一般的な症状、64ビットターゲットのループ内の大量のシンボリック拡張)。 ClangとGCCはどちらも "-fwrapv"フラグを許可します。これにより、コンパイラーは署名付きのもののオーバーフローを特定のものと見なします(INT_MINを-1で除算することを除く)。



変数のビット深度よりも大きい量のシフト:uint32_tの32ビット以上のシフトは定義されていません。 私の推測では、これは異なるCPUでのシフト操作が異なる方法で実行されるという事実が原因で発生したものです。たとえば、X86は32ビットシフト値を5ビットに切り捨てます(つまり、32ビットシフトは0ビットシフトと同じです)、ただし、PowerPCは32ビットシフトを6ビットに切り捨てます(32ビットシフトの結果はゼロです)。 これらのハードウェアの違いにより、動作はC言語で完全に定義されていません(つまり、PowerPCの32ビットシフトはハードドライブをフォーマットできるため、結果としてゼロを生成する保証はありません)。 そのような未定義の動作を排除するコストは、コンパイラーがシフト変数に対して追加の操作(「and」など)を生成する必要があるため、一般的なCPUでこの操作が2倍の費用がかかることです。



ワイルドポインタの参照解除と配列へのアクセス :任意のポインタ(NULL、未割り当てメモリへのポインタなど)の参照解除、および境界を越えて配列にアクセスする場合は、Cアプリケーションの一般的なバグです。明確化が必要です。 この未定義の動作の原因を排除するには、配列にアクセスするときに範囲チェックを実行し、アドレス演算で使用できる各ポインターに範囲情報が付随するようにABIを変更する必要があります。 これは、多くの数値およびその他のアプリケーションにとって非常に高価であり、既存のすべてのCライブラリとのバイナリ互換性を壊します。



ヌルポインターの逆参照:一般的な考えに反して、ヌルポインターの逆参照はCでは定義されていません。 trapコマンドの呼び出しとして定義されていないため、アドレス0でページのmmapを作成すると、このページにアクセスできなくなります。 これは、ワイルドポインターの逆参照およびウォッチドッグ値としてのNULLの使用を禁止する規則に違反しています。 NULLポインターの逆参照は未定義であり、広範囲の最適化を行うことができます。これに対して、Javaは、コンパイラーが、オプティマイザーがゼロ以外の保証を考慮できないオブジェクト間で副作用操作を移動できないようにします。 これは、計画およびその他の最適化を著しく損ないます。 Cのような言語では、NULLの間接参照の不確実性により、多数のスカラー最適化が可能になり、マクロとインライン関数の展開の結果が改善されます。



LLVMベースのコンパイラを使用する場合、揮発性オブジェクトの読み取りおよび書き込み操作は一般的に最適化されていないため、「揮発性」ポインターをnullに逆参照し、必要に応じてクラッシュさせることができます。 現在、NULLへのポインターからの任意の読み取り操作を有効な操作と見なしたり、NULLであることがわかっているポインターからの任意の読み取り操作を許可したりするフラグはありません。



型違反の規則 :未定義の動作の場合は、int *からfloat *への変換とそれに続く逆参照(「int」へのアクセスが「float」であるかのように)です。 C言語では、memcpyを介してこのタイプの変換を行う必要があります。ポインター変換の使用は正しくなく、結果は未定義です。 これらのルールにはかなりのニュアンスがありますが、ここでは詳しく説明しません(char *には例外があり、ベクトルには特殊なプロパティがあり、ユニオンの動作は異なります)。 この動作により、コンパイラの幅広いメモリアクセス最適化で使用されるType-Based Alias Analysis(TBAA)が有効になり、生成されたコードのパフォーマンスが大幅に向上します。 たとえば、このルールにより、clangはそのような機能を最適化できます。



 float *P; void zero_array() { int i; for (i = 0; i < 10000; ++i) P[i] = 0.0f; }
      
      





「memset(P、0、40,000)」。 この最適化により、サイクルごとに多くの読み取り操作を実行したり、共通の部分式を最適化することもできます。 このクラスの未定義の動作は、分析を無効にする-fno-strict-aliasingフラグによって無効にできます。 フラグが設定されると、Clangはこのループを10,000の4バイト書き込み操作にコンパイルします(これは何倍も遅くなります)。これは、次の例のように、これらの書き込み操作のそれぞれがPの値を変更すると想定する必要があるためです



 int main() { P = (float*)&P; // cast causes TBAA violation in zero_array. zero_array(); }
      
      





このようなタイピングの違反は非常にまれであるため、標準化委員会は、「合理的な」タイプ変換による予期しない結果にパフォーマンスの大幅な向上は価値があると判断しました。 Java自体は安全ではないポインターのキャストを持たないため、Javaはこのような欠点のない型変換の最適化を利用していることに注意してください。



いずれにせよ、Cの不定の振る舞いによって最適化のクラス全体がどのように可能になるのかを理解していただければ幸いです。もちろん、「foo(i、++ i )」、マルチスレッドプログラムのコンテスト、アクセス違反、ゼロ除算など。



次の投稿では、パフォーマンスがあなたの唯一の目標ではない場合、Cでの未定義の動作が非常に恐ろしいことである理由について説明します。 シリーズの最後の投稿では、LLVMとClangが未定義の動作を処理する方法について説明します。



All Articles