Cの未定義の動作がいつ因果関係の壁を跳んだのか


8

一部のハイパーモダンCコンパイラは、特定の入力が与えられたときにプログラムが未定義の動作を呼び出す場合、そのような入力は決して受信されないと推測します。その結果、そのような入力が受信されない限り無関係であるコードは排除される可能性があります。

簡単な例として、

void foo(uint32_t);

uint32_t rotateleft(uint_t value, uint32_t amount)
{
  return (value << amount) | (value >> (32-amount));
}

uint32_t blah(uint32_t x, uint32_t y)
{
  if (y != 0) foo(y);
  return rotateleft(x,y);
}

コンパイラは、の評価が0のvalue >> (32-amount)場合に未定義の動作を生成するためamount、関数が0でblah呼び出されることはないと推測yします。fooしたがって、呼び出しは無条件にすることができます。

私が言うことができることから、この哲学は2010年頃にいつか定着したようです。そのルーツについて私が見た最も初期の証拠は2009年に遡り、未定義の動作が発生した場合に明示的に述べているC11標準に安置されていますプログラムの実行のある時点で、プログラム全体の動作が遡って定義されなくなります。

コンパイラは、IEで未定義の動作(逆因果最適化を正当化するために未定義の動作を使用することを試みるべきであるという考えたrotateleft機能がそれを前提とするコンパイラを起こすべきではblah非ゼロと呼ばれている必要がありますy何が今まで引き起こすかどうか、yにゼロ以外の値を保持する)2009年より前に真剣に提唱されましたか?最適化手法として最初に真剣に提案されたのはいつですか。

[補遺]

一部のコンパイラには、20世紀でさえ、ループとその中で計算された値に関する特定の種類の推論を有効にするオプションが含まれています。たとえば、

int i; int total=0;
for (i=n; i>=0; i--)
{
  doSomething();
  total += i*1000;
}

コンパイラーは、オプションの推論がない場合でも、次のように書き直します。

int i; int total=0; int x1000;
for (i=n, x1000=n*1000; i>0; i--, x1000-=1000)
{
  doSomething();
  total += x1000;
}

そのコードの動作は元のコードと正確に一致するため、コンパイラがそのint値を常にmod-65536の2の補数形式でラップするように指定したとしても。追加の推論のオプションは、コンパイラがいるのでそれを認識させるだろうix1000同時にゼロを横断する必要があり、元の変数を排除することができます。

int total=0; int x1000;
for (x1000=n*1000; x1000 > 0; x1000-=1000)
{
  doSomething();
  total += x1000;
}

int値がmod 65536でラップされたシステムでは、最初の2つのループのいずれかnを33と実行しようとすると、doSomething()33回呼び出されます。対照的にdoSomething()、最初の呼び出しがdoSomething()算術オーバーフローの前にあったとしても、最後のループはまったく呼び出されません。このような動作は「非因果的」と見なされる可能性がありますが、影響はかなりよく抑制されており、動作が明らかに無害である場合が多くあります(入力が与えられたときに関数が何らかの値を生成する必要がある場合でも、値入力が無効である場合は任意である可能性があり、無効な値が指定された場合にループがより速く終了しますn実際に有益です)。さらに、コンパイラーのドキュメントは、UBに関係するプログラムであっても、プログラムの動作を変更するという事実に対して謝罪する傾向がありました。

私は、コンパイラーの作者の態度が、プラットフォームによって標準で義務付けられていない場合でもいくつかの使用可能な動作制約を実際に文書化する必要があるという考えから、によって義務付けられていない動作に依存するすべての構文が標準は、ほとんどの既存のコンパイラで同じ要件を満たす厳密に準拠したコードと同じかそれ以上に機能する場合でも、不正にブランド化する必要があります(多くの場合、厳密に準拠したコードでは不可能な最適化を許可します)。


コメントは詳細な議論のためのものではありません。この会話はチャットに移動しました
maple_shaft

1
@rwong:それが時間の法則にどのように違反しているのかはわかりません。不正な静的キャストがプログラムの実行を終了させた場合(実行モデルが曖昧になることはほとんどない)、ビルド後にモデルがリンク後に追加のクラスを定義できない場合は、プログラムへの入力が、shape->Is2D()派生していないオブジェクトで呼び出されることはありません。からShape2D。ある巨大な重要な未定義の動作をしている場合にのみ適用されるであろうコードから最適化の違い、すでに起こった場合のみで関連すると思われるコード対はどこ...
supercat

...何らかの形の未定義の動作を呼び出さないダウンストリーム実行パスはありません。(IIRC)Annex Lにある「クリティカルな未定義の動作」の定義は少しあいまいですが、実装では、静的なキャストと仮想ディスパッチを適切に実装して、指定されたコードが任意のアドレスにジャンプするようにします(クリティカルUBです)。常にジャンプすることShape2D::Is­2Dは、プログラムに値するよりも実際には優れています。
スーパーキャット2015

1
@rwong:重複に関して、私の質問の意図は、人類の歴史において、たとえばオーバーフローに関する未定義の動作の規範的な解釈が「プラットフォームの自然な動作がエラーチェックなしでアプリケーションがその要件を満たすことができる場合、標準に反対の動作を義務付けると、プログラムが要件を満たすために必要なソースコード、実行可能コード、およびコンパイラコードの複雑さが増す可能性があるため、標準では、代わりにプラットフォームが最善を達成できるようにする必要があります。そのプログラマ...
スーパーキャット2015

1
...核ミサイルを発射したくない場合は、常にオーバーフローを防ぐためのコードが含まれます。」歴史的に、仕様が与えられた場合、「int型の2つの引数が与えられた場合に、製品が「int」として表現できる、またはプログラムを終了するか、任意の数値を返す」でint prod(int x, int y) {return x*y;}十分です。ただし、厳密に準拠した方法で「核を起動しない」に準拠するには、読みにくいコードが必要になり、ほとんどの場合確かに、多くのプラットフォームでは実行速度がかなり遅くなります
supercat

回答:


4

未定義の動作は、仕様で動作を指定することが現実的ではない状況で使用され、絶対に可能な動作をすべて許可するように常に記述されています。

UBの非常に緩いルールは、仕様に準拠したコンパイラが通過する必要があることを考えるときに役立ちます。悪いUBを実行したときにエラーを出力するのに十分なコンパイル能力があるかもしれませんが、再帰を数層追加すると、警告を表示できるようになります。仕様には「警告」の概念がないため、仕様に動作が指定されている場合、「エラー」である必要があります。

この副作用がますます多く見られる理由は、最適化の推進です。仕様に適合するオプティマイザを書くのは難しいです。仕様に準拠したオプティマイザを作成することも、仕様の外に出たときに意図したことを推測することで非常に良い仕事をすることはたまたま残酷です。UBがUBを意味すると仮定すると、コンパイラーははるかに簡単になります。

これは、同じコンパイラで多くの多くの命令セットをサポートしようとするgccに特に当てはまります。すべてのUBコードがすべてのプラットフォームで失敗する可能性があるすべての方法に取り組み、オプティマイザの初期のフレーズにそれを組み込むよりも、UBにUBの動作を譲らせるほうがはるかに簡単です。


1
Cが速度についての評判を得た理由は、標準が要件を課さない場合でも、プラットフォームの動作が要件を満たし、そのような動作を利用できることを知っているプログラマーです。が「int」として表現できないx-y > zときに任意に0または1を生成することをプラットフォームが保証している場合、そのようなx-yプラットフォームは、式をUINT_MAX/2+1+x+y > UINT_MAX/2+1+zまたはとして記述する必要があるプラットフォームよりも最適化の機会が多くなります(long long)x+y > z
スーパーキャット2015

1
およそ2009年以前は、多くの形式のUBの一般的な解釈は、たとえば、「Nが-1のときに、ターゲットCPUで右シフトN命令を実行して、割り込みを無効にしてバスを数分間ストールさせることができたとしても[そのようなCPUを聞いたことがある]場合、コンパイラは検証なしでNを右シフトN命令に渡すか、Nを31でマスクするか、これらのオプションから任意に選択することができます。上記の動作のいずれかが要件を満たす場合、プログラマーは(それらまたはマシンの)時間を無駄にしてそれらの発生を防ぐ必要はありませんでした。変更のきっかけは何ですか?
スーパーキャット2015

@supercat 2015年でさえ、UBの「典型的な」解釈は同様に無害であると私は言うでしょう。問題はUBの典型的なケースではなく、その異常なケースです。UBの95〜98%が誤って「意図したとおりに機能した」と書いたと思います。それが残りの2〜5%であり、gccのオプティマイザの奥深くまで3年かけて掘り下げて、それが実際に彼らが考えていたのと同じように異例であることに気づくまで、UBが手に負えなくなった理由を理解できません。 ...最初は簡単に見えました。
Cort Ammon、2015

私が遭遇した優れた例は、私が誤って書いたOne Definition Rule(ODR)違反であり、2つのクラスに同じ名前を付け、1つの関数を呼び出して他の関数を実行したときに深刻なスタックバッシングが発生しました。リンカがネーミングの問題をどのように解決するかを理解するまでは、「なぜ最も近いクラス名を使用しなかったか、少なくとも警告を発しなかったのか」と言うのは理にかなっているようです。それをキャッチするためにコンパイラに大量の追加コードを配置しない限り、「良い」動作オプションはありませんでした。
Cort Ammon、2015

リンカー技術は一般的に恐ろしいですが、それを改善するために鶏と卵の障壁がかなりあります。そうでなければ、私が必要と考えるのは、Cが移植性/最適化のtypedefとマクロのセットを標準化して、暗黙の動作をサポートする既存のコンパイラーヘッダーファイルを追加するだけで新しい標準をサポートし、コードを使用して新しいコードを使用できるようにすることです。 "互換性"ヘッダーファイルを含めるだけで動作をサポートしていた古いコンパイラで機能を使用できますが、コンパイラはより優れた最適化を実現できます...
supercat

4

「未定義の動作により、コンパイラーがコードを書き換える可能性があります」は、ループの最適化で長時間発生しました。

ループを取る(たとえば、aとbはdoubleへのポインター)

for (i = 0; i < n; ++i) a [i] = b [i];

intをインクリメントし、配列要素をコピーして、制限と比較します。最適化コンパイラは、最初にインデックスを削除します。

double* tmp1 = a;
double* tmp2 = b;
for (i = 0; i < n; ++i) *tmp1++ = *tmp2++;

n <= 0の場合を削除します。

i = 0;
if (n > 0) {
    double* tmp1 = a;
    double* tmp2 = b;
    for (; i < n; ++i) *tmp1++ = *tmp2++;
}

次に、変数iを削除します。

i = 0;
if (n > 0) {
    double* tmp1 = a;
    double* tmp2 = b;
    double* limit = tmp1 + n;
    for (; tmp1 != limit; tmp1++, tmp2++) *tmp1 = *tmp2;
    i = n;
}

32ビットシステムでn = 2 ^ 29、64ビットシステムで2 ^ 61の場合、一般的な実装ではtmp1 ==の制限があり、コードは実行されません。ここで、割り当てを長時間かかるものに置き換えます。これにより、元のコードは時間がかかりすぎてコンパイラがコードを変更したために不可避のクラッシュが発生することがなくなります。


どちらのポインターも揮発性でない場合、コードの書き換えとは見なしません。コンパイラーは長い間、非volatileポインターへの書き込みを再順序付けすることを許可されていました。nそのため、ポインターがラップするほど大きい場合の動作は、境界ストアクローバーが一時ストレージロケーションをi何よりも先に保持するのと同等になります。起こります。揮発性であるab、揮発性であった場合、プラットフォームは、揮発性アクセスが要求されたシーケンスで物理的なロード/ストア操作を生成することを文書化し、プラットフォームがそのような要求を行うためのあらゆる手段を定義する...
supercat

...プログラムの実行に決定的な影響を与える可能性がある場合、コンパイラは示された最適化を控えるべきだと主張します(ただし、i揮発性にしない限り、一部がそのような最適化を防ぐようにプログラムされていない可能性があることはすぐに認めます)。ただし、これは非常にまれな行動のケースです。abが非揮発性である場合、すべてのメモリを上書きするほど大きい場合にコードが何をすべきかについて、もっともらしい意図的な意味はないことをお勧めしnます。対照的に、UBの他の多くの形式には、もっともらしい意図された意味があります。
スーパーキャット2015

さらに検討すると、整数演算の法則に関する仮定が原因で、UBが発生する前にループが途中で終了し、ループなしで不正なアドレス計算が行われる状況を考えることができます。FORTRANでは、ループ制御変数と他の変数の間に明確な違いがありますが、Cにはその違いがありません。ループ制御変数のオーバーフローを常にプログラマーに防ぐ必要があるプログラマーは、オーバーフローを防ぐ必要があるよりも効率的なコーディングの妨げにはならないので残念です。どこでも。
スーパーキャット2015

本当に有用な最適化を妨げることなく、オーバーフローの場合に一連の動作に満足するコードがそれを言えるように、言語ルールをどのように記述するのが最も良いのでしょうか?有効な入力が与えられるとコードが厳密に定義された出力を生成しなければならない場合が多く、無効な入力が与えられると緩く定義された出力が生成されます。たとえば、オーバーフローが他に影響を与えない限り、オーバーフローが発生した場合に実行するif (x-y>z) do_something()かどうかdo_somethingを気にしないプログラマーが考えます。上記の内容を書き換えない方法はありますか?
supercat

...少なくともいくつかのプラットフォームで不必要に非効率なコードを生成します(呼び出すかどうかの自由な選択が与えられた場合にそれらのプラットフォームが生成できるものと比較してdo_something)?ルーズオーバーフローモデルと矛盾する動作を生成することからループ最適化が禁止されていても、プログラマーはコンパイラーが最適なコードを生成できるような方法でコードを書くことができます。「すべてのコストでオーバーフローを回避する」モデルによって強制される非効率を回避する方法はありますか?
スーパーキャット2015

3

CおよびC ++では、未定義の動作の結果として、何かが発生する可能性があります。したがって、コードが未定義の動作を呼び出さないとコンパイラが想定できることも常にあります。コードに未定義の動作がない場合、想定は正しいものでした。または、コードに未定義の動作があり、誤った仮定の結果としてが起こって、「何でも起こり得ます」でカバーされます。

あなたがCに特徴を「制限する」を見れば、機能の全体のポイントは、コンパイラは未定義動作がないことを想定することができるということですので、我々は、コンパイラだけでなく点が到達するが、実際にする必要があり未定義がないと仮定し動作。

提供する例では、シフトカウントが32ビットコードで32、64ビットコードで64の場合、左または右シフトを実装するためにx86ベースのコンピューターで通常使用されるアセンブラー命令は0ビットシフトします。これは、ほとんどの場合、望ましくない結果(およびARMやPowerPCとは異なる結果など)につながるため、コンパイラーは、この種の未定義の動作が発生しないと想定することは十分に正当化されます。コードを次のように変更できます

uint32_t rotateleft(uint_t value, uint32_t amount)
{
   return amount == 0 ? value : (value << amount) | (value >> (32-amount));
}

そして、シフトコード用に生成されたアセンブラコードは、量== 0の場合と同じ結果を生成するため、ほとんどのプロセッサでは、「量== 0」コードをコンパイラによって削除する必要があることをgccまたはClang開発者に示唆します。


1
変数が0から31までの任意の値を保持し、0 以外の値を生成するときに機能するx>>y[for unsigned x]の実装がy他の何かを実行するものx>>(y & 31)と同じくらい効率的であるプラットフォームは非常に少ないと考えることができます; 上記のいずれか以外のアクションが発生しないことを保証すると、かなりのコストがかかるプラットフォームはありません。プログラマーが、あいまいなマシンで実行する必要のない、より複雑な定式化をコードで使用する必要があるという考えは、ばかげていると見なされていました。
スーパーキャット2015

3
私の質問は、「x >> 32`が恣意的に譲歩しxたり0、不明瞭なプラットフォームでトラップしたりする可能性がある」から「x>>32コンパイラが他のコードの意味を書き換えさせる可能性がある」に移行したのはいつですか?私が見つけることができる最も早い証拠は2009年からですが、以前の証拠が存在するかどうか私は興味があります。
スーパーキャット2015

@Deduplicator:私の置換は、意味のある値、つまり0 <=量<32に対して完全に正常に機能します。そして、(値>> 32-量)が正しいかどうかは関係ありませんが、かっこは省略しますバグと同じくらい悪いです。
gnasher729 2015

への制限が表示されませんでした0<=amount && amount<32。大きい値/小さい値が意味があるかどうか?彼らがそうであるかどうかは問題の一部だと思いました。そして、ビット操作の前で括弧を使用しないことはおそらく悪い考えです、しかし確かにバグではありません。
Deduplicator

@Deduplicatorこれは、一部のCPU(y mod 32)が32ビットxおよび(y mod 64)64 ビットの場合と同様にシフト演算子ネイティブに実装しているためですx。シフト量をマスキングすることにより、すべてのCPUアーキテクチャで均一な動作を実現するコードを発行するのは比較的簡単です。これには通常、1つの追加の指示が必要です。しかし、悲しいかな...
15

-1

これは、コードにバグがあるためです。

uint32_t blah(uint32_t x, uint32_t y)
{
    if (y != 0) 
    {
        foo(y);
        return x; ////// missing return here //////
    }
    return rotateleft(x, y);
}

言い換えると、特定の入力が与えられたときに、疑いを超えて未定義の動作呼び出しているとコンパイラが認識した場合にのみ、因果関係の壁を飛び越えます。

未定義の動作の呼び出しの直前に戻ることにより、その未定義の動作の実行を意識的に防止していることをコンパイラに伝え、コンパイラはそれを認めます。

言い換えると、非常に厳密な方法で仕様を適用しようとするコンパイラーがある場合、可能なすべての引数検証をコードに実装する必要があります。さらに、これらの検証は、前述の未定義の動作を呼び出す前に行う必要があります。

待つ!そして、もっとあります!

さて、コンパイラーがこれらの非常にクレイジーでありながら超論理的なことを行うので、関数が実行を継続することは想定されていないことをコンパイラーに伝えることが不可欠です。したがって、関数のnoreturnキーワードは必須になりますfoo()


4
申し訳ありませんが、それは問題ではありません。
重複排除機能2015

@Deduplicatorそれが問題ではなかった場合、この問題は意見に基づいたものである(公開討論)ために閉じられるべきでした。効果的には、CおよびC ++委員会の動機とコンパイラベンダー/ライターの動機を推測します。
rwong 2015

1
これは、標準と委員会に限定されない、C言語でのUBの歴史的な解釈、使用、および理論的根拠に関する質問です。委員会を推測する?そうではありません。
Deduplicator

1
...後者は1つの命令のみをとるべきであることを認識してください。ハイパーモダニストがCが置き換えられた分野でCを競争力のあるものにするために必死に努力しているかどうかはわかりませんが、彼らは王である唯一の分野でその有用性を損なうものであり、有用な最適化をより困難にすることから私が言えることです簡単というよりは。
スーパーキャット2015

1
@ago:実際、24ビット整数のシステムでは、最初のシステムは成功し、2番目のシステムは失敗します。組み込みシステムでは、何でも起こります。またはたくさん行く; 私は、int = 32ビット、およびlong = 40ビットであるものを覚えています。これは、私が今まで見た中で、longがint32_tよりもはるかに効率が悪い唯一のシステムです。
gnasher729 2015
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.