web-dev-qa-db-ja.com

gccが構造体から投機的にロードできるのはなぜですか?

Gccの最適化とエラーの可能性があるユーザーコードを示す例

以下のスニペットの関数「foo」は、構造体メンバーAまたはBのいずれか1つのみをロードします。少なくとも、それは最適化されていないコードの意図です。

typedef struct {
  int A;
  int B;
} Pair;

int foo(const Pair *P, int c) {
  int x;
  if (c)
    x = P->A;
  else
    x = P->B;
  return c/102 + x;
}

Gcc -O3が提供するものを次に示します。

mov eax, esi
mov edx, -1600085855
test esi, esi
mov ecx, DWORD PTR [rdi+4]   <-- ***load P->B**
cmovne ecx, DWORD PTR [rdi]  <-- ***load P->A***
imul edx
lea eax, [rdx+rsi]
sar esi, 31
sar eax, 6
sub eax, esi
add eax, ecx
ret

そのため、分岐を排除するために、gccは両方の構造体メンバーを投機的にロードすることが許可されているようです。しかし、その後、次のコードは未定義の動作と見なされますか、上記のgcc最適化は違法ですか?

#include <stdlib.h>  

int naughty_caller(int c) {
  Pair *P = (Pair*)malloc(sizeof(Pair)-1); // *** Allocation is enough for A but not for B ***
  if (!P) return -1;

  P->A = 0x42; // *** Initializing allocation only where it is guaranteed to be allocated ***

  int res = foo(P, 1); // *** Passing c=1 to foo should ensure only P->A is accessed? ***

  free(P);
  return res;
}

上記のシナリオで負荷投機が発生する場合、P-> Bの最後のバイトが未割り当てメモリにある可能性があるため、P-> Bをロードすると例外が発生する可能性があります。最適化がオフになっている場合、この例外は発生しません。

質問

上記の負荷投機のgcc最適化は合法ですか?仕様はどこでそれが大丈夫だと言ったり暗示したりしていますか?最適化が合法である場合、「naughtly_caller」のコードはどのように未定義の動作になりますか?

53
zr.

変数(volatileとして宣言されていない)の読み取りは、C標準で指定されている「副作用」とは見なされません。したがって、C標準に関する限り、プログラムは場所を自由に読み取って結果を破棄できます。

これは非常に一般的です。 4バイト整数から1バイトのデータを要求するとします。コンパイラは、それがより高速であれば32ビット全体を読み取り(整列読み取り)、要求されたバイト以外のすべてを破棄します。あなたの例はこれに似ていますが、コンパイラは構造体全体を読むことにしました。

正式には、これは「抽象マシン」の動作、C11章5.1.2.3にあります。コンパイラーはそこで指定された規則に従うので、自由に実行できます。そして、リストされている唯一のルールは、volatileオブジェクトと命令の順序に関するものです。 volatile構造体の異なる構造体メンバーを読み取ることはできません。

構造体全体に割り当てるメモリが少なすぎる場合は、未定義の動作です。通常、構造体のメモリレイアウトはプログラマが決定するものではないため、たとえば、コンパイラは最後にパディングを追加できます。十分なメモリが割り当てられていない場合、コードが構造体の最初のメンバーでのみ機能する場合でも、禁止されたメモリにアクセスすることになります。

55
Lundin

いいえ、*Pが正しく割り当てられている場合、P->Bは割り当てられていないメモリには決してありません。初期化されていない可能性があります、それだけです。

コンパイラーは、コンパイラーが行うことを実行するすべての権利を持っています。許可されていない唯一のことは、P->Bのアクセスについて、初期化されていないという言い訳をすることです。しかし、彼らがこれをどのように、どのように行うかは、あなたの懸念ではなく実装の裁量の下にあります。

mallocによって返されるブロックへのポインターをPairを保持するのに十分な幅が保証されていないPair*にキャストした場合、プログラムの動作は未定義です。

13
Jens Gustedt

いくつかのメモリロケーションを読み取ることは、一般的なケースでは観察可能な動作とは見なされないため、これは完全に合法です(volatileはこれを変更します)。

あなたのサンプルコードは確かに未定義の振る舞いですが、これを明示的に述べている標準文書には一節も見当たりません。しかし、有効なタイプ ... N1570、§6.5p6のルールを見るだけで十分だと思います。

文字型ではない型を持つ左辺値を介して宣言された型を持たないオブジェクトに値が格納されている場合、左辺値の型は、そのアクセスおよびその後のアクセスを変更しないオブジェクトの有効な型になります格納された値。

したがって、*Pへの書き込みアクセスは、実際にそのオブジェクトにPair型を与えます。したがって、割り当てられなかったメモリに拡張されるだけで、結果は範囲外アクセスになります。

8
user2371524

->演算子と識別子が後に続く後置式は、構造体または共用体オブジェクトのメンバーを指定します。値は、最初の式が指すオブジェクトの名前付きメンバーの値です

P->Aの呼び出しが明確に定義されている場合、Pは実際にstruct Pair型のオブジェクトを指している必要があり、その結果P->Bも明確に定義されています。

7
Hurkyl

_->_の_Pair *_演算子は、Pairオブジェクト全体が完全に割り当てられていることを意味します。 ( @ Hurkylは標準を引用 。)

x86(通常のアーキテクチャと同様)には、通常に割り当てられたメモリにアクセスするための副作用がないため、x86メモリのセマンティクスは、_volatile以外のC抽象マシンのセマンティクスと互換性があります。メモリ。コンパイラは、特定の状況でチューニングしているターゲットマイクロアーキテクチャのパフォーマンスが向上すると考えられる場合に、投機的にロードできます。

X86では、メモリ保護はページの粒度で動作することに注意してください。コンパイラーは、タッチされたすべてのページにオブジェクトのバイトが含まれている限り、ループを展開するか、オブジェクトの外部を読み取る方法でSIMDでベクトル化します。 x86とx64の同じページ内のバッファーの終わりを超えて読み取ることは安全ですか? 。 Assemblyで手書きされたlibc strlen()実装はこれを行いますが、AFAIK gccはそうしません。代わりに、既にベクトル化されたポインタの位置で自動ベクトル化ループの最後に残った要素にスカラーループを使用します(完全に展開された)起動ループ。 (おそらく、valgrindによる実行時の境界チェックが難しくなるためです。)


期待した動作を得るには、_const int *_ argを使用します。

配列は単一のオブジェクトですが、ポインターは配列とは異なります。 (両方の配列要素がアクセス可能であることが知られているコンテキストにインライン化しても、構造体の場合のようにgccにコードを出力させることができなかったので、構造体コードが勝った場合、最適化が見逃されます配列に対しても安全なときに行います。).

Cでは、intがゼロでない限り、この関数に単一のcへのポインターを渡すことができます。 x86用にコンパイルする場合、gccは、ページ内の最後のintを指し、次のページはマップされていないと想定する必要があります。

Godbolt Compiler Explorerのこのバリエーションおよびその他のバリエーションのソース+ gccおよびclang出力

_// exactly equivalent to  const int p[2]
int load_pointer(const int *p, int c) {
  int x;
  if (c)
    x = p[0];
  else
    x = p[1];  // gcc missed optimization: still does an add with c known to be zero
  return c + x;
}

load_pointer:    # gcc7.2 -O3
    test    esi, esi
    jne     .L9
    mov     eax, DWORD PTR [rdi+4]
    add     eax, esi         # missed optimization: esi=0 here so this is a no-op
    ret
.L9:
    mov     eax, DWORD PTR [rdi]
    add     eax, esi
    ret
_

Cでは、youcanは、配列オブジェクトを(参照により)関数に渡します、 Cアブストラクトマシンがそうでなくても、すべてのメモリにアクセスできることを関数に保証します。 構文は_int p[static 2]_ です

_int load_array(const int p[static 2], int c) {
  ... // same body
}
_

しかし、gccは利点を活用せず、load_pointerに同一のコードを出力します。


オフトピック:clangはすべてのバージョン(構造体と配列)を同じ方法でコンパイルし、cmovを使用してロードアドレスを分岐なしで計算します。

_    lea     rax, [rdi + 4]
    test    esi, esi
    cmovne  rax, rdi
    add     esi, dword ptr [rax]
    mov     eax, esi            # missed optimization: mov on the critical path
    ret
_

これは必ずしも良いとは限りません。ロードアドレスはいくつかの追加のALU uopに依存しているため、gccの構造体コードよりもレイテンシが高くなります。両方のアドレスが安全に読み取れず、ブランチの予測が不十分である場合はかなり良いです。

setcc(Intelでは2 uops)の代わりに、cmovcc(いくつかの本当に古いCPUを除くすべてのCPUで1uopのレイテンシー)を使用して、gccとclangから同じ戦略のより良いコードを取得できます。 Skylakeの前)。 xor-- zeroingは、LEAよりも常に安価です。

_int load_pointer_v3(const int *p, int c) {
  int offset = (c==0);
  int x = p[offset];
  return c + x;
}

    xor     eax, eax
    test    esi, esi
    sete    al
    add     esi, dword ptr [rdi + 4*rax]
    mov     eax, esi
    ret
_

gccとclangはどちらも、最終的なmovをクリティカルパスに配置します。また、Intel Sandybridgeファミリでは、インデックス付きアドレス指定モードはaddとの微融合を維持しません。したがって、これは、ブランチバージョンでの動作と同様に、より適切です。

_    xor     eax, eax
    test    esi, esi
    sete    al
    mov     eax, dword ptr [rdi + 4*rax]
    add     eax, esi
    ret
_

_[rdi]_や_[rdi+4]_のような単純なアドレス指定モードは、Intel SnBファミリCPUのレイテンシよりも1c低いレイテンシを持っているため、これは実際にはSkylake(cmovが安い)でレイテンシが悪化する可能性があります。 testleaは並行して実行できます。

インライン化した後、その最後のmovはおそらく存在せず、単にaddesiに入れることができます。

5
Peter Cordes

これは、適合プログラムが違いを認識できない場合、「as-if」ルールの下で常に許可されます。たとえば、実装は、mallocで割り当てられた各ブロックの後に、副作用なしでアクセスできる少なくとも8バイトがあることを保証できます。そのような状況では、コンパイラーは、コードに記述した場合に未定義の動作になるコードを生成できます。したがって、P [0]が正しく割り当てられているときはいつでも、コンパイラがP [1]を読み取ることは正当です。

しかし、あなたの場合、構造体に十分なメモリを割り当てなければ、any memberを読むことは未定義の振る舞いです。そのため、ここでコンパイラは、P-> Bの読み取りがクラッシュした場合でも、これを行うことができます。

4
gnasher729