.NETでメモリがどのように機能するかをよりよく理解しようとしているので、 BenchmarkDotNetおよびdiagnozers で遊んでいます。配列項目を合計して、class
とstruct
のパフォーマンスを比較するベンチマークを作成しました。値の型の合計が常に速くなることを期待していました。しかし、短い配列の場合はそうではありません。誰かがそれを説明できますか?
コード:
internal class ReferenceType
{
public int Value;
}
internal struct ValueType
{
public int Value;
}
internal struct ExtendedValueType
{
public int Value;
private double _otherData; // this field is here just to make the object bigger
}
3つの配列があります。
private ReferenceType[] _referenceTypeData;
private ValueType[] _valueTypeData;
private ExtendedValueType[] _extendedValueTypeData;
同じランダム値のセットで初期化します。
次に、ベンチマークされたメソッド:
[Benchmark]
public int ReferenceTypeSum()
{
var sum = 0;
for (var i = 0; i < Size; i++)
{
sum += _referenceTypeData[i].Value;
}
return sum;
}
Size
はベンチマークパラメータです。他の2つのベンチマークメソッド(ValueTypeSum
とExtendedValueTypeSum
)は同じですが、_valueTypeData
または_extendedValueTypeData
。 ベンチマークの完全なコード 。
ベンチマーク結果:
DefaultJob:.NET Framework 4.7.2(CLR 4.0.30319.42000)、64ビットRyuJIT-v4.7.3190.0
Method | Size | Mean | Error | StdDev | Ratio | RatioSD |
--------------------- |----- |----------:|----------:|----------:|------:|--------:|
ReferenceTypeSum | 100 | 75.76 ns | 1.2682 ns | 1.1863 ns | 1.00 | 0.00 |
ValueTypeSum | 100 | 79.83 ns | 0.3866 ns | 0.3616 ns | 1.05 | 0.02 |
ExtendedValueTypeSum | 100 | 78.70 ns | 0.8791 ns | 0.8223 ns | 1.04 | 0.01 |
| | | | | | |
ReferenceTypeSum | 500 | 354.78 ns | 3.9368 ns | 3.6825 ns | 1.00 | 0.00 |
ValueTypeSum | 500 | 367.08 ns | 5.2446 ns | 4.9058 ns | 1.03 | 0.01 |
ExtendedValueTypeSum | 500 | 346.18 ns | 2.1114 ns | 1.9750 ns | 0.98 | 0.01 |
| | | | | | |
ReferenceTypeSum | 1000 | 697.81 ns | 6.8859 ns | 6.1042 ns | 1.00 | 0.00 |
ValueTypeSum | 1000 | 720.64 ns | 5.5592 ns | 5.2001 ns | 1.03 | 0.01 |
ExtendedValueTypeSum | 1000 | 699.12 ns | 9.6796 ns | 9.0543 ns | 1.00 | 0.02 |
Core:.NET Core 2.1.4(CoreCLR 4.6.26814.03、CoreFX 4.6.26814.02)、64ビットRyuJIT
Method | Size | Mean | Error | StdDev | Ratio | RatioSD |
--------------------- |----- |----------:|----------:|----------:|------:|--------:|
ReferenceTypeSum | 100 | 76.22 ns | 0.5232 ns | 0.4894 ns | 1.00 | 0.00 |
ValueTypeSum | 100 | 80.69 ns | 0.9277 ns | 0.8678 ns | 1.06 | 0.01 |
ExtendedValueTypeSum | 100 | 78.88 ns | 1.5693 ns | 1.4679 ns | 1.03 | 0.02 |
| | | | | | |
ReferenceTypeSum | 500 | 354.30 ns | 2.8682 ns | 2.5426 ns | 1.00 | 0.00 |
ValueTypeSum | 500 | 372.72 ns | 4.2829 ns | 4.0063 ns | 1.05 | 0.01 |
ExtendedValueTypeSum | 500 | 357.50 ns | 7.0070 ns | 6.5543 ns | 1.01 | 0.02 |
| | | | | | |
ReferenceTypeSum | 1000 | 696.75 ns | 4.7454 ns | 4.4388 ns | 1.00 | 0.00 |
ValueTypeSum | 1000 | 697.95 ns | 2.2462 ns | 2.1011 ns | 1.00 | 0.01 |
ExtendedValueTypeSum | 1000 | 687.75 ns | 2.3861 ns | 1.9925 ns | 0.99 | 0.01 |
BranchMispredictions
およびCacheMisses
ハードウェアカウンターを使用してベンチマークを実行しましたが、キャッシュミスやブランチの予測ミスはありません。私はリリースILコードもチェックしました。ベンチマークメソッドは、参照変数または値型変数をロードする命令によってのみ異なります。
配列のサイズが大きい場合、値の型の配列の合計は常に速くなります(たとえば、値の型が占めるメモリが少ないため)が、短い配列の場合はなぜ遅いのかわかりません。ここで何が恋しいですか?そして、struct
を大きくすると(ExtendedValueType
を参照)、合計が少し速くなるのはなぜですか?
----更新----
@usrのコメントに触発されて、LegacyJitでベンチマークを再実行しました。 @Silver Shroudから着想を得たメモリ診断機能も追加しました(そうです、ヒープの割り当てはありません)。
Job = LegacyJitX64 Jit = LegacyJit Platform = X64 Runtime = Clr
Method | Size | Mean | Error | StdDev | Ratio | RatioSD | Gen 0/1k Op | Gen 1/1k Op | Gen 2/1k Op | Allocated Memory/Op |
--------------------- |----- |-----------:|-----------:|-----------:|------:|--------:|------------:|------------:|------------:|--------------------:|
ReferenceTypeSum | 100 | 110.1 ns | 0.6836 ns | 0.6060 ns | 1.00 | 0.00 | - | - | - | - |
ValueTypeSum | 100 | 109.5 ns | 0.4320 ns | 0.4041 ns | 0.99 | 0.00 | - | - | - | - |
ExtendedValueTypeSum | 100 | 109.5 ns | 0.5438 ns | 0.4820 ns | 0.99 | 0.00 | - | - | - | - |
| | | | | | | | | | |
ReferenceTypeSum | 500 | 517.8 ns | 10.1271 ns | 10.8359 ns | 1.00 | 0.00 | - | - | - | - |
ValueTypeSum | 500 | 511.9 ns | 7.8204 ns | 7.3152 ns | 0.99 | 0.03 | - | - | - | - |
ExtendedValueTypeSum | 500 | 534.7 ns | 3.0168 ns | 2.8219 ns | 1.03 | 0.02 | - | - | - | - |
| | | | | | | | | | |
ReferenceTypeSum | 1000 | 1,058.3 ns | 8.8829 ns | 8.3091 ns | 1.00 | 0.00 | - | - | - | - |
ValueTypeSum | 1000 | 1,048.4 ns | 8.6803 ns | 8.1196 ns | 0.99 | 0.01 | - | - | - | - |
ExtendedValueTypeSum | 1000 | 1,057.5 ns | 5.9456 ns | 5.5615 ns | 1.00 | 0.01 | - | - | - | - |
従来のJITの結果は予想どおりですが、以前の結果よりも遅くなります。これは、RyuJitが魔法のようなパフォーマンスの改善を行っていることを示唆しています。
----更新2 ----
すばらしい回答をありがとう!私はたくさん学んだ!
さらに別のベンチマークの結果の下。 @usrと@xoofxで提案されているように、最初にベンチマークされたメソッド、最適化されたメソッドを比較しています。
[Benchmark]
public int ReferenceTypeOptimizedSum()
{
var sum = 0;
var array = _referenceTypeData;
for (var i = 0; i < array.Length; i++)
{
sum += array[i].Value;
}
return sum;
}
上記の最適化が追加された、@ AndreyAkinshinによって提案された展開されたバージョン:
[Benchmark]
public int ReferenceTypeUnrolledSum()
{
var sum = 0;
var array = _referenceTypeData;
for (var i = 0; i < array.Length; i += 16)
{
sum += array[i].Value;
sum += array[i + 1].Value;
sum += array[i + 2].Value;
sum += array[i + 3].Value;
sum += array[i + 4].Value;
sum += array[i + 5].Value;
sum += array[i + 6].Value;
sum += array[i + 7].Value;
sum += array[i + 8].Value;
sum += array[i + 9].Value;
sum += array[i + 10].Value;
sum += array[i + 11].Value;
sum += array[i + 12].Value;
sum += array[i + 13].Value;
sum += array[i + 14].Value;
sum += array[i + 15].Value;
}
return sum;
}
ベンチマーク結果:
BenchmarkDotNet = v0.11.3、OS = Windows 10.0.17134.345(1803/April2018Update/Redstone4)Intel Core i5-6400 CPU 2.70GHz(Skylake)、1 CPU、4つの論理コアと4つの物理コア= 2648439 Hz、解像度= 377.5809 ns、タイマー= TSC
DefaultJob:.NET Framework 4.7.2(CLR 4.0.30319.42000)、64ビットRyuJIT-v4.7.3190.0
Method | Size | Mean | Error | StdDev | Ratio | RatioSD |
------------------------------ |----- |---------:|----------:|----------:|------:|--------:|
ReferenceTypeSum | 512 | 344.8 ns | 3.6473 ns | 3.4117 ns | 1.00 | 0.00 |
ValueTypeSum | 512 | 361.2 ns | 3.8004 ns | 3.3690 ns | 1.05 | 0.02 |
ExtendedValueTypeSum | 512 | 347.2 ns | 5.9686 ns | 5.5831 ns | 1.01 | 0.02 |
ReferenceTypeOptimizedSum | 512 | 254.5 ns | 2.4427 ns | 2.2849 ns | 0.74 | 0.01 |
ValueTypeOptimizedSum | 512 | 353.0 ns | 1.9201 ns | 1.7960 ns | 1.02 | 0.01 |
ExtendedValueTypeOptimizedSum | 512 | 280.3 ns | 1.2423 ns | 1.0374 ns | 0.81 | 0.01 |
ReferenceTypeUnrolledSum | 512 | 213.2 ns | 1.2483 ns | 1.1676 ns | 0.62 | 0.01 |
ValueTypeUnrolledSum | 512 | 201.3 ns | 0.6720 ns | 0.6286 ns | 0.58 | 0.01 |
ExtendedValueTypeUnrolledSum | 512 | 223.6 ns | 1.0210 ns | 0.9550 ns | 0.65 | 0.01 |
Haswellで、Intelは小さなループの分岐予測のための追加の戦略を導入しました(そのため、IvyBridgeでこの状況を観察できません)。特定の分岐戦略は、ネイティブコードの配置を含む多くの要因に依存しているようです。 LegacyJITとRyuJITの違いは、メソッドのさまざまなアライメント戦略によって説明できます。残念ながら、私はこのパフォーマンス現象の関連する詳細をすべて提供することはできません(インテルは実装の詳細を秘密にしています。私の結論は自分のCPUリバースエンジニアリング実験にのみ基づいています)。しかし、このベンチマークを改善する方法を説明できます。
結果を改善する主なトリックは、RyuJITを使用したHaswell +でのナノベンチマークにとって重要な手動ループ展開です。上記の現象は小さなループにのみ影響するため、巨大なループ本体の問題を解決できます。実際、次のようなベンチマークがある場合
[Benchmark]
public void MyBenchmark()
{
Foo();
}
BenchmarkDotNetは次のループを生成します。
for (int i = 0; i < N; i++)
{
Foo(); Foo(); Foo(); Foo();
Foo(); Foo(); Foo(); Foo();
Foo(); Foo(); Foo(); Foo();
Foo(); Foo(); Foo(); Foo();
}
このループの内部呼び出しの数は、UnrollFactor
を使用して制御できます。ベンチマーク内に小さなループがある場合は、同じ方法で展開する必要があります。
[Benchmark(Baseline = true)]
public int ReferenceTypeSum()
{
var sum = 0;
for (var i = 0; i < Size; i += 16)
{
sum += _referenceTypeData[i].Value;
sum += _referenceTypeData[i + 1].Value;
sum += _referenceTypeData[i + 2].Value;
sum += _referenceTypeData[i + 3].Value;
sum += _referenceTypeData[i + 4].Value;
sum += _referenceTypeData[i + 5].Value;
sum += _referenceTypeData[i + 6].Value;
sum += _referenceTypeData[i + 7].Value;
sum += _referenceTypeData[i + 8].Value;
sum += _referenceTypeData[i + 9].Value;
sum += _referenceTypeData[i + 10].Value;
sum += _referenceTypeData[i + 11].Value;
sum += _referenceTypeData[i + 12].Value;
sum += _referenceTypeData[i + 13].Value;
sum += _referenceTypeData[i + 14].Value;
sum += _referenceTypeData[i + 15].Value;
}
return sum;
}
別のトリックは、積極的なウォームアップ(たとえば、30回の反復)です。これが私のマシンでのウォームアップステージの様子です。
WorkloadWarmup 1: 4194304 op, 865744000.00 ns, 206.4095 ns/op
WorkloadWarmup 2: 4194304 op, 892164000.00 ns, 212.7085 ns/op
WorkloadWarmup 3: 4194304 op, 861913000.00 ns, 205.4961 ns/op
WorkloadWarmup 4: 4194304 op, 868044000.00 ns, 206.9578 ns/op
WorkloadWarmup 5: 4194304 op, 933894000.00 ns, 222.6577 ns/op
WorkloadWarmup 6: 4194304 op, 890567000.00 ns, 212.3277 ns/op
WorkloadWarmup 7: 4194304 op, 923509000.00 ns, 220.1817 ns/op
WorkloadWarmup 8: 4194304 op, 861953000.00 ns, 205.5056 ns/op
WorkloadWarmup 9: 4194304 op, 862454000.00 ns, 205.6251 ns/op
WorkloadWarmup 10: 4194304 op, 862565000.00 ns, 205.6515 ns/op
WorkloadWarmup 11: 4194304 op, 867301000.00 ns, 206.7807 ns/op
WorkloadWarmup 12: 4194304 op, 841892000.00 ns, 200.7227 ns/op
WorkloadWarmup 13: 4194304 op, 827717000.00 ns, 197.3431 ns/op
WorkloadWarmup 14: 4194304 op, 828257000.00 ns, 197.4719 ns/op
WorkloadWarmup 15: 4194304 op, 812275000.00 ns, 193.6615 ns/op
WorkloadWarmup 16: 4194304 op, 792011000.00 ns, 188.8301 ns/op
WorkloadWarmup 17: 4194304 op, 792607000.00 ns, 188.9722 ns/op
WorkloadWarmup 18: 4194304 op, 794428000.00 ns, 189.4064 ns/op
WorkloadWarmup 19: 4194304 op, 794879000.00 ns, 189.5139 ns/op
WorkloadWarmup 20: 4194304 op, 794914000.00 ns, 189.5223 ns/op
WorkloadWarmup 21: 4194304 op, 794061000.00 ns, 189.3189 ns/op
WorkloadWarmup 22: 4194304 op, 793385000.00 ns, 189.1577 ns/op
WorkloadWarmup 23: 4194304 op, 793851000.00 ns, 189.2688 ns/op
WorkloadWarmup 24: 4194304 op, 793456000.00 ns, 189.1747 ns/op
WorkloadWarmup 25: 4194304 op, 794194000.00 ns, 189.3506 ns/op
WorkloadWarmup 26: 4194304 op, 793980000.00 ns, 189.2996 ns/op
WorkloadWarmup 27: 4194304 op, 804402000.00 ns, 191.7844 ns/op
WorkloadWarmup 28: 4194304 op, 801002000.00 ns, 190.9738 ns/op
WorkloadWarmup 29: 4194304 op, 797860000.00 ns, 190.2246 ns/op
WorkloadWarmup 30: 4194304 op, 802668000.00 ns, 191.3710 ns/op
デフォルトでは、BenchmarkDotNetはそのような状況を検出し、ウォームアップの反復回数を増やします。残念ながら、それが常に可能であるとは限りません(「単純な」ケースで「高速な」ウォームアップステージを持ちたいと仮定します)。
そして、これが私の結果です(更新されたベンチマークの完全なリストはここにあります: https://Gist.github.com/AndreyAkinshin/4c9e0193912c99c0b314359d5c5d0a4e ):
BenchmarkDotNet=v0.11.3, OS=macOS Mojave 10.14.1 (18B75) [Darwin 18.2.0]
Intel Core i7-4870HQ CPU 2.50GHz (Haswell), 1 CPU, 8 logical and 4 physical cores
.NET Core SDK=3.0.100-preview-009812
[Host] : .NET Core 2.0.5 (CoreCLR 4.6.0.0, CoreFX 4.6.26018.01), 64bit RyuJIT
Job-IHBGGW : .NET Core 2.0.5 (CoreCLR 4.6.0.0, CoreFX 4.6.26018.01), 64bit RyuJIT
IterationCount=30 WarmupCount=30
Method | Size | Mean | Error | StdDev | Median | Ratio | RatioSD |
--------------------- |----- |---------:|----------:|----------:|---------:|------:|--------:|
ReferenceTypeSum | 256 | 180.7 ns | 0.4514 ns | 0.6474 ns | 180.8 ns | 1.00 | 0.00 |
ValueTypeSum | 256 | 154.4 ns | 1.8844 ns | 2.8205 ns | 153.3 ns | 0.86 | 0.02 |
ExtendedValueTypeSum | 256 | 183.1 ns | 2.2283 ns | 3.3352 ns | 181.1 ns | 1.01 | 0.02 |
これは確かに非常に奇妙な動作です。
参照型のコアループの生成コードは次のとおりです:
M00_L00:
mov r9,rcx
cmp edx,[r9+8]
jae ArrayOutOfBound
movsxd r10,edx
mov r9,[r9+r10*8+10h]
add eax,[r9+8]
inc edx
cmp edx,r8d
jl M00_L00
一方、値型ループの場合:
M00_L00:
mov r9,rcx
cmp edx,[r9+8]
jae ArrayOutOfBound
movsxd r10,edx
add eax,[r9+r10*4+10h]
inc edx
cmp edx,r8d
jl M00_L00
つまり、違いは次のようになります。
参照タイプの場合:
mov r9,[r9+r10*8+10h]
add eax,[r9+8]
value typeの場合:
add eax,[r9+r10*4+10h]
1つの命令と間接的なメモリアクセスがない場合、値のタイプはより高速になるはずです...
これを Intel Architecture Code Analyzer で実行しようとしましたが、参照タイプのIACA出力は次のとおりです。
Throughput Analysis Report
--------------------------
Block Throughput: 1.72 Cycles Throughput Bottleneck: Dependency chains
Loop Count: 35
Port Binding In Cycles Per Iteration:
--------------------------------------------------------------------------------------------------
| Port | 0 - DV | 1 | 2 - D | 3 - D | 4 | 5 | 6 | 7 |
--------------------------------------------------------------------------------------------------
| Cycles | 1.0 0.0 | 1.0 | 1.5 1.5 | 1.5 1.5 | 0.0 | 1.0 | 1.0 | 0.0 |
--------------------------------------------------------------------------------------------------
DV - Divider pipe (on port 0)
D - Data fetch pipe (on ports 2 and 3)
F - Macro Fusion with the previous instruction occurred
* - instruction micro-ops not bound to a port
^ - Micro Fusion occurred
# - ESP Tracking sync uop was issued
@ - SSE instruction followed an AVX256/AVX512 instruction, dozens of cycles penalty is expected
X - instruction not supported, was not accounted in Analysis
| Num Of | Ports pressure in cycles | |
| Uops | 0 - DV | 1 | 2 - D | 3 - D | 4 | 5 | 6 | 7 |
-----------------------------------------------------------------------------------------
| 1* | | | | | | | | | mov r9, rcx
| 2^ | | | 0.5 0.5 | 0.5 0.5 | | 1.0 | | | cmp edx, dword ptr [r9+0x8]
| 0*F | | | | | | | | | jnb 0x22
| 1 | | | | | | | 1.0 | | movsxd r10, edx
| 1 | | | 0.5 0.5 | 0.5 0.5 | | | | | mov r9, qword ptr [r9+r10*8+0x10]
| 2^ | 1.0 | | 0.5 0.5 | 0.5 0.5 | | | | | add eax, dword ptr [r9+0x8]
| 1 | | 1.0 | | | | | | | inc edx
| 1* | | | | | | | | | cmp edx, r8d
| 0*F | | | | | | | | | jl 0xffffffffffffffe6
Total Num Of Uops: 9
value typeの場合:
Throughput Analysis Report
--------------------------
Block Throughput: 1.74 Cycles Throughput Bottleneck: Dependency chains
Loop Count: 26
Port Binding In Cycles Per Iteration:
--------------------------------------------------------------------------------------------------
| Port | 0 - DV | 1 | 2 - D | 3 - D | 4 | 5 | 6 | 7 |
--------------------------------------------------------------------------------------------------
| Cycles | 1.0 0.0 | 1.0 | 1.0 1.0 | 1.0 1.0 | 0.0 | 1.0 | 1.0 | 0.0 |
--------------------------------------------------------------------------------------------------
DV - Divider pipe (on port 0)
D - Data fetch pipe (on ports 2 and 3)
F - Macro Fusion with the previous instruction occurred
* - instruction micro-ops not bound to a port
^ - Micro Fusion occurred
# - ESP Tracking sync uop was issued
@ - SSE instruction followed an AVX256/AVX512 instruction, dozens of cycles penalty is expected
X - instruction not supported, was not accounted in Analysis
| Num Of | Ports pressure in cycles | |
| Uops | 0 - DV | 1 | 2 - D | 3 - D | 4 | 5 | 6 | 7 |
-----------------------------------------------------------------------------------------
| 1* | | | | | | | | | mov r9, rcx
| 2^ | | | 1.0 1.0 | | | 1.0 | | | cmp edx, dword ptr [r9+0x8]
| 0*F | | | | | | | | | jnb 0x1e
| 1 | | | | | | | 1.0 | | movsxd r10, edx
| 2 | 1.0 | | | 1.0 1.0 | | | | | add eax, dword ptr [r9+r10*4+0x10]
| 1 | | 1.0 | | | | | | | inc edx
| 1* | | | | | | | | | cmp edx, r8d
| 0*F | | | | | | | | | jl 0xffffffffffffffea
Total Num Of Uops: 8
したがって、参照タイプにはわずかな利点があります(ループあたり1.72サイクルvs 1.74サイクル)
私はIACA出力の解読の専門家ではありませんが、それはポートの使用状況に関連していると思います(参照タイプの場合は2〜3のほうが適切に分散されます)。
「ポート」は、CPU内のマイクロ実行ユニットです。たとえばSkylakeの場合、これらは次のように分割されます(から Agnerの最適化リソースからの命令テーブル )
Port 0: Integer, f.p. and vector ALU, mul, div, branch
Port 1: Integer, f.p. and vector ALU
Port 2: Load
Port 3: Load
Port 4: Store
Port 5: Integer and vector ALU
Port 6: Integer ALU, branch
Port 7: Store address
非常に微妙なマイクロ命令(uop)最適化のように見えますが、その理由を説明することはできません。
次のようにループのcodegenを改善できることに注意してください。
[Benchmark]
public int ValueTypeSum()
{
var sum = 0;
// NOTE: Caching the array to a local variable (that will avoid the reload of the Length inside the loop)
var arr = _valueTypeData;
// NOTE: checking against `array.Length` instead of `Size`, to completely remove the ArrayOutOfBound checks
for (var i = 0; i < arr.Length; i++)
{
sum += arr[i].Value;
}
return sum;
}
ループは少しだけ最適化され、より一貫した結果が得られるはずです。
結果がこのように近い理由は、非常に小さいサイズを使用し、オブジェクト配列要素をフラグメント化するために(配列初期化ループ内の)ヒープに何も割り当てていないためです。
ベンチマークコードでは、オブジェクト配列要素のみがヒープから割り当てられます(*)。このようにして、MemoryAllocatorは各要素をヒープ内で順次(**)割り当てることができます。ベンチマークコードの実行が開始されると、データがRAMからCPUキャッシュに読み込まれ、オブジェクト配列要素がRAMにシーケンシャル(連続したブロック内)の順序で書き込まれるため、キャッシュされ、キャッシュミスが発生しません。
これをよりよく理解するために、ベンチマークされたオブジェクト配列要素をフラグメント化するためにヒープに割り当てる別のオブジェクト配列(できれば、より大きなオブジェクト)を使用できます。これにより、現在の設定よりも早くキャッシュミスが発生する可能性があります。実際のシナリオでは、同じヒープに割り当てられ、配列の実際のオブジェクトをさらに断片化する他のスレッドがあります。また、RAMへのアクセスには、CPUキャッシュ(またはCPUサイクル)へのアクセスよりも多くの時間がかかります。 (このトピックに関しては post を確認してください)。
(*)ValueType配列は、new ValueType[Size]
で初期化すると、配列要素に必要なすべてのスペースを割り当てます。 ValueType配列要素はRAM内で隣接します。
(**)objectArr [i]オブジェクト要素とobjectArr [i + 1](以下同様)はヒープ内で並んでいます。RAMブロックがキャッシュされると、おそらくすべてのオブジェクト配列要素がCPUキャッシュに読み込まれるため、配列を反復処理する場合、RAMアクセスは必要ありません。
.NET Core 2.1 x64で逆アセンブリを確認しました。
Refタイプのコードは私には最適に見えます。マシンコードは、各オブジェクト参照をロードしてから、各インスタンスからフィールドをロードしています。
値タイプのバリアントには、配列範囲チェックがあります。ループの複製は成功しませんでした。ループの上限がSize
であるため、この範囲チェックが行われます。そのはず array.Length
JITがこのパターンを認識し、範囲チェックを生成しないようにするため。
これは参照バージョンです。コアループにマークを付けました。コアループを見つけるコツは、最初にループの先頭へのバックジャンプを見つけることです。
これは値のバリアントです:
jae
は範囲チェックです。
したがって、これはJITの制限です。これに関心がある場合は、coreclrリポジトリでGitHubの問題を開き、ループのクローン作成がここで失敗したことを伝えます。
4.7.2の非レガシーJITの範囲チェック動作は同じです。生成されたコードは、refバージョンでも同じように見えます。
私はレガシーJITコードを見たことはありませんが、範囲チェックを排除できなかったと思います。ループクローンはサポートされていません。