This benchmark is designed to compare the performance difference of:
- The difference in performance between sealed and non-sealed classes.
- The impact of virtual dispatch.
- The cost of overriding, chaining (
base.Method(x)), and extending base logic.
We will look at 2 different scenarios:
- Explicitly declared derived types (e.g.,
SealedClassSameImpl). Test if the compiler can optimize virtual calls when it knows the exact type at compile time. - Derived types are declared as their base class type:
BaseClass. Test if the compiler can optimize virtual method calls when classes are marked assealedand the type is only known at runtime.
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.
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.
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.
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.
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.
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.
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.
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.
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
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 |
Most of these should be unsurprising for developers familiar with OOP.
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.
-
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.
-
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.
-
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 thesealedclass cannot be further derived and skips the virtual dispatch. -
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, thesealedmodifier loses its performance benefits.
This suggests that when designing class hierarchies:
- Use
sealedfor 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
vtablelookup. - 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
sealedwhen 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.
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 throughBaseClass, thesealedmodifier provides no measurable performance improvement. This is because virtual dispatch (via thevtable) 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.