Thomaz's blog

Performance in Csharp with sealed

06/19/20253 min read

Understanding the sealed keyword

We ofter assume that if a class is not inherited os doesn't contain any virtual methods, it will behave as if it were implicity "sealed", but that's not happens.

The sealed keyword explicitly tells the .NET compiler and JIT that a class cannot be inherited any moment, and not contain any derivation. And this enables certain runtime optimizations.

Also is good when sealed classes inherits other classes. When the sealed class overrides a virtual method, are more likely to be devirtualized by the runtime, informin they can be inlined or invoke the operation directly avoiding a virtual dispatch operation.

Some example:

public class BaseType { public virtual int M() => 1; }

public class NonSealedType : BaseType
{
    public override int M() => 2;
}

public sealed class SealedType : BaseType
{
    public override int M() => 2;

    private SealedType _sealed = new();
    private NonSealedType _nonSealed = new();
}

[Benchmark(Baseline = true)]
public int NonSealed() => _nonSealed.M() + 42;

[Benchmark]
public int SealedTypee() => _sealed.M() + 42;
| Method      | Mean      | Error     | StdDev    | Ratio | RatioSD |
|------------ |----------:|----------:|----------:|------:|--------:|
| NonSealed   | 0.0160 ns | 0.0093 ns | 0.0087 ns |  1.59 |    1.73 |
| SealedTypee | 0.0037 ns | 0.0045 ns | 0.0037 ns |  0.36 |    0.56 |

Another example, is making type checker.

For example:

private object _o = "hello";

[Benchmark(Baseline = true)]
public bool NonSealed() => _o is NonSealedType;

[Benchmark]
public bool Sealed() => _o is SealedType;


public class NonSealedType { }
public sealed class SealedType { }
| Method    | Mean      | Error     | StdDev    | Median    | Ratio | RatioSD |
|---------- |----------:|----------:|----------:|----------:|------:|--------:|
| NonSealed | 0.0038 ns | 0.0057 ns | 0.0051 ns | 0.0007 ns |     ? |       ? |
| Sealed    | 0.0009 ns | 0.0019 ns | 0.0018 ns | 0.0000 ns |     ? |       ? |

? indicates that it was not possible to compute, because the baseline or benchmark could not be found, or the baseline value is too close to zero.

The results show 4.32x and 4.22x faster, respectively.

I won't go into every detail, but take a look at the assembly code. Notice the difference: at first glance we can see the NonSealed involves more operations than Sealed. However, this doesn't always imply worse performance -- other factor like runtime helpers calls, JIT operations, CPU-bound, memory-bound, inline methods, can affect the generated code.

The NonSealed is doing a virtual dispatch, calling the series of mov instructions. And in Sealed is reduced to a null check followed by returning a constant value, and the SealedTypee().M was devirtualized and inlined.

Sealed:NonSealed():int:this (FullOpts):
       push     rbp
       mov      rbp, rsp
       mov      rdi, gword ptr [rdi+0x10]
       mov      rax, qword ptr [rdi]
       mov      rax, qword ptr [rax+0x40]
       call     [rax+0x20]Sealed+BaseType:M():int:this
       add      eax, 42
       pop      rbp
       ret

Sealed:SealedTypee():int:this (FullOpts):
       mov      rax, gword ptr [rdi+0x08]
       cmp      byte  ptr [rax], al
       mov      eax, 44
       ret

In dotnet team there are changing many classes to be sealed -- dotnet/runtime#49958, dotnet/runtime#50225, and dotnet/runtime#49969.

Recommendation

If a class is private/internal or not inherited, are sealed, if it's public and not inherited, sealed too.

So, just use sealed as default, if in some moment you need to be inherited, you remove the sealed.

Reference

Search for "Peanut butter" in the article (the direct link are broken due to a microsoft issue):