Skip to content

Instantly share code, notes, and snippets.

@udlose
Last active January 27, 2025 23:13
Show Gist options
  • Select an option

  • Save udlose/712f7c157e10c742d870934bba53c2a3 to your computer and use it in GitHub Desktop.

Select an option

Save udlose/712f7c157e10c742d870934bba53c2a3 to your computer and use it in GitHub Desktop.
Benchmark to measure performance difference between calling overridden methods on: open vs sealed classes

Overview

This benchmark is designed to compare the performance difference of:

  1. The difference in performance between sealed and non-sealed classes.
  2. The impact of virtual dispatch.
  3. The cost of overriding, chaining (base.Method(x)), and extending base logic.

We will look at 2 different scenarios:

  1. Explicitly declared derived types (e.g., SealedClassSameImpl). Test if the compiler can optimize virtual calls when it knows the exact type at compile time.
  2. Derived types are declared as their base class type: BaseClass. Test if the compiler can optimize virtual method calls when classes are marked as sealed and the type is only known at runtime.

Benchmark Descriptions

SealedClassSameImpl

Measures the performance of calling a method on a sealed class that has the same implementation as its base class. This is important because sealed classes allow the compiler to make aggressive optimizations since it knows the class cannot be inherited. The method resolves directly without virtual dispatch. This represents the fastest possible method call scenario. Demonstrates the performance benefit of sealing a class when polymorphism is unnecessary, highlighting reduced overhead in method calls.

OpenClassSameImpl

Measures the performance of calling a method on an open class that has the same implementation as its base class that isn't virtual. This is important for understanding if merely leaving a class open for inheritance (without virtual methods) impacts performance. Calls require virtual dispatch to resolve the method. Highlights the cost of virtual dispatch in non-sealed classes, showing the trade-off between extensibility and performance.

SealedClassDiffImpl

Measures the performance of calling a method on a sealed class that has a different implementation than its base class. This is important because sealed classes allow the compiler to make aggressive optimizations since it knows the class cannot be inherited. The method resolves directly to the unique sealed implementation. Measures the performance impact of overriding base methods in sealed classes with unique behavior, isolating the benefit of sealing.

OpenClassDiffImpl

Measures the performance of calling a method on an open class that has a different implementation than its base class. Method calls require virtual dispatch to resolve the overridden logic.

SealedClassJustCallsBase

Measures the performance of calling a method on a sealed class that only calls the base class implementation without additional work. Isolates the cost of invoking the base class method within a sealed class, showing the overhead of chaining in the absence of virtual dispatch.

OpenClassJustCallsBase

Measures the performance of calling a method on an open class that only calls the base class implementation without additional work. Highlights the added cost of virtual dispatch when chaining base class logic in non-sealed classes, emphasizing the trade-off between extensibility and performance.

SealedClassExtendsBaseImpl

Measures the performance of calling a method on a sealed class that calls the base class implementation and performs additional work. Measures the combined cost of extending base functionality and directly resolving methods in sealed classes without virtual dispatch.

OpenClassExtendsBaseImpl

Measures the performance of calling a method on an open class that calls the base class implementation and performs additional work. Measures the combined cost of extending base functionality and directly resolving methods with virtual dispatch.


Environment

Benchmark Process Environment Information:

  • BenchmarkDotNet v0.14.0
  • Runtime=.NET 9.0.1 (9.0.124.61010), X64 RyuJIT AVX-512F+CD+BW+DQ+VL+VBMI
  • GC=Concurrent Workstation
  • HardwareIntrinsics=AVX-512F+CD+BW+DQ+VL+VBMI,AES,BMI1,BMI2,FMA,LZCNT,PCLMUL,POPCNT VectorSize=256

Results

Explicitly declare all members as their respective types to help the runtime. Noticable gains!

Method Mean Error StdDev Ratio RatioSD
SealedClassSameImpl 198.4 ns 1.68 ns 1.57 ns 1.00 0.01
OpenClassSameImpl 343.7 ns 4.19 ns 3.92 ns 1.73 0.02
Method Mean Error StdDev Ratio RatioSD
SealedClassDiffImpl 200.3 ns 1.80 ns 1.60 ns 1.01 0.01
OpenClassDiffImpl 346.0 ns 6.83 ns 6.71 ns 1.74 0.04
Method Mean Error StdDev Ratio RatioSD
SealedClassJustCallsBase 200.5 ns 2.65 ns 2.35 ns 1.01 0.01
OpenClassJustCallsBase 338.4 ns 4.48 ns 4.19 ns 1.71 0.02
Method Mean Error StdDev Ratio RatioSD
SealedClassExtendsBaseImpl 379.8 ns 4.98 ns 4.66 ns 1.91 0.03
OpenClassExtendsBaseImpl 339.5 ns 3.26 ns 3.05 ns 1.71 0.02

Now let's see what happens when we declare all members as BaseClass so we access the derived instance thru it's base type - i.e. the .NET runtime has to determine the type during execution. Sadly, but unsurprisingly we lose the gains from sealed :(

Method Mean Error StdDev Ratio RatioSD
SealedClassSameImpl 333.3 ns 3.67 ns 3.43 ns 1.00 0.01
OpenClassSameImpl 337.3 ns 6.44 ns 6.02 ns 1.01 0.02
Method Mean Error StdDev Ratio RatioSD
SealedClassDiffImpl 339.7 ns 4.68 ns 4.15 ns 1.02 0.02
OpenClassDiffImpl 336.8 ns 4.07 ns 3.81 ns 1.01 0.01
Method Mean Error StdDev Ratio RatioSD
SealedClassJustCallsBase 335.6 ns 5.66 ns 5.29 ns 1.01 0.02
OpenClassJustCallsBase 334.2 ns 4.83 ns 4.52 ns 1.00 0.02
Method Mean Error StdDev Ratio RatioSD
SealedClassExtendsBaseImpl 337.9 ns 6.30 ns 6.47 ns 1.01 0.02
OpenClassExtendsBaseImpl 337.7 ns 4.83 ns 4.52 ns 1.01 0.02

Analysis

Most of these should be unsurprising for developers familiar with OOP.

Scenario #1: Explicitly declared derived types

When derived instances are explicitly declared as their actual type (e.g., SealedClassSameImpl), the compiler/runtime optimizations for sealed classes can take effect. These optimizations eliminate virtual dispatch and allow the method call to be resolved directly, yielding improved performance for sealed classes.

  1. Direct Calls on sealed Classes Are Faster: When the compiler knows the type at compile-time and the class is sealed, the call can bypass virtual dispatch, significantly improving performance. This is particularly evident in methods with identical or unique implementations.

  2. Polymorphism Costs More: Non-sealed classes incur a noticeable performance penalty due to the overhead of virtual dispatch. The results for non-sealed classes show ~1.7x slower performance compared to sealed classes for identical or differing implementations.

  3. Base Calls in Sealed Classes Still Benefit: Even when the method calls base.Method(x) or extends it, sealed classes retain their performance advantage. The runtime knows the sealed class cannot be further derived and skips the virtual dispatch.

  4. Type Declaration at Compile-Time Matters: Declaring instances explicitly as their derived type (e.g., SealedClassSameImpl) enables the compiler and runtime to apply optimizations. Without this, the sealed modifier loses its performance benefits.

This suggests that when designing class hierarchies:

  • Use sealed for Performance: When polymorphism isn't required, sealing classes provides meaningful performance gains, especially for frequently invoked methods.
  • Virtual Dispatch is Expensive: Accessing instances through a base class type negates the benefits of sealing, as the runtime must always perform a vtable lookup.
  • Explicit Type Declarations Matter: Declaring instances as their exact type allows the runtime to apply direct-call optimizations for sealed classes.
  • Balance Extensibility and Performance: Use sealed when you know a class won't need further derivation to avoid unnecessary runtime costs. Conversely, weigh the trade-offs carefully when flexibility is needed. If you are designing a public API, remember you can always change a sealed class to an open class, but you can't seal an open class without making a breaking change. Note that I'm not a fan of making changes in either direction here - remember what the 'O' stands for in the SOLID acronym :P.
  • If you need virtualization: don't worry too much about the specific implementation details - they all perform similarly.
  • Don't avoid calling base.Method() for performance reasons: it's just as fast as any other virtual call.

Scenario #2: Derived Types Declared as BaseClass

When derived instances are accessed through a BaseClass reference, the runtime uses virtual dispatch to resolve method calls, regardless of whether the derived class is sealed. This removes the key optimization benefit of sealing, as the runtime cannot bypass the vtable lookup.

  • No Performance Gain from sealed: When instances are accessed through BaseClass, the sealed modifier provides no measurable performance improvement. This is because virtual dispatch (via the vtable) is always required to resolve the method call.

  • Virtual Dispatch Dominates: The consistent timing across both sealed and non-sealed classes shows that the bottleneck lies in virtual dispatch. Whether the implementation is the same or differs, the cost of looking up the method in the vtable outweighs any potential optimizations.

  • Base Calls Are Neutralized: Methods that explicitly call base.Method(x) or extend the base implementation (e.g., base.Method(x) + 1) show similar performance across sealed and non-sealed classes. This indicates that the runtime's handling of base method chaining is unaffected by whether the class is sealed.

  • Overriding Cost is Uniform: Whether the derived class overrides the base method with identical or different logic has negligible impact on performance in this scenario. The runtime overhead of resolving virtual calls remains the same.

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
/// <summary>
/// Refer to the README.MD for details about:
/// 1. description of each benchmark
/// 2. results
/// 3. analysis of the results
///
/// </summary>
public static void Main()
{
BenchmarkRunner.Run<MethodCall_SealedVsNonSealedVsVirtual_Benchmark>();
}
// Base class with virtual method
public abstract class BaseClass
{
public virtual int Method(int x) => x * 2;
}
#region same implementation as base
// Derived sealed class with same implementation as base class
public sealed class SealedClassSameImpl : BaseClass
{
public override int Method(int x) => x * 2;
}
// Derived non-sealed class with identical implementation as base class
public class OpenClassSameImpl : BaseClass
{
public override int Method(int x) => x * 2;
}
#endregion same implementation as base
#region diff implementation from base
// Derived sealed class with different implementation from base class
public sealed class SealedClassDiffImpl : BaseClass
{
public override int Method(int x) => x * 3;
}
// Derived non-sealed class with different implementation from base class
public class OpenClassDiffImpl : BaseClass
{
public override int Method(int x) => x * 3;
}
#endregion diff implementation from base
#region just calls base impl
// Derived sealed class calling base implementation
public sealed class SealedClassJustCallsBase : BaseClass
{
public override int Method(int x) => base.Method(x);
}
// Derived sealed class calling base implementation
public class OpenClassJustCallsBase : BaseClass
{
public override int Method(int x) => base.Method(x);
}
#endregion just calls base
#region extends base impl
// Derived sealed class that extends base implementation
public sealed class SealedClassExtendsBaseImpl : BaseClass
{
public override int Method(int x) => base.Method(x) + 1;
}
// Derived open class that extends base implementation
public class OpenClassExtendsBaseImpl : BaseClass
{
public override int Method(int x) => base.Method(x) + 1;
}
#endregion extends base impl
[MemoryDiagnoser(false)]
public class MethodCall_SealedVsNonSealedVsVirtual_Benchmark
{
private int Iterations = 1_000;
// declare all members as BaseClass types so we access the derived instance
// thru a base type reference
// private readonly BaseClass _sealedClassSameImpl = new SealedClassSameImpl();
// private readonly BaseClass _openClassSameImpl = new OpenClassSameImpl();
//
// private readonly BaseClass _sealedClassDiffImpl = new SealedClassDiffImpl();
// private readonly BaseClass _openClassDiffImpl = new OpenClassDiffImpl();
//
// private readonly BaseClass _sealedClassJustCallsBase = new SealedClassJustCallsBase();
// private readonly BaseClass _openClassJustCallsBase = new OpenClassJustCallsBase();
//
// private readonly BaseClass _sealedClassExtendsBaseImpl = new SealedClassExtendsBaseImpl();
// private readonly BaseClass _openClassExtendsBaseImpl = new OpenClassExtendsBaseImpl();
// explicitly declare all members as their respective types to help the runtime
private readonly SealedClassSameImpl _sealedClassSameImpl = new();
private readonly OpenClassSameImpl _openClassSameImpl = new();
private readonly SealedClassDiffImpl _sealedClassDiffImpl = new();
private readonly OpenClassDiffImpl _openClassDiffImpl = new();
private readonly SealedClassJustCallsBase _sealedClassJustCallsBase = new();
private readonly OpenClassJustCallsBase _openClassJustCallsBase = new();
private readonly SealedClassExtendsBaseImpl _sealedClassExtendsBaseImpl = new();
private readonly OpenClassExtendsBaseImpl _openClassExtendsBaseImpl = new();
[Benchmark(Baseline = true)]
public int SealedClassSameImpl()
{
int result = 0;
for (int i = 0; i < Iterations; i++)
{
result += _sealedClassSameImpl.Method(i);
}
return result;
}
[Benchmark]
public int OpenClassSameImpl()
{
int result = 0;
for (int i = 0; i < Iterations; i++)
{
result += _openClassSameImpl.Method(i);
}
return result;
}
[Benchmark]
public int SealedClassDiffImpl()
{
int result = 0;
for (int i = 0; i < Iterations; i++)
{
result += _sealedClassDiffImpl.Method(i);
}
return result;
}
[Benchmark]
public int OpenClassDiffImpl()
{
int result = 0;
for (int i = 0; i < Iterations; i++)
{
result += _openClassDiffImpl.Method(i);
}
return result;
}
[Benchmark]
public int SealedClassJustCallsBase()
{
int result = 0;
for (int i = 0; i < Iterations; i++)
{
result += _sealedClassJustCallsBase.Method(i);
}
return result;
}
[Benchmark]
public int OpenClassJustCallsBase()
{
int result = 0;
for (int i = 0; i < Iterations; i++)
{
result += _openClassJustCallsBase.Method(i);
}
return result;
}
[Benchmark]
public int SealedClassExtendsBaseImpl()
{
int result = 0;
for (int i = 0; i < Iterations; i++)
{
result += _sealedClassExtendsBaseImpl.Method(i);
}
return result;
}
[Benchmark]
public int OpenClassExtendsBaseImpl()
{
int result = 0;
for (int i = 0; i < Iterations; i++)
{
result += _openClassExtendsBaseImpl.Method(i);
}
return result;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment