Skip to content

Proposal: Extension interfaces implementation through witness types #133245

Description

@hez2010

Background

Today, .NET requires interface implementations to be declared directly on the implementing type. This restriction blocks two recurring patterns:

  • Third-party interface implementations on foreign types: A library defining IPrint cannot make int, string, or foreign types implement it without wrappers, adapter registries, or dynamic lookups outside the type system.
  • Conditional implementations: Generic types cannot implement interfaces conditionally based on their type arguments, such as making List<T> implement IDeepEqual<List<T>> only when T implements IDeepEqual<T>.
static class DeepEqualityExtensions
{
    extension<T>(List<T> self) : IDeepEqual<List<T>>
        where T : IDeepEqual<T>
    {
        bool IDeepEqual<List<T>>.DeepEquals(List<T> other)
        {
            if (self.Count != other.Count)
                return false;

            for (int i = 0; i < self.Count; i++)
                if (!self[i].DeepEquals(other[i]))
                    return false;

            return true;
        }
    }
}

While other languages like Rust support these patterns through type classes or traits, .NET languages currently force developers to abandon interface abstractions entirely.

This proposal shows that extension interfaces do not require mutating interface maps. Instead, the runtime can answer interface queries lazily and out of band, caching the resulting witness type in existing structures. And this is mostly inspired from dotnet/csharplang#9319, with some changes to the semantic and the runtime model. Some implementation notes are based on the implementation of my prototype here: https://github.com/hez2010/runtime/tree/extiface.

Disclaimer: AI was used to assist in writing this proposal and developing the prototype. I have manually reviewed the proposal and verified the implementation and tested the prototype.

Considerations

There're several ongoing discussions happening in dotnet/csharplang, but a viable runtime execution model is still missing. Appending the interface to the InterfaceMap of every matching MethodTable simply won't work because it complicates interface map building, inflates generic instantiation costs, and makes failed casts depend on assembly load order, and unaffected code would pay huge performance penalties continuously.

Given this, the design is shaped by several runtime requirements:

Pay-for-play: Interface casts and dispatch are among the most performance-sensitive paths in the runtime. The design cannot add branches, lookups, or MethodTable bloat to code that does not participate in the feature. Extension metadata is inspected only after nominal resolution fails.

Object identity: Managed interface references must remain direct object references, and boxed value types must retain their standard header, MethodTable*, and payload layout. Introducing fat pointers, wrapper objects, or extra box fields would ripple across calling conventions, GC scanning, generic sharing, and P/Invoke. ReferenceEquals and GetType() must continue reporting true identity.

Generic identity: Passing witness tokens as additional generic arguments (such as transforming M<T>() into M<T, TWitness>()) changes the identity of participating types and leaks implementation choices into public signatures. List<int> must remain List<int>.

Stable negative results: The runtime caches failed casts. If a cast could fail initially and succeed later simply because an unrelated assembly loaded, the negative cast cache would require widespread synchronization and invalidation. Negative results must remain permanently valid.

Bounded discovery: Answering whether T implements I cannot involve scanning all loaded modules or executing module initializers. Discovery must rely strictly on metadata reachable from T and I.

Value-type semantics: Constrained calls to implementations declared on the value type itself preserve in-place mutation without an extra box.

Design

The witness relation

Rather than modifying T's interface map, the runtime evaluates an immutable relation on demand:

(exact receiver runtime type, requested interface) -> witness type

The witness is a compiler-generated interface type that nominally implements the declared contract and supplies its member implementations. One type definition represents the declaration; each closed witness represents its application to particular type arguments.

Crucially, the receiver object never instantiates the witness, and the witness is not stored in object headers, boxed payloads, interface references, or generic signatures. It is derived lazily from the (receiver, interface) pair and cached in the pair cache and existing dispatch/generic lookup machinery.

Type satisfaction follows a two-tier rule: T satisfies I if it does so nominally; failing that, T satisfies I if the relation produces a valid witness. Unaffected unmarked paths remain unchanged; extension lookup occurs only after nominal failure for extension-sensitive pairs.

Declarations have no per-instance state, are independent of namespace imports, and define relations that remain fixed for the lifetime of their loaded modules.

Coherence and candidate discovery

Ownership bounds candidate discovery; a separate coherence rule requires a unique effective implementation for each pair.

Ownership rule: An extension implementation must be declared in the module defining either the outermost target type or the contract interface.

Declarations must reside in the compilation defining the type or the interface. When both are defined in the current module, the compiler emits the declaration as type-owned. This limits lookup to the modules defining the receiver, its nominal bases and interfaces, and the requested interface. Together with coherence and immutable declarations, it makes lookup results stable under unrelated assembly loads.

Coherence rule: Compilers conservatively reject observable overlaps, including those through base interfaces or variance. Multiple paths to the same closed witness count as one implementation. Distinct applicable closed witnesses are ambiguous, even when they come from the same declaration. There is no priority between competing implementations; an ambiguity detected at runtime causes TypeLoadException.

A key restriction arises for interface-owned declarations. Suppose module A defines interface IChild : Foreign.IBase and declares an interface-owned implementation of IChild for foreign type T. A subsequent cast from IChild to Foreign.IBase must succeed, but resolving (T, Foreign.IBase) can only inspect the modules for T and IBase (neither of which is A). Therefore, interface-owned declarations are valid only if all transitive base interfaces of the contract belong to the same module. Type-owned declarations carry no such restriction, as pair resolution inspects the receiver's nominal hierarchy and discovers base declarations directly.

Lowering and member shape

Each extension declaration lowers to exactly one witness interface definition, generic when needed, marked with a runtime-recognized attribute:

[CompilerGenerated]
[ExtensionInterfaceImplementation]
private interface __ListPrint<T> : IPrint
    where T : IPrint
{
    // Adapter member: reuses existing default interface method (DIM) resolution
    void IPrint.Print() => __Print((List<T>)(object)this);

    // Canonical static body: accepts the receiver explicitly
    private static void __Print(List<T> self)
    {
        foreach (T item in self)
            item.Print();
    }
}

Emitting an interface avoids altering the GC, debugger, or object layout, as no object instance ever has the witness as its MethodTable (matching IDynamicInterfaceCastable).

Each user-provided instance implementation emits two relevant forms:

  • An adapter member, which is an ordinary interface method enabling boxed and reference-type dispatch through standard default interface method infrastructure.
  • A canonical static body, which accepts the receiver explicitly (Target for reference types, ref Target or in Target for value types). For an implementation declared on the value receiver itself, this preserves mutation without an extra box during constrained calls. Static interface members only require the static body, as they have no receiver instance.

For each explicitly implemented instance member, the compiler emits at most one adapter and one canonical body. Closing the witness reuses these definitions.

When a declaration is generic over its receiver, the witness carries that receiver type parameter. For example:

extension<TReceiver>(TReceiver self) : IIncrement
    where TReceiver : struct, IIncrementableStorage
{
    void IIncrement.Increment()
    {
        self.Value++;
    }
}

the compiler emits one generic witness definition:

[CompilerGenerated]
[ExtensionInterfaceImplementation]
internal interface __IncrementImpl<TReceiver> : IIncrement
    where TReceiver : struct, IIncrementableStorage
{
    // Generic boxed/interface-dispatch adapter.
    void IIncrement.Increment()
    {
        ref TReceiver receiver =
            ref Unsafe.Unbox<TReceiver>((object)this);

        __Body(ref receiver);
    }

    // One canonical generic IL body.
    private static void __Body(ref TReceiver receiver)
    {
        receiver.Value++;
    }
}

Note that using a non-generic witness with a generic static method doesn't work here. For example, if we have:

interface __IncrementImpl
{
    static void Body<TReceiver>(ref TReceiver receiver);
}

then every receiver would have the same implementation type __IncrementImpl, therefore we end up losing the closed implementation identity.

Metadata representation and module indexing

To make declarations discoverable without scanning all loaded assemblies, the compiler emits extension declarations into two metadata tables in the defining module:

  1. ExtensionInterfaceImpl: Records each declaration mapping with an Owner token (the lookup anchor), an Implementation token (the witness interface), Target and Interface blob signatures evaluated within the witness generic context, and an ownership flag (TypeOwned or InterfaceOwned). Rows are sorted by Owner.
  2. ExtensionInterfaceMethodImpl: Maps each contract member (Declaration) to its canonical static body (Body) on the witness (Implementation). Instance members have an extra receiver parameter.

Validation requires a marked witness interface in the declaring module, a complete and unique binding for all witness type parameters under the inference rules below, and valid implementations of the declared contracts. Interface-owned declarations must satisfy the base-interface visibility restriction above. The marker alone does not establish an implementation. Nullable<T> is excluded because boxing erases its wrapper; byref-like receivers are outside the scope of this proposal, but it could be added later.

Only modules containing ExtensionInterfaceImpl rows allocate an extension index, creating compact owner-to-row-range mappings. Modules without extension metadata allocate nothing.

Note that this doesn't necessarily require a metadata table change.

Actually, the above two tables are only the logical representation of the information required by the runtime; the same information can be encoded in a compiler-reserved module-level custom attribute containing a versioned binary manifest. The manifest can contain an owner-sorted index followed by extension declarations, with each declaration encoding its ownership kind, owner and witness references, target and contract CLI signature blobs, and the mappings from contract members to canonical static bodies. A separate metadata stream is another possible encoding.

The runtime can then recognize the presence of this manifest and lazily construct the same owner-to-row-range index that it would have constructed from dedicated metadata tables, preserving the bounded lookup and pay-for-play properties without requiring any new ECMA-335 table definitions.

In my prototype I also used this approach to avoid a metadata breaking change.

Runtime markers

To make this feature pay-for-play, the runtime must never inspect extension metadata or query module indices during ordinary nominal interface operations. Instead, participating types and interfaces are identified using two opt-in marker bits on their MethodTable:

  • MayHaveTypeOwnedExtensionImplementations: Set on types that define or inherit type-owned extension declarations. During MethodTable construction, this marker propagates through the nominal base-type and interface closures so derived types inherit the marker without altering their interface maps.
  • MayHaveInterfaceOwnedExtensionImplementations: Set on contract interface definitions that carry interface-owned declarations (and on base interfaces defined in the same module).

The type-owned marker can fold directly into the existing enum_flag_NonTrivialInterfaceCast mask, because CastHelpers.IsInstanceOfInterface inspects this mask only after the nominal interface map scan misses, nominal casts execute with zero additional overhead. Similarly, for nonvariant interfaces, JIT helper selection routes to extension-aware helpers only when the target interface carries the interface-owned marker. Types and interfaces lacking these markers bypass extension logic entirely.

Pair resolution algorithm

When a nominal cast, dispatch, or constraint check misses and the marker bits indicate that the receiver or interface participates in the feature, the runtime triggers pair resolution to evaluate (receiver, interface):

  1. Candidate collection: The runtime queries the relevant module indices. For type-owned declarations, it inspects ExtensionInterfaceImpl rows anchored to nominal projections of the receiver (itself, base classes, and nominally implemented interfaces). For interface-owned declarations, it inspects rows indexed by the definition of the requested interface.
  2. Matching & constraint validation: Match the target against a nominal projection of the receiver, or the exact receiver for a bare type-parameter target. Complete the witness arguments using the receiver, requested interface, and declared constraints. A binding is applicable only if all constraints hold and the closed contract converts to the requested interface.
  3. Fixed-point evaluation: Constraint checks recursively evaluate extension-aware satisfaction, allowing conditional implementations to compose naturally (such as List<A> implementing IPrint when A satisfies IPrint). A cycle alone cannot satisfy a constraint. When satisfaction is established independently of that cycle, affected candidates are evaluated again before selecting a witness. Only completed results are cached: a unique witness, no implementation, or ambiguity. Ambiguity causes TypeLoadException.

A bare type-parameter target must be constrained as a reference type or a non-nullable value type; its declaration is interface-owned.

Witness arguments may be inferred by exact matching from the receiver, invariant arguments of the requested interface, and declared constraints matched against nominal bases or interfaces of already known types. These sources may be combined.

An applicable binding must determine every witness argument, satisfy all constraints, and allow the closed contract to convert to the requested interface. Multiple applicable closed witnesses are ambiguous. Variance and extension satisfaction may validate known arguments, but cannot supply missing ones. Member signatures and method type arguments are not inference sources.

For example, the requested interface can provide information absent from the receiver:

// ICodec<T> is invariant.
extension<T>(Receiver self) : ICodec<T> { ... }

Requesting ICodec<int> determines T = int. Requiring every argument to occur in the receiver would unnecessarily exclude this case.

Constraints can also expose information already present in a known type:

extension<TElement, TList>(TList self) : ICount
    where TList : class, IList<TElement>
{ ... }

For a List<int> receiver, its nominal IList<int> implementation determines TElement = int. If a receiver implements both IList<int> and IList<string>, both possibilities must be considered; other constraints may distinguish them.

Variance does not provide the same certainty:

interface IProducer<out T> { T Produce(); }

extension<T>(Receiver self) : IProducer<T> { ... }

A request for IProducer<object> does not determine T: both IProducer<string> and IProducer<object> can convert to it. The argument must therefore come from another permitted source.

Every interface view relying on the extension must recover the same closed witness. This is necessary because an ordinary interface reference carries only the receiver, with no additional state recording which implementation was selected.

Exact pair cache

Once resolved, the result for an exact (receiver TypeHandle, interface TypeHandle) pair is stored in an exact-match resolution cache. Because declarations are bounded by the ownership rule to modules that are already loaded when T and I exist, results are permanently stable:

  • Successes: Store the resolved closed witness and declaration descriptor.
  • Negatives and ambiguities: Cached permanently as NotImplemented or Ambiguous. Loading an unrelated assembly cannot introduce a new candidate or alter the outcome.
  • ALC hygiene: The cache is partitioned across loader allocators, tracking dependencies to ensure collectible AssemblyLoadContext instances remain eligible for unloading.

New reflection API

Introduce a new API for reflection:

Type? GetExtensionInterfaceImplementation(Type receiverType, Type interfaceType);

This can be used for asking which extension implementation is responsible for a specific (receiver type, interface) pair, i.e. the witness type. For example, GetExtensionInterfaceImplementation(typeof(List<Foo>), typeof(IPrint)) will yield __ListPrint<Foo>.

For closed runtime types, this API returns the effective witness, or null if no extension applies or a nominal implementation wins. Ambiguity raises TypeLoadException. Returning a witness does not make the receiver assignable to that witness interface itself.

Runtime operations

Extension interface implementation participates in the following runtime operations:

Casts (isinst, castclass): Nominal lookup executes first. On failure, marker bits are checked and the pair is resolved. The original reference is returned directly, preserving ReferenceEquals and GetType(). Value types undergo a standard single box with no secondary wrapper. Array stores and other semantic casts use the same extension-aware assignability check.

Interface dispatch: On dispatch cache misses, pair resolution runs after nominal failure. The slot resolves against the witness adapter, and the resulting entry point is stored in the standard dispatch cache. Extension resolution explicitly precedes IDynamicInterfaceCastable and COM fallback so dispatch can consistently re-derive the implementation from the receiver type and interface alone.

Casts, member calls, delegates, and reflection must all resolve a given receiver/interface pair to the same witness.

Generic constraints: Nominal checks run first. Shared generic dictionary slots are populated with the resolved witness canonical body or thunk on nominal miss. The steady-state hot path remains an indirect call through the generic dictionary without additional branches.

Value types: Constrained calls forward managed pointers (ref T or in T) directly into the canonical static body, avoiding allocations and preserving in-place mutation. Calling through boxed interface references mutates the box payload as in normal boxed dispatch. Nullable<T> is excluded because boxing erases its wrapper.

Reflection: Pair-based queries (IsAssignableFrom, IsAssignableTo, GetInterfaceMap) and a new proposed API (Type? GetExtensionInterfaceImplementation(Type receiverType, Type interfaceType)) are extension-aware. GetInterfaceMap maps contract members to the witness adapter methods. In contrast, reverse enumeration (Type.GetInterfaces() and TypeInfo.ImplementedInterfaces) remains nominal-only, because discovering every interface-owned implementation across arbitrary unreferenced assemblies in an open-world model is impossible without a global registry or load-order dependencies.

Optimizations

When the exact receiver type is statically known, the JIT may devirtualize dispatch, inline static bodies, and forward value-type managed references directly.

NativeAOT can analyze explicit metadata dependency edges to trim unused witness implementations cleanly. Besides, it allows more optimizations here because the compilation is a closed world.

Alternatives

Mutating nominal interface maps: While this makes the feature appear nominal, it substantially complicates interface map building, inflates generic instantiation costs, requires eager propagation to derived and array types, and invalidates negative cast caches upon assembly loading. Unaffected code pays these costs unconditionally.

Wrapper objects: Wrapping preserves the nominal type system but allocates wrapper instances, breaks GetType() and ReferenceEquals, introduces alias inconsistencies, double-boxes value types, and cannot express implementation identity in generics.

Fat interface references (pointer + witness): Carrying object and witness pointers together would allow lexically scoped implementations, but at the cost of fundamentally breaking the managed ABI, calling conventions, stack layout, GC pointer tracking, and P/Invoke across every interface in the runtime.

Hidden witness generic arguments (M<T, TWitness>): The classical type-class approach is sound, but altering generic signatures changes method and type identities, leaks implementation choices into public APIs, and requires widespread compiler and runtime ABI adjustments. Coherence makes this extra argument unnecessary.

Compiler-only call rewriting: Lowering calls to static extension methods handles simple cases, but fails for casts from object, interface-typed storage, cross-assembly constraints, reflection, interface arrays, and static abstract interface members.

Global runtime registry: Populating a process-wide registry via module initializers creates load-order dependencies, prevents negative cast caching, increases startup overhead, and degrades trimming and AOT compatibility.

Direct IDynamicInterfaceCastable usage: Reusing this mechanism conceptually is valuable, but exposing it as the primary model requires modifying the target type, excludes value types, operates per-instance rather than per-type, and cannot satisfy generic constraints or static abstract interface members.

Performance study

To verify the performance of this design, we implemented a prototype in CoreCLR and ran a series of benchmarks. See the branch here and the benchmark set here.
In this prototype all the semantics including object / generic type identities are preserved correctly and tested, see the test cases here.

All benchmarks were run with tiered compilation disabled, and the benchmark code was explicitly opting-out devirtualization and inlining.

Main branch vs feature branch in ordinary paths

Below is the benchmark that compares nominal operations.

Method Id Mean Error StdDev Ratio MannWhitney(1%) Allocated
PositiveCast main branch 2.693 ns 0.0107 ns 0.0089 ns 1.00 Baseline -
PositiveCast feature branch 2.430 ns 0.0329 ns 0.0308 ns 0.90 Faster -
InterfaceOwnedPositiveCast main branch 2.700 ns 0.0147 ns 0.0137 ns 1.00 Baseline -
InterfaceOwnedPositiveCast feature branch 2.603 ns 0.0171 ns 0.0160 ns 0.96 Faster -
NegativeCast main branch 2.939 ns 0.0049 ns 0.0046 ns 1.00 Baseline -
NegativeCast feature branch 2.745 ns 0.0042 ns 0.0040 ns 0.93 Faster -
ExplicitCast main branch 2.681 ns 0.0126 ns 0.0118 ns 1.00 Baseline -
ExplicitCast feature branch 2.693 ns 0.0151 ns 0.0134 ns 1.00 Same -
InterfaceDispatch main branch 2.567 ns 0.0046 ns 0.0040 ns 1.00 Baseline -
InterfaceDispatch feature branch 2.527 ns 0.0025 ns 0.0024 ns 0.98 Faster -
InterfaceOwnedDispatch main branch 2.568 ns 0.0042 ns 0.0039 ns 1.00 Baseline -
InterfaceOwnedDispatch feature branch 2.538 ns 0.0024 ns 0.0022 ns 0.99 Same -
BaseInterfaceDispatch main branch 2.548 ns 0.0034 ns 0.0031 ns 1.00 Baseline -
BaseInterfaceDispatch feature branch 2.527 ns 0.0022 ns 0.0020 ns 0.99 Same -
DelegateDispatch main branch 2.744 ns 0.0028 ns 0.0027 ns 1.00 Baseline -
DelegateDispatch feature branch 2.729 ns 0.0038 ns 0.0035 ns 0.99 Same -
ArrayStore main branch 7.238 ns 0.0644 ns 0.0603 ns 1.00 Baseline -
ArrayStore feature branch 6.927 ns 0.0515 ns 0.0457 ns 0.96 Faster -
BoxedValueGet main branch 3.877 ns 0.0053 ns 0.0044 ns 1.00 Baseline -
BoxedValueGet feature branch 3.874 ns 0.0053 ns 0.0049 ns 1.00 Same -
BoxedValueIncrement main branch 6.168 ns 0.0079 ns 0.0074 ns 1.00 Baseline -
BoxedValueIncrement feature branch 6.150 ns 0.0137 ns 0.0128 ns 1.00 Same -
ExactDevirtualization main branch 2.938 ns 0.0041 ns 0.0034 ns 1.00 Baseline -
ExactDevirtualization feature branch 2.764 ns 0.0070 ns 0.0066 ns 0.94 Faster -
ReferenceConstraint main branch 4.749 ns 0.0411 ns 0.0384 ns 1.00 Baseline -
ReferenceConstraint feature branch 4.739 ns 0.0357 ns 0.0317 ns 1.00 Same -
ValueConstraint main branch 3.127 ns 0.0042 ns 0.0038 ns 1.00 Baseline -
ValueConstraint feature branch 2.935 ns 0.0066 ns 0.0062 ns 0.94 Faster -
GenericValueConstraint main branch 5.610 ns 0.0068 ns 0.0063 ns 1.00 Baseline -
GenericValueConstraint feature branch 5.569 ns 0.0060 ns 0.0050 ns 0.99 Same -
StaticValueConstraint main branch 3.124 ns 0.0074 ns 0.0069 ns 1.00 Baseline -
StaticValueConstraint feature branch 3.104 ns 0.0016 ns 0.0014 ns 0.99 Same -
StaticReferenceConstraint main branch 4.821 ns 0.0134 ns 0.0119 ns 1.00 Baseline -
StaticReferenceConstraint feature branch 4.813 ns 0.0049 ns 0.0046 ns 1.00 Same -
ConditionalPositiveDispatch main branch 2.910 ns 0.0050 ns 0.0047 ns 1.00 Baseline -
ConditionalPositiveDispatch feature branch 2.524 ns 0.0043 ns 0.0040 ns 0.87 Faster -
ConditionalNegativeCast main branch 2.746 ns 0.0039 ns 0.0036 ns 1.00 Baseline -
ConditionalNegativeCast feature branch 2.552 ns 0.0035 ns 0.0033 ns 0.93 Faster -
ReflectionIsAssignable main branch 1.590 ns 0.0021 ns 0.0020 ns 1.00 Baseline -
ReflectionIsAssignable feature branch 1.592 ns 0.0005 ns 0.0004 ns 1.00 Same -
ReflectionInterfaceMap main branch 127.719 ns 0.2136 ns 0.1998 ns 1.00 Baseline 64 B
ReflectionInterfaceMap feature branch 131.158 ns 0.4335 ns 0.4055 ns 1.03 Slower 64 B

Type.GetInterfaceMap is the only Slower result at about 3%. The interface-map performs the extension fallback decision for each instance interface method, which is expected to be slower than the nominal path. The other ordinary paths are either Faster or Same, which is expected because the extension-aware paths are only executed after nominal failure.

Managed allocation is unchanged.

Feature path vs matched nominal path

Each pair contains a nominal baseline and its extension-interface counterpart.

Method Mean Error StdDev Ratio MannWhitney(1%) Allocated
NominalArrayStore 7.656 ns 0.0639 ns 0.0597 ns 1.00 Baseline -
ExtensionArrayStore 9.011 ns 0.0951 ns 0.0890 ns 1.18 Slower -
NominalExplicitCast 2.879 ns 0.0134 ns 0.0126 ns 1.00 Baseline -
ExtensionExplicitCast 3.118 ns 0.0106 ns 0.0094 ns 1.08 Slower -
NominalInterfaceOwnedPositiveCast 2.452 ns 0.0156 ns 0.0146 ns 1.00 Baseline -
ExtensionInterfaceOwnedPositiveCast 2.632 ns 0.0161 ns 0.0151 ns 1.07 Slower -
NominalTypeOwnedPositiveCast 2.661 ns 0.0156 ns 0.0146 ns 1.00 Baseline -
ExtensionTypeOwnedPositiveCast 3.130 ns 0.0069 ns 0.0065 ns 1.18 Slower -
NominalUnrelatedNegativeCast 2.742 ns 0.0073 ns 0.0068 ns 1.00 Baseline -
ExtensionUnrelatedNegativeCast 3.278 ns 0.0219 ns 0.0205 ns 1.20 Slower -
NominalConditionalNegativeCast 2.562 ns 0.0046 ns 0.0039 ns 1.00 Baseline -
ExtensionConditionalNegativeCast 3.290 ns 0.0165 ns 0.0154 ns 1.28 Slower -
NominalConditionalPositiveDispatch 2.950 ns 0.0066 ns 0.0062 ns 1.00 Baseline -
ExtensionConditionalPositiveDispatch 4.462 ns 0.0132 ns 0.0124 ns 1.51 Slower -
NominalGenericValueConstraint 5.634 ns 0.0118 ns 0.0110 ns 1.00 Baseline -
ExtensionGenericValueConstraint 5.635 ns 0.0077 ns 0.0072 ns 1.00 Same -
NominalReferenceConstraint 5.014 ns 0.0062 ns 0.0058 ns 1.00 Baseline -
ExtensionReferenceConstraint 5.397 ns 0.0141 ns 0.0132 ns 1.08 Slower -
NominalStaticReferenceConstraint 4.857 ns 0.0099 ns 0.0092 ns 1.00 Baseline -
ExtensionStaticReferenceConstraint 5.036 ns 0.0063 ns 0.0053 ns 1.04 Slower -
NominalStaticValueConstraint 3.111 ns 0.0025 ns 0.0021 ns 1.00 Baseline -
ExtensionStaticValueConstraint 2.935 ns 0.0027 ns 0.0024 ns 0.94 Faster -
NominalValueConstraint 3.132 ns 0.0044 ns 0.0042 ns 1.00 Baseline -
ExtensionValueConstraint 2.922 ns 0.0044 ns 0.0041 ns 0.93 Faster -
NominalBaseInterfaceDispatch 2.934 ns 0.0046 ns 0.0043 ns 1.00 Baseline -
ExtensionBaseInterfaceDispatch 4.195 ns 0.0172 ns 0.0161 ns 1.43 Slower -
NominalBoxedValueGet 4.051 ns 0.0024 ns 0.0021 ns 1.00 Baseline -
ExtensionBoxedValueGet 3.621 ns 0.0102 ns 0.0095 ns 0.89 Faster -
NominalBoxedValueIncrement 6.141 ns 0.0034 ns 0.0031 ns 1.00 Baseline -
ExtensionBoxedValueIncrement 5.356 ns 0.0139 ns 0.0130 ns 0.87 Faster -
NominalDelegateDispatch 2.773 ns 0.0083 ns 0.0078 ns 1.00 Baseline -
ExtensionDelegateDispatch 2.852 ns 0.0295 ns 0.0276 ns 1.03 Same -
NominalInterfaceOwnedDispatch 2.542 ns 0.0029 ns 0.0028 ns 1.00 Baseline -
ExtensionInterfaceOwnedDispatch 4.230 ns 0.0171 ns 0.0160 ns 1.66 Slower -
NominalTypeOwnedDispatch 2.922 ns 0.0025 ns 0.0023 ns 1.00 Baseline -
ExtensionTypeOwnedDispatch 3.490 ns 0.0034 ns 0.0032 ns 1.19 Slower -
NominalExactDevirtualization 2.749 ns 0.0027 ns 0.0024 ns 1.00 Baseline -
ExtensionExactDevirtualization 2.742 ns 0.0085 ns 0.0079 ns 1.00 Same -
NominalReflectionInterfaceMap 133.660 ns 0.3166 ns 0.2962 ns 1.00 Baseline 64 B
ExtensionReflectionInterfaceMap 304.667 ns 0.8002 ns 0.7093 ns 2.28 Slower 64 B
NominalReflectionIsAssignable 1.590 ns 0.0008 ns 0.0007 ns 1.00 Baseline -
ExtensionReflectionIsAssignable 4.276 ns 0.0065 ns 0.0055 ns 2.69 Slower -

It confirms the direction for the main performance characteristics:

  • Adapter dispatch: type-owned witness dispatch is about 20% slower, interface-owned dispatch about 65–70% slower, and inherited base-interface dispatch about 45% slower.
  • Delegate and array paths: delegate dispatch is about 3% slower and classified Same, while array store plus readback is about 20% slower.
  • Casts: interface-owned positive cast is about 7% slower, type-owned positive cast about 20% slower, explicit cast about 8% slower, and an unrelated negative cast on a marked receiver about 20% slower.
  • Conditional resolution: negative lookup is about 30% slower and positive adapter dispatch about 50% slower.
  • Constrained calls: the reference constrained call is about 8% slower and the static reference constrained call about 4% slower.
  • No-box value paths: the shared generic value constraint is effectively the same as nominal; non-generic and static value constraints are about 5–10% faster.
  • Exact devirtualization: effectively the same as nominal and classified Same.
  • Boxed adapters: reads are about 10% faster and increment-plus-readback about 15% faster.
  • Reflection: extension IsAssignableFrom is about 170% slower and extension GetInterfaceMap about 130% slower.

Managed allocation is still unchanged.

Do note that although the extension-aware benchmarks are slower than their nominal counterparts, all these results were benchmarked against a prototype implementation that is not optimized for performance, with tiered compilation and PGO disabled. With further optimizations and PGO, I believe the performance of extension-aware paths can be improved significantly.

Also, the performance penalties here are pay-for-play. If the receiver type and interface type are not marked with the runtime markers, the extension-aware paths are never executed, and the performance is identical to nominal as shown in the previous benchmark.

Conclusion

This proposal implements extension interfaces not by mutating interface maps or wrapping objects, but by defining an immutable, coherent relation:

$$\text{(Receiver Type, Requested Interface)} \longrightarrow \text{Witness Interface Type}$$

This relation is evaluated lazily only after nominal resolution fails, caches results into existing runtime structures, and preserves object layout, normal interface maps, box layouts, and generic identities without enlarging MethodTable.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions