ねののお庭。

かりかりもふもふ。

【C#】Generics と null 比較と boxing。

Generic Method 内部で null 比較を行ったら、はたして boxing するんでしょうか? たとえば以下みたいな関数があったとします。

// 参照型の null と、Nullable<T> の null の数が知りたい。
public static int CountNull<T1, T2, T3>(T1? value1, T2? value2, T3? value3)
{
    int count = 0;

    count += value1 is null ? 1 : 0;
    count += value2 is null ? 1 : 0;
    count += value3 is null ? 1 : 0;

    return count;
}

この関数はコメントにあるように、参照型の null と、Nullable<T> の null の数が知るためのものです。 なので、参照型と値型の両方を引数に取る可能性があるため、class constraint (where T1 : class) 等ができません。で、ここで当然の疑問が湧きます。

Nullable<T> はともかく普通の値型ってboxingさせないと null 比較できないよな...?boxingしてるのか...??」

結論を先に書いておくと、 IL ではなく、JITコンパイル時に出力される ASM で最適化が走るため boxing は起きません。 なので Generic Method 内部で null 比較は boxing の事を気にせず書いて問題なしです。

ここから下は余談ですが、( ´_ゝ`)フーンという感じで軽く眺めてもらえれば嬉しいです。

コンパイラによって生成される IL と JIT コンパイラで生成される asm

さて、もうちょっと例をさっぱりさせて、こんな感じで行きましょう。sharplab へのリンクはこちら

static class Utilities
{
    public static int Method<T>(T? value1)
    {
        // 0x63 is 99 !
        return value1 is null ? 0x63 : 0x7;
    }
}

こいつが IL になると、こんな感じになります。

.class private auto ansi abstract sealed beforefieldinit Utilities
    extends [System.Runtime]System.Object
{
    // Methods
    .method public hidebysig static 
        int32 Method<T> (
            !!T value1
        ) cil managed 
    {
        .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
            01 00 02 00 00
        )
        // Method begins at RVA 0x208e
        // Code size 13 (0xd)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: box !!T
        IL_0006: brfalse.s IL_000a

        IL_0008: ldc.i4.7
        IL_0009: ret

        IL_000a: ldc.i4.s 99
        IL_000c: ret
    } // end of method Utilities::Method

} // end of class Utilities

うわぁぁぁぁぁぁぁ boxing だぁぁぁぁ!!!! となります。

では、以下のように class constraint をつけてあげたら boxing 消えてくれるんじゃね?と推察し、where T : classを付けてあげる。

static class Utilities
{
    public static int Method<T>(T? value1)
        where T : class
    {
        return value1 is null ? 0x63 : 0x7;
    }
}

実際に吐き出される IL がこちら。

.class private auto ansi abstract sealed beforefieldinit Utilities
    extends [System.Runtime]System.Object
{
    // Methods
    .method public hidebysig static 
        int32 Method<class T> (
            !!T value1
        ) cil managed 
    {
        .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = (
            01 00 01 00 00
        )
        .custom instance void [SharpLab.Runtime]SharpLab.Runtime.JitGenericAttribute::.ctor(class [System.Runtime]System.Type[]) = (
            01 00 01 00 00 00 60 53 79 73 74 65 6d 2e 53 74
            72 69 6e 67 2c 20 53 79 73 74 65 6d 2e 52 75 6e
            74 69 6d 65 2c 20 56 65 72 73 69 6f 6e 3d 36 2e
            30 2e 30 2e 30 2c 20 43 75 6c 74 75 72 65 3d 6e
            65 75 74 72 61 6c 2c 20 50 75 62 6c 69 63 4b 65
            79 54 6f 6b 65 6e 3d 62 30 33 66 35 66 37 66 31
            31 64 35 30 61 33 61 00 00
        )
        .param [1]
            .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = (
                01 00 02 00 00
            )
        // Method begins at RVA 0x208e
        // Code size 13 (0xd)
        .maxstack 8

        IL_0000: ldarg.0
        IL_0001: box !!T
        IL_0006: brfalse.s IL_000a

        IL_0008: ldc.i4.7
        IL_0009: ret

        IL_000a: ldc.i4.s 99
        IL_000c: ret
    } // end of method Utilities::Method

} // end of class Utilities

バカな、まだ box !!T がいるぞ...! ということで、参照型に制約しようが吐き出される IL に違いがない事が分かってしまいました。

では実際に boxing が発生するか?と考えると、いや絶対そんな手抜き実装してないだろうなぁ、となるので、JIT コンパイル 時に最適化されるんだろうなぁ~と思いを馳せます。 という事で JIT コンパイラ が吐き出す asm を眺める事になります。sharplab は [JitGeneric(typeof(T))] とかつけると、値型に関しては良しなに展開してくれます。参照型はうまくいかないので、適当な関数経由でみてみましょう。

以下みたいなコードがあったとして、

using SharpLab.Runtime;

static class Utilities
{
    // [JitGeneric(typeof(object))] の代替。
    public static int F(object? o)
    {
        return Method(o);
    }
    
    [JitGeneric(typeof(int))]
    [JitGeneric(typeof(int?))]
    // [JitGeneric(typeof(object))] // not work
    public static int Method<T>(T? value1)
    {
        return value1 is null ? 0x63 : 0x7;
    }
}

吐き出される asm は以下。

; Core CLR 6.0.722.32202 on amd64

Microsoft.CodeAnalysis.EmbeddedAttribute..ctor()
    L0000: ret

System.Runtime.CompilerServices.NullableAttribute..ctor(Byte)
    L0000: push rdi
    L0001: push rsi
    L0002: sub rsp, 0x28
    L0006: mov rsi, rcx
    L0009: mov edi, edx
    L000b: mov rcx, 0x7ffef6690a90
    L0015: mov edx, 1
    L001a: call 0x00007fff5611b660
    L001f: mov [rax+0x10], dil
    L0023: lea rcx, [rsi+8]
    L0027: mov rdx, rax
    L002a: call 0x00007fff5611aeb0
    L002f: nop
    L0030: add rsp, 0x28
    L0034: pop rsi
    L0035: pop rdi
    L0036: ret

System.Runtime.CompilerServices.NullableAttribute..ctor(Byte[])
    L0000: lea rcx, [rcx+8]
    L0004: call 0x00007fff5611aeb0
    L0009: nop
    L000a: ret

System.Runtime.CompilerServices.NullableContextAttribute..ctor(Byte)
    L0000: mov [rcx+8], dl
    L0003: ret

Utilities.F(System.Object)
    L0000: test rcx, rcx
    L0003: je short L000c
    L0005: mov eax, 7
    L000a: jmp short L0011
    L000c: mov eax, 0x63
    L0011: ret

Utilities.Method[[System.Int32, System.Private.CoreLib]](Int32)
    L0000: mov eax, 7
    L0005: ret

Utilities.Method[[System.Nullable`1[[System.Int32, System.Private.CoreLib]], System.Private.CoreLib]](System.Nullable`1<Int32>)
    L0000: mov [rsp+8], rcx
    L0005: cmp byte ptr [rsp+8], 0
    L000a: je short L0012
    L000c: mov eax, 7
    L0011: ret
    L0012: mov eax, 0x63
    L0017: ret

int (null非許容の値型) の場合

該当の asm はここですね。完全に null 比較が消し飛んで定数が返るようになっています。 というか C# の Generics は値型に関しては C++ のテンプレートみたいにそれぞれの型専用にコードが吐き出されるという事を思い出せば、そりゃこういう最適化効いて当然か...って感じ。 まぁ、実際に自分の目で確かめる事は重要なので良しとしましょう。

Utilities.Method[[System.Int32, System.Private.CoreLib]](Int32)
    L0000: mov eax, 7
    L0005: ret

int? (null許容の値型、Nullalbe<T>) の場合

該当の asm はここ。

Utilities.Method[[System.Nullable`1[[System.Int32, System.Private.CoreLib]], System.Private.CoreLib]](System.Nullable`1<Int32>)
    L0000: mov [rsp+8], rcx
    L0005: cmp byte ptr [rsp+8], 0
    L000a: je short L0012
    L000c: mov eax, 7
    L0011: ret
    L0012: mov eax, 0x63
    L0017: ret

cmp byte ptr [rsp+8], 0、なので、[rsp+8]0byte ptr (1 byte = 8 bit)分cmp比較するぞといってるわけですね。 この比較箇所は、Nullable<T>がもっている hasValueの値との比較でしょう。 で、比較が等価だったら、ZFフラグが立って、 je short L0012によって L0012にジャンプして eax0x63を積んで、ret。 比較に失敗したら eax7を積んでret。

無駄な事は一切してなさそう。良き良き。

public struct Nullable<T> where T : struct
{
    private readonly bool hasValue;
    internal T value;

    public Nullable(T value)
    {
        this.value = value;
        hasValue = true;
    }
    // impl...
}

object (参照型) の場合

該当の asm はここ。

Utilities.F(System.Object)
    L0000: test rcx, rcx
    L0003: je short L000c
    L0005: mov eax, 7
    L000a: jmp short L0011
    L000c: mov eax, 0x63
    L0011: ret

test はオペランドの論理積を取って、その結果によって SF, ZF, PF にフラグが立つ命令。 test rcx, rcxなので、同じものの論理積とってて一瞬おや?とはなりますが、論理積を取った結果がゼロならそれはつまり null だよね、ということですね。 null だった場合は ZFにフラグが立ちますから、jeでジャンプしてあげればよろしいでしょう、という事になります。あとは Nullalbe<T>の場合と同じ。

無駄な事はなさそうですね、ヨシ!

まとめ。

今回の null 比較ネタは JIT が頑張ってくれるので問題なし!最高!以上!

...という話ではあるんですが、このくらいの最適化は序の口で。 最新の .NET7 でのパフォーマンス改善記事とか眺めていると意味不明なレベルの JIT 時最適化が走ってるんですよね、わけがわからないよ(CV:加藤英美里)。

IL を見るだけじゃダメなのです...!asm まで見ないとなのです...!

devblogs.microsoft.com

References