🛡️

【.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を用いて確認しているケースもあります。

https://godbolt.org/

境界チェックの判断基準

この後に紹介しますが、例えばuintキャストを用いたインデックスの比較では、最適化によって境界チェックがなくなります。
では、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]);
        }
    }
uintキャストを削除した場合
+       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]でも記載されている、よく知られている手法になります。

uintキャストを用いたインデックス比較
[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は存在しません。

forループ内でのindexアクセス
    [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は存在しません。

whileループ内でのindexアクセス
[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さんが過去に紹介もされているハイパフォーマンス手法でも取り上げられているなど、ある意味有名な手法です。

https://speakerdeck.com/neuecc/cedec-2023-modanhaipahuomansuc-number-2023-edition?slide=72

確認ソースコード
サンプルコード
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で実際に確認したところ、境界チェックが最初の一回のみでそれ以外はなかったので、無事含まれたようです。

https://github.com/dotnet/runtime/pull/124571

確認ソースコード
サンプルコード
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]を追加すると、この部分だけ境界チェックが追加されます。
どちらにしても例外が発生するのであまり意味はないですが...。

span[4]を追加
[MethodImpl(MethodImplOptions.NoInlining)]
static void IndexAccess(ReadOnlySpan<int> span)
{
    var slice = span.Slice(0, 4);
    ...
    // span[4]を追加(必ずエラーが発生)
    Console.WriteLine(slice[4]);  // 境界チェックあり
}
span[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からクローニング(ブロッククローニング)という最適化が発生します。
実際に上記のコードは以下のようなコードに置き換わります。

クローニングされた場合(アセンブル結果をC#化した仮想コード)
[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からこの最適化がかかるようでした。

https://github.com/dotnet/runtime/pull/113862

確認ソースコード
サンプルコード
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でも紹介されていた、境界チェックが消えるパターンです。
https://github.com/dotnet/runtime/pull/124242

良いタイトルが浮かばなかったので、この最適化が行われたPRのタイトルを拝借しました。
(uint)(i + 2) < (uint)span.Lengthが正しい場合には、span[i + 1]span[i + 2]は必ずアクセスできるため、境界チェックは行われません。

注意事項としては、uintキャストが無くなると境界チェックが追加されます。
おそらくコンパイラとして、uintキャストが無い場合、(i + 2)が負になる可能性があるから、最適化できないのかと思っています。

index + count < length パターン
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であることから、範囲外へアクセスすることが無いため、境界チェックが行われない仕組みになっているようです。

https://github.com/dotnet/runtime/pull/122263

確認ソースコード
サンプルコード
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]の境界チェックが消えます。

Span.Length <= 1とSpan.Length == 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から適用されます。

別の配列のLengthをインデックスとして使用
static bool Is(string prefix, string path)
{
    if (prefix.Length < path.Length)
        return (path[prefix.Length] == ':');

    return false;
}

ただし、現時点の注意事項としては、配列(int[])や文字列をReadOnlySpan<int>ReadOnlySpan<char>に変更すると、なんと境界チェックが残ります!
uintキャストなども追加しても境界チェックは残ります。

別の配列のLengthをインデックスとして使用
- 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;
}
stringとReadOnlySpan<char>での差分
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として提出されていますが、他のオブジェクトとの兼ね合いなどまだ色々と問題があるようです。
このパターンは意外に多くありそうなので、改善を期待したいところです。

https://github.com/dotnet/runtime/issues/116073

確認ソースコード
サンプルコード
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;
    }
}

アセンブル結果
stringの場合(境界チェックなし)
; 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
ReadOnlySpan<char>の場合(境界チェックあり)
; 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と名前の通り、安全な処理ではないため、確実にエラーが出ないとわかっているケース(例:長さが事前にわかっているなど)のみ使用するべき方法です。

Unsafe.AddとMemoryMarshal.GetReferenceを用いて境界チェックを消す
[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

まとめ

境界チェックが削除される条件自体は、どのパターンにおいても基本的には、範囲外へアクセスしないことが確定できる場合になると思います。この確定できるというのが意外に判断が難しいとはおもいますが...。

ということで今回はこのへんで。

脚注
  1. Microsoft Dev Blogs - Performance Improvements in .NET 9 https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-9/ ↩︎

  2. dotnet/runtime - codereview SKILL.md - https://github.com/dotnet/runtime/blob/main/.github/skills/code-review/SKILL.md ↩︎

  3. .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 ↩︎

  4. 【.NET】直近でマージされたPRの紹介 その2 https://zenn.dev/prozolic/articles/85a62abbbb672d#[%23124571]-eliminate-redundant-bounds-checks-for-arr[^n-1]-after-arr[^n] ↩︎

  5. Microsoft Dev Blogs - Performance Improvements in .NET 10 - .https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-10/#cloning ↩︎

Discussion