Skip to content

Conversation

@EgorBo
Copy link
Member

@EgorBo EgorBo commented Dec 3, 2025

Closes #50915

Today we never inline & stack-allocate (via the escape analysis) boxed Nullable<>, let's see if we can fix that.

object Test(long? n)
{
    return n;
}

Main:

; Method My:Test(System.Nullable`1[long]):System.Object:this (FullOpts)
       sub      rsp, 40
       mov      rcx, 0x7FFBCE1680C0      ; System.Nullable`1[long]
       call     [CORINFO_HELP_BOX_NULLABLE]
       nop      
       add      rsp, 40
       ret      
; Total bytes of code: 26

PR:

; Method My:Test(System.Nullable`1[long]):System.Object:this (FullOpts)
       push     rbx
       sub      rsp, 32
       mov      rbx, rdx
       cmp      byte  ptr [rbx], 0
       je       SHORT G_M7100_IG04
       mov      rcx, 0x7FFBDFBC08B0      ; System.Int64
       call     CORINFO_HELP_NEWSFAST
       mov      rcx, qword ptr [rbx+0x08]
       mov      qword ptr [rax+0x08], rcx
       jmp      SHORT G_M7100_IG05
G_M7100_IG04:
       xor      rax, rax
G_M7100_IG05:
       add      rsp, 32
       pop      rbx
       ret      
; Total bytes of code: 46

Another example (from #114497 (comment)) cc @pentp

public static string? Format<T>(T value)
{
    if (value is IFormattable formattable)
        return formattable.ToString(null, null);
    return null;
}

diff.
Doesn't allocate anymore to box that nullable.

Benchmarks: EgorBot/runtime-utils#563

@github-actions github-actions bot added the area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI label Dec 3, 2025
@EgorBo

This comment was marked as outdated.

@EgorBo
Copy link
Member Author

EgorBo commented Dec 4, 2025

Enabled structs too (basically, all types, even shared), so now #114497 (comment) is properly handed.

@EgorBo
Copy link
Member Author

EgorBo commented Dec 4, 2025

@EgorBot -amd -arm

using BenchmarkDotNet.Attributes;

[MemoryDiagnoser]
[DisassemblyDiagnoser]
public class Bench
{
    private Nullable<bool> _null = null;
    private Nullable<bool> _nonnull = true;

    [Benchmark] public object BoxNull() => _null; 

    [Benchmark] public object BoxNonNull() => _nonnull;

    // https://github.com/dotnet/runtime/issues/50915
    private S? _ns = (S?)default(S);
    [Benchmark] public int Issue50915() => CallM(_ns);
    static int CallM<T>(T t)
    {
        if (t is IMyInterface)
            return ((IMyInterface)t).M();
        return 0;
    }
}

interface IMyInterface
{
    int M();
}

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

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes nullable boxing operations by introducing early inline expansion instead of using helper calls. The optimization enables escape analysis to stack-allocate boxed nullable values when they don't escape the method, eliminating heap allocations in hot paths. The implementation adds a new early QMARK expansion phase that runs after import but before other optimizations, allowing subsequent passes to optimize the expanded control flow.

Key changes:

  • Inline expansion of nullable box operations in hot, optimized code paths using conditional allocation
  • New early QMARK expansion phase to enable object stack allocation optimizations
  • COMMA node splitting during QMARK expansion to improve optimization opportunities

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/coreclr/jit/importer.cpp Adds inline expansion of nullable boxing with conditional allocation based on hasValue field
src/coreclr/jit/morph.cpp Enhances QMARK expansion to support early phase, adds COMMA splitting, changes return type to PhaseStatus
src/coreclr/jit/compiler.cpp Adds early QMARK expansion phase after import and updates late expansion call signature
src/coreclr/jit/compiler.h Adds OMF_HAS_EARLY_QMARKS flag and updates fgExpandQmarkNodes signature
src/coreclr/jit/compphases.h Defines new PHASE_EARLY_QMARK_EXPANSION phase

@EgorBo EgorBo force-pushed the inline-nullable-allocators branch from c1431db to ab99de1 Compare December 5, 2025 21:27
@EgorBo EgorBo force-pushed the inline-nullable-allocators branch from 23f505f to 69f6243 Compare December 6, 2025 01:30
@EgorBo
Copy link
Member Author

EgorBo commented Dec 6, 2025

The superpmi diffs are obviously useless due to missing contexts. The normal jit-diff (e.g. this) is also a mess due to the way PMI works - we take all generics and try to instantiate them with one of the 7 predefined types (see here) and int? in that list which meant tons of boxings on top of nullable where nullable is never really used. If I remove from that list and only leave real usage, the diff become much more smaller and reasonable (it's expected for it to be a size increase obviously).

@EgorBo EgorBo force-pushed the inline-nullable-allocators branch from 8e2a401 to b2987b3 Compare December 6, 2025 13:55
@EgorBo EgorBo enabled auto-merge (squash) January 7, 2026 16:34
@EgorBo EgorBo merged commit 242f7b2 into dotnet:main Jan 7, 2026
122 checks passed
@EgorBo EgorBo deleted the inline-nullable-allocators branch January 7, 2026 16:35
@pentp
Copy link
Contributor

pentp commented Jan 8, 2026

It seems that the allocations are elided only for primitive types, for structs there's still an allocation: https://godbolt.org/z/aTxMocvhW

@EgorBo
Copy link
Member Author

EgorBo commented Jan 8, 2026

It seems that the allocations are elided only for primitive types, for structs there's still an allocation: https://godbolt.org/z/aTxMocvhW

This problem is not related to nullable, it's a general limitation of the escape analysis, can be reproduced via:

static string Test1(Guid g)
{
    object o = g;
    return (o).ToString();
}

static string Test2(int g)
{
    object o = g;
    return (o).ToString();
}

works for int, doesn't work for Guid 🙁

@EgorBo
Copy link
Member Author

EgorBo commented Jan 8, 2026

it will work if e.g. Guid.ToString is just a wrapper that calls a static string GuidToString(Guid g) method so jit can see it's passed by value

@pentp
Copy link
Contributor

pentp commented Jan 8, 2026

But why is passing by reference a problem? It's still just a struct, so it can't escape anywhere...

@pentp
Copy link
Contributor

pentp commented Jan 8, 2026

The example you provided doesn't actually allocate, it has something to do with Nullable specifically, not escape analysis: https://godbolt.org/z/4MEjcfraq

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-CodeGen-coreclr CLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

5 participants