-
Notifications
You must be signed in to change notification settings - Fork 1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Proposal: Struct Lambdas #1060
Comments
I believe that type classes (#110) would provide most if not all of the benefits of this proposal. |
@gafter How do type classes let you convert a lambda (including its closure) to a struct? |
I have a feeling that this, as well as many similar issues, would be better if they were fixed by introducing escape analysis (like HotSpot) in the jitter |
@erik-kallen is picked up in section "Overlap with automatic stack allocation" |
@gafter, I'm afraid I'm not following. One aspect of this proposal is about parameterizing code on logic that will be injected by jit-time specialization rather than run-time indirections, and I totally see how type classes do that in a more general way. The other aspect of this proposal is about being able to use the convenient lambda abstraction without incurring the cost of heap allocations. I don't see at all how type classes are related to this second aspect. Are you asserting that lambdas without heap allocations would provide no real benefit, or that type classes somehow enable that, or ... ? |
We could allow ref struct lambdas that follow a similar design to Span. Existing delegates can be easily converted and passed to functions that take ref struct lambdas.
An implicit conversion of Func<P,R> or similar delegate transfers values of target and function to the struct fields. A regular closure simply uses a generated struct to hold the captured variables and passes the location of the struct and the address of the compiler generated function to the constructor of the Function struct. |
I was once propose https://github.com/dotnet/roslyn/issues/11882 about sync keyword on lambda and I wish the same thing as you, ability to create function that directly translate into JIT code instead of lambda stack All in all I think this is already in the realm of metaprogramming |
FWIW, I recently had to build a similar mechanism as part of an optimizer in the context of a data processing system, where we're sensitive about allocations. Similar to this proposal, it introduced a notion of "value delegates", and relied on a rewrite pattern for "value delegate" declarations, as well as construction and invocation sites of such delegates. The trick is the same as the one described here, namely forcing the use of a With "class delegates", one writes this: delegate T Return<T>();
public static void Demo()
{
int x = 5;
int y = Eval(() => x);
}
static T Eval<T>(Return<T> f) => f(); which gets compiled into something like this: public static class Lowered
{
delegate T Return<T>();
public static void Demo()
{
var d = new DisplayClass();
d.x = 5;
int y = Eval(new Return<int>(d.D));
}
static T Eval<T>(Return<T> f) => f.Invoke();
class DisplayClass
{
public int x;
public int D() => x;
}
} The heap allocations are for the With "struct delegates", one could write something like this (allowing some hypothetical syntax): ref struct delegate T Return<T>();
public static void Demo()
{
int x = 5;
int y = Eval(() => x);
}
static T Eval<T>(Return<T> f) => f(); possibly with some more The lowering steps I currently employ for this look like this, which can readily be compiled and run today: interface Return<TClosure, T>
{
T Invoke(ref TClosure closure);
}
public static void Demo()
{
DisplayStruct d = new DisplayStruct();
d.x = 5;
int y = Eval<DisplayStruct, ReturnImpl, int>(ref d, new ReturnImpl());
}
static T Eval<TClosure, TDelegate, T>(ref TClosure d, TDelegate f) where TDelegate : struct, Return<TClosure, T>
=> f.Invoke(ref d); // results in a constrained.callvirt to Invoke
struct DisplayStruct
{
public int x;
}
struct ReturnImpl : Return<DisplayStruct, int>
{
public int Invoke(ref DisplayStruct d)
{
return d.x;
}
} That is, struct delegates become interfaces with a (hidden) first type parameter for the closure type, and instances of the delegate are effectively pairs of (a Obviously, signatures like Some performance numbers:
|
I am not sure what is precluded and how that is to ensure safety. In the original proposal there is a nested scope example:
The problem is, even though we cannot instantiate a ValueFunc<...>.Reference<DisplayStruct> outerScopeVar;
{
DisplayStruct displayStruct = ...;
//The compiler will translate the following:
// var innerScopeVar = ref struct (int i) => ...;
//to:
var innerScopeVar = Wrap(ref displayStruct);
//This is allowed: you can copy it to outer scope:
outerScopeVar = innerScopeVar;
}
//Here the display struct has become invalid!
outerScopeVar.Invoke(...); This can easily make unsafe codes. |
Any news on this O_o its a really beloved feature I think. |
I think the last time this was addressed was in LDM-2021-04-07 |
You can get a slight improvement in @bartdesmet's example by using static abstract members which I believe is how "type classes" would be implemented. // shape Return<T> {
// T Invoke();
// }
interface Return<T, TSelf> {
static abstract T Invoke(ref TSelf self);
}
// compiler generated
struct DisplayStruct {
public int x;
}
// compiler generated
// generally, this is how `implement Return<int> for DisplayStruct {..}` gets lowered
struct ReturnImpl : Return<int, DisplayStruct> {
public static int Invoke(ref DisplayStruct self) => self.x;
}
public static void Demo() {
// int x = 5;
// int y = Eval(() => x);
DisplayStruct d = new DisplayStruct();
d.x = 5;
int y = Eval<DisplayStruct, ReturnImpl, int>(ref d);
}
// static T Eval<T>(Return<T> f) => f();
static T Eval<TSelf, TDelegate, T>(ref TSelf d) where TDelegate : struct, Return<T, TSelf>
=> TDelegate.Invoke(ref d); This is basically how Rust does closures. |
This is something that I would really like to see incorporated into the language, but in my opinion, if we really want the C# Language Design Team to pick any feature suggestion among a million others, we better make it as simple as possible for them to implement it. So my question is: What is the point of that WrappedReference thing, and that nested class with unsafe code? Not only the syntax is pretty bad, with the receiving method taking a parameter of type "ValueFunc<T, TResult>.Reference<TLambda>, but it apparently makes the implementation just more complex. Could we instead have an implementation somewhat similar to this:
Then the usage would be just like this:
All of this is already valid C# code, the only thing that is needed now is the syntax sugar to transform lambda expression into a "DisplayClass" struct, that implements the corresponding interface. This approach would still capture variables by reference, and it should work exactly the same way as you proposed, while being much simpler to implement and to use. Am I missing something? |
Sorry if I have missed it in this discussion, but I hope generic-invoke functional interfaces which are vital to situations with type producers are considered: public interface IGenericAction
{
void Invoke<T>(T arg);
} Some situations are almost impossible to handle without these, but it is still such a hassle to implement them. |
Proposal: Struct Lambdas
It would be useful to have a syntax and library support to create and invoke lambda "delegates" without needing heap allocations for their closures/environments. It would also often be useful to statically parameterize code on some "delegate"/action such that the code is specialized for different inputs, each specialization having a direct inlinable call rather than an indirection through a delegate.
This proposal outlines some new library types, parallel to but distinct from delegates, that would have both of these characteristics, and a syntax to allow lambda expressions to be used to create these "value funcs", parallel to the current support for using lambda expressions to create
Delegate
andExpression
objects.Assumptions
Avoiding heap allocations for lambdas would require the compiler to generate value types for such lambdas' closures, and the value types thus-generated would need to implement a common interface (per signature) to be invokable. This, in turn, means that functions taking these struct lambdas as parameters would be generic over the struct type. A consequence of being generic over structs is that callsites where these lambdas get invoked would be direct, inlinable calls, without the overhead of the run-time indirection that delegates use (at the expense of generating separate code for each instantiation of their callers); it's because of this dependence that these orthogonal concerns (heap allocation and indirect calling) get intertwined in this proposal.
Exposing these struct lambda values to the source code in a way that would allow them to be copied would break the capture-by-reference semantics of C# closures, so they must be wrapped in some abstraction that precludes copying. Since the goal is to avoid requiring heap allocations for closures, the abstraction likewise shouldn't live in the heap, so it should be a value type. To separate copying the abstraction from copying the closure, the abstraction should reference the closure via an indirection. The obvious solution is to encapsulate the closures by wrapping references to them in a byref-like struct type that exposes a way to invoke them but not a way to copy them; source code would deal with this byref-like wrapper rather than the underlying "value funcs" directly.
The restrictions on byref-like types give exactly the assurances we need to prevent references escaping, so exposing them via byref-likes makes it safe to allocate the "value funcs" themselves on the stack. The ref struct proposal describes these restrictions. My understanding is that the proposal as currently written is slightly out-of-date, and that, on one hand, the current design actually precludes creating a byref-like which holds a reference to a stack local, but that, on the other hand, the possibility is still on the table that support for this will be added, with probably some special well-known type that is allowed to wrap a reference to a local (but not subsequently allowed to be returned), which would be created by some special syntax. This proposal depends on all that, and for the sake of discussion I'm going to assume a special type called
WrappedReference<T>
, with propertyref T Value { get {...} }
, which can be constructed with a specialwrap
syntax; e.g. that, given localint x
, the expressionwrap(ref x)
would create aWrappedReference<int>
that holds the address ofx
.Library Support: New Types
Here's a sketch of what the library types could look like that would support using this abstraction. Shown here are just the variants for methods that take one argument and return a value (corresponding to
Func`2
), but we'd need similar types corresponding to eachFunc
andAction
type.Syntax
Two rules would govern the syntax for creating value funcs:
struct <lambda-expr>
(i.e. the keywordstruct
followed by a lambda expression) would create a "Value Func". The naming and syntax here are intentionally chosen to parallel the naming ofValueTuple
and its syntax in F#, wherestruct <tuple-expr>
creates aValueTuple
.ref
keyword. The captured reference is of typeValueFunc<...>.Reference<TLambda>
orValueAction<...>.Reference<TLambda>
, where...
matches the signature of the lambda, andTLambda
is a compiler-generated type specific to this lambda expression, which implementsIFunc<...>
/IAction<...>
.So, for example,
Code taking a reference to a value func as a parameter would be generic in the lambda type:
Implementation Considerations
This section is effectively a series of thought experiments regarding how this construct could be implemented and how multiple lambdas could coexist in the same method.
Initial Example
For a point of comparison, consider this code using a lambda passed as a delegate:
The C# compiler translates that into something roughly like so:
Using a Value Func instead of a delegate, the source would look like this:
The transformation would be similar. It would use a display struct rather than a display class, which would have all the same data members for captured state, and which would implement the appropriate
IFunc
interface:Mutiple Struct Lambdas in the Same Method
Display classes are shared across multiple lambdas in the same function. Given source like this:
the C# compiler generates something roughly like this:
The display class simply has three methods (one for each lambda). For value funcs, we can't just make the
DisplayStruct
type implement an interface for each lambda, since some of them have the same signature. But we can arbitrarily order the lambdas, and nest the structs inside each other. I.e., given the corresponding source using value funcs:The C# compiler could generate something like the following:
Methods with Value Func Lambdas and Delegate Lambdas Together
Lambdas of different kinds might capture the same variables. Since capture is by-reference, any such variables need to be allocated in a display that the different lambda kinds can both reference. Consider this example, similar to the previous one except that
addHi
is a delegate lambda (and can't be a value func lambda, since it gets passed toStashAway
, from which the delegate escapes) and the other two are value func lambdas:The value func lambda types are struct types to allow them to be allocated on the stack, but nothing precludes them being allocated in the heap. Therefore, in mixed cases like this, we can create an outermost display class for any delegate lambdas, with a field which holds the display structs for any value func lambdas, like so:
Nested Scopes
The preceding examples all capture variables in the root scope of their method. When variables from different scopes are captured by delegate lambdas, the different scopes need different display classes, and child scope display classes need pointers to their parent scope display classes, in order to preserve capture-by-reference semantics in the face of lambdas escaping the captured variables' scopes. This example demonstrates:
Accordingly, when the C# compiler processes the method above, it creates display classes with the parent/child structure like so:
An instance of each display class is created on each entry to its scope.
Value func lambdas would require different treatment. If the parent display is a struct, including a reference to it in the child display would be illegal, since then the child display would need to be a byref-like and so couldn't be used as the type argument to
LocalFunc<...>.Reference<TLambda>
. Including the parent display by value in the child display, on the other hand, would break the capture-by-reference semantics of sibling children sharing the same parent (at least without inserting potentially lots of copying at scope transitions). Fortunately, since value funcs would be exposed to the source only via byref-like references, and the rules for byref-likes preclude escaping their scope, value funcs can only reference captured variables from the current iteration of their scope. This means that the backing storage can be shared across different iterations of a their declaration scope, and thus we can turn things on their head and include the child scope by value in its parent scope (with sibling scopes' displays being included as sibling fields in the parent). Updating the nested scope example to use value funcs, but removing the operations that are illegal on byref-like types, it looks like this:Since the backing storage for subsequent child iterations can be re-used, the compiler could translate this to something like the following:
Putting these together, trees of mixed lambda types could be constructed according to the following rules:
Invoke
method of a lambda struct type generated for it. These lambda structs will appear as fields of display classes (or other lambda structs) only, never as fields of display structs; the innermost display class enclosing some value func's scope will contain the lambda struct as a field, and the lambda struct will in turn contain the outermost display struct of that display class as a field. When there are multiple such lambdas in the same display class, they will be nested within each other. This ensures that each lambda func has access to any parent scopes whose captured variables it references.So, for example, with a tree of scopes/variables/lambdas like this:
we could generate display classes/structs like this:
Delegate -> Value Func Conversions
It should be possible to obtain a
ValueFunc<...>.Reference<...>
from a correspondingFunc<...>
. That way, code which is provided a delegate can call a method that takes a value func. Doing this with a helper type that is allocated whenever such a conversion is performed would be very straightforward:Of course, it would be nice to avoid the allocation; this could be achieved by changing the internal structure of delegates to have instance fields of some struct type that implements
IFunc<...>
.Note that, with this conversion available, any code which wants to call a method that takes a value func has the option of using a delegate lambda, like so:
Any callsite using a delegate like this would call instantiation
PrintSome<ValueFunc<int, bool>.DelegateHelper>
. Note that this instantiation is not using any type particular to the lambda in question, so all such callsites would be calling the same instantiation. In this way, delegates could be used in cases where a callee takes a value func, but the allocation and delegate dispatch overheads are not a concern compared to the code bloat of generating multiple instantiations.Tradeoffs/Limitations
This section discusses some constraints that limit where this construct can be applied, and also compares to other possible changes with overlapping goals.
Type name availability
Given a statement like
var isEven = ref struct (int i) => i % 2 == 0;
, the type of local variableisEven
will beValueFunc<int, bool>.Reference<???>
. The???
will be the struct type generated by the compiler for the lambda, but its name isn't available as an identifier in the source code. This means that there's no type expression for the type of variableisEven
, and when subsquently calling a method likePrintSome(ints, isEven)
, inference can determine that this isPrintSome<???>(ints, isEven)
(with the same type for???
), but there's no way to explicitly name the type argument. Usingvar
for the variables holding local func references and inference for type arguments covers a lot of use cases, but it doesn't cover, e.g., calling a method with multiple type arguments, where one of the arguments can't be deduced. To cover such use cases, we'd need to make other changes, like using a syntax for defining struct lambdas that includes an identifier to be used as the name of the lambda type, or perhaps having a way to explicitly specify some type arguments while flagging others to be inferred...Byref-like restrictions
The same restrictions on byref-like types that ensure that allocating the lambda and display structs on the stack is safe also limit the situations where byref-like type
ValueFunc<...>.Reference<TLambda>
can be used. Notably, it can't be stored to the heap (e.g. via boxing or storing to a class field), so it can exist only as a local or as a field of a local which itself has a byref-like struct type. This means that the various Linq extension methods onIEnumerable<T>
which take delegates and return enumerables can't be rewritten to take value funcs, since they embed the delegates passed to them in the enumerables that they return (and even trying to create similar methods with struct enumerable/enumerator types would be problematic, since those struct types would have to be byref-like themselves and thus couldn't implement interfaces likeIEnumberable<T>
).Code expansion
Methods with value func parameters need corresponding type parameters to be generic in the lambda struct type. This means that separate instantiations will be generated in the jitted native code, since instantiations aren't shared across struct type arguments (at least on .NET Core and .NET Framework). This code growth may be undesirable and degrade performance.
The upshot of the specialization is that the actual calls to the value funcs will be direct and inlineable, without delegate call overhead. Still, for scenarios where direct calling via specialization is a desired goal but stack allocation is not, the specialization could be achieved without incurring the byref-like type limitations, by introducing struct delegate-like types that can be freely copied. Implementing capture-by-value lambdas, for example, could be done in a way that produces struct-typed copyable lambdas. Likewise, this proposal could be adapted to produce copyable struct lambdas at the expense of heap allocations by putting the display state in display classes rather than display structs, and passing around lambda struct types that include an object reference to their display class (though subsequently trying to write code that returns such a struct lambda from a function would hit the type name availability issues in naming the return type).
As noted above, this code bloat can be mitigated somewhat by opting to use delegate lambdas and delegate-to-value-func conversion at callsites where the code growth is a bigger concern than the heap allocations and delegate calls.
Stack expansion
Switching some code from delegate lambdas to value func lambdas would mean moving the backing storage of the display that's currently heap-allocated to the stack, which is a goal because it reduces GC pressure, but involves a tradeoff because it also, of course, increases the required amount of stack space. Since most of the space in a display class/struct is taken up by captured variables which are locals at source, this typically amounts to allocating locals on the stack, which shouldn't be much of a problem since that's typically where locals are allocated. The nested scopes requirement that child display structs be members of their parent display structs, however, specifically in the case of nested value func lambdas, would mean that captured locals of the inner method get allocated on the stack of the parent method. This is a bit odd, but likely often acceptable.
Overlap with automatic stack allocation
It's possible to recognize non-escaping object allocations and use stack space rather than heap allocations for them, as a compiler optimization. There's a partial implementation of this in RyuJIT currently. Completing a robust implementation of this optimization could avoid heap allocations for delegates and closures in many of the scenarios where value funcs could, without needing to introduce new types and syntax. However, automatic stack allocation has a different set of limitations, which are worth comparing to value func limitations:
ref
of a variable or storing a value in a struct can quickly make the optimizer use conservative analysis that assumes the worst-case, even when the actual value propagation “is obvious” to people reading the code.x.field1
isldloca x; ldfld field1
, so there’s aref
tox
on the evaluation stack and now the analysis is in an arms race with the MSIL generator to “know” that that’s “not really” having its address taken. For example, you could just have the analysis pattern-match theldloca
+ldfld
sequence, butx.field1.field2
isldloca
+ldflda
+ldfld
, so now you need to pattern match that, and perhaps the next version of the C# compiler will translatex.field1 … x.field2
asldloca x; dup; ldfld field1; … ldfld field2
, so that’s another thing for the analysis to have to see through, etc.x
conservatively because it happened to be local number 33, or happened to be in a method with a filter, or happened to be live across a finally handler which wasn’t even in the source but got inserted because the source had aforeach
over a type with anIDisposable
enumerator, …Ultimately, the main benefit of relying on automatic stack allocation and avoiding value funcs would be that it avoids introducing new types/syntax/concepts, and the main benefit of introducing value funcs over relying on automatic stack allocation would be that value funcs can guarantee stack allocation (so long as they don't share captured state with lambdas of other types) in the face of virtual calls, multiple assemblies, and the various subtle limitations on the compiler's analysis and infrastructure.
The text was updated successfully, but these errors were encountered: