runtime: Introduce static methods to allocate and throw key exception types.

EDITED 03/06/2021 by @stephentoub to add revised proposal:

public class ArgumentNullException
{
+    public static void ThrowIfNull([NotNull] object? argument, [CallerArgumentExpression("argument")] string? argumentName = null);
}

Background and Motivation

The .NET ecosystem uses extensive argument checking to improve code reliability and predictability. These checks have a substantial impact on code size and often dominate the code for small functions and property setters. This in consumes more RAM, takes more time to JIT, prevents inlining, and most important it causes substantial instruction cache pollution. Ultimately, the presence of these checks slows code down.

Many libraries, including the framework libraries themselves, implement exception throwing helpers to compensate for this bloat. These simple static functions centralize the exception creation and throwing logic. Why not enshrine this pattern in the exception API surface to encourage smaller/faster code, and avoid library authors having to create these stubs themselves?

Proposed API

I propose that the core framework ArgumentXXXException classes be augmented with static functions responsible for both allocating and throwing the exceptions:

public class ArgumentNullException : ArgumentException
{
    [MethodImpl(MethodImplOptions.NoInlining)]
    public static void Throw(string paramName) =>
        throw new ArgumentNullException(nameof(paramName));
}

There would be one static method corresponding to each constructor signature of the exception type.

This pattern would be warranted for any exception type that has a sufficiently large usage footprint. Certainly the ArgumentXXXException types would qualify for this, perhaps a few others.

Usage Examples

With such functions, the following code

public void DoSomething(Foo foo)
{
    if (foo == null) throw new ArgumentNullException(nameof(foo));
}

would become

public void DoSomething(Foo foo)
{
    if (foo == null) ArgumentNullException.Throw(nameof(foo));
}

Analyzer and Fixer or Compiler Voodoo

It would be trivial to include an analyzer and associated fixer to upgrade a code base to the new approach, thus encouraging a rapid migration.

Alternatively, the C# compiler could potentially be upgraded to automatically replace canonical uses into calls to the static method which wouldn’t require any code changes to yield the perf benefits.

Generated Code

The code required to create and throw an exception costs > 70 bytes of instructions. Here is an example null check compiled in release mode for .NET 5:

00007FFADA6D510B 48837D1800           cmp     qword ptr [rbp+18h],0
00007FFADA6D5110 7541                 jne     short LBL_0
00007FFADA6D5112 48B980C974DAFA7F0000 mov     rcx,offset methodtable(System.ArgumentNullException)
00007FFADA6D511C E83F26B25F           call    CORINFO_HELP_NEWSFAST
00007FFADA6D5121 488945A0             mov     [rbp-60h],rax
00007FFADA6D5125 B901000000           mov     ecx,1
00007FFADA6D512A 48BA70DB88DAFA7F0000 mov     rdx,7FFADA88DB70h
00007FFADA6D5134 E8C7B3C45F           call    CORINFO_HELP_STRCNS
00007FFADA6D5139 48894598             mov     [rbp-68h],rax
00007FFADA6D513D 488B5598             mov     rdx,[rbp-68h]
00007FFADA6D5141 488B4DA0             mov     rcx,[rbp-60h]
00007FFADA6D5145 E8D61BFEFF           call    System.ArgumentNullException..ctor(System.String)
00007FFADA6D514A 488B4DA0             mov     rcx,[rbp-60h]
00007FFADA6D514E E87D62AE5F           call    CORINFO_HELP_THROW
LBL_0:
00007FFADA6D5153 488B5510             mov     rdx,[rbp+10h]

About this issue

  • Original URL
  • State: closed
  • Created 3 years ago
  • Reactions: 5
  • Comments: 36 (36 by maintainers)

Most upvoted comments

https://github.com/dotnet/runtime/pull/50112 and https://github.com/dotnet/runtime/pull/48589 improved jit codegen for these new kind of throw helpers.

It will emit a call to a ThrowIfNull method identical to this one but injected as internal into the assembly that’s using it. As some point once we better address cross-module inlining with R2R, the compiler might switch to using this method rather than having one per assembly.

The point of this proposal was to move the code that allocates and throws an exception out of the main hot code path.

I understand. This achieves that:

public static void ThrowIfNull([NotNull] object? argument, string argumentName)
{
    if (argument is null)
        Throw(argumentName);
}

private static void Throw(string argumentName) =>
    throw new ArgumentNullException(argumentName);

with less IL at each call site but the same generated asm at each call site. ThrowIfNull gets inlined and Throw does not.

e.g. https://sharplab.io/#v2:EYLgxg9gTgpgtADwGwBYA0AXEBDAzgWwB8BiAOwFcAbS7YSmAAhlNvoFgAoAAQCYBGTlwAMDLnwB0AEQCW2AOakIuDNLC5xAYQgATGAEEWlAJ65puANydBAZlE8GGhgG9ODN6NtcUDACoxlfAAUEMAAVjBgGAzYUHIAlK7uLhzuqQzSAGYMgTFy6bgMFNQJKWllPgAWUBAA7oEs+DAQGTmxcXGWpW4AvolufR6i3n7KPMFhEVG5JanJZW6V1TUAkhkAclSUrXJohdiNzdvtnam9XQwDAA5Q0gBu2BiMYkhDvlW1qxvUgQDaaxAYL6UAC6DBC4UiAH5orFyI1SBhdmIRLk4cxAfsYDMkgNUplsqj4VEzIVNtj5u5FrVtmiEWtMR0Bmcrjd7o9RHwXl43ktAsiYXJaRjGnEGABeAB8uPcGHeNUKMHlelhRKBAFEEGAYJcVBBSDTVQzOt0gA===

#nullable enable
using System;
using System.Diagnostics.CodeAnalysis;

public class C {
    public void Test1(object arg)
    {
        if (arg is null)
            Throw(nameof(arg));
    }
    
    public void Test2(object arg)
    {
        ThrowIfNull(arg, nameof(arg));
    }
    
    private static void ThrowIfNull([NotNull] object? argument, string argumentName)
    {
        if (argument is null)
            Throw(argumentName);
    }

    private static void Throw(string argumentName) =>
        throw new ArgumentNullException(argumentName);
}

produces smaller IL for Test2:

    .method public hidebysig 
        instance void Test1 (
            object arg
        ) cil managed 
    {
        // Method begins at RVA 0x208e
        // Code size 14 (0xe)
        .maxstack 8

        IL_0000: ldarg.1
        IL_0001: brtrue.s IL_000d

        IL_0003: ldstr "arg"
        IL_0008: call void C::Throw(string)

        IL_000d: ret
    } // end of method C::Test1

    .method public hidebysig 
        instance void Test2 (
            object arg
        ) cil managed 
    {
        // Method begins at RVA 0x209d
        // Code size 12 (0xc)
        .maxstack 8

        IL_0000: ldarg.1
        IL_0001: ldstr "arg"
        IL_0006: call void C::ThrowIfNull(object, string)
        IL_000b: ret
    } // end of method C::Test2

and identical asm for Test1 and Test2:

C.Test1(System.Object)
    L0000: test rdx, rdx
    L0003: jne short L0017
    L0005: mov rcx, 0x14fc806f730
    L000f: mov rcx, [rcx]
    L0012: jmp C.Throw(System.String)
    L0017: ret

C.Test2(System.Object)
    L0000: test rdx, rdx
    L0003: jne short L0017
    L0005: mov rcx, 0x14fc806f730
    L000f: mov rcx, [rcx]
    L0012: jmp C.Throw(System.String)
    L0017: ret

I’m not at my computer so I may be missing something, but what I meant is

// user code
ANE.ThrowIfNull(arg, nameof(arg));

// ANE
public ThrowifNull(object arg, string argname) // inlines 
{
   if (arg is null)
       Throw(argname);
}

private Throw(string argname) // not inlined 
{
    throw new ANE(..);
}