【.NET】境界チェックが消えるパターン集
はじめに
ReadOnlySpan<T>や配列などインデックスアクセスする場合、基本的には内部動作では境界チェックが実行されています。
ただし、実行処理や条件によって、この境界チェックはコンパイラの最適化で削除することができるため、小さいですがパフォーマンス向上をすることができます。
PRなどで確認していた境界チェックを外すいくつかのパターンについて、備忘録代わりに残しておきます。
前提条件
ランタイムバージョンは、.NET 11 preview 2もしくは.NET 11 preview 3で確認しています。
> dotnet --version
11.0.100-preview.2.26159.112
11.0.100-preview.3.26207.106
ローカル環境で確認する際には、Tiered Compilationを無効化して、最初から最適化が行われるようにして確認しています。
$env:DOTNET_JitDisasmDiffable = 1
// Tiered Compilationを無効化
$env:DOTNET_TieredCompilation = 0
// 対象メソッド
$env:DOTNET_JitDisasm = "IndexAccess"
ローカル以外での確認には、Compiler Explorerを用いて確認しているケースもあります。
境界チェックの判断基準
この後に紹介しますが、例えばuintキャストを用いたインデックスの比較では、最適化によって境界チェックがなくなります。
では、uintキャストを削除した場合には、どうなるかというと以下の差分が発生します。
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span, int index)
{
- if ((uint)index < (uint)span.Length)
+ if (index < span.Length)
{
Console.WriteLine(span[index]);
}
}
+ cmp edx, ecx ;コンパイラによる境界チェック
+ jae SHORT G_M000_IG06 ;境界外ならG_M000_IG06へジャンプ
mov ecx, edx
mov ecx, dword ptr [rax+4*rcx]
call [System.Console:WriteLine(int)]
...
G_M000_IG06:
+ call CORINFO_HELP_RNGCHKFAIL ;
ここから境界チェックは、以下のようなcmp命令やCORINFO_HELP_RNGCHKFAILが存在しているかどうかで判断できます[1]。 CORINFO_HELP_RNGCHKFAILは、IndexOutOfRangeException をスローするために呼び出されるヘルパー関数になります。
アセンブル結果(uintキャスト無しの場合)
; Assembly listing for method Program:IndexAccess(System.ReadOnlySpan`1[int],int) (FullOpts)
; Emitting BLENDED_CODE for X64 with AVX512 - Windows
; FullOpts code
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
G_M000_IG01:
sub rsp, 40
G_M000_IG02:
mov rax, bword ptr [rcx]
mov ecx, dword ptr [rcx+0x08]
cmp edx, ecx
jge SHORT G_M000_IG04
G_M000_IG03:
cmp edx, ecx
jae SHORT G_M000_IG06
mov ecx, edx
mov ecx, dword ptr [rax+4*rcx]
call [System.Console:WriteLine(int)]
G_M000_IG04:
nop
G_M000_IG05:
add rsp, 40
ret
G_M000_IG06:
call CORINFO_HELP_RNGCHKFAIL
int3
; Total bytes of code 41
境界チェックを消えるパターン
uintキャストを用いたインデックス比較
uintキャストを用いたコンパイラ最適化による境界チェックを外す方法になります。
dotnet/runtimeのレビュー用Skill[2]でも記載されている、よく知られている手法になります。
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span, int index)
{
if ((uint)index < (uint)span.Length)
{
Console.WriteLine(span[index]); // 境界チェックなし
}
}
確認ソースコード
using System.Runtime.CompilerServices;
class Program
{
public static void Main()
{
int[] array = [1, 2, 3, 4, 5];
Span<int> span = array;
int index = 0;
IndexAccess(span, index);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span, int index)
{
if ((uint)index < (uint)span.Length)
{
Console.WriteLine(span[index]); // 境界チェックなし
}
}
}
アセンブル結果
; Assembly listing for method Program:IndexAccess(System.ReadOnlySpan`1[int],int) (FullOpts)
; Emitting BLENDED_CODE for X64 with AVX512 - Windows
; FullOpts code
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
G_M000_IG01:
sub rsp, 40
G_M000_IG02:
mov rax, bword ptr [rcx]
mov ecx, dword ptr [rcx+0x08]
cmp edx, ecx
jae SHORT G_M000_IG04
G_M000_IG03:
mov ecx, edx
mov ecx, dword ptr [rax+4*rcx]
call [System.Console:WriteLine(int)]
G_M000_IG04:
nop
G_M000_IG05:
add rsp, 40
ret
; Total bytes of code 31
定数配列と定数値によるアクセス
コンパイラが「arrayは定数5の配列」、「span.Lengthは5」、「indexが0」とコンパイル段階で判断できるため、span[index]では境界チェックがなくなります。
以下の例だと定数ではないが、結果的に定数配列と同等の判断されているものと思われます。
int[] array = [1, 2, 3, 4, 5];
Span<int> span = array;
int index = 0;
if (index < span.Length)
{
Console.WriteLine(span[index]); // 境界チェックなし
}
アセンブル結果
; Assembly listing for method Program:<Main>$(System.String[]) (FullOpts)
; Emitting BLENDED_CODE for x64 + VEX + EVEX on Windows
; FullOpts code
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
; 1 inlinees with PGO data; 1 single block inlinees; 0 inlinees without PGO data
G_M000_IG01:
sub rsp, 88
vxorps xmm4, xmm4, xmm4
vmovdqu ymmword ptr [rsp+0x20], ymm4
xor eax, eax
mov qword ptr [rsp+0x40], rax
G_M000_IG02:
mov rcx, 0xD1FFAB1E
mov qword ptr [rsp+0x20], rcx
lea rcx, [rsp+0x20]
mov dword ptr [rcx+0x08], 5
lea rcx, [rsp+0x20]
mov rax, 0xD1FFAB1E
vmovdqu xmm0, xmmword ptr [rax]
vmovdqu xmmword ptr [rcx+0x10], xmm0
mov edx, dword ptr [rax+0x10]
mov dword ptr [rcx+0x20], edx
add rcx, 16
mov bword ptr [rsp+0x48], rcx
mov ecx, dword ptr [rcx]
call [System.Console:WriteLine(int)]
nop
G_M000_IG03:
add rsp, 88
ret
; Total bytes of code 101
forループ内でのindexアクセス その1
i = 0から始まり、上限値チェックがi < span.Lengthであるforループ内でのindexアクセスについて、境界チェックは消えます。
アセンブル結果を見ても、cmp命令による比較やCORINFO_HELP_RNGCHKFAIL への callは存在しません。
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span, int index)
{
for (int i = 0; i < span.Length; i++)
{
Console.WriteLine(span[i]); // 境界チェックなし
}
}
確認ソースコード
class Program
{
public static void Main()
{
int[] array = [1, 2, 3, 4, 5];
Span<int> span = array;
int index = 0;
IndexAccess(span, index);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span, int index)
{
for (int i = 0; i < span.Length; i++)
{
Console.WriteLine(span[i]); // 境界チェックなし
}
}
}
アセンブル結果
; Assembly listing for method Program:IndexAccess(System.ReadOnlySpan`1[int],int) (FullOpts)
; Emitting BLENDED_CODE for X64 with AVX512 - Windows
; FullOpts code
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
G_M000_IG01:
push rdi
push rsi
push rbx
sub rsp, 32
G_M000_IG02:
mov rbx, bword ptr [rcx]
mov esi, dword ptr [rcx+0x08]
test esi, esi
jle SHORT G_M000_IG05
G_M000_IG03:
xor edi, edi
G_M000_IG04:
mov ecx, dword ptr [rbx+rdi]
call [System.Console:WriteLine(int)]
add rdi, 4
dec esi
jne SHORT G_M000_IG04
G_M000_IG05:
add rsp, 32
pop rbx
pop rsi
pop rdi
ret
; Total bytes of code 44
whileループ内でのindexアクセス その1
forループと同様にwhileループでのindexアクセスについて、境界チェックはありません。
アセンブル結果を見ても、cmp命令による比較やCORINFO_HELP_RNGCHKFAIL への callは存在しません。
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span, int index)
{
int i = 0;
while(i < span.Length)
{
Console.WriteLine(span[i++]); // 境界チェックなし
}
}
forループでのアセンブル結果を比較するとわずかにforループのほうが命令数が少なくなっていました。
主な違いとしては以下になります。
-
i < span.Lengthがforループだと削除(cmp命令が無くなっている) -
span[i]へのアクセスがi * 4ではなくi + 4になっている
; forループ
G_M000_IG04:
mov ecx, dword ptr [rbx+rdi]
call [System.Console:WriteLine(int)]
add rdi, 4
dec esi
jne SHORT G_M000_IG04
; whileループ
G_M000_IG03:
lea ecx, [rdi+0x01]
mov ebp, ecx
mov ecx, edi
mov ecx, dword ptr [rbx+4*rcx]
call [System.Console:WriteLine(int)]
cmp ebp, esi
mov edi, ebp
jl SHORT G_M000_IG03
確認ソースコード
class Program
{
public static void Main()
{
int[] array = [1, 2, 3, 4, 5];
Span<int> span = array;
int index = 0;
IndexAccess(span, index);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span, int index)
{
int i = 0;
while(i < span.Length)
{
Console.WriteLine(span[i++]); // 境界チェックなし
}
}
}
アセンブル結果
; Assembly listing for method Program:IndexAccess(System.ReadOnlySpan`1[int],int) (FullOpts)
; Emitting BLENDED_CODE for X64 with AVX512 - Windows
; FullOpts code
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
G_M000_IG01:
push rdi
push rsi
push rbp
push rbx
sub rsp, 40
G_M000_IG02:
mov rbx, bword ptr [rcx]
mov esi, dword ptr [rcx+0x08]
xor edi, edi
test esi, esi
jle SHORT G_M000_IG04
G_M000_IG03:
lea ecx, [rdi+0x01]
mov ebp, ecx
mov ecx, edi
mov ecx, dword ptr [rbx+4*rcx]
call [System.Console:WriteLine(int)]
cmp ebp, esi
mov edi, ebp
jl SHORT G_M000_IG03
G_M000_IG04:
add rsp, 40
pop rbx
pop rbp
pop rsi
pop rdi
ret
; Total bytes of code 51
最初に末尾のインデックスアクセスを行う
span[0]からspan[N]にアクセスする際に、0からアクセスするのではなく、末尾のspan[N]からアクセスすることで、span[N]が正常であれば、span.Length >= Nであるということが判断できるため、span[0]からspan[N-1]の境界チェックが消えます。
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span)
{
var i = span[3]; // ここは境界チェックあり
i *= span[0]; // 境界チェックなし
i *= span[1]; // 境界チェックなし
i *= span[2]; // 境界チェックなし
Console.WriteLine(i);
}
この境界チェックを無くす方法は、ハイパフォーマンス.NETライブラリOSSを数々出されているneueccさんが過去に紹介もされているハイパフォーマンス手法でも取り上げられているなど、ある意味有名な手法です。
確認ソースコード
class Program
{
public static void Main()
{
int[] array = [ 1, 2, 3, 4, 5 ];
Span<int> span = array;
IndexAccess(span);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span)
{
var i = span[3]; // ここは境界チェックあり
i *= span[0]; // 境界チェックなし
i *= span[1]; // 境界チェックなし
i *= span[2]; // 境界チェックなし
Console.WriteLine(i);
}
}
アセンブル結果
; Assembly listing for method Program:IndexAccess(System.ReadOnlySpan`1[int]) (FullOpts)
; Emitting BLENDED_CODE for x64 + VEX + EVEX on Windows
; FullOpts code
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
G_M000_IG01:
sub rsp, 40
G_M000_IG02:
mov rax, bword ptr [rcx]
mov ecx, dword ptr [rcx+0x08]
cmp ecx, 3
jbe SHORT G_M000_IG04
mov ecx, dword ptr [rax+0x0C]
imul ecx, dword ptr [rax]
imul ecx, dword ptr [rax+0x04]
imul ecx, dword ptr [rax+0x08]
call [System.Console:WriteLine(int)]
nop
G_M000_IG03:
add rsp, 40
ret
G_M000_IG04:
call CORINFO_HELP_RNGCHKFAIL
int3
; Total bytes of code 47
最初に末尾のインデックスアクセスを行う その2
.NET11 preview 3のRuntimeリリースノート[3]にも記載されている最適化になります。
先ほどと類似ケースになりますが、span[^N],span[^N-1],span[^N-2]...のようなアクセスの場合、span[^N]が正常であれば、span.Length >= Nであるために、後続の境界チェックが消えます。
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span, int index)
{
var i = span[^4] | span[^3] | span[^2] | span[^1]; // span[^4]のみ境界チェックあり、それ以降はなし
Console.WriteLine(i);
}
このJIT最適化は、私の前に書いた記事[4]でも紹介しましたが、比較的最近マージされたものになります。
.NET11 preview 3で実際に確認したところ、境界チェックが最初の一回のみでそれ以外はなかったので、無事含まれたようです。
確認ソースコード
class Program
{
public static void Main()
{
int[] array = [1, 2, 3, 4, 5];
Span<int> span = array;
int index = 0;
IndexAccess(span, index);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span, int index)
{
var i = span[^4] | span[^3] | span[^2] | span[^1]; // span[^4]のみ境界チェックあり、それ以降はなし
Console.WriteLine(i);
}
}
アセンブル結果
; Assembly listing for method Program:IndexAccess(System.ReadOnlySpan`1[int],int) (FullOpts)
; Emitting BLENDED_CODE for x64 + VEX + EVEX on Windows
; FullOpts code
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
G_M000_IG01:
sub rsp, 40
G_M000_IG02:
mov rax, bword ptr [rcx]
mov ecx, dword ptr [rcx+0x08]
lea edx, [rcx-0x04]
cmp edx, ecx
jae SHORT G_M000_IG04
mov edx, dword ptr [rax+4*rdx]
lea r8d, [rcx-0x03]
or edx, dword ptr [rax+4*r8]
lea r8d, [rcx-0x02]
or edx, dword ptr [rax+4*r8]
dec ecx
or edx, dword ptr [rax+4*rcx]
mov ecx, edx
call [System.Console:WriteLine(int)]
nop
G_M000_IG03:
add rsp, 40
ret
G_M000_IG04:
call CORINFO_HELP_RNGCHKFAIL
int3
; Total bytes of code 61
Span.Sliceした後のインデックスアクセス
Span.Sliceした後のSpanに対して、インデックスアクセスする場合にも境界チェックはなくなります。
以下のケースだと、Span.Sliceした後のSpanはサイズ4と確定するので、0から3までのインデックスアクセスは境界チェックが消えます。
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span)
{
var slice = span.Slice(0, 4); // sliceで境界チェックが入るだけ
Console.WriteLine(slice[0]); // 境界チェックなし
Console.WriteLine(slice[1]); // 境界チェックなし
Console.WriteLine(slice[2]); // 境界チェックなし
Console.WriteLine(slice[3]); // 境界チェックなし
}
一応span[4]を追加すると、この部分だけ境界チェックが追加されます。
どちらにしても例外が発生するのであまり意味はないですが...。
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span)
{
var slice = span.Slice(0, 4);
...
// span[4]を追加(必ずエラーが発生)
Console.WriteLine(slice[4]); // 境界チェックあり
}
G_M000_IG02:
cmp dword ptr [rcx+0x08], 4
jl SHORT G_M000_IG04
mov rbx, bword ptr [rcx]
mov ecx, dword ptr [rbx] ; slice[0]
call [System.Console:WriteLine(int)]
mov ecx, dword ptr [rbx+0x04] ; slice[1]
call [System.Console:WriteLine(int)]
mov ecx, dword ptr [rbx+0x08] ; slice[2]
call [System.Console:WriteLine(int)]
mov ecx, dword ptr [rbx+0x0C] ; slice[3]
call [System.Console:WriteLine(int)]
+ mov ecx, 4
+ cmp ecx, 4 ; 境界チェック
+ jbe SHORT G_M000_IG05
+ mov ecx, dword ptr [rbx+0x10] ; slice[4]
+ call [System.Console:WriteLine(int)]
nop
+G_M000_IG05:
+ call CORINFO_HELP_RNGCHKFAIL
+ int3
確認ソースコード
class Program
{
public static void Main()
{
int[] array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
Span<int> span = array;
IndexAccess(span);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span)
{
var slice = span.Slice(0, 4);
Console.WriteLine(slice[0]);
Console.WriteLine(slice[1]);
Console.WriteLine(slice[2]);
Console.WriteLine(slice[3]);
}
}
アセンブル結果
; Assembly listing for method Program:IndexAccess(System.ReadOnlySpan`1[int]) (FullOpts)
; Emitting BLENDED_CODE for x64 + VEX + EVEX on Windows
; FullOpts code
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
; 0 inlinees with PGO data; 1 single block inlinees; 1 inlinees without PGO data
G_M000_IG01:
push rbx
sub rsp, 32
G_M000_IG02:
cmp dword ptr [rcx+0x08], 4
jl SHORT G_M000_IG04
mov rbx, bword ptr [rcx]
mov ecx, dword ptr [rbx]
call [System.Console:WriteLine(int)]
mov ecx, dword ptr [rbx+0x04]
call [System.Console:WriteLine(int)]
mov ecx, dword ptr [rbx+0x08]
call [System.Console:WriteLine(int)]
mov ecx, dword ptr [rbx+0x0C]
call [System.Console:WriteLine(int)]
nop
G_M000_IG03:
add rsp, 32
pop rbx
ret
G_M000_IG04:
call [System.ThrowHelper:ThrowArgumentOutOfRangeException()]
int3
; Total bytes of code 63
Span.Sliceした後のインデックスアクセス その2
先ほどのspan.Slice(0, 4)からspan.Slice(4)に変更すると取得するSpanの長さは固定ではなくなります。
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span)
{
var slice = span.Slice(4); // sliceで境界チェックが入る
// コンパイラによってクローニングされる。
Console.WriteLine(slice[0]);
Console.WriteLine(slice[1]);
Console.WriteLine(slice[2]);
Console.WriteLine(slice[3]);
}
この場合、最初の方で紹介した「最初に末尾のインデックスアクセスを行う」ことで境界チェックを一回だけに抑えることは可能ですが、この方法をしない場合にも、.NET 10からクローニング(ブロッククローニング)という最適化が発生します。
実際に上記のコードは以下のようなコードに置き換わります。
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span)
{
var slice = span.Slice(4);
// if 分岐が入る
if (slice.Length >= 4)
{
Console.WriteLine(slice[0]); // 境界チェックなし
Console.WriteLine(slice[1]); // 境界チェックなし
Console.WriteLine(slice[2]); // 境界チェックなし
Console.WriteLine(slice[3]); // 境界チェックなし
}
else
{
Console.WriteLine(slice[0]); // 境界チェックあり
Console.WriteLine(slice[1]); // 境界チェックあり
Console.WriteLine(slice[2]); // 境界チェックあり
Console.WriteLine(slice[3]); // 境界チェックあり
}
}
この分岐によって、if (slice.Length >= 4)側のインデックスアクセスでは境界チェックがなくアクセスすることができます。
これは昔から行われている手法になり、主にループで適用されていたようですが、Span<T>についても最近適用されるようになりました。
この手法は、Performance Improvements in .NET 10[5]でも紹介されています。
確認ソースコード
class Program
{
public static void Main()
{
int[] array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
Span<int> span = array;
IndexAccess(span);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span)
{
var slice = span.Slice(4);
Console.WriteLine(slice[0]);
Console.WriteLine(slice[1]);
Console.WriteLine(slice[2]);
Console.WriteLine(slice[3]);
}
}
アセンブル結果
; Assembly listing for method Program:IndexAccess(System.ReadOnlySpan`1[int]) (FullOpts)
; Emitting BLENDED_CODE for x64 + VEX + EVEX on Windows
; FullOpts code
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
; 0 inlinees with PGO data; 1 single block inlinees; 1 inlinees without PGO data
G_M000_IG01:
push rsi
push rbx
sub rsp, 40
G_M000_IG02:
mov rbx, bword ptr [rcx]
mov esi, dword ptr [rcx+0x08]
cmp esi, 4
jl SHORT G_M000_IG05
add rbx, 16
add esi, -4
cmp esi, 3
jle SHORT G_M000_IG06
mov ecx, dword ptr [rbx]
call [System.Console:WriteLine(int)]
mov ecx, dword ptr [rbx+0x04]
call [System.Console:WriteLine(int)]
mov ecx, dword ptr [rbx+0x08]
call [System.Console:WriteLine(int)]
mov ecx, dword ptr [rbx+0x0C]
call [System.Console:WriteLine(int)]
G_M000_IG03:
nop
G_M000_IG04:
add rsp, 40
pop rbx
pop rsi
ret
G_M000_IG05:
call [System.ThrowHelper:ThrowArgumentOutOfRangeException()]
int3
G_M000_IG06:
test esi, esi
je SHORT G_M000_IG07
mov ecx, dword ptr [rbx]
call [System.Console:WriteLine(int)]
cmp esi, 1
jbe SHORT G_M000_IG07
mov ecx, dword ptr [rbx+0x04]
call [System.Console:WriteLine(int)]
cmp esi, 2
jbe SHORT G_M000_IG07
mov ecx, dword ptr [rbx+0x08]
call [System.Console:WriteLine(int)]
cmp esi, 3
jbe SHORT G_M000_IG07
mov ecx, dword ptr [rbx+0x0C]
call [System.Console:WriteLine(int)]
jmp SHORT G_M000_IG03
G_M000_IG07:
call CORINFO_HELP_RNGCHKFAIL
int3
; Total bytes of code 141
ループ展開後の余った最後のループ処理
以下のコードだと4個ずつ繰り返しアクセスするループアンローリングした後の余った要素に対して、forループでアクセスした際に境界チェックは行われません。
最後のループ時点でのインデックスは0以上なのは確定であるため、境界チェックを消える条件が揃っているため、最適化によって削除されるようです。
static int UnrollSum(ReadOnlySpan<int> span)
{
int sum = 0;
int i = 0;
for (; i < span.Length - 3; i += 4)
{
sum += span[i + 0];
sum += span[i + 1];
sum += span[i + 2];
sum += span[i + 3];
}
for (; i < span.Length; i++)
sum += span[i]; // 境界チェックなし
return sum;
}
この最適化は、以下のPRにて対応されており、確認する限り.NET 10からこの最適化がかかるようでした。
確認ソースコード
class Program
{
public static void Main()
{
int[] array = [ 1, 2, 3, 4, 5 ];
Span<int> span = array;
Console.WriteLine(UnrollSum(span));
}
static int UnrollSum(ReadOnlySpan<int> span)
{
int sum = 0;
int i = 0;
for (; i < span.Length - 3; i += 4)
{
sum += span[i + 0];
sum += span[i + 1];
sum += span[i + 2];
sum += span[i + 3];
}
for (; i < span.Length; i++)
sum += span[i]; // 境界チェックなし
return sum;
}
}
アセンブル結果(net10.0以降)
; Assembly listing for method Program:UnrollSum(System.ReadOnlySpan`1[int]):int (FullOpts)
; Emitting BLENDED_CODE for generic X64 + VEX + EVEX on Windows
; FullOpts code
; optimized code
; rsp based frame
; fully interruptible
; No PGO data
G_M000_IG01:
G_M000_IG02:
mov rax, bword ptr [rcx]
mov ecx, dword ptr [rcx+0x08]
xor edx, edx
xor r8d, r8d
lea r10d, [rcx-0x03]
test r10d, r10d
jle SHORT G_M000_IG04
align [0 bytes for IG03]
G_M000_IG03:
mov r9d, r8d
add edx, dword ptr [rax+4*r9]
lea r9d, [r8+0x01]
add edx, dword ptr [rax+4*r9]
lea r9d, [r8+0x02]
add edx, dword ptr [rax+4*r9]
lea r9d, [r8+0x03]
add edx, dword ptr [rax+4*r9]
add r8d, 4
cmp r8d, r10d
jl SHORT G_M000_IG03
G_M000_IG04:
cmp r8d, ecx
jge SHORT G_M000_IG07
G_M000_IG05:
test r8d, r8d
jl SHORT G_M000_IG09
align [0 bytes for IG06]
G_M000_IG06:
mov r10d, r8d
add edx, dword ptr [rax+4*r10]
inc r8d
cmp r8d, ecx
jl SHORT G_M000_IG06
G_M000_IG07:
mov eax, edx
G_M000_IG08:
ret
G_M000_IG09:
mov r10d, r8d
add edx, dword ptr [rax+4*r10]
inc r8d
cmp r8d, ecx
index + count < length パターン
.NET 11 preview 2でも紹介されていた、境界チェックが消えるパターンです。
良いタイトルが浮かばなかったので、この最適化が行われたPRのタイトルを拝借しました。
(uint)(i + 2) < (uint)span.Lengthが正しい場合には、span[i + 1]とspan[i + 2]は必ずアクセスできるため、境界チェックは行われません。
注意事項としては、uintキャストが無くなると境界チェックが追加されます。
おそらくコンパイラとして、uintキャストが無い場合、(i + 2)が負になる可能性があるから、最適化できないのかと思っています。
static void IndexAccess(ReadOnlySpan<int> span)
{
for (int i = 0; i < span.Length; i++)
{
if ((uint)(i + 2) < (uint)span.Length)
{
if (span[i + 1] == 3 && span[i + 2] == 4) // 境界チェックなし
{
Console.WriteLine($"{i}");
}
}
}
}
確認ソースコード
class Program
{
public static void Main()
{
int[] array = [ 1, 2, 3, 4, 5 ];
Span<int> span = array;
IndexAccess(span);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span)
{
for (int i = 0; i < span.Length; i++)
{
if ((uint)(i + 2) < (uint)span.Length)
{
if (span[i + 1] == 3 && span[i + 2] == 4) // 境界チェックなし
{
Console.WriteLine($"{i}");
}
}
}
}
}
アセンブル結果
; Assembly listing for method Program:IndexAccess(System.ReadOnlySpan`1[int]) (FullOpts)
; Emitting BLENDED_CODE for x64 + VEX + EVEX on Windows
; FullOpts code
; optimized code
; rsp based frame
; fully interruptible
; No PGO data
; 5 inlinees with PGO data; 9 single block inlinees; 0 inlinees without PGO data
G_M000_IG01:
push r14
push rdi
push rsi
push rbp
push rbx
sub rsp, 96
xor eax, eax
mov qword ptr [rsp+0x28], rax
vxorps xmm4, xmm4, xmm4
vmovdqu ymmword ptr [rsp+0x30], ymm4
vmovdqa xmmword ptr [rsp+0x50], xmm4
G_M000_IG02:
mov rbx, bword ptr [rcx]
mov esi, dword ptr [rcx+0x08]
xor edi, edi
cmp edi, esi
jge G_M000_IG11
G_M000_IG03:
lea ecx, [rdi+0x02]
cmp ecx, esi
jae G_M000_IG10
G_M000_IG04:
lea eax, [rdi+0x01]
cmp dword ptr [rbx+4*rax], 3 ;境界チェックなし
jne G_M000_IG10
mov ecx, ecx
cmp dword ptr [rbx+4*rcx], 4 ;境界チェックなし
jne G_M000_IG10
xor rcx, rcx
mov gword ptr [rsp+0x38], rcx
test byte ptr [(reloc)], 1
je G_M000_IG12
G_M000_IG05:
mov rbp, 0xD1FFAB1E
mov rcx, gword ptr [rbp]
mov edx, 256
cmp dword ptr [rcx], ecx
call [System.Buffers.SharedArrayPool`1[char]:Rent(int):char[]:this]
mov gword ptr [rsp+0x40], rax
test rax, rax
je G_M000_IG13
lea rcx, bword ptr [rax+0x10]
mov edx, dword ptr [rax+0x08]
G_M000_IG06:
mov bword ptr [rsp+0x50], rcx
mov dword ptr [rsp+0x58], edx
xor ecx, ecx
mov dword ptr [rsp+0x48], ecx
mov byte ptr [rsp+0x4C], 0
lea rcx, [rsp+0x38]
mov edx, edi
call [System.Runtime.CompilerServices.DefaultInterpolatedStringHandler:AppendFormatted[int](int):this]
mov ecx, dword ptr [rsp+0x48]
cmp ecx, dword ptr [rsp+0x58]
ja G_M000_IG14
mov rax, bword ptr [rsp+0x50]
mov bword ptr [rsp+0x28], rax
mov dword ptr [rsp+0x30], ecx
lea rcx, [rsp+0x28]
call System.String:.ctor(System.ReadOnlySpan`1[char]):this
mov r14, rax
mov rdx, gword ptr [rsp+0x40]
xor rcx, rcx
mov gword ptr [rsp+0x40], rcx
vxorps xmm0, xmm0, xmm0
vmovdqu xmmword ptr [rsp+0x50], xmm0
G_M000_IG07:
mov dword ptr [rsp+0x48], ecx
test rdx, rdx
je SHORT G_M000_IG09
G_M000_IG08:
mov rcx, gword ptr [rbp]
xor r8d, r8d
cmp dword ptr [rcx], ecx
call [System.Buffers.SharedArrayPool`1[char]:Return(char[],bool):this]
G_M000_IG09:
mov rcx, r14
call [System.Console:WriteLine(System.String)]
G_M000_IG10:
inc edi
cmp edi, esi
jl G_M000_IG03
G_M000_IG11:
add rsp, 96
pop rbx
pop rbp
pop rsi
pop rdi
pop r14
ret
G_M000_IG12:
mov rcx, 0xD1FFAB1E
call CORINFO_HELP_GET_GCSTATIC_BASE
jmp G_M000_IG05
G_M000_IG13:
xor rcx, rcx
xor edx, edx
jmp G_M000_IG06
G_M000_IG14:
call [System.ThrowHelper:ThrowArgumentOutOfRangeException()]
int3
; Total bytes of code 339
ビット演算子が絡むパターン
個人的にはほぼ使ったことが無いパターンですが、ビット演算子が絡んだ境界チェックが消えるパターンです。
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(byte inData)
{
// base64.Length = 65
ReadOnlySpan<byte> base64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="u8;
// base64からpを取得する
Console.WriteLine(base64[((inData & 0x03) << 4) | ((inData & 0xf0) >> 4)]); // 112(`p`)
}
理論的には、まず(inData & 0x03) << 4は、inDataがどんな値でも最大値が0x30(48)となり、そして(inData & 0xf0) >> 4は、inDataがどんな値でも最大値が0x0f(15)となります。
-
0b00000011(0x03)から4ビット左シフトすると0b00110000(0x30) -
0b11110000(0xf0)から4ビット右シフトすると0b00001111(0x0f)
ビット演算子の計算としては0b00110000(48) | 0b00001111(15) = 0b00111111(63)となり、これよりinDataがどんな値でも63を超えることはないことが確定します。
そしてReadOnlySpan<byte>.Length = 65であることから、範囲外へアクセスすることが無いため、境界チェックが行われない仕組みになっているようです。
確認ソースコード
class Program
{
public static void Main()
{
IndexAccess(0x92);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(byte inData)
{
// base64.Length = 65
ReadOnlySpan<byte> base64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="u8;
// base64からpを取得する
// 境界チェックなし
Console.WriteLine(base64[((inData & 0x03) << 4) | ((inData & 0xf0) >> 4)]); // 112(`p`)
}
}
アセンブル結果
; Assembly listing for method Program:IndexAccess(byte) (FullOpts)
; Emitting BLENDED_CODE for x64 + VEX + EVEX on Windows
; FullOpts code
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
; 1 inlinees with PGO data; 0 single block inlinees; 0 inlinees without PGO data
G_M000_IG01:
sub rsp, 40
G_M000_IG02:
movzx rcx, cl
mov eax, ecx
and eax, 3
shl eax, 4
and ecx, 240
sar ecx, 4
or ecx, eax
mov rax, 0xD1FFAB1E
movzx rcx, byte ptr [rax+rcx] ;境界チェックなし
call [System.Console:WriteLine(int)]
nop
G_M000_IG03:
add rsp, 40
ret
; Total bytes of code 52
Span.Length <= 1とSpan.Length == 0の組み合わせ
ReadOnlySpan<T>.Length <= 1という条件下において、ReadOnlySpan<T>.Length == 0ではない場合、ReadOnlySpan<T>.Length == 1ということが確定するため、span[0]の境界チェックが消えます。
static void IndexAccess(ReadOnlySpan<int> span)
{
if (span.Length <= 1)
{
var item = span.Length == 0 ? 0 : span[0]; // span[0]が境界チェックなし
Console.WriteLine(item);
}
}
確認ソースコード
class Program
{
public static void Main()
{
int[] array = [ 1 ];
Span<int> span = array;
IndexAccess(array);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span)
{
if (span.Length <= 1)
{
var item = span.Length == 0 ? 0 : span[0];
Console.WriteLine(item);
}
}
}
アセンブル結果
; Assembly listing for method Program:IndexAccess(System.ReadOnlySpan`1[int]) (FullOpts)
; Emitting BLENDED_CODE for x64 + VEX + EVEX on Windows
; FullOpts code
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
G_M000_IG01:
sub rsp, 40
G_M000_IG02:
mov rax, bword ptr [rcx]
mov ecx, dword ptr [rcx+0x08]
cmp ecx, 1
jg SHORT G_M000_IG06
G_M000_IG03:
test ecx, ecx
jne SHORT G_M000_IG04
xor ecx, ecx
jmp SHORT G_M000_IG05
G_M000_IG04:
mov ecx, dword ptr [rax]
G_M000_IG05:
call [System.Console:WriteLine(int)]
G_M000_IG06:
nop
G_M000_IG07:
add rsp, 40
ret
; Total bytes of code 37
[.NET11時点では配列と文字列のみ] 別の配列のLengthをインデックスとして使用
別の配列のLengthをインデックスとして使用したパターンで、以下は配列ではなく文字列ですが、境界チェックは消えます。この最適化は、.NET 10.0から適用されます。
static bool Is(string prefix, string path)
{
if (prefix.Length < path.Length)
return (path[prefix.Length] == ':');
return false;
}
ただし、現時点の注意事項としては、配列(int[])や文字列をReadOnlySpan<int>やReadOnlySpan<char>に変更すると、なんと境界チェックが残ります!
uintキャストなども追加しても境界チェックは残ります。
- static bool Is(string prefix, string path)
+ static bool Is(ReadOnlySpan<char> prefix, ReadOnlySpan<char> path)
{
if (prefix.Length < path.Length)
return (path[prefix.Length] == ':'); // 境界チェックなし
return false;
}
G_M000_IG05:
- mov eax, ecx ;境界チェックなし
- cmp word ptr [rdx+2*rax+0x0C], 58 ;path[prefix.Length] == ':'
+ cmp ecx, edx ;境界チェックがあり
+ jae SHORT G_M000_IG07
+ mov eax, ecx
+ cmp word ptr [r8+2*rax], 58
sete al
movzx rax, al
+G_M000_IG07:
+ call CORINFO_HELP_RNGCHKFAIL
+ int3
G_M000_IG05:
cmp ecx, edx
jae SHORT G_M000_IG07
mov eax, ecx
cmp word ptr [r8+2*rax], 58
sete al
movzx rax, al
この問題はすでにissueとして提出されていますが、他のオブジェクトとの兼ね合いなどまだ色々と問題があるようです。
このパターンは意外に多くありそうなので、改善を期待したいところです。
確認ソースコード
class Program
{
public static void Main()
{
Console.Write(Is("http", "http://example.com"));
}
[MethodImpl(MethodImplOptions.NoInlining)]
static bool Is(string prefix, string path)
{
if (prefix.Length < path.Length)
return (path[prefix.Length] == ':');
return false;
}
}
アセンブル結果
; Assembly listing for method Program:Is(System.String,System.String):bool (FullOpts)
; Emitting BLENDED_CODE for x64 + VEX + EVEX on Windows
; FullOpts code
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
G_M000_IG01:
G_M000_IG02:
mov ecx, dword ptr [rcx+0x08]
mov eax, dword ptr [rdx+0x08]
cmp ecx, eax
jl SHORT G_M000_IG05
G_M000_IG03:
xor eax, eax
G_M000_IG04:
ret
G_M000_IG05:
mov eax, ecx ;境界チェックなし
cmp word ptr [rdx+2*rax+0x0C], 58 ;path[prefix.Length] == ':'
sete al
movzx rax, al
G_M000_IG06:
ret
; Total bytes of code 28
; Assembly listing for method Program:Is(System.ReadOnlySpan`1[char],System.ReadOnlySpan`1[char]):bool (FullOpts)
; Emitting BLENDED_CODE for x64 + VEX + EVEX on Windows
; FullOpts code
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
G_M000_IG01:
sub rsp, 40
G_M000_IG02:
mov r8, bword ptr [rdx]
mov edx, dword ptr [rdx+0x08]
mov ecx, dword ptr [rcx+0x08]
cmp ecx, edx
jl SHORT G_M000_IG05
G_M000_IG03:
xor eax, eax
G_M000_IG04:
add rsp, 40
ret
G_M000_IG05:
cmp ecx, edx
jae SHORT G_M000_IG07
mov eax, ecx
cmp word ptr [r8+2*rax], 58
sete al
movzx rax, al
G_M000_IG06:
add rsp, 40
ret
G_M000_IG07:
call CORINFO_HELP_RNGCHKFAIL
int3
Unsafeクラスの使用
境界チェックを強引に行わないようにする場合には、例えばUnsafe.Addを用いることで、境界チェックなしでアクセスすることが可能です。
ただし、Unsafeと名前の通り、安全な処理ではないため、確実にエラーが出ないとわかっているケース(例:長さが事前にわかっているなど)のみ使用するべき方法です。
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span)
{
Console.WriteLine(Unsafe.Add(ref MemoryMarshal.GetReference(span), 0)); // 境界チェックなし
}
確認ソースコード
class Program
{
public static void Main()
{
int[] array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
Span<int> span = array;
IndexAccess(span);
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span)
{
Console.WriteLine(Unsafe.Add(ref MemoryMarshal.GetReference(span), 0)); // 境界チェックなし
Console.WriteLine(Unsafe.Add(ref MemoryMarshal.GetReference(span), 1)); // 境界チェックなし
Console.WriteLine(Unsafe.Add(ref MemoryMarshal.GetReference(span), 2)); // 境界チェックなし
Console.WriteLine(Unsafe.Add(ref MemoryMarshal.GetReference(span), 3)); // 境界チェックなし
}
}
アセンブル結果
; Assembly listing for method Program:IndexAccess(System.ReadOnlySpan`1[int]) (FullOpts)
; Emitting BLENDED_CODE for x64 + VEX + EVEX on Windows
; FullOpts code
; optimized code
; rsp based frame
; partially interruptible
; No PGO data
; 0 inlinees with PGO data; 4 single block inlinees; 0 inlinees without PGO data
G_M000_IG01:
push rbx
sub rsp, 32
G_M000_IG02:
mov rbx, bword ptr [rcx]
mov ecx, dword ptr [rbx]
call [System.Console:WriteLine(int)]
mov ecx, dword ptr [rbx+0x04]
call [System.Console:WriteLine(int)]
mov ecx, dword ptr [rbx+0x08]
call [System.Console:WriteLine(int)]
mov ecx, dword ptr [rbx+0x0C]
call [System.Console:WriteLine(int)]
nop
G_M000_IG03:
add rsp, 32
pop rbx
ret
; Total bytes of code 50
まとめ
境界チェックが削除される条件自体は、どのパターンにおいても基本的には、範囲外へアクセスしないことが確定できる場合になると思います。この確定できるというのが意外に判断が難しいとはおもいますが...。
ということで今回はこのへんで。
-
Microsoft Dev Blogs - Performance Improvements in .NET 9 https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-9/ ↩︎
-
dotnet/runtime - codereview SKILL.md - https://github.com/dotnet/runtime/blob/main/.github/skills/code-review/SKILL.md ↩︎
-
.NET Runtime in .NET 11 Preview 3 - Release Notes https://github.com/dotnet/core/blob/main/release-notes/11.0/preview/preview3/runtime.md#jit-optimizations-improve-switches-bounds-checks-and-casts ↩︎
-
【.NET】直近でマージされたPRの紹介 その2 https://zenn.dev/prozolic/articles/85a62abbbb672d#[%23124571]-eliminate-redundant-bounds-checks-for-arr[^n-1]-after-arr[^n] ↩︎
-
Microsoft Dev Blogs - Performance Improvements in .NET 10 - .https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-10/#cloning ↩︎
Discussion