Skip to content

Nullable<T> interface check / dispatch is comparatively very slow #50915

Description

@stephentoub

Repro:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Runtime.CompilerServices;

[MemoryDiagnoser]
[DisassemblyDiagnoser]
public class Program
{
    static void Main(string[] args) => BenchmarkSwitcher.FromAssemblies(new[] { typeof(Program).Assembly }).Run(args);

    private S _s = default(S);
    private C _c = new C();
    private S? _ns = (S?)default(S);

    [Benchmark] public int Struct() => CallM(_s);
    [Benchmark] public int Class() => CallM(_c);
    [Benchmark] public int Nullable() => CallM(_ns);
    [Benchmark] public int NullableSpecialized() => CallMSpecial(_ns);

    static int CallM<T>(T t)
    {
        if (t is IMyInterface)
        {
            return ((IMyInterface)t).M();
        }

        return 0;
    }

    static int CallMSpecial<T>(T? t) where T : struct
    {
        if (t.HasValue)
        {
            return CallM(t.GetValueOrDefault());
        }

        return 0;
    }
}

interface IMyInterface
{
    int M();
}

struct S : IMyInterface
{
    public int M() => 42;
}

class C : IMyInterface
{
    public int M() => 42;
}
Method Mean Error StdDev Gen 0 Gen 1 Gen 2 Allocated Code Size
Struct 0.4807 ns 0.0044 ns 0.0037 ns - - - - 16 B
Class 3.0842 ns 0.0045 ns 0.0038 ns - - - - 86 B
Nullable 88.6692 ns 0.5664 ns 0.4729 ns 0.0076 - - 48 B 132 B
NullableSpecialized 1.5149 ns 0.0087 ns 0.0077 ns - - - - 64 B

Is there anything we can do to make the nullable case faster and less inclined to box without needing a special code path to handle it (e.g. my CallMSpecial, which in my current actual code is another overload so that a nullable binds to it more tightly).

Here's what the codegen for the nullable case looks like:

; Program.CallM[[System.Nullable`1[[S, Benchmarks]], System.Private.CoreLib]](System.Nullable`1<S>)
       sub       rsp,28
       mov       [rsp+30],rcx
       lea       rdx,[rsp+30]
       mov       rcx,offset MT_System.Nullable`1[[S, Benchmarks]]
       call      CORINFO_HELP_BOX_NULLABLE
       mov       rdx,rax
       mov       rcx,offset MT_IMyInterface
       call      CORINFO_HELP_ISINSTANCEOFINTERFACE
       test      rax,rax
       je        short M01_L00
       lea       rdx,[rsp+30]
       mov       rcx,offset MT_System.Nullable`1[[S, Benchmarks]]
       call      CORINFO_HELP_BOX_NULLABLE
       mov       rdx,rax
       mov       rcx,offset MT_IMyInterface
       call      CORINFO_HELP_CHKCASTINTERFACE
       mov       rcx,rax
       mov       r11,7FFCDCEE05D0
       call      qword ptr [7FFCDD2705D0]
       nop
       add       rsp,28
       ret
M01_L00:
       xor       eax,eax
       add       rsp,28
       ret
; Total bytes of code 122

category:cq
theme:basic-cq
skill-level:intermediate
cost:medium
impact:small

Activity

  1. ghost added
    area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI
    on Apr 8, 2021
  2. ghost added
    untriagedNew issue has not been triaged by the area owner
    on Apr 8, 2021
  3. JulieLeeMSFT commented on Apr 8, 2021

    @JulieLeeMSFT
    Member

    cc @dotnet/jit-contrib.

  4. EgorBo commented on Apr 9, 2021

    @EgorBo
    Member

    I can take a look, so basically some of our optimizations are just not Nullable<> friendly, e.g. impBoxPatternMatch
    We need to handle IL sequences like this:

    box [Nullable<S>]
    isinst [IMyInterface]
    brtrue/brfalse
    

    to be optimized to:

    call get_HasValue()   // ldfld
    brtrue/brfalse
    

    (same for box+isinst+ldnull+cgt.un).

    Another issue here is that we might want to optimize boxing for Nullables from this:

    return CORINFO_HELP_BOX_NULLABLE(o)
    

    to something like this:

    if (!o.HasValue)
        return null;
    else 
        return CORINFO_HELP_BOX_NULLABLE(o)
    
  5. self-assigned this
    on Apr 9, 2021
  6. removed
    untriagedNew issue has not been triaged by the area owner
    on Apr 9, 2021
  7. added this to the 6.0.0 milestone on Apr 9, 2021
  8. Joe4evr commented on Apr 9, 2021

    @Joe4evr
    Contributor

    Another issue here is that we might want to optimize boxing for Nullables from this:
    [...]
    to something like this:

    Would that really make a difference? IIRC, boxing a no-value Nullable<T> already makes the reference point to actual null.

  9. EgorBo commented on Apr 9, 2021

    @EgorBo
    Member

    Another issue here is that we might want to optimize boxing for Nullables from this:
    [...]
    to something like this:

    Would that really make a difference? IIRC, boxing a no-value Nullable<T> already makes the reference point to actual null.

    It would allow us to avoid "call" overhead (by slightly regressing size and additional check for non-null case - so I agree that one is questionable and requires some analysis or how frequent we pass null there in a general case) branch: EgorBo@a0d8ee9

  10. ghost added
    in-prThere is an active PR which will close this issue when it is merged
    on Apr 9, 2021
  11. stephentoub commented on Apr 9, 2021

    @stephentoub
    MemberAuthor

    Thanks for looking at this, @EgorBo. Knowing whether these can and will (eventually) be improved will impact what overloads we choose to expose in #50635. This is a significant enough difference that if we don't think this can be improved meaningfully, we'll need to expose the T? overloads there. If we can make it work better, we can leave those out.

    What's particularly interesting to me is how much worse the nullable case is than just boxing it from the start:

    Method Mean Error StdDev Code Size Gen 0 Gen 1 Gen 2 Allocated
    Struct 0.5162 ns 0.0226 ns 0.0211 ns 16 B - - - -
    Class 3.3559 ns 0.0216 ns 0.0192 ns 86 B - - - -
    Nullable 87.4553 ns 0.3305 ns 0.3091 ns 132 B 0.0076 - - 48 B
    NullableBoxed 47.8314 ns 0.2854 ns 0.2670 ns 114 B 0.0038 - - 24 B
    NullableSpecialized 1.7265 ns 0.0309 ns 0.0289 ns 64 B - - - -
        [Benchmark]
        public int NullableBoxed() => CallM((object)_ns);
  12. EgorBo commented on Apr 9, 2021

    @EgorBo
    Member

    What's particularly interesting to me is how much worse the nullable case is than just boxing it from the start:

    Yeah that's funny, you box it once while original Nullable() benchmark actually boxes it twice (in isinst and then in cast) 🙂

  13. EgorBo commented on Apr 9, 2021

    @EgorBo
    Member

    Knowing whether these can and will (eventually) be improved will impact what overloads we choose to expose in #50635

    I think this specific case definitely can be improved as part of 6.0, the first part is already ready: #50997
    the second one (to basically handle this: sharplab.io) also looks doable.

  14. ghost removed
    in-prThere is an active PR which will close this issue when it is merged
    on Apr 27, 2021
  15. 13 remaining items

  16. modified the milestones: 9.0.0, Future on May 2, 2024
  17. moved this to Optimizations in .NET Core CodeGenon Jun 5, 2024
  18. EgorBot commented on Jul 28, 2024

    @EgorBot
  19. EgorBo commented on Sep 3, 2024

    @EgorBo
    Member

    @EgorBot -intel -arm64 --runtimes net6.0 net7.0 net8.0 net9.0

    using BenchmarkDotNet.Attributes;
    
    [MemoryDiagnoser]
    [DisassemblyDiagnoser]
    public class Bench
    {
        private S _s = default(S);
        private C _c = new C();
        private S? _ns = (S?)default(S);
    
        [Benchmark] public int Struct() => CallM(_s);
        [Benchmark] public int Class() => CallM(_c);
        [Benchmark] public int Nullable() => CallM(_ns);
        [Benchmark] public int NullableSpecialized() => CallMSpecial(_ns);
    
        static int CallM<T>(T t)
        {
            if (t is IMyInterface)
            {
                return ((IMyInterface)t).M();
            }
    
            return 0;
        }
    
        static int CallMSpecial<T>(T? t) where T : struct
        {
            if (t.HasValue)
            {
                return CallM(t.GetValueOrDefault());
            }
    
            return 0;
        }
    }
    
    interface IMyInterface
    {
        int M();
    }
    
    struct S : IMyInterface
    {
        public int M() => 42;
    }
    
    class C : IMyInterface
    {
        public int M() => 42;
    }
  20. deleted a comment from EgorBot on Sep 3, 2024
  21. deleted a comment from EgorBot on Sep 3, 2024
  22. modified the milestones: Future, 11.0.0 on Dec 4, 2025
  23. added
    in-prThere is an active PR which will close this issue when it is merged
    on Dec 4, 2025
  24. moved this from Optimizations to Done in .NET Core CodeGenon Jan 7, 2026
  25. added a commit that references this issue on Jan 7, 2026
  26. locked and limited conversation to collaborators on Feb 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMIin-prThere is an active PR which will close this issue when it is merged

Type

No type

Projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions