dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
14.95k stars 4.65k forks source link

[Feature Proposal]: Structural Delegates #106582

Open timcassell opened 3 weeks ago

timcassell commented 3 weeks ago

Background and motivation

Motivating csharplang discussions: https://github.com/dotnet/csharplang/discussions/8343 https://github.com/dotnet/csharplang/discussions/7785

Today, delegates are nominal types, and delegates with matching signatures are not interchangeable. Func<T, bool> != Predicate<T>. It would be ideal to be able to declare delegates by their signature rather than by their nominal type, similar to how function pointers can be declared. delegate<T, bool> (or whatever syntax C# decides to go with).

Moreover, at a fundamental level, delegates need not be more than an object reference + a function pointer. The existing class-based delegates have a lot more baggage, making them less efficient than they theoretically could be.

Details

The runtime adds new structural delegate types, along with new IL metadata to use them.

A delegate declared in assembly A delegate<T, bool> and a delegate declared in assembly B delegate<T, bool> are the exact same type, and therefore are identity convertible. delegate<ref T, void> can be used also.

System.Delegate type already exists, so a new base type can be added.

namespace System;

public abstract class StructuralDelegate : ValueType
{
}

Yes, structural delegates are struct-based instead of class-based. It essentially looks like this:

public struct delegate<T, TResult> : IEquatable<delegate<T, TResult>>
{
    private object _obj;
    private void* _mPtr;

    // ...
}

These new structural delegates do not support Begin/EndInvoke or multi-cast like existing delegates.

To avoid possible tearing of the struct due to thread races, each read/write of a structural delegate will be made atomic by the runtime (this may be blocked by #105054 or #31911). If a struct contains structural delegate fields (or nested struct with structural delegate fields), copying that struct requires each structural delegate field to be copied atomically (the entire struct copy need not be atomic). As an optimization, the atomic r/w could be relaxed if the target is on the stack instead of the heap.

Additionally, to ensure that these structural delegate types are "safe" managed types, the GC should be taught to specially treat the function pointer as an ALC reference.

These types should also be specially treated similar to Nullable<T>, such that comparing to null becomes the same as comparing the pointer to zero, and boxing a default value results in a null object. With that in mind, Nullable<delegate<>> may also need to be treated specially.


There could also be additional APIs to convert to/from existing delegates, or bypassing the atomic r/w with Unsafe, and other APIs that exist for current delegates, but those can be left to future proposals if this ever goes anywhere.

Feature Usage

public class MyEvent<T>
{
    // Not thread-safe, but this should get the point across.
    private HashSet<delegate<T, void>> _callbacks = new();

    public void AddListener(delegate<T, void> callback)
        => _callbacks.Add(callback);

    public void RemoveListener(delegate<T, void> callback)
        => _callbacks.Remove(callback);

    public void Raise(T arg)
    {
        foreach (var callback in _callbacks.ToArray())
        {
            callback(arg);
        }
    }
}
// Very common pattern
public class MyComponent : IDisposable
{
    private readonly MyEvent<int> _event;

    public MyComponent(MyEvent<int> e)
    {
        _event = e;
        // Existing delegate allocates here, new structural delegate does not allocate.
        e.AddListener(OnEvent);
    }

    public void Dispose()
    {
        // Existing delegate allocates here, new structural delegate does not allocate.
        _event.RemoveListener(OnEvent);
    }

    private void OnEvent(int arg)
    {
        // ...
    }
}

Alternative Designs

Structural delegates are class-based like existing delegates. This avoids the issue with atomic r/w and ALC reference, but also adds allocations and other overhead.

Risks

Enforcing atomic r/w could impact performance of shared generics in AOT runtimes.

huoyaoyuan commented 3 weeks ago

This would be a massive feature request, rather than API request.

I'm positive to this, but I'd hope we can include more if we are making changes to this.

teo-tsirpanis commented 3 weeks ago

I am not sure that the need for this feature justifies the required work to design and implement it. The request already mentions metadata changes and three quite major special cases for the JIT, the GC and the type system. With function pointers being always available for the performance-minded people that don't care about capturing objects or unloadability, I cannot see what niche struct delegates would fill.

Some of the ideas described here can be extracted into individual feature suggestions:

These new structural delegates do not support […] multi-cast like existing delegates. I remember reading that multicast delegates are pay-to-play, i.e. if you don't use them they won't affect performance.

neon-sunset commented 3 weeks ago

I think the way for successful structural delegates is a generalized shape provided by an interface abstraction (IDelegate, IAction, IFunc, etc.) that users can provide struct implementations for and constrain generic arguments on. Then Roslyn could target that by emitting anonymous struct-based delegate implementations for methods eligible for those. To better enable scenarios that are currently replicated by hand e.g in TensorPrimitives.

Notably, function pointers are not a performance optimization in such case as they do not enable monorphization and subsequent inlining of delegate calls. Nor they enable struct or ref struct closures.

teo-tsirpanis commented 3 weeks ago

I think the way for successful structural delegates is a generalized shape provided by an interface abstraction (IDelegate, IAction, IFunc, etc.) that users can provide struct implementations for and constrain generic arguments on. Then Roslyn could target that by emitting anonymous struct-based delegate implementations for methods eligible for those. To better enable scenarios that are currently replicated by hand e.g in TensorPrimitives.

I understand that static genericity on functions instead of data types is sometimes needed, but I am not sure if it's a good idea to make this easily accessible by elevating it to a first-class language feature. I am concerned by its downsides (increase in generated machine code size and fragility when used across logical modules) and its potential for abuse which I have seen happening in C++.

enable monorphization and subsequent inlining of delegate calls. Nor they enable struct or ref struct closures.

How common do you believe are the cases where this would be beneficial? I don't personally believe that merely low-level code like TensorPrimitives would meet the bar.

timcassell commented 3 weeks ago

This would be a massive feature request, rather than API request.

Very true. I just worked from the issue template.

I'd hope we can include more if we are making changes to this.

Can you elaborate?

timcassell commented 3 weeks ago

I am not sure that the need for this feature justifies the required work to design and implement it.

Yeah, I kinda figured that would be the reaction to this proposal.

I cannot see what niche struct delegates would fill.

It's a similar niche to ref structs, possibly wider reaching, since just about everyone uses delegates, while not everyone uses ref structs.

Considering the amount of effort put into improving performance and usability of safe managed code in .Net recently, this proposal is along the same trajectory, and shouldn't be dismissed just because it's a large amount of work, IMO.

  • Treating different delegate types with the same signature (such as Func<T,bool> and Predicate<T>) as the same types in the eyes of the compiler and the runtime.

That looks like an issue that has been open for 9 years with no action. #4331 I don't know all the details about it, but I wouldn't be surprised if there are complications and possibly breaking changes by changing the type compatibility of existing delegates.

This proposal is a fresh start from scratch, new types, no worries about breaking anything.

  • Adding an () operator like C++ has, or supporting directly invoking values of types with an Invoke method.

I'm not sure where that came from. This proposal has absolutely nothing to do with an invoke operator.

  • Allowing delegate type definitions to omit the Begin/EndInvoke methods.

    • Isn't this already allowed?

These new structural delegates do not support […] multi-cast like existing delegates. I remember reading that multicast delegates are pay-to-play, i.e. if you don't use them they won't affect performance.

Those were mentioned, not because it may or may not be possible to exclude them on existing delegates, but just to highlight that the new types absolutely will not support them.

timcassell commented 3 weeks ago

I think the way for successful structural delegates is a generalized shape provided by an interface abstraction (IDelegate, IAction, IFunc, etc.) that users can provide struct implementations for and constrain generic arguments on. Then Roslyn could target that by emitting anonymous struct-based delegate implementations for methods eligible for those. To better enable scenarios that are currently replicated by hand e.g in TensorPrimitives.

That looks like the functional interfaces proposal. https://github.com/dotnet/csharplang/issues/3452

That is similar to this proposal, but serves a different purpose. Namely, it can achieve zero allocation by using variable-sized structs, while this is a fixed-size type.

julealgon commented 3 weeks ago

Does this essentially make Action and Func types obsolete? Or would there be any scenario where they would still be wanted?

timcassell commented 3 weeks ago

I think this would be the preferred delegate type to use in most situations, but I wouldn't say those would be obsolete. Tons of APIs exist today that use those delegate types, and those aren't going anywhere. If you want to use the multi-cast feature of delegates (C# event), and don't want to allocate a custom event class, you would use those types.

neon-sunset commented 3 weeks ago

How common do you believe are the cases where this would be beneficial? I don't personally believe that merely low-level code like TensorPrimitives would meet the bar.

TensorPrimitives is simply the latest example of a general "zero-cost value delegate" case that numerous libraries need and have to reimplement today manually.

The lowest effort kind of improvement in this area comes down to simply introducing the aforementioned interfaces and teaching Roslyn that method invocation operator applies to types implementing them.

This can be subsequently augmented with Roslyn learning to generate anonymous struct and ref struct^1 delegates with respective closure types, possibly offering better UX with generic arguments displayed as e.g. Where<int, impl IFunc<int, bool>>. Notably, the area of this change would belong to dotnet/csharplang and an API proposal to dotnet/runtime but would not require changes in the runtime itself, beyond API work in CoreLib.

Once done, this could open up the possibility to introduce value-delegate taking method overloads to System.Linq which would further cut down on LINQ overhead without introducing breaking changes to existing customers or them to make changes in order to gain performance. There are other callsites that would also benefit from this like string.Create. This is in spirit of similar changes like the introduction of params ReadOnlySpan<T>.

At the end of the day, this is but one of many possible ways to improve delegate/higher order func/lambda story, and as we've seen with "Runtime handled Tasks", a more out-of-box proposal could be suggested.

huoyaoyuan commented 3 weeks ago

Finally have time to leave my words here.

With stack promotion finally being a thing, it's now less important for being struct or class. If we don't provide extra support for reflection etc, structs of 2-pointers size would be OK.

I'm more interested about how can we provide implementation about this. For nominal types, the pain is that we can't declare one method that satisfies different arities. Without changing this, the implementation of structural delegates can only be runtime magic. If we have variable arity support, we may declare them in ordinal managed code. Hypothetical syntax:

struct StructuralDelegate<..TArgs, TReturn>
    where TReturn : allows ref struct, allows void
{
    private object? target;
    private delegate*<object?, ..TArgs, TReturn> fnptr;
    public TReturn Invoke(..TArgs args) => fnptr(target, args);
}
julealgon commented 3 weeks ago

With stack promotion finally being a thing, it's now less important for being struct or class.

@huoyaoyuan would you mind elaborating a bit on this or just providing some link with more information about it? It is the first time I hear about it so I'm curious.

huoyaoyuan commented 3 weeks ago

With stack promotion finally being a thing, it's now less important for being struct or class.

@huoyaoyuan would you mind elaborating a bit on this or just providing some link with more information about it? It is the first time I hear about it so I'm curious.

See more at #103361, and more follow-up PRs.

Note that I'm not saying that allocation is not a problem. It's just having different impact now. Large structs have their issues. If we can implement structural delegates in 2 pointers and cut unnecessary features, then struct should be better.

hez2010 commented 3 weeks ago

The idea is great, but I don't think such thing can be implemented in the proposed way. Existing generic metadata has too many restrictions like no support for variadic type parameters as well as ref/in/out/void parameters etc. I expect "structural delegates" to work like how function pointers work today, i.e., introducing new TypeDesc for structural delegates, and encoding structural delegate types directly in the signature.

timcassell commented 3 weeks ago

The idea is great

Why the downvote then?

but I don't think such thing can be implemented in the proposed way. Existing generic metadata...

I'm not sure what you mean. I proposed to add new IL metadata.

timcassell commented 3 weeks ago

I'm more interested about how can we provide implementation about this. For nominal types, the pain is that we can't declare one method that satisfies different arities. Without changing this, the implementation of structural delegates can only be runtime magic. If we have variable arity support, we may declare them in ordinal managed code. Hypothetical syntax:

struct StructuralDelegate<..TArgs, TReturn>
    where TReturn : allows ref struct, allows void
{
    private object? target;
    private delegate*<object?, ..TArgs, TReturn> fnptr;
    public TReturn Invoke(..TArgs args) => fnptr(target, args);
}

I really like the idea, and I think it deserves its own proposal. I would love to be able to write a method that can return ValueTuple<..TArgs>.

julealgon commented 3 weeks ago

I really like the idea, and I think it deserves its own proposal. I would love to be able to write a method that can return ValueTuple<..TArgs>.

I'm sure I'm missing something... I thought there would already be a proposal for variadic generics, but I searched both here and on csharplang and didn't find a single issue proposing it?

I would've assumed folks would be proposing that ever since generics was introduced 😅

Maybe it just got stuck in discussions and was never promoted to an issue?

Would definitely be nice to finally stop needing to maintain dozens of variations of Action, Func and tuples, besides all other obvious benefits it would bring.

tannergooding commented 3 weeks ago

Variadics don't necessarily translate well to generics. Despite generics and templates having similar syntax, they are not the same thing and the implications of them are drastically different. This is particularly relevant as it pertains to the general ABI (Application Binary Interface) of an exposed API.

Outside of templates, variadic functions themselves necessitate a different calling convention and specialized support for parameters. There is no support for variadic returns on concrete methods even in C++. The reason variadic returns look like they exist for templates is because templates don't really exist, they're "erased" at C++ compilation time. That is, they're just symbols that get specialized based on their usage and synthesize types as part of compilation. You can't "export" them from a library, the non-specialized implementation needs to exist in the header itself, you can't do RTTI over some non specialized type, etc (there is no typeof(List<>) like .NET has). So from the ABI perspective, there is no MyType<...TArgs> return, there are many individual methods like MyType<int> or MyType<int, int>, etc that happened to have used templates to simplify the implementation. -- They are more akin to the erased generics that Java does.

Versioning IL metadata is also incredibly breaking and we want to avoid it wherever possible; so if you really want a feature then the best approach is to find a way it can be represented without needing to go and break the entire ecosystem, such as by using attributes or other mechanisms that let the runtime and languages work with it using the existing support. In some cases there might not be an obvious alternative and it may require such revisions to IL, but that also makes it drastically less likely to happen in the near term.

huoyaoyuan commented 3 weeks ago

Variadics don't necessarily translate well to generics. Despite generics and templates having similar syntax, they are not the same thing and the implications of them are drastically different. This is particularly relevant as it pertains to the general ABI (Application Binary Interface) of an exposed API.

To make it more clear: I'm not talking about va_args in C, or managed va_args in IL/managed C++. Instead, I'm requesting an general IL/C# syntax for "any signature". The signature is applicable to any calling convention because the using methods will just pass every arguments and returns as-is. It's effectively using signatures as generic parameters.

It's a goal of higher-kind generic, and will naturally cover many runtime magics for delegates.

julealgon commented 3 weeks ago

@tannergooding I'm aware of the differences between C++ templates and the C# generics system. I wasn't trying to make any point about it being easy or even feasible for that matter to do variadic generics in C#, but just expressing surprise in not seeing a proposal case anywhere (not even a closed one).

I think anyone would immediately frown upon seeing this for the first time, and would immediately jump to such proposal: image

tannergooding commented 3 weeks ago

I was simply trying to give some suggestions on how to approach this in a way that would give the highest possibility of a solution being found.

If the community wants some variadic generics like support, then someone should start by writing up proposal which covers how it could integrate with existing IL metadata in a pay for play mechanism and some basics on how it could reasonably be handled in reflection, the type system, etc.

timcassell commented 3 weeks ago

In some cases there might not be an obvious alternative and it may require such revisions to IL, but that also makes it drastically less likely to happen in the near term.

Tbh, I don't expect this feature to be implemented any time soon. Besides the IL revisions, there is also the atomic r/w of 2 pointers which has been requested for 8+ years, and new GC tracking of managed function pointers.

This is a chance to learn from 22 years of imperfect delegates, and to do it right from the ground up. I wouldn't want to shoe horn it into existing functionalities just to get it done faster if it's not the best it can be.

tannergooding commented 3 weeks ago

We can't require atomic r/w of 2 pointers, its not guaranteed to exist.

It's one of the reasons we can't trivially expose a BCL API for it, although a hardware intrinsic per platform might be feasible.

timcassell commented 3 weeks ago

We can't require atomic r/w of 2 pointers, its not guaranteed to exist.

It's one of the reasons we can't trivially expose a BCL API for it, although a hardware intrinsic per platform might be feasible.

It doesn't have to be an API only for 2 pointers. The BitwiseAtomic<T> proposal (#105054) suggests to fall back to a SpinLock if the architecture doesn't support it.

timcassell commented 1 week ago

I'm sure I'm missing something... I thought there would already be a proposal for variadic generics, but I searched both here and on csharplang and didn't find a single issue proposing it?

I found this old issue on roslyn repo. https://github.com/dotnet/roslyn/issues/5058 Not really any newer issues since then, and LDM would not likely adopt the proposed solution (hacky, compiler-driven). Probably more likely to gain traction as a runtime feature.