Skip to content
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

[SwiftBindings] Describe projecting generic structs #2796

Open
wants to merge 2 commits into
base: feature/swift-bindings
Choose a base branch
from

Conversation

jkurdek
Copy link
Member

@jkurdek jkurdek commented Nov 21, 2024

Follow up to #2771. Proposes projections of generic structs.

@jkurdek jkurdek added the area-SwiftBindings Swift bindings for .NET label Nov 21, 2024
@jkurdek jkurdek self-assigned this Nov 21, 2024
Copy link

@stephen-hawley stephen-hawley left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor adjustments to make and notes


public static TypeMetadata Metadata
{
/* This should be cached */

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Let's open an issue to design the caching mechanism.
Starting point:

public static class TypeMetadataCache {
    TypeMetadata MetadataOf(Type t, Func<Type, TypeMetadata> generator) {
          // make thread safe:
          if (cache.TryGet(t, out var result)) return result;
          var md = generator(t);
          cache.Add(t, md);
          return md;
}

Then you can rewrite this as:

static TypeMetadata typeMetadata = TypeMetadataCache.MetadataOf(typeof(Pair<T, U>), (t) => {
    var typeMetadataT = TypeMetadataCache.MetadataOf (typeof (T), (tT) => TypeMetadata.GetTypeMetadataOrThrow(tT));
    var typeMetadataU = TypeMetadataCache.MetadataOf (typeof(U), (tU) => TypeMetadata.GetTypeMetadataOrThrow(tU));
    return PairPInvokes.PInvokeMetadata (typeMetadataRequest.Complete, tT, TU);
});
public static TypeMetadata Metadata => typeMetadata;

Again, this is a start. The notion is that there is a cache which has a dictionary of Type onto TypeMetadata and takes a type to look up and if it's not present, takes a generator to call to retrieve that type.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get
{
TypeMetadata.TryGetTypeMetadata<T>(out var firstMetadata);
TypeMetadata.TryGetTypeMetadata<U>(out var secondMetadata);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seeing that you didn't check the return tells me that we should absolutely have a GetOrThrow accessor that will throw an exception on fail.

private unsafe Pair(NativeHandle payload)
{
_payload = new NativeHandle(NativeMemory.Alloc(PayloadSize));
NativeMemory.Copy(payload, _payload, PayloadSize);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should use the InitializeWithCopy from the value witness table or else you aren't abiding by swift's memory model.

Copy link
Member Author

@jkurdek jkurdek Nov 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out. I am wondering what approach should we take here:

  1. We could make this constructor take an ownership over the NativeHandle - this should then eliminate the need to call the InitializeWithCopy. The caveat is that the caller would need to make sure that it does not use the NativeHandle after the constructor is called. Which should be ok as I presume we will only need the caller to forward a buffer from Swift for object creation.

  2. Keep the current model and implement InitializeWithCopy. But then the caller would have to make sure to call deallocate on the no longer necessary NativeHandle.

I think I am leaning to the first solution. Let me know what you think

1. Non-frozen struct and at least one non-frozen generic parameter
2. Non-frozen struct and (recursively) frozen generic parameters
3. Frozen struct and at least one non-frozen generic parameter
4. Frozen struct and (recursively) frozen generic parameters

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like the way you're breaking this out into the possible cases.

Copy link
Member

@matouskozak matouskozak Nov 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the @frozen generic struct with non-frozen parameters a supported (or recommended) scenario in Swift? To me it seems like @frozen on the generic struct wouldn't help much with ABI stability then...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I cannot speak about whether it is recommended. It is definitely supported. There are some frozen generics in SwiftUI. I do not think it is possible to enforce using frozen parameters (maybe I am wrong), so it only up to the user of such generic struct to be kind enough to instantiate it with frozen types.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, more info here https://github.com/swiftlang/swift-evolution/blob/main/proposals/0260-library-evolution.md

It does not necessarily have a known size or alignment. A generic frozen struct's layout might depend on the generic argument provided at run time.

Even concrete instantiations may not have a known size or alignment. A frozen struct with a field that's a *non-*frozen, has a size that may not be known until run time.


#### Frozen and non-frozen generic structs

The frozen keyword can be applied to a generic struct just like it can be applied to a regular struct. However because the generic parameters type (and hence size and layout) is unknown at the compile time, so is the size and the layout of the generic struct. Specifically, the generic parameter can be a frozen or a non-frozen struct.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean that the number of fields is fixed and won't change?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe so. Both number of fields and their order

public static extern Pair<FrozenStruct, FrozenStruct> WithTwoFrozenParameters(Pair<FrozenStruct, FrozenStruct> pair);
```

This however would not work as `Pair<FrozenStruct, FrozenStruct>` is a class. Runtime would try to pass the value by reference (it does not even know the layout to begin with). We could solve this problem on the projection layer.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's treat representation and runtime lowering separately for a moment.

If a frozen struct doesn't require a copy constructor when moved in memory, I would still consider it a value type (struct). Additionally, as we discussed offline, the projection will flip if a generic used becomes frozen. From the user’s perspective, this can occur without their control, which is my primary concern when introducing this rule.

I understand that it doesn't match the lowering on the ABI level. However, I see this as implementation detail that belongs to the runtime. @amanasifkhalid I would like to hear your perspective.

Copy link
Member

@matouskozak matouskozak Nov 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the problem is that user-facing C# Pair is a class. In the specific case of Pair<FrozenStruct, FrozenStruct>, it could be a struct (and that's the wrapper introduced later) but we would need to have 2x C# Pair (class and a struct) to handle the case of NonFrozenStruct as well. @jkurdek please correct me if I'm wrong here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a frozen struct doesn't require a copy constructor when moved in memory, I would still consider it a value type (struct).

So for generics strictly. I think we cannot tell at compile time whether a generic will have or wont have to use a copy constructor as we have no idea what the generic parameter will be. Some instances of the generic, Pair<SomethingThatRequiresCopyConstructor, int> will require it. Some like Pair< FrozenStruct, FrozenStruct> will not.

@matouskozak I think you got it right. Pair<FrozenStruct, FrozenStruct> would ideally be represented as struct.

Additionally, as we discussed offline, the projection will flip if a generic used becomes frozen. From the user’s perspective, this can occur without their control, which is my primary concern when introducing this rule.

Making something non-frozen frozen is an ABI breaking change. I do not think we can do much about it. One way of solving this would be representing every struct as a class on C# side and abstracting the implementation details

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One way of solving this would be representing every struct as a class on C# side and abstracting the implementation details

Do you mean every generic struct? In this case, the size and alignment of an object of opaque layout, as well as whether it is trivial or bitwise movable, is determined by querying its value witness table. If contains frozen structs, the helper can be used.

Copy link
Member Author

@jkurdek jkurdek Nov 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant all structs. Its more of stretch idea thing than actual proposal. I feel that projecting some as structs and some as classes creates a weird UX for developers who are not Swift professionals. I would prefer to have uniformity - e.g. all structs to be classes. I do understand however that this would require a lot custom runtime work and would bring its own set of problems. Anyway it is a different discussion.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant all structs. Its more of stretch idea thing than actual proposal. I feel that projecting some as structs and some as classes creates a weird UX for developers who are not Swift professionals.

It seems we need this layer of abstraction for structs with opaque layouts (those not known until runtime). In other cases, this abstraction isn’t necessary. We should prioritize simplicity, ensuring that users reading Swift docs know what to expect. If a struct can be projected directly, we should do so.

I do understand however that this would require a lot custom runtime work and would bring its own set of problems. Anyway it is a different discussion.

Let's do cost estimate for this.

/cc: @jkoritzinsky

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets move this to the #2797 issue


This however would not work as `Pair<FrozenStruct, FrozenStruct>` is a class. Runtime would try to pass the value by reference (it does not even know the layout to begin with). We could solve this problem on the projection layer.

We could generate a helper struct per instance of frozen closed generic type. This could look something like:
Copy link
Member

@kotlarmilos kotlarmilos Nov 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is closed generic type? Is it constrained by a protocol?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming we have generic Foo<T>, by closed generic type I meant Foo<ConcreteType> what would be a better way to phrase?

Copy link
Member

@matouskozak matouskozak left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good job Jeremi!

src/docs/binding-generics.md Show resolved Hide resolved
src/docs/binding-generics.md Show resolved Hide resolved
@jkurdek
Copy link
Member Author

jkurdek commented Nov 21, 2024

While working on those projections I have encountered interesting combinations of frozen and non-frozen structs. I think they might be relevant to this discussion #2797


```csharp
[StructLayout(LayoutKind.Sequential, Size = 16)]
public struct Pair_FrozenStruct_FrozenStruct
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this is not part of the class?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it can be part of the class, probably should.

We could generate a helper struct per instance of frozen closed generic type. This could look something like:

```csharp
[StructLayout(LayoutKind.Sequential, Size = 16)]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure whether the explicit size can be obtained at compile time for a generic instance, or it can be only done at runtime. We should be able to calculate it though in the worst scenario.

I also wonder whether we need to even specify the explicit size at all (cc @amanasifkhalid @kotlarmilos )

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@matouskozak Do we use the size during lowering?

Copy link
Member

@matouskozak matouskozak Nov 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Essentialy the lowering algorithm can be done just with the knowledge about the fields.

Potentially, we could hit some issues where we assume the size is specified exactly but I don't think it would cause too much problems and we should be able to work around it. I would have to try it to verify.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potentially, we could hit some issues where we assume the size is specified exactly but I don't think it would cause too much problems and we should be able to work around it. I would have to try it to verify.

Do you have any example of that?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding is that we would need to specify size if the Swift type had some extra padding which is not visible on the c# side, dont know whether this is something we should worry about

Copy link
Member

@matouskozak matouskozak Nov 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think particularly nested struct would cause a problem because of added padding to the inner struct on C# side. For example,

    [StructLayout(LayoutKind.Sequential, Size = 12)]
    struct F3_S0_S0
    {
        public nint F0;
        public uint F1;

        public F3_S0_S0(nint f0, uint f1)
        {
            F0 = f0;
            F1 = f1;
        }
    }

    [StructLayout(LayoutKind.Sequential, Size = 24)]
    struct F3_S0
    {
        public sbyte F0; // Offset 0
        public F3_S0_S0 F1; // Offset 8
        public uint F2; // Offset 20

        public F3_S0(sbyte f0, F3_S0_S0 f1, uint f2)
        {
            F0 = f0;
            F1 = f1;
            F2 = f2;
        }
    }

The F3_S0 should get lowered as: i8, i64, i64.

Without specifying the size of F3_S0_S0, the public uint F2 would be at offset 24 (because F3_S0_S0 size would be padded to 16), causing the current implementation of lowering algorithm to create a different lowered sequence: i8, i64, i32, i32 (because of the padding between the F1 field from F3_S0_S0 and F2 from F3_S0, preventing merging the two uint fields into single i64).

Note: I described the process based on Mono implementation, CoreCLR might differ, but I suspect the outcome to be similar.

While we should be able to work around this by using exact sizes of individual fields, that would still require us to have knowledge about (ALL) fields that are going to be present in the struct. I wonder if we would be able to define some kind of meta lowering for the generic struct that would get adjusted on the go based on different specializations.

Copy link
Member

@matouskozak matouskozak Nov 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

However, I forgot that this discussion is only about frozen closed generic type (which are known at compile time #2796 (comment)), we should be able to get the sizes ahead of time, no? Or does the closed generic type refer only to the main struct and not recursively?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Frozen generic struct might contain other frozen generic structs as members.

@frozen
public struct BigStruct<T>
{
    let x : T
    let y : SmallStruct<T>
    let z : SmallStruct<Float>
}

@frozen 
public struct SmallStruct<U>
{
    public let x: U
}

Also closed generic type might be used to close another generic like BigStruct<SmallStruct<Int>>

We are able to get the size of the non-generic frozen members ahead of time, we should also have access to the layout, so we should be able to recursively calculate the size ahead of time. But I do not think we can just extract the size of the the closed / bound frozen generic from the abi.json or dylib. Do you have any ideas @stephen-hawley?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-SwiftBindings Swift bindings for .NET
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants