web-dev-qa-db-ja.com

GCCがC ++でnullポインターの削除を最適化しないのはなぜですか?

簡単なプログラムを考えてみましょう。

_int main() {
  int* ptr = nullptr;
  delete ptr;
}
_

GCC(7.2)では、結果のプログラムに_operator delete_に関するcall命令があります。 ClangおよびIntelコンパイラーでは、このような命令はありません。ヌルポインターの削除は完全に最適化されます(すべての場合で_-O2_)。ここでテストできます: https://godbolt.org/g/JmdoJi

このような最適化は、GCCで何らかの形で有効にできるのだろうか? (私のより広い動機は、移動可能な型のカスタムswap vs _std::swap_の問題に由来します。nullポインターの削除は、2番目のケースでパフォーマンスの低下を表す可能性があります。 https:// stackoverflow.com/a/45689282/58008 詳細については)

[〜#〜] update [〜#〜]

質問の動機を明確にするために:move assignment operatorおよびdestructorif (ptr)ガードなしで_delete ptr;_だけを使用する場合クラス、次にそのクラスのオブジェクトを持つ_std::swap_は、GCCで3つのcall命令を生成します。これは、たとえば、そのようなオブジェクトの配列をソートする場合など、パフォーマンスが大幅に低下する可能性があります。

さらに、どこでもif (ptr) delete ptr;を書くことができますが、delete式もptrもチェックする必要があるので、これがパフォーマンスの低下にならないかどうか疑問に思います。しかし、ここでは、コンパイラーは単一のチェックのみを生成します。

また、ガードなしでdeleteを呼び出す可能性が非常に好きで、異なる(パフォーマンス)結果が得られることは驚きでした。

[〜#〜] update [〜#〜]

単純なベンチマーク、つまりオブジェクトの並べ替えを行いました。これは、移動割り当て演算子とデストラクタでdeleteを呼び出します。ソースはこちら: https://godbolt.org/g/7zGUvo

GCC 7.1で測定された_std::sort_の実行時間とXeon E2680v3上の_-O2_フラグ:

リンクされたコードにはバグがあり、ポイントされた値ではなくポインタを比較します。修正された結果は次のとおりです:

  1. ifガードなし: 17.6 [秒] 40.8 [s]
  2. ifガード付き: 10.6 [秒] 31.5 [s]
  3. ifガードとカスタムswapを使用: 10.4 [s] 31.3 [秒]。

これらの結果は、最小の偏差で多くの実行にわたって完全に一貫していました。最初の2つのケースのパフォーマンスの違いは大きく、これがコードのような「非常にまれなコーナーケース」であるとは言いません。

45
Daniel Langr

C++ 14 [expr.delete]/7によると:

Delete-expressionのオペランドの値がNULLポインター値でない場合、次のようになります。

  • [...省略...]

それ以外の場合、割り当て解除関数が呼び出されるかどうかは指定されていません。

したがって、両方のコンパイラは、operator deleteは、nullポインターの削除のために呼び出されます。

Godboltオンラインコンパイラは、リンクせずにソースファイルをコンパイルするだけです。そのため、その段階のコンパイラは、operator deleteは別のソースファイルに置き換えられます。

別の回答ですでに推測されているように、gccは、置換operator delete;この実装は、誰かがその関数をデバッグ目的でオーバーロードし、delete式のすべての呼び出しで、それがたまたまNULLポインターを削除する場合でも中断できることを意味します。

更新:OPが実際にそうであることを示すベンチマークを提供したため、これは実用的な問題ではない可能性があるという推測を削除しました。

29
M.M

これはQOIの問題です。 clangは確かにテストを排除します:

https://godbolt.org/g/nBSykD

main:                                   # @main
        xor     eax, eax
        ret
7
Richard Hodges

標準では、実際に割り当て関数と割り当て解除関数が呼び出される場合と呼び出されない場合を示しています。この句(@ n4296)

ライブラリは、グローバル割り当ておよび割り当て解除機能のデフォルト定義を提供します。一部のグローバル割り当ておよび割り当て解除関数は置き換え可能です(18.6.1)。 C++プログラムは、交換可能な割り当てまたは割り当て解除関数の定義を最大1つ提供するものとします。そのような関数定義は、ライブラリで提供されるデフォルトバージョン(17.6.4.6)を置き換えます。次の割り当ておよび割り当て解除関数(18.6)は、プログラムの各変換単位のグローバルスコープで暗黙的に宣言されます。

おそらく、これらの関数呼び出しが任意に省略されない主な理由でしょう。もしそうであれば、ライブラリの実装を置き換えると、コンパイルされたプログラムの一貫性のない機能が発生します。

最初の選択肢(オブジェクトの削除)では、deleteのオペランドの値は、nullポインター値、前のnew-expressionによって作成された非配列オブジェクトへのポインター、またはそのようなオブジェクトの基本クラス(10項)。そうでない場合、動作は未定義です。

標準ライブラリ内の割り当て解除関数に与えられた引数がNULLポインター値(4.10)ではないポインターである場合、割り当て解除関数は、ポインターによって参照されるストレージの割り当てを解除し、割り当て解除されたストレージの一部を参照するすべてのポインターを無効にします。無効なポインター値を介した間接指定と、無効なポインター値を割り当て解除関数に渡すと、動作が未定義になります。無効なポインター値のその他の使用には、実装定義の動作があります。

...

Delete-expressionのオペランドの値がNULLポインター値でない場合、

  • 削除されるオブジェクトのnew-expressionの割り当て呼び出しが省略されず、割り当てが拡張されなかった場合(5.3.4)、delete-expressionは割り当て解除関数(3.7.4.2)を呼び出します。 new-expressionの割り当て呼び出しから返された値は、割り当て解除関数の最初の引数として渡されます。

  • そうでない場合、割り当てが拡張されるか、別のnewexpressionの割り当てを拡張することによって提供され、拡張new-expressionによって提供されるストレージを持つnew-expressionによって生成される他のすべてのポインター値のdelete-expressionが評価された場合、delete -expressionは、割り当て解除関数を呼び出します。拡張new-expressionの割り当て呼び出しから返された値は、割り当て解除関数の最初の引数として渡されます。

    • そうでない場合、delete-expressionは割り当て解除関数を呼び出しません。

それ以外の場合、割り当て解除関数が呼び出されるかどうかは指定されていません。

標準では、ポインターがNULLでない場合に何をすべきかが示されています。その場合の削除を意味するのは何もありませんが、何のために指定されていません。

7

プログラムがoperator deleteをnullptrで呼び出せるようにすることは(正確さのために)常に安全です。

パフォーマンスのために、コンパイラ生成のasmがoperator deleteの呼び出しをスキップするための追加のテストと条件分岐を実際に実行することは、非常にまれです。 (ただし、ランタイムチェックを追加せずに、gccがコンパイル時のnullptr削除を最適化するのを支援できます。以下を参照してください)。

まず、実際のホットスポットの外側のコードサイズが大きくなると、L1Iキャッシュへのプレッシャーが増大し、1つ(Intel SnBファミリー、AMD Ryzen)を備えたx86 CPU上のデコードされたuopキャッシュがさらに小さくなります。

第二に、追加の条件分岐は分岐予測キャッシュのエントリを使い果たします(BTB = Branch Target Bufferなど)。 CPUによっては、一度も実行されていないブランチでも、BTBでエイリアスを作成すると、他のブランチの予測が悪化する可能性があります。 (他では、そのようなブランチは、フォールスルーのデフォルトの静的予測が正確であるブランチのエントリを保存するために、BTBにエントリを取得しません。) https://xania.org/201602/bpu-パート1

特定のコードパスでnullptrがまれな場合、平均してcallを回避するためのチェックと分岐は、チェックが保存するよりも多くの時間をチェックに費やすことになります。

プロファイリングでdeleteを含むホットスポットがあり、インスツルメンテーション/ロギングで実際にdeleteをnullptrで呼び出すことが多いことが示されている場合は、試してみる価値があります。
if (ptr) delete ptr;delete ptr;だけではなく

特に近くにある他のブランチと相関関係がある場合は、operator delete内のブランチよりも、1つの呼び出しサイトでブランチの予測がうまくいく可能性があります。 (明らかに、最近のBPUは各ブランチを分離して見るだけではありません。)これは、無条件のcallをライブラリ関数に保存することに加えて(さらに、動的リンクのオーバーヘッドからPLTスタブから別のjmp Unix/Linuxで)。


他の理由でnullをチェックしている場合は、コードの非nullブランチ内にdeleteを配置するのが理にかなっています。

gccが(インライン化後に)ポインターがnullであることを証明できる場合に、delete呼び出しを回避できますが、そうでない場合はランタイムチェックを行いません

static inline bool 
is_compiletime_null(const void *ptr) {
#ifdef   __GNUC__
    // __builtin_constant_p(ptr) is false even for nullptr,
    // but the checking the result of booleanizing works.
    return __builtin_constant_p(!ptr) && !ptr;
#else
    return false;
#endif
}

インライン化する前に __builtin_constant_p を評価するため、clangでは常にfalseを返します。しかし、clangは、ポインターがnullであることを証明できる場合、すでにdelete呼び出しをスキップするため、必要ありません。

これは、実際にはstd::moveの場合に役立ち、パフォーマンスの低下がない(理論上)どこでも安全に使用できます。私は常にif(true)またはif(false)にコンパイルするので、if(ptr)とは大きく異なります。コンパイラーはおそらくポインターがほとんどの場合、nullではありません。 (ただし、null derefはUBであり、最新のコンパイラはコードにUBが含まれていないという仮定に基づいて最適化されるため、逆参照が行われる可能性があります)。

これをマクロにして、最適化されていないビルドを肥大化させないようにすることができます(したがって、最初にインライン化することなく「機能」します)。 GNU Cステートメント式を使用して、マクロargの二重評価を回避できます( (GNU C min()およびmax() )。GNU拡張機能のないコンパイラーのフォールバックのために、副作用のために((ptr), false)または何かを一度評価するために何かを書くことができますfalse結果を生成します。

デモンストレーション: Godboltコンパイラエクスプローラーのgcc6.3 -O3からのasm

void foo(int *ptr) {
    if (!is_compiletime_null(ptr))
        delete ptr;
}

    # compiles to a tailcall of operator delete
    jmp     operator delete(void*)


void bar() {
    foo(nullptr);
}

    # optimizes out the delete
    rep ret

MSVC(コンパイラExplorerリンクでも)で正しくコンパイルされますが、テストでは常にfalseが返され、bar()は次のようになります。

    # MSVC doesn't support GNU C extensions, and doesn't skip nullptr deletes itself
    mov      edx, 4
    xor      ecx, ecx
    jmp      ??3@YAXPEAX_K@Z      ; operator delete

MSVCのoperator deleteは、オブジェクトのサイズを関数arg(mov edx, 4)として受け取りますが、gcc/Linux/libstdc ++コードはポインターを渡すだけであることに注意してください。


関連: このブログ投稿 、C11(C++ 11ではない)_Genericを使用して、静的初期化子内で__builtin_constant_p nullポインターチェックなどの移植性のあることを試みました。

5
Peter Cordes

まず第一に、バグではないという点で以前の回答者に同意します。GCCはここで喜んでやります。そうは言っても、単純な最適化が行われていないため、GCCでは一般的で単純なRAIIコードがClangよりも遅くなる可能性があるのではないかと思っていました。

そこで、RAIIの小さなテストケースを作成しました。

struct A
{
    explicit A() : ptr(nullptr) {}
    A(A &&from)
        : ptr(from.ptr)
    {
        from.ptr = nullptr;
    }

    A &operator =(A &&from)
    {
        if ( &from != this )
        {
            delete ptr;
            ptr = from.ptr;
            from.ptr = nullptr;
        }
        return *this;
    }

    int *ptr;
};

A a1;

A getA2();

void setA1()
{
    a1 = getA2();
}

here を見るとわかるように、GCCdoessetA1deleteへの2番目の呼び出しを省略します(getA2の呼び出しで作成された移動元のテンポラリー用)。 a1またはa1.ptrが以前に割り当てられていた可能性があるため、プログラムを正確にするには最初の呼び出しが必要です。

明らかに、「韻と理性」のほうが好きです。なぜ最適化が行われるのかというと、必ずしもそうではないのですが、RAIIコード全体に冗長なif ( ptr != nullptr )チェックをかけるつもりはありません。

2
Arne Vogel

コンパイラには「削除」についての知識がなく、特に「nullの削除」はNOOPであると思います。

明示的に記述することができるため、コンパイラは削除に関する知識を暗示する必要はありません。

警告:これを一般的な実装として推奨しません。次の例は、その非常に特別で限定的なプログラムでコードを削除するために、限定されたコンパイラを「納得させる」方法を示しているはずです。

int main() {
 int* ptr = nullptr;

 if (ptr != nullptr) {
    delete ptr;
 }
}

私の記憶が正しければ、「削除」を独自の機能に置き換える方法があります。そして、この場合、コンパイラーによる最適化が失敗します。


@RichardHodges:コンパイラーに呼び出しを削除するヒントを与えると、なぜ最適化を解除する必要があるのですか?

削除ヌルは一般にNOOP(操作なし)です。ただし、削除を置換または上書きすることが可能であるため、すべての場合に保証はありません。

そのため、nullを削除することで常に削除できるという知識を使用するかどうかを知って決定するのはコンパイラ次第です。両方の選択肢に良い議論があります

ただし、コンパイラは、デッドコード、つまり「if(false){...}」または「if(nullptr!= nullptr){...}」を常に削除できます。

そのため、コンパイラはデッドコードを削除し、明示的なチェックを使用すると次のようになります。

int main() {
 int* ptr = nullptr;

 // dead code    if (ptr != nullptr) {
 //        delete ptr;
 //     }
}

最適化の解除はどこにありますか?

私は自分の提案を防御スタイルのコーディングと呼んでいますが、最適化を解除することはしていません

誰かが議論するかもしれない場合、今では非nullptrがnullptrの2回のチェックを引き起こすと私は返信する必要があります

  1. 申し訳ありませんが、これは元の質問ではありませんでした
  2. コンパイラが削除について知っている場合、特にdelete nullがヌープである場合、コンパイラはどちらかがあれば外側を削除できます。ただし、コンパイラがそれほど具体的であるとは思わない

@Peter Cordes:ifが一般的な最適化ルールではないことに注意することに同意します。ただし、一般的な最適化はオープナーの問題ではありませんでした。問題は、非常に短い意味のないプログラムで、一部のコンパイラが削除を削除しない理由です。とにかくそれを排除するコンパイラを作成する方法を示しました。

その短いプログラムのように状況が発生した場合、おそらく他の何かが間違っています。一般に、呼び出しはかなり高価なので、new/delete(malloc/free)を避けるようにします。可能であれば、スタックを使用することを好みます(自動)。

その間に文書化された実際のケースを見ると、クラスXの設計が間違っているため、パフォーマンスが低下し、メモリが過剰になります。 ( https://godbolt.org/g/7zGUvo

の代わりに

class X {
  int* i_;
  public:
  ...

で設計する

class X {
  int i;
  bool valid;
  public:
  ...

それ以上の場合、空のアイテムや無効なアイテムを並べ替える感覚を求めます。最後に、私も「有効」を取り除きたいです。

2
stefan bachert