(雑記) int の範囲チェック速度 小ネタ
int型の整数の引数の範囲チェックで、0 から Size の範囲外だったら例外を投げる、のような範囲チェックはする機会が多いが、
int valueとint Sizeに対して
if(value < 0 || value >= Size) throw new ArgumentOutOfRangeException();
よりも
if((uint)value >= (uint)Size) throw new ArgumentOutOfRangeException();
の方が演算が1回に減るため速い。これは C# に限った話ではなく整数に2の補数表現を使う処理系全般で使える。整数のビット表現を考えれば当然そうなる。同ビット幅のintとunsigned intのキャスト自体になんらかのコストが発生する言語は別。
(C# 雑記) Linq の Count() と IReadOnlyCollection<T>
Linq のCount()、つまりint Enumerable.Count(IEnumerable<T>)は原理的には全列挙による数え上げだが、配列ライクなものは初めから要素数を持っているのでO(N)を回す必要はない。そのため、配列ライクなものについては直接要素数を取るよう最適化が施されている。
それで、ここで言う「配列ライク」なものというのはICollectionとICollection<T>。(.NET Core の場合はもう一つIIListProvider<T>も入っているが、こちらはinternalなのでユーザーにはあまり関係ない)
このあたりの継承関係は、ジェネリック版は
IList<T>→ICollection<T>→IEnumerable<out T>→IEnumerable
非ジェネリックは
IList→ICollection→IEnumerable
の継承関係にある。Countを持っているのはICollection<T>とICollection。
これとは別に、.NET Framework 4.5 の時点で後から追加されたインターフェースにIReadOnlyList<out T>とIReadOnlyCollection<out T>があり、それぞれ
IReadOnlyList<out T>はT this[int index] { get; }
IReadOnlyCollection<out T>はint Count { get; }
のみを提供している。継承関係は
IReadOnlyList<out T>→IReadOnlyCollection<out T>→IEnumerable<out T>→IEnumerable
になっている。
ここで、IReadOnlyCollection<out T>はCountを持っているにもかかわらず、ICollectionとICollection<T>のどちらでもないため、Linq のCount()の最適化にヒットしない。
では、なぜIReadOnlyCollection<out T>に最適化が入っていないかだが、おそらくis演算子のコストがタダではないからだと思われる。(あくまで個人的推測) IReadOnlyCollection<out T>を継承しているクラスは高確率でICollection<T>を継承しているはずなので、そこでヒットするだろうという考えな気がする。
でも、ICollection<T>やICollectionは余計なメソッドがいっぱいある上、setter を提供してしまうのであまり ReadOnly なクラスにつけたくない。もちろん、いらないものは明示的実装で隠蔽しつつ、インターフェース経由で呼ばれたらNotSupportedExceptionでも投げればいいのだけど、Linq の内部実装の最適化ヒット読みでわざわざそんなことしないといけないなら、なぜIReadOnlyCollection<out T>なんていうインタフェースをわざわざ作ったのか……と思わなくもない。特に、本来静的エラーにできるものを実行時エラーで解決するのは何のための静的型付けだ!!と思ってしまう。
最適化が入ってないのは何か理由があるんですかね?
(C#) 共変性による参照型配列のパフォーマンス
C# の配列は共変性 (covariance) があり、以下のコードはコンパイルできます。
object[] array = new int[10]; array[2] = 5;
そして困ったことに、次のコードもコンパイルできます。
object[] array = new int[10]; array[2] = "hello"; // ← 代入できる!?!? (実行時エラー)
しかし、コンパイルできますが、実際に実行すると実行時エラーになります。arrayインスタンスはint[]なのでstringを代入できるはずがありません。にもかかわらず、静的にはエラーになりません。
CLR は C#1.0 の時点から配列をサポートしており、C# の型の中でも超特殊な扱いを受けているため、ランタイムの中の見えないところで色々とハックな実装がされています。
上記の共変性の問題はある意味C#の言語設計のミスで、C# の登場時にはジェネリックがなく、汎用型に使える設計にはobjectで受ける以外の選択肢がありませんでした。そして、C#が設計のもとにした Java が同様に配列の共変性を持っているのをそのまま持ってきたのが原因です。もちろん、当時の Java にもジェネリックはありません。
ジェネリック登場以降も、後方互換性のため、今更仕様を変更できずこのようなコードが書けてしまいます。
ジェネリック登場以降は汎用型の用途でobject[]を使うような行儀の悪いコードはまず書きませんが、配列の共変性自体はobjectに限らないため、例えば継承関係にあるクラスAnimalとDogでAnimal[] animals = new Dog[10];などのコードは今でも十分ありえます。
abstract class Animal { } class Dog : Animal { } class Cat : Animal { } Animal[] animals = new Dog[10]; animals[2] = new Cat(); // runtime error !!!
この時、CLR から実行時エラーが出るということは、暗黙的に型チェックが行われているということです。 つまり、全ての参照型の配列はこの共変性の仕様のせいでパフォーマンスに影響が出ます。(配列変数の型とインスタンスの型が一致している or していないに関わらず発生します)
Animal[] _animals = new Dog[100000]; Dog _dog = new Dog(); int[] _intArray = new int[100000]; int _num = 0; // 参照型の配列の場合 void M1() { for(int i = 0; i < _animals.Length; i++) { _animals[i] = _dog; // 毎回型チェックが入る } } // 値型の配列の場合 void M2() { for(int i = 0; i < _intArray.Length; i++) { _intArray[i] = _num; // 型チェックは発生しない } }
上記のM1メソッドはM2メソッドより遅いです。M1は参照型配列の共変性の仕様により、代入できない型のインスタンスが代入されないように暗黙的に型チェックが毎ループ走ります。
一方で、C# の構造体は継承はないため、共変性・反変性とは無縁であり、値型の配列に型チェックは発生しません。
この型チェックを回避するための方法として構造体にラップするというのがあります。
struct Wrapper { public readonly Animal animal; public Wrapper(Animal animal) => Animal = animal; }
Wrapper[] _wrapperArray = new Wrapper[100000]; Wrapper _wrapper = new Wrapper(new Dog()); void M3() { for(int i = 0; i < _wrapperArray.Length; i++) { _wrapperArray[i] = _wrapper; // 型チェックは発生しない } }
構造体にラップすると、値型の配列になり、共変性はないため型チェックが消えます。
これは CLR が変わらない限り (後方互換性のためまず変わらないと思うけど)、参照型の配列全てで発生する問題です。もちろん、よっぽどホットな高速化が必要な部分以外は気にする必要がない問題ですが、知っておいて損はないはずです。 とはいえ、気にし過ぎは可読性や実装コストの面で有害ですらあるので、必要な場面以外は忘れておきましょう。。。
参考文献
(C#) 参照型インスタンスのアドレスを取得する
C# では unsafe キーワードを使うことで値型のポインタや、unmanaged型配列の配列要素へのポインタを取得できますが、
参照型インスタンスへのポインタは取得できません。ガベージコレクション (GC) によるコンパクションでアドレスが移動する可能性があり、C# 側からはメモリ移動を検知する手段も用意されていないため言語仕様として当然です。
が、System.Runtime.CompilerServices.Unsafeクラスを使うと実は取得できます。
Unsafeクラスについては以前の記事にも少し書きましたが、かなり乱暴なことができます。unsafeキーワード以上にアンセーフです。
この記事の内容はたぶん言語としての動作保証仕様外。言語仕様の外に片足突っ込んてるそれなりにハックな内容。
手法
// 何かしらの参照型 class Sample() { } unsafe struct Pointer { public void* P; } // ~~~~~~~~~~~~~~~~~~~~~~~~ // 参照型インスタンスのポインタを取得 var sample = new Sample(); ref byte refSample = ref Unsafe.AsRef<byte>(Unsafe.As<Sample, Pointer>(ref sample).P);
Sampleはクラスです。クラスへのポインタは言語仕様としては取れませんが、強引に取得します。
まず、上記のようにvoid*型のフィールドを1つだけ持つ構造体を用意し、
Unsafe.As()をつかって参照型インスタンスをこの構造体に強制キャストします。この時、得られるvoid*型のフィールドが参照型インスタンスのアドレスです。
void* address = Unsafe.As<Sample, Pointer>(ref sample);
[2020/07/11 追記] よく考えると上記の「
void*型のフィールドを1つだけもつ構造体」はIntPtrそのものなので、 わざわざ自分で用意するまでもなくIntPtrでできますね。
これだけで目的は達しているのですが、前述の通りGCのコンパクションで移動しうるので、
このポインタはいつどのタイミングで無効なポインタになってもおかしくないです。
そこで、このポインタをref byteに変換します。
ref byte refSample = ref Unsafe.AsRef<byte>(address);
refにするとGCによるトラッキングを受けられるため、コンパクションによるメモリ移動時に無効な参照になりません。
本来、参照型に対してrefなんて使えるものではないのに本当にGCにトラッキングされるのか?という疑問が当然出ますが、実際確認した結果、きちんとトラッキングされていました。
確認したコードは以下の gist に。
安全性
本当にコンパクションによるメモリ移動で無効な参照を引かないのか。 正直なところ100%安全だと私は断言できませんでした。でもまあ十中八九大丈夫でしょう。
JITコンパイル結果を見ます。.NET Core で JITは64bitのリリースビルドを見ます。 JIT結果は sharplab で確認しました。リンクはこちら。
参照型としてstring型を見てますが、別に参照型なら何でも同じです。参照型インスタンスをref byteに変換するメソッドを用意しました。
[MethodImpl(MethodImplOptions.AggressiveInlining)] public unsafe ref byte GetRef(string obj) { return ref Unsafe.AsRef<byte>(Unsafe.As<string, Pointer>(ref obj).P); }
このメソッドの JIT コンパイル結果 (amd64) は以下の通り。
C.GetRef(System.String) L0000: mov [rsp+0x10], rdx L0005: mov rax, [rsp+0x10] L000a: ret
スタックに乗せて、乗せた値を返しているだけです。 当然と言えば当然ですが、引数を素通しで返すだけの虚無みたいな逆コンパイル結果が吐かれます。テンションが上がりますね。
Unsafe.AsRef(), Unsafe.As() のどちらも.NET Core のソースコード
(AsRef(),
As()
) を見てもらえば分かりますが (どちらも C# で書けないため IL で書かれてますが)、両者とも引数を素通しするだけの非常にクールでイカしたメソッドなので、
最適化によって何もしない虚無が生成されます。非常にテンション上がりますね。結果、上記のようになるわけですけど、これって GC が挟まる余地があるんでしょうか?
ref byteになってしまった後のコードは全て GC のトラッキングを受けられるため安全ですが、ILの型として一瞬ポインタを経由する瞬間に移動したら死亡しますが、その部分って最適化で跡形もなく消えてるのでは?と思うため個人的には安全だと思っています。
しかし、私自身が C# のコンパイラや .NET のランタイムのソースコードを読んだりしていないため、何とも言えません。 あくまで言語仕様外のことを強引に行っているのをお忘れなく。。。
その他
コンパクションで移動しうる参照をrefでつかむとコンパクションに対して確実に安全なのだとしたら、マネージド配列に対するfixedっていらなくない?
同じようなことやっている記事を見かけたのだけれど。
なんというか、MSからUnsafeクラスなんていう危険なオモチャが公式で提供されてるのですから、遊ばずにはいられないですよね。。。
(雑記) xorshiftって0出ないよね
疑似乱数生成アルゴリズムの Xorshift を実装してみて思ったんですが、これって何回生成しても0は出てこないですよね。
Xorshift のアルゴリズムは元論文見るのが早いです。すごい短いし。解説サイトだとこのページが丁寧。
要約すると、32bit版はこれです。
// unsigned int を符号なし32bitとする unsigned int seed; unsigned int Xorshift() { seed ^= (seed << 13); seed ^= (seed >> 17); seed ^= (seed << 5); return seed; }
で、seedが最初0だったら永遠に0しか出ない。それはseedに0を入れなければいいだけかもしれないが、seedが0以外だと0は出ない。
暗号学とか乱数アルゴリズムとか全然詳しくないんですが、疑似乱数ってそういうものなんですかね?0も乱数列内に含んでほしい時ってどうすればいいんでしょうか。詳しい方いたら教えてほしいです
(C#) ReadOnlyCollection<T> を Unsafe.As で違法に書き換える
System.Runtime.CompilerServices.Unsafe クラス
System.Runtime.CompilerServices.Unsafeクラスは C# において safe なコンテキストで unsafe なことができる、.NET の標準ライブラリにある公式黒魔術書です。Unsafeという名前から明らかですが、safe で使える (ポインタをかかない) にもかかわらず unsafe と同等かそれ以上に危険なことができます。
むしろUnsafeクラスが提供しているメソッドの内容は C# の表現力ではコードとして書き表せないものが大半です。
なのでUnsafeクラスのソースコードは直接 IL で書かれています。.NET Core 3.1 のUnsafeクラスのソースコードはこれです。
各メソッドでどんなことができるのかは MSDN を見るか、このページが分かりやすいです。
Unsafe.As メソッド
いろいろあるUnsafeクラスの中でもかなり危険だと (個人的に) 思うのが、T Unsafe.As<T>(object) where T : classメソッドで、継承関係に関わらず強制的に型変換できるというものです。
// ClassA と ClassB は互いに継承関係にない class ClassA a = new ClassA(); // ClassA のインスタンス ClassB b = Unsafe.As<ClassB>(a); // 強制型変換 (本来はキャスト不可)
これが何の例外も出ずに実行できてしまいます。一体何が起こっているのかさっぱり分からないのでUnsafeクラスのソースコードを覗いてみましょう。ソースコードは前述の通り IL です。
.method public hidebysig static !!T As<class T>(object o) cil managed aggressiveinlining
{
.custom instance void System.Runtime.Versioning.NonVersionableAttribute::.ctor() = ( 01 00 00 00 )
.maxstack 1
ldarg.0
ret
} // end of method Unsafe::As
スマートすぎる、天才か?
- 1行目 :
.custom instance ...はおまじないです。(このメソッドの属性の宣言) - 2行目 : 使用するスタックの数の宣言 (スタック数 1)
- 3行目 : 引数をスタックの0番目にロード
- 4行目 : スタックの先頭を return
仰々しく書いたがやっていることは完全に素通しです。IL にとっては引数のobject oが何の型だろうが知ったことではなく、所詮はただの参照、それをT型として返すだけ。
こんな単純なことなのに C# の表現力ではこれは書けません。如何に C# がコンパイラによって安全に守られているか分かります。
しかし、これは IL が素通しで型を変えただけであるため、それが C# の型としてメモリ上で有効な状態になっている保証は一切ありません。レイアウトが異なる型を変換すると、意図したプロパティとは違うプロパティが呼ばれてしまう、アクセスできないメモリを参照しようとしてクラッシュして落ちる、等おかしな挙動が起きます。
つまり、型情報や仮装関数テーブルを経由するメソッド呼び出しは、本来ありえない型のテーブルを引くため未定義動作になります。(たいていはAccessViolationException等を吐いて落ちますが、たまたま有効な別メソッドを引くこともできるので動きは未定義)
継承関係にないがメモリレイアウトが同じ型のキャストを無理やり実行したい、というのが使い道です。 勘違いしそうですが、このメソッドは実行時のインスタンスの動的型を変える魔法ではなく、静的型をすり替えてコンパイラの型チェックをごまかすだけです。
ちなみにUnsafe.Asメソッドにはもう一つオーバーロードがあって、Unsafe.As<TFrom, TTo>(ref TFrom)です。先ほどのはclass用なので、構造体を見かけ上、別の構造体として扱いたいときはこちらです。
ロジックは先ほどと同じで、あくまで参照を素通しして見かけの型を変えるものであるため、入力・出力ともにrefです。`構造体を値のコピーではなくダイレクトに別のものとして叩き込みたい、のようなアグレッシブな場面で使えます。
ReadOnlyCollection<T> の中身を書き替え
Unsafe.As<T>(object)の悪用(?)法として、System.Collections.ObjectModel.ReadOnlyCollection<T>の中身を書き替えてみたい、というのが今回の本題。
.NET Core のReadOnlyCollection<T>のソースは[これです]。(https://source.dot.net/#System.Private.CoreLib/ReadOnlyCollection.cs)
メソッドとプロパティがいっぱいありますが、メモリ上でのレイアウトに関係ないものを取っ払うと、以下のようになってます。
public class ReadOnlyCollection<T> { private IList<T> list; }
本当にただIList<T>を read only にラップしただけなのがよくわかります。要はこのprivateな変数を引っこ抜いてこれば書き換えができます。普通ならリフレクションでprivateフィールドを取ってくるんですが、今回の趣旨はUnsafeクラスを使ってやります。
Unsafe.As<T>(object)を使って、ReadOnlyCollection<T>を同じメモリレイアウトで中身がpublicな別の型にすり替えます。
// ReadOnlyCollection<T> と同じメモリレイアウトを持つクラス public class IllegalExposure<T> { public IList<T> Content; }
これを使って型をすり替えます
// もとになる ReadOnlyCollection ReadOnlyCollection<int> collection = ###; // 強制キャストで型をすり替える var illegal = Unsafe.As<IllegalExposure<int>>(collection); // 中身が public になったので抜き出す var content = illegal.Content; content[0] = 1234; // 中身を書き替える
抜き出した中身を書きかえると、元のReadOnlyCollection<T>の中身も当然書き換わります。
これ自体はリフレクションでも全く同じことができるのですが、速度が桁違いです。リフレクション経由でのprivateフィールドの取得などは余裕で2, 3桁実行速度が遅くなるのに対し、この強制キャストの中身は先ほど見た通り参照素通しなので爆速です。比になりません。
Unsafeクラス、フォースの暗黒面は素晴らしい
(C#) 配列の for の JIT 最適化処理
[前提] C# のコンパイルと JIT コンパイル
C# --> [コンパイル] --> IL -> [JIT] --> バイナリ の流れを知ってる人は読み飛ばしてください
forの最適化の話をする前に、C#のソースコードが実行可能バイナリ (アセンブリ) にコンパイルされるまでの流れをおさえておきます。
C# はJava等と同じで中間言語にコンパイルされます。Java が Java 仮想マシン (JVM; Java Virtual Machine) 上で動く Java バイトコードにコンパイルされるように、C# は .NET 上で動く MSIL (通称 IL; Microsoft Intermediate Language) にコンパイルされます。その後、実行時に実行プラットフォーム (OSとかCPUのアーキテクチャ) に合わせた実行可能バイナリに再度コンパイルされることで実行されます。
この IL からアセンブリへのコンパイルのことを JIT コンパイル (Just in Time Compile) と呼びます。

C# で作られたソフトウェア (Windows の場合 exe ファイル) はこの IL の状態であるため、プラットフォームによらず同一のプログラムファイルで実行できます。つまりC#製の exe の場合、Windows で動かしている exe ファイルをそのまま Mac にコピーすれば動きます。(まあモノに依りますが。monoだけに)
で、なぜかと言うと IL は .NET という仮想マシン向けの命令だからです。プラットフォーム間の差異は .NET が吸収してくれるので IL はプラットフォーム非依存になります。(Javaバイトコードが JVM という仮想マシン上で動くのと全く同じです。と言うか C# は Java の真似して作られた)
わざわざ JIT コンパイルという二度手間を踏んでまで中間言語にコンパイルする意味は、いろいろメリットがあるからなんですが、ここではその話は省略します。
あと余談ですが、Unity の iOS 向けビルド(など)で行われる IL2CPP は 事前コンパイル (AOT: Ahead of Time compile) の一種で、本来実行時に JIT コンパイルされる IL を、あらかじめアセンブリまでコンパイルしてしまうものです。メリット・デメリット双方あります。
for の配列アクセスの最適化
本題です。
以下に簡単なサンプルコードを書きました。2種類のやり方でint配列の合計をforで回して計算しているだけのメソッドです。
public class C { public int L = 10000000; public int M1() { var array = new int[L]; int sum = 0; for(int i = 0; i < array.Length; i++) { sum += array[i]; } return sum; } public int M2() { var array = new int[L]; int sum = 0; for(int i = 0; i < L; i++) { sum += array[i]; } return sum; } }
配列の要素は全て0なのでどっちも答えは0ですが、計算に特に意味はありません。2つのメソッドの違いはforの終了条件に配列のLengthプロパティを使うか否かだけです。
結果から言うと、M1()メソッドの方が実行速度が速いです。JIT コンパイル時に最適化がかかるからです。
実際に逆アセンブルしてアセンブリを見てみましょう。(C#のコンパイルは Release ビルド、アセンブリはAMD64)。逆アセンブルツールはいろいろありますが、パッと簡単なコードの結果を見たいだけの時は sharplab がすごく簡単です。ソースをコピペして2秒で見れます。今回もこれを使いました。
; Core CLR v4.700.19.51502 (coreclr.dll) on amd64. C..ctor() L0000: mov dword [rcx+0x8], 0x989680; L = 10000000 L0007: ret ; return C.M1() L0000: sub rsp, 0x28 ; L0004: mov edx, [rcx+0x8] ; L0007: movsxd rdx, edx ; L000a: mov rcx, 0x7ffec35ef000 ; L0014: call 0x7fff230847e0 ; array = new int[L] L0019: xor edx, edx ; sum = 0 L001b: xor ecx, ecx ; i = 0 L001d: mov r8d, [rax+0x8] ; L0021: test r8d, r8d ; if (i >= L) L0024: jle L0035 ; goto -------┐ L0026: movsxd r9, ecx ; <----|--┐ L0029: add edx, [rax+r9*4+0x10] ; sum += array[i] | | L002e: inc ecx ; i += 1 | | L0030: cmp r8d, ecx ; if (i < L) | | L0033: jg L0026 ; goto -------|--┘ L0035: mov eax, edx ; <----┘ L0037: add rsp, 0x28 ; L003b: ret ; return sum C.M2() L0000: push rsi ; L0001: sub rsp, 0x20 ; L0005: mov esi, [rcx+0x8] ; L0008: movsxd rdx, esi ; L000b: mov rcx, 0x7ffec35ef000 ; L0015: call 0x7fff230847e0 ; array = new int[L] L001a: xor edx, edx ; sum = 0 L001c: xor ecx, ecx ; i = 0 L001e: test esi, esi ; if (i >= L) L0020: jle L0039 ; goto ---------------┐ L0022: mov r8d, [rax+0x8] ; | L0026: cmp ecx, r8d ; if (i < 0 or i >= L) <--|--┐ L0029: jae L0041 ; goto ----------┐ | | L002b: movsxd r9, ecx ; | | | L002e: add edx, [rax+r9*4+0x10] ; sum += array[i] | | | L0033: inc ecx ; i += 1 | | | L0035: cmp ecx, esi ; if (i < L) | | | L0037: jl L0026 ; goto ----------|----|--┘ L0039: mov eax, edx ; <------|----┘ L003b: add rsp, 0x20 ; | L003f: pop rsi ; | L0040: ret ; return sum | L0041: call 0x7fff231aef00 ; throw Exception <--┘ L0046: int3 ;
アセンブリを眺めてスラスラ読める宇宙人の方はいいですが、人間には厳しいので、隣に高級言語的に意味ある部分に疑似コードを書き足しておきました。あくまで左はアセンブリなので高級言語の命令と1対1には対応しません、だいたいです。
処理の流れは右の疑似コードを見てもらえば、きちんと元のC#のソースと同じことをしているのが理解できます (当たり前だけど)。2つのメソッドの違いは、M2()メソッドの L0026, L0029 の部分の有無です。
これは配列の境界チェックです。C#では配列外のインデックスにアクセスすると、必ずIndexOutOfRangeExceptionの例外が発生します。
var array = new int[10]; array[15] = 4; // ← throw new IndexOutOfRangeException()
C++とかだとこの辺の境界外アクセスは未定義なので色々と危険なことが起こります (起こせます) が、C#はメモリの安全性が言語として担保されてます。ここで、例外が発生するということは内部的にはインデックスが0以上かつ配列長未満であるかをチェックしているということで、これがM2()メソッドの L0026, L0029の部分です。
安全性が担保されているのはいいことですが、これは実行速度とトレードオフで、このチェックはループ毎にインデックスアクセスで行われるため、オーバーヘッドが発生します。
ではなぜM1()メソッドの方はこのチェックがないのかと言うと、M1()のforの条件は初期値がi=0で終了条件がi < array.Length、更新条件がi++なため、原理的に配列の境界外にアクセスが発生せず、JIT コンパイラがそれを認識して高速化のために境界値チェックを削除したためです。
ならばM2()メソッドの方も境界外アクセスが発生しないのは自明な気がしますが、この最適化が行われるのは終了条件にarray.Lengthプロパティを使った時のみ行われます。Lがpublicだから、あるいはクラスフィールドだから途中で非同期から変更される可能性があるためチェックを排除できない、というのもありますが、これに関してはメソッド内のローカル変数にLを置いてもチェックは消えません。あくまでarray.Lengthの時のみ高速化されます。
一応補足しておきますが、この最適化が行われるのは JIT コンパイル時です。JIT コンパイラがもう少し頑張って処理の流れを追って解析すればarray.Lengthプロパティ以外でも必ず安全性が保たれる場合なら最適化できそうな気もします。が、JIT コンパイルは実行時に行われるため、処理の解析と最適化に時間をかけることができないという理由もあって、たぶん行われていません。IL の状態では、上記2つのインデックスアクセスの部分は全く同じ IL 命令にコンパイルされています。
ベンチマーク
最後に上記の2つのメソッドが本当に速度差があるのかベンチマークを取っておきましょう。測定はいつも通り Benchmark.NET を使います。github から取ってこなくても Nuget からパッケージを取ってこられます。
BenchmarkDotNet=v0.12.0, OS=Windows 10.0.18362 Intel Core i5-4300U CPU 1.90GHz (Haswell), 1 CPU, 4 logical and 2 physical cores .NET Core SDK=3.0.100 [Host] : .NET Core 3.0.0 (CoreCLR 4.700.19.46205, CoreFX 4.700.19.46214), X64 RyuJIT DefaultJob : .NET Core 3.0.0 (CoreCLR 4.700.19.46205, CoreFX 4.700.19.46214), X64 RyuJIT
| Method | Mean | Error | StdDev | Ratio | RatioSD |
|---|---|---|---|---|---|
| M1 | 34.59 ms | 0.805 ms | 2.374 ms | 1.00 | 0.00 |
| M2 | 35.14 ms | 1.013 ms | 2.971 ms | 1.02 | 0.09 |
……誤差の範囲では……?思ったよりも差が出なかったです。最後の最後で話の腰を折られてしまった気がする。
が、事実としてLengthプロパティをforの終了条件として使うか否かだけでJITコンパイルの最適化結果のアセンブリが異なりますよ、という記事でした。
[追記] この話は2020/01月現在、 .NET Core 3.0 (およびそれ以前の .NET のバージョン) での JIT の話です。将来的にはJITがもっと賢くなって変わる可能性もあります。可能性は低いと思いますが。