dotnet / runtimelab

This repo is for experimentation and exploring new ideas that may or may not make it into the main dotnet/runtime repo.
MIT License
1.36k stars 188 forks source link

Review struct and enum projections in C# for CryptoKit dev templates #2533

Closed kotlarmilos closed 3 months ago

kotlarmilos commented 3 months ago

Description

This issue discusses the projection of structs and enums in C#. The primary goal is to validate the design of these projections and implement CryptoKit dev templates.

Here is a CryptoKit template in Swift that we want to implement:

guard let nonce = try? ChaChaPoly.Nonce(data: nonceData) else {
        return 0
    }

guard let sealedBox = try? ChaChaPoly.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag) else {
    return 0
}

do {
    let result = try ChaChaPoly.open(sealedBox, using: symmetricKey, authenticating: aad)

    assert(plaintextPtrLength >= result.count)
    result.copyBytes(to: plaintextPtr, count: result.count)
    return 1
}
catch CryptoKitError.authenticationFailure {
    return -1
}
catch {
    return 0
}

ChaChaPoly enum includes Nonce and SealedBox structs and utilizes a SymmetricKey struct. In Swift, structs and enums are value types allocated on the stack, with type metadata singletons accessible through metadata accessor functions ($Ma).

Structs and enums with a sequence of less than 4 machine words are lowered to a set of registers. Initializers follow the same rules, if the size of the return type is less than 4 machine words, it is passed via registers. For larger structs or enums, Swift utilizes an indirect result location register that contains a memory location where the valuetype is stored. Here is an example of a Swift struct initializer signature in LLVM IR:

; Value type passed via registers
define swiftcc { i64, i64 } @"$s6output8MyStructV_A5KtcfC"(i64 %0, i64 %1)

; Value type passed via reference
define protected swiftcc void @"$s6output8MyStructV_A5KtcfC"(%T6output8MyStructV* noalias nocapture sret(%T6output8MyStructV) %0, i64 %1, ...)

The code snippet below illustrates handling of large structs when a direct register pass is not possible due to the size. Before the initializer is called, the x8 register (indirect result register) is loaded with a memory reference to the struct's location. Within the initializer, the struct reference is saved onto the stack. A local copy of the struct is then initialized on the stack. After that, this local copy is transferred into the struct memory location stored on the stack.

; Load indirect result location register with a memory reference to the struct's location
000000010000379c    adrp    x8, 1 ; 0x100004000
00000001000037a0    ldr x8, [x8, #0x30] ; literal pool symbol address: _$sypN
00000001000037a4    add x8, x8, #0x8
; ...
00000001000037c8    bl  _$s15libHelloLibrary11StructV7number17number2ACs5Int64V_AGtcfC

_$s15libHelloLibrary11StructV7number17number2ACs5Int64V_AGtcfC:
00000001000038c0    sub sp, sp, #0x70
00000001000038c4    stp x29, x30, [sp, #0x60]
00000001000038c8    add x29, sp, #0x60
; Store indirect result location register onto the stack
00000001000038cc    str x8, [sp, #0x18] 
; ...
; Load struct address into x9
00000001000038f4    ldr x9, [sp, #0x18]
; ...
; Store results into indirect result location
000000010000393c    str x0, [x9]
0000000100003940    str x1, [x9, #0x8]
0000000100003944    str x12, [x9, #0x10]
0000000100003948    str x11, [x9, #0x18]
000000010000394c    str x10, [x9, #0x20]
0000000100003950    str x8, [x9, #0x28]

In Swift, enums have richer semantics compared to C#. When projecting Swift enums into C#, they can be represented in the same way as structs with a static enum field. This approach should support the expected lowering behavior, while preserving functionality of the enums.

Proposal

There are two distinct projection approaches for structs based on their sequence length: projecting as C# structs for sequences less than or equal to 4 machine words, and projecting as C# classes for sequences greater than 4 machine words.

Projecting Swift structs as C# structs

These structs are passed by value. The layout of the struct is retrieved by projection tooling from metadata nominal type descriptor. The memory management is implicit and handled by the GC. Here is an example is Swift:

public struct MyStruct {
    private var number1: Int32
    private var number2: Int32
    private static var luckyNumber: Int32 = 7
    public init (number1: Int32 = 1, number2: Int32 = 2) {
        self.number1 = number1
        self.number2 = number2
    }

    public func getMagicNumber() -> Int32 {
        return self.number1 + self.number2
    }

    public static func GetLuckyNumber() -> Int32 {
        return luckyNumber
    }
}

And the corresponding projection in C#:

public unsafe struct MyStruct {
    // Fields and properties are retrieved from the type metadata
    public Int32 number1;
    public Int32 number2;

    // Singleton
    private static void* _metadata_payload = null;

    [UnmanagedCallConv(CallConvs = new Type[] { typeof(CallConvSwift) })]
    [DllImport("libHelloLibrary.dylib", EntryPoint = "$s12HelloLibrary11MyStructVMa")]
    internal static extern void* PIfunc_GetMetadata();

    [UnmanagedCallConv(CallConvs = new Type[] { typeof(CallConvSwift) })]
    [DllImport("libHelloLibrary.dylib", EntryPoint = "$s12HelloLibrary11MyStructV7number17number2ACs5Int32V_AGtcfC")]
    internal static extern MyStruct PIfunc_MyStruct(Int32 number1, Int32 number2);
    public MyStruct(Int32 number1, Int32 number2)
    {
        this = PIfunc_MyStruct(number1, number2);
        if (_metadata_payload == null)
            _metadata_payload = PIfunc_GetMetadata();

        Console.WriteLine("Metadata kind: " + Marshal.ReadIntPtr((IntPtr)_metadata_payload, 0));
    }

    [UnmanagedCallConv(CallConvs = new Type[] { typeof(CallConvSwift) })]
    [DllImport("libHelloLibrary.dylib", EntryPoint = "$s12HelloLibrary11MyStructV14getMagicNumbers5Int32VyF")]
    internal static extern Int32 PIfunc_getMagicNumber(MyStruct self);
    public Int32 getMagicNumber()
    {
        return PIfunc_getMagicNumber(this);
    }

    [UnmanagedCallConv(CallConvs = new Type[] { typeof(CallConvSwift) })]
    [DllImport("libHelloLibrary.dylib", EntryPoint = "$s12HelloLibrary11MyStructV14GetLuckyNumbers5Int32VyFZ")]
    internal static extern Int32 PIfunc_GetLuckyNumber(SwiftSelf self);
    public static Int32 GetLuckyNumber()
    {
        SwiftSelf self = new SwiftSelf(_metadata_payload);
        return PIfunc_GetLuckyNumber(self);
    }
}

This approach enables calling the constructor and invoking both instance and static methods. It's important to note that instance methods require the self instance in the call context (self) register only when passed by reference, while static methods require metadata in the call context register.

Projecting Swift structs as C# classes

These structs are passed by reference. The layout of the struct is retrieved by projection tooling from metadata nominal type descriptor. The memory management is explicit and handled by implementing IDIsposable interface. Here is an example is Swift:

public struct MyStruct {
    private var number1: Int64
    private var number2: Int64
    private var number3: Int64
    private var number4: Int64
    private var number5: Int64
    private var number6: Int64
    private static var luckyNumber: Int32 = 7
    public init (number1: Int64, number2: Int64, number3: Int64, number4: Int64, number5: Int64, number6: Int64) {
        self.number1 = number1
        self.number2 = number2
        self.number3 = number3
        self.number4 = number4
        self.number5 = number5
        self.number6 = number6
    }

    public func getMagicNumber() -> Int64 {
        return self.number1 + self.number2 + self.number3 + self.number4 + self.number5 + self.number6
    }

    public static func GetLuckyNumber() -> Int32 {
        return luckyNumber
    }
}

And the corresponding projection in C#:

public unsafe class MyStruct : IDisposable
{
    // Payload allocated on the .NET side
    private void* _payload;
    private const int _payloadSize = /*Determined from type metadata*/;
    private static void* _metadata_payload = null;

    [UnmanagedCallConv(CallConvs = new Type[] { typeof(CallConvSwift) })]
    [DllImport("libHelloLibrary.dylib", EntryPoint = "$s12HelloLibrary11MyStructVMa")]
    internal static extern void* PIfunc_GetMetadata();

    [UnmanagedCallConv(CallConvs = new Type[] { typeof(CallConvSwift) })]
    [DllImport("libHelloLibrary.dylib", EntryPoint = "$s12HelloLibrary11MyStructV7number17number2ACs5Int64V_AGtcfC")]
    internal static extern MyStruct PIfunc_MyStruct(Int32 number1, Int32 number2, SwiftIndirectResult payload);
    public MyStruct(Int32 number1, Int32 number2)
    {
        _payload = Marshal.AllocHGlobal(_payloadSize).ToPointer();
        SwiftIndirectResult swiftIndirectResult = new SwiftIndirectResult(_payload);
        PIfunc_MyStruct(number1, number2, swiftIndirectResult);
        if (_metadata_payload == null)
            _metadata_payload = PIfunc_GetMetadata();

        Console.WriteLine("Metadata kind: " + Marshal.ReadIntPtr((IntPtr)_metadata_payload, 0));
    }

    [UnmanagedCallConv(CallConvs = new Type[] { typeof(CallConvSwift) })]
    [DllImport("libHelloLibrary.dylib", EntryPoint = "$s12HelloLibrary11MyStructV14getMagicNumbers5Int32VyF")]
    internal static extern Int32 PIfunc_getMagicNumber(SwiftSelf self);
    public Int32 getMagicNumber()
    {
        SwiftSelf swiftSelf = new SwiftSelf(_payload);
        return PIfunc_getMagicNumber(swiftSelf);
    }

    [UnmanagedCallConv(CallConvs = new Type[] { typeof(CallConvSwift) })]
    [DllImport("libHelloLibrary.dylib", EntryPoint = "$s12HelloLibrary11MyStructV14GetLuckyNumbers5Int32VyFZ")]
    internal static extern Int32 PIfunc_GetLuckyNumber(SwiftSelf self);
    public static Int32 GetLuckyNumber()
    {
        SwiftSelf self = new SwiftSelf(_metadata_payload);
        return PIfunc_GetLuckyNumber(self);
    }

    public void Dispose()
    {
        Marshal.FreeHGlobal((IntPtr)_payload);
    }

    public void Dispose(bool disposing)
    {
        if (disposing)
        {
            Dispose();
        }
    }

    ~MyStruct()
    {
        Dispose(false);
    }
}

This approach enables calling the constructor and invoking both instance and static methods.

Runtime API

According to the calling convention, the indirect result location uses dedicated registers and is always passed through them when passing types by reference. When calling Swift constructors for types that do not transform to a primitive sequence, the result is returned via an indirect result location register.

This process can be handled either by the runtime itself or through projection tooling.

Implicit handling of indirect result location register

This approach proposes the runtime by handling the indirect result location register implicitly. For structs that cannot be directly passed, the runtime loads the dedicated register with a reference to the struct's memory.

Explicit handling of indirect result location register

This approach proposes introducing a new runtime API for explicit management of the indirect result location register.

+ namespace System.Runtime.InteropServices.Swift
+ {
+     public readonly struct SwiftIndirectResult
+     {
+         public SwiftIndirectResult(IntPtr value)
+         {
+             Value = value;
+         }
+
+         public IntPtr Value { get; }
+     }

This API would be used in the constructors loading passed memory reference to the indirect result location register.

[UnmanagedCallConv(CallConvs = new Type[] { typeof(CallConvSwift) })]
[DllImport("libHelloLibrary.dylib", EntryPoint = "$s12HelloLibrary11MyStructV7number17number2ACs5Int64V_AGtcfC")]
internal static extern MyStruct PIfunc_MyStruct(Int32 number1, Int32 number2, SwiftIndirectReturn payload*/);
public MyStruct(Int32 number1, Int32 number2)
{
    _payload = Marshal.AllocHGlobal(_payloadSize).ToPointer();
    SwiftIndirectReturn swiftIndirectReturn = new SwiftIndirectReturn(_payload);
    PIfunc_MyStruct(number1, number2, swiftIndirectReturn);
}

Note: This discussion primarily focuses on struct projections, with the intention to apply similar constructs to enums. Generics are not covered in this current proposal.

/cc: @jkotas @jkoritzinsky @AaronRobinsonMSFT @stephen-hawley @rolfbjarne @vitek-karas

jkotas commented 3 months ago

How does this relate to frozen structs vs. non-frozen structs? My assumption was that we will map frozen Swift structs to C# structs and non-frozen Swift structs to C# classes.

stephen-hawley commented 3 months ago

Notes: I think you mean 4 machine words and not 4 bytes. I like the approach of having a cached metadata type - that will certainly improve performance. In either case, we need to think ahead for how the type metadata gets used (and it gets used often) and should have a dedicated type for this. In BTfS, we have something that looks like this:

public struct SwiftMetatype {
        internal NativeHandle handle;
        public MetatypeKind Kind { get { return MetatypeKindFromHandle (handle); } }
        public bool IsValid { get { return handle != IntPtr.Zero; } }

        public SwiftMetatype (NativeHandle handle)
        {
            this.handle = handle;
        }

        void ThrowOnInvalid ()
        {
            if (!IsValid)
                throw new NotSupportedException ();
        }

        public IntPtr Handle { get { return handle; }}
}

This is a minimal definition. What's left out of it is the tooling that's necessary for runtime marshaling of types - for example, getting the length and element types for tuples. Yes, I know this is beyond the scope here, but this will all be needed.

Every swift type has a metadata accessor function. This is a static function which takes 0 or more arguments (0 for a non-generic type).

To that end, we might want all nominal types (in swift these are types that can have names: structs, classes, enums, actors) to implement an interface, say, ISwiftMetadata:

public interface ISwiftMetadata {
    // either this - have to pass in the generic specialization
    static abstract SwiftMetadata MetadataOf (params SwiftMetadata [] types); // don't like the name because of possible name conflicts
    // or this - the type handles the generic specialization, which I prefer
    SwiftMetadata SwiftMetadata { get; }

}

Let's create classifications of the swift structs in order to map them cleanly:

Examples:

public struct ABlittableStruct { // blittable, lowerable
    public var a: Int // careful - the size of this depends on the platform. Do we care about non-64 bit anymore?
}

public struct NonBlittableStruct { // non-blittable, lowerable, referencecountable
    public var a: SomeClass // reference count issue
    public var b: Bool // blitable issue
}

public struct BlittableNonLowerable {
    public var a: Int, b: Int, c: Int, d: Int, e: Int
}

In addition to the issues with blitability etc, for lowerable structs we need to match, size, stride and alignment. In addition, we can only expose members in frozen structs.

I like to think about how we need to think of the overall type system in doing this and how we can make the task both easy and performant for us.

We will need functions to live in runtime support somewhere that includes this C# method:

public static bool TryGetSwiftMetadata (object o, out SwiftMetadata md) { }
public static bool TryGetSwiftMetadata (Type t, out SwiftMetadata md) { }
public static bool TryGetSwiftMetadata (object o, Type [] interfaceConstraints, out SwiftMetadata md) { } 
public static bool TryGetSwiftMetadata (Type t, Type [] interfaceConstraints, out SwiftMetadata md) { }

The actual API in BTfS is:

public static SwiftMetatype Metatypeof (Type t) { }
public static SwiftMetatype Metatypeof (Type t, Type [] interfaceConstraints) { }

This works, but without the instance, we have to call use reflection to get the method to retrieve the type metadata which is, of course, not optimal.

And inside those methods, I would really like to see something like:

public static bool TryGetSwiftMetadata (object o, out SwiftMetadata md) {
    if (o is ISwiftMetadata omd) {
        md = omd.SwiftMetadata;
        return true;
    } else {
        return TryGetSwiftMetadata (o.GetType (), out md);
    }
}

Other notes - PInvokes need to absolutely live in a different class than the class/struct that implements the binding. When we do generics, this is a requirement of the type system.

It's useful to be able to handle types generically and to be able to ask questions about them. We can either do that with interfaces or attributes or a common base class. In the case of the second struct in your example, how do we know that it's a swift struct? Using a common base class allows us to put the IDisposable implementation in one place.

Things will also get challenging when we consider structs that contain enums. As mentioned before, because of the non-documented fragile layout of swift enums and their discriminators, those may also be technically blittable and lowerable but not practically so.

kotlarmilos commented 3 months ago

How does this relate to frozen structs vs. non-frozen structs? My assumption was that we will map frozen Swift structs to C# structs and non-frozen Swift structs to C# classes.

This proposal should be aligned with mapping frozen Swift struct to C# structs and non-frozen Swift structs to C# classes. Frozen structs and enums are not considered opaque and will be enregistered, while non-frozen structs and enums are considered opaque and will be passed via reference.

rolfbjarne commented 3 months ago

There are two distinct projection approaches for structs based on their sequence length: projecting as C# structs for sequences less than 4 machine words, and projecting as C# classes for sequences equal to or greater than 4 machine words.

It seems this is a split based on the CPU architecture, and not something inherent to the language: will this stay true for all future CPU architectures?

jkotas commented 3 months ago

This proposal should be aligned with mapping frozen Swift struct to C# structs and non-frozen Swift structs to C# classes

The following sentence from above sems to contradict that "projecting as C# structs for sequences less than 4 machine words, and projecting as C# classes for sequences equal to or greater than 4 machine words.". Am I missing some nuance?

kotlarmilos commented 3 months ago

I think you mean 4 machine words and not 4 bytes.

Thanks, updated.

This is a minimal definition. What's left out of it is the tooling that's necessary for runtime marshaling of types - for example, getting the length and element types for tuples. Yes, I know this is beyond the scope here, but this will all be needed.

I think we need this to project Swift structs as we need layout information to support runtime lowering. I tried to focus on projections initially, and I suggest we review implementation of metadata in a subsequent PR.

Other notes - PInvokes need to absolutely live in a different class than the class/struct that implements the binding. When we do generics, this is a requirement of the type system.

It's useful to be able to handle types generically and to be able to ask questions about them. We can either do that with interfaces or attributes or a common base class. In the case of the second struct in your example, how do we know that it's a swift struct?

Yes, good points.

kotlarmilos commented 3 months ago

It seems this is a split based on the CPU architecture, and not something inherent to the language: will this stay true for all future CPU architectures?

Good point. Maybe the proposed handling should be transitioned to the runtime layer to ensure uniform projections.

The following sentence from above sems to contradict that "projecting as C# structs for sequences less than 4 machine words, and projecting as C# classes for sequences equal to or greater than 4 machine words.". Am I missing some nuance?

I overlooked this. I need to verify, but my understanding is that structs and enums annotated with the frozen attribute are not considered opaque and will be enregistered only if its sequence is of length 4 or less. Frozen structs and enums that cannot be broken down in this way are passed by-reference to their specified frozen layout.

rolfbjarne commented 3 months ago

private var number1: Int32

public Int32 number1;

Not sure if this is significant, but the fields are private in swift and public in C#.


    public MyStruct(Int32 number1, Int32 number2)
    {
        this = PIfunc_MyStruct(number1, number2);
        if (_metadata_payload == null)
            _metadata_payload = PIfunc_GetMetadata();

        Console.WriteLine("Metadata kind: " + Marshal.ReadIntPtr((IntPtr)_metadata_payload, 0));
    }

Maybe the initialization of _metadata_payload should be in a static constructor instead?


    public void Dispose()
    {
        Marshal.FreeHGlobal((IntPtr)_payload);
    }

This doesn't support calling Dispose more than once:

    public void Dispose()
    {
        Marshal.FreeHGlobal((IntPtr)_payload);
        _payload = null;
    }

    public Int32 getMagicNumber()
    {
        SwiftSelf swiftSelf = new SwiftSelf(_payload);
        return PIfunc_getMagicNumber(swiftSelf);
    }

What happens if someone calls Dispose before calling getMagicNumber? Maybe check for disposed and throw an ObjectDisposedException.

jkotas commented 3 months ago

I think it would be useful to clearly separate:

kotlarmilos commented 3 months ago

Projection tooling public API

This API is exposed to end users.

The projection of Swift structs is determined based on their size and layout. Structs with a size less than or equal to 4 machine words are enregistered and passed as a sequence of primitives. Structs with a size greater than 4 machine words are passed by reference. The frozen annotation defines that no stored properties will be added to or removed from the struct, but these structs must comply with struct lowering algorithm.

Let's consider the following scenarios: initializers, methods, static methods, and functions.

On both arm64 and x64 architectures, Swift struct initializers pass structs as enregistered if they fit within the lowering sequence. Otherwise, indirect return registers are used (x8/rax). For example:

define swiftcc { i64, i64 } @"$s12HelloLibrary8MyStructV7number17number2ACs5Int64V_AGtcfC"(i64 %0, i64 %1)
define swiftcc void @"$s12HelloLibrary8MyStructV7number17number2ACs5Int64V_AGtcfC"(ptr noalias nocapture sret(%T12HelloLibrary8MyStructV) %0, i64 %1, i64 %2)

Similarly, Swift struct methods expect structs as enregistered if they fit within the lowering sequence. If not, the swiftself register is utilized. Example:

define swiftcc i64 @"$s12HelloLibrary8MyStructV14getMagicNumbers5Int64VyF"(i64 %0, i64 %1)
define swiftcc i64 @"$s12HelloLibrary8MyStructV14getMagicNumbers5Int64VyF"(ptr noalias nocapture swiftself dereferenceable(40) %0)

Static methods require metadata in the call context but do not introduce additional requirements in this context. Struct parameters in functions are implicitly lowered by the runtime when the struct's size is within the lowering sequence.

The proposal is:

Please let me know if this looks like a good direction.

Public API implementation

This is the implementation of public API.

Swift structs as C# structs

The projection tool generates identical struct layouts on the C# side. Memory management is implicit and handled by the GC.

Constructor:

[UnmanagedCallConv(CallConvs = new Type[] { typeof(CallConvSwift) })]
[DllImport("libHelloLibrary.dylib", EntryPoint = "$s12HelloLibrary11MyStructV7number17number2ACs5Int32V_AGtcfC")]
internal static extern MyStruct PIfunc_MyStruct(Int32 number1, Int32 number2);

public MyStruct(Int32 number1, Int32 number2)
{
    this = PIfunc_MyStruct(number1, number2);
}

Instance method:

[UnmanagedCallConv(CallConvs = new Type[] { typeof(CallConvSwift) })]
[DllImport("libHelloLibrary.dylib", EntryPoint = "$s12HelloLibrary11MyStructV14getMagicNumbers5Int32VyF")]
internal static extern Int32 PIfunc_getMagicNumber(MyStruct self);

public Int32 getMagicNumber()
{
    return PIfunc_getMagicNumber(this);
}

Swift structs as C# classes

The C# class allocates the same struct layout with explicit memory management by implementing IDisposable. This also requires runtime support for indirect return register (SwiftIndirectResult).

Constructor:

[UnmanagedCallConv(CallConvs = new Type[] { typeof(CallConvSwift) })]
[DllImport("libHelloLibrary.dylib", EntryPoint = "$s12HelloLibrary11MyStructV7number17number2ACs5Int64V_AGtcfC")]
internal static extern MyStruct PIfunc_MyStruct(Int32 number1, Int32 number2, SwiftIndirectResult payload);

public MyStruct(Int32 number1, Int32 number2)
{
    // _payloadSize is retrieved from the type metadata
    void* _payload = Marshal.AllocHGlobal(_payloadSize).ToPointer();
    SwiftIndirectResult swiftIndirectResult = new SwiftIndirectResult(_payload);

    PIfunc_MyStruct(number1, number2, swiftIndirectResult);
}

~MyStruct()
{
    Dispose(false);
}

Instance method:

[UnmanagedCallConv(CallConvs = new Type[] { typeof(CallConvSwift) })]
[DllImport("libHelloLibrary.dylib", EntryPoint = "$s12HelloLibrary11MyStructV14getMagicNumbers5Int32VyF")]
internal static extern Int32 PIfunc_getMagicNumber(SwiftSelf self);

public Int32 getMagicNumber()
{
    // Before this, check for disposed and throw an ObjectDisposedException
    SwiftSelf swiftSelf = new SwiftSelf(_payload);
    return PIfunc_getMagicNumber(swiftSelf);
}

I suggest reviewing and aligning on the direction first before introducing additional details.

jkotas commented 3 months ago

The proposal is: Project Swift structs with a size less than or equal to 4 machine words into C# structs Project Swift structs with size greater that 4 machine words into C# IDisposable classes

The size of the non-frozen structs can fluctuate. Would this proposal mean that the projected type can flip from struct to class (or vice versa) from version to version?

It does not look right to leak the 4 machine words threshold into the higher-level programming model.

kotlarmilos commented 3 months ago

The size of the non-frozen structs can fluctuate. Would this proposal mean that the projected type can flip from struct to class (or vice versa) from version to version?

Changing non-frozen struct sizes means the Swift runtime emits different code, and it can be expected to regenerate bindings in case of breaking changes. However, I agree that the tooling should provide uniform public API to the users.

It does not look right to leak the 4 machine words threshold into the higher-level programming model.

An alternative is to project Swift structs to C# structs with implicit memory management. In this case, the tooling will project identical struct layout and capture lowering sequence information. We can determine whether to integrate lowering logic into the tooling or runtime.

Option 1: Runtime thunk wrappers

The runtime can implement a thin wrapper around constructors to ensure parameters and results are always passed via reference. For instance methods, when a struct cannot be enregistered, the runtime can utilize swiftself.

In this case, the projection tolling will have uniform mapping of structs.

Option 2: Projections lowering logic

The runtime can support SwiftIndirectResult parameter. The tooling can project Swift structs that are passed via reference into C# structs utilizing runtime Swift types.

In this case, the projection tolling will have the same public API, but different implementation for constructors and instance methods based on lowering sequence information.

jkotas commented 3 months ago

Changing non-frozen struct sizes means the Swift runtime emits different code, and it can be expected to regenerate bindings in case of breaking changes.

Is changing non-frozen struct size consider to be a breaking change in Swift? My assumption has been that it is not breaking change, the whole point of non-frozen structs is to make it non-breaking. If my assumption is correct, taking a dependency on non-frozen struct layout would also mean that it may not be possible to build a single app binary that works on both iOS N and iOS N+1 if there was a non-frozen struct layout change between the two iOS versions. I do not think that it would be an acceptable experience.

SwiftIndirectResult

I think that we have to add this to CallConvSwift to have proper for support returning non-frozen struct. I am not sure how we have missed this detail in https://github.com/dotnet/runtime/issues/64215.

kotlarmilos commented 3 months ago

Sorry, I didn't consider the library evolution mode at first.

Is changing non-frozen struct size consider to be a breaking change in Swift? My assumption has been that it is not breaking change, the whole point of non-frozen structs is to make it non-breaking.

You are right, it is not a breaking change in the library evolution mode. Non-frozen structs and enums in the library evolution mode are passed by reference.

I think that we have to add this to CallConvSwift to have proper for support returning non-frozen struct. I am not sure how we have missed this detail in https://github.com/dotnet/runtime/issues/64215.

Yes, we need to add them to support non-frozen structs and frozen structs that exceed lowering sequence limit. Let's create a proposal separately.

It does not look right to leak the 4 machine words threshold into the higher-level programming model.

It seems this is a split based on the CPU architecture, and not something inherent to the language: will this stay true for all future CPU architectures?

Primary concerns here are initializers and instance methods for frozen structs in library evolution mode. For initializers, If a struct exceeds lowering sequence limit, indirect result register is utilized. For instance methods, If a struct exceeds lowering sequence limit, the runtime will pass it by reference, but won't use the swiftself as the arg register.

Do you think this is something that should be implemented within the runtime or tooling?

If we proceed with the runtime changes, then the SwiftIndirectResult type may not be implemented as a public API as handling will be done within the runtime.

jkotas commented 3 months ago

Primary concerns here are initializers and instance methods for frozen structs in library evolution mode. For initializers, If a struct exceeds lowering sequence limit, indirect result register is utilized. For instance methods, If a struct exceeds lowering sequence limit, the runtime will pass it by reference, but won't use the swiftself as the arg register.

Do you think this is something that should be implemented within the runtime or tooling?

@jakobbotsch Could you please advice on where this should live based on your current understanding of the responsibilities split between CallConvSwift and the higher-level projections?

jakobbotsch commented 3 months ago

My assumption so far has been that any struct we see in a CallConvSwift signature is a frozen struct. Non-frozen structs should in turn be handled at the projection layer by turning them into pointers in the CallConvSwift signature. I agree that leaves a hole when returning non-frozen structs that need to go through ret buffer, where the tooling needs to be able to instruct the runtime what to pass as the ret buffer. I think it sounds reasonable to add SwiftIndirectResult parameter that has intrinsic handling to handle the non frozen struct cases.

I do not see how it's possible to describe the frozen struct instance calls with the current design if we want to retain the lowering to happen in the runtime instead of the tooling. I think to do that we need to change SwiftSelf to be generic so that you can pass SwiftSelf<T> for a frozen struct type T which then is either enregistered into multiple registers or passed by reference in the Swift self register.

It seems doable to do this within the runtime with these additional changes. But of course my personal opinion was always that we should do the lowering in the tooling :-)

kotlarmilos commented 3 months ago

Thanks for sharing your perspective! Let's try to frame this.

In library evolution mode, structs are passed by reference, except for frozen structs with a sequence less than or equal to 4 machine words.

When structs are passed by reference:

By mapping any Swift struct into C# struct, we can ensure the same public API for end-users with implicit memory management. An alternative would be to map Swift structs passed by reference into C# classes. In such cases, users would have explicit memory management but different public API.

Let's first align on the end-users public API, and then we can focus on the implementation and runtime integration.


Here is a proposal for mapping Swift structs passed by reference (non-frozen and frozen with sequence length > 4) into C# structs:

// consider that the tooling projected the same layout

// constructor
public MyStruct(){
    fixed (void* thisPtr = &this){
        SwiftIndirectResult swiftResult = new SwiftIndirectResult(thisPtr);
        PIfunc_MyStruct(swiftResult, args);
    }
}
// instance method
public Int64 getMagicNumber()
{
    fixed (void* thisPtr = &this) {
        SwiftSelf self = new SwiftSelf(thisPtr);
        return PIfunc_getMagicNumber(self);
    }
}

Here is a proposal for mapping frozen Swift structs passed by value:

// consider that the tooling projected the same layout

// constructor
public MyStruct(){
    this = PIfunc_MyStruct(args);
}

// instance method
public Int64 getMagicNumber()
{
    return PIfunc_getMagicNumber(this);
}

An alternative implementation is to perform struct lowering at the runtime layer. The swiftself and swifterror are designed to provide "stateless" handles into the runtime and are not designed for implementing lowering semantics within the runtime.

jkotas commented 3 months ago

Let's first align on the end-users public API

+1. How does the code that end-user needs to write looks like with these proposals? Can we take a few examples of real-world Swift that calls APIs with structs and show the equivalent C# code written against the projections?

jakobbotsch commented 3 months ago

An alternative implementation is to perform struct lowering at the runtime layer. The swiftself and swifterror are designed to provide "stateless" handles into the runtime and are not designed for implementing lowering semantics within the runtime.

I'm not sure I understand what this means. Can you elaborate? @jkoritzinsky already implemented the lowering in the runtimes in https://github.com/dotnet/runtime/pull/99438, https://github.com/dotnet/runtime/pull/98831, https://github.com/dotnet/runtime/pull/99439.

Since "self" in Swift can be passed by value (does not match .NET) it seems like we need to expand SwiftSelf to allow describing the exact type of "self" being passed.

I do not think the user facing representation of the types should be related to the lowering at all, regardless of where the lowering happens. (Also, minor note: the lowering is not 4 machine words, but 4 primitives. Those primitives can be SIMD types as well that are larger than machine words.). I agree with the earlier comments in this issue that it would make sense to map frozen types to C# structs and other types to C# classes.

If we do the lowering in the tooling then I do not think SwiftSelf<T> will be necessary. But if we do the lowering in the runtime, then we need it to describe instance calls on frozen structs.

Regardless of anything I do not think we can make the projection tooling generate wrappers that are pointer size agnostic with the way things are currently designed. That's because there are Swift types whose layout is not describable in a pointer size agnostic way in .NET. For example,

@frozen
public struct S0
{
  let a : Int;
  let b : Int32;
}
@frozen
public struct S1
{
  let c : S0;
  let d : Int32;
}

has the following layout in 64-bit:

[StructLayout(LayoutKind.Sequential, Size = 12)]
public struct S0
{
  public nint a;
  public int b;
}

[StructLayout(LayoutKind.Sequential, Size = 16)]
public struct S1
{
  public S0 c;
  public int d; // Starts at offset = 12
}

and the following layout on 32-bit:

[StructLayout(LayoutKind.Sequential, Size = 8)]
public struct S0
{
  public nint a;
  public int b;
}

[StructLayout(LayoutKind.Sequential, Size = 12)]
public struct S1
{
  public S0 c;
  public int d; // Starts at offset = 8
}

I do not think these types are describable in a pointer size agnostic way in .NET. This might be unrelated, but it's something to keep in mind when we are deciding whether to keep the lowering in the runtime or move it back to the tooling. If the goal is to have a fully agnostic way to generate the projections and keep all type layout and ABI handling in the runtime then it seems like we need a higher fidelity way to describe the Swift source of truth types in IL.

kotlarmilos commented 3 months ago

I'm not sure I understand what this means. Can you elaborate? @jkoritzinsky already implemented the lowering in the runtimes in dotnet/runtime#99438, dotnet/runtime#98831, dotnet/runtime#99439.

I was referring to lowering bits proposed in the projection tooling, such as SwiftIndirectResult and passing SwiftSelf by value (basically your comments below).

I do not see how it's possible to describe the frozen struct instance calls with the current design if we want to retain the lowering to happen in the runtime instead of the tooling. I think to do that we need to change SwiftSelf to be generic so that you can pass SwiftSelf for a frozen struct type T which then is either enregistered into multiple registers or passed by reference in the Swift self register.

Since "self" in Swift can be passed by value (does not match .NET) it seems like we need to expand SwiftSelf to allow describing the exact type of "self" being passed.


I agree with the earlier comments in this issue that it would make sense to map frozen types to C# structs and other types to C# classes.

What are the primary reasons for mapping non-frozen structs to C# classes? If one of the reasons is lowering, then we make the public API representation dependent on lowering semantics.

This might be unrelated, but it's something to keep in mind when we are deciding whether to keep the lowering in the runtime or move it back to the tooling.

Thank you for bringing this up.

jakobbotsch commented 3 months ago

What are the primary reasons for mapping non-frozen structs to C# classes? If one of the reasons is lowering, then we make the public API representation dependent on lowering semantics.

I think the main reason would be that non-frozen structs have associated lifetime management around allocating/releasing the dynamically sized memory. With structs it becomes very easy to have use-after-free bugs.

kotlarmilos commented 3 months ago

The indirect result register follows the ARM64 calling convention and is supported by the runtime. The LLVM IR made me think explicit handling was necessary, leading to the use of an incorrect initializer signature. The indirect result register is loaded with a value based on the invocation. Thanks to @janvorli for the assistance.

I will try to narrow down the discussion to the public API.

kotlarmilos commented 3 months ago

Here is a Swift code and equivalent C# code written against the projections for the CryptoKit scenario. In this scenario, Swift structs are mapped to C# structs. Mapping non-frozen structs to C# classes doesn't change the existing API, but introduces explicit memory management API.

let plaintext = "Hello, World!".data(using: .utf8)!
let aad = "Additional Authenticated Data".data(using: .utf8)!

let plaintextPointer = plaintext.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UnsafeRawBufferPointer in
    return bytes
}

let aadPointer = aad.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UnsafeRawBufferPointer in
    return bytes
}

let nonce = ChaChaPoly.Nonce()
let symmetricKey = SymmetricKey(size: SymmetricKeySize.bits256)

do {
    let sealedBox = try ChaChaPoly.seal(plaintextPointer, using: symmetricKey, nonce: nonce, authenticating: aadPointer)
    let result = try ChaChaPoly.open(sealedBox, using: symmetricKey, authenticating: aad)

    return 1
}
catch {
    return 0
}
// Prepare plaintext
byte[] plaintext = System.Text.Encoding.UTF8.GetBytes("Hello, World!");
byte[] aad = System.Text.Encoding.UTF8.GetBytes("Additional Authenticated Data");

// Generate nonce and symmetric key
var nonce = new ChaChaPoly.Nonce();
var symmetricKey = new SymmetricKey(new SymmetricKeySize(256));

fixed (byte* aadPtr = aad)
fixed (void* plaintextPtr = plaintext)
{
    var aadBuffer = new UnsafeRawBufferPointer(aadPtr, aad.Length);
    var plaintextBuffer = new UnsafeRawBufferPointer(plaintextPtr, plaintext.Length);

    try
    {
        // public static SealedBox<A, B> seal<A, B> (A plaintext, SymmetricKey key, Nonce nonce, B aad)
        var sealedBox = ChaChaPoly.seal<UnsafeRawBufferPointer, UnsafeRawBufferPointer>(plaintextBuffer, symmetricKey, nonce, aadBuffer);
        // public static T open<T, A, B> (SealedBox<A, B> sealedBox, SymmetricKey key, T aad)
        var result = ChaChaPoly.open<UnsafeRawBufferPointer, UnsafeRawBufferPointer, UnsafeRawBufferPointer>(sealedBox, symmetricKey, aadBuffer);

        return 1;
    }
    catch
    {
        return 0;
    }
}

Please note that this proposal doesn't implement Swift protocols.

jkotas commented 3 months ago

This helps, but it does not look like typical Swift code to me - I would not expect Swift programmers to go through unmanaged pointers to encrypt a block of plaintext. We may want to look outside CryptoKit for motivating examples for how structs are used by Swift. What are good examples of structs used by public APIs in either Swift core libraries or in Apple SDKs? It would be useful to find examples of both structs that require explicit memory management and that do not require explicit memory management.

kotlarmilos commented 3 months ago

Below are additional examples, the most of them involve implicit memory management. The audio example requires explicit memory management, which is not handled internally within type, but externally. I will try to find more examples with explicit memory management.

CoreGraphics example

Structures like CGPoint, CGSize, and CGRect are allocated on the stack.

import CoreGraphics

let point = CGPoint(x: 10, y: 20)
let size = CGSize(width: 100, height: 200)
let rect = CGRect(origin: point, size: size)

let vector = CGVector(dx: 10, dy: 20)

Date example

Structs are allocated on the stack.

let birthday = Date(timeIntervalSince1970: 0)
let today = Date()
let age = today.timeIntervalSince(birthday)

SwiftUI example

These structs are "lightweight" with implicit memory management.

import SwiftUI

let color = Color.red
let font = Font.system(size: 14, weight: .bold, design: .default)
let padding = EdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20)

var text: some View {
    Text("Hello, World!")
    .foregroundColor(color)
    .font(font)
    .padding(padding)
}

let circle = Circle()
    .fill(Color.blue)

var body: some View {
        Image("link")
            .overlay {
                Rectangle()
                    .stroke(Color.green, lineWidth: 50)
                    .frame(width: 1179 / 3,
                           height: 2556 / 3)
            }
    }

Map example

Stack allocated value types.

import CoreLocation
import MapKit

let coordinate = CLLocationCoordinate2D(latitude: 50.0755, longitude: 14.4378)
let span = MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)

print ("Latitude: \(coordinate.latitude), Longitude: \(coordinate.longitude)")

URL example

Stack allocated value types.

let runtimeURL = URL(string: "https://github.com/dotnet/runtime")
applewe
if let url = runtimeURL {
   print("URL: \(url.absoluteString)")
}

Time example

Stack allocated value types.

import AVFoundation
let time = CMTime(seconds: 1.5, preferredTimescale: 600)

Audio example

This example involves explicit memory management for the buffer, using allocate and deallocate. Often requires working with low-level APIs and handling buffers that can exceed the stack's size limits, thus heap allocated.

var numberBuffers = 1024
var bufferSize = 32
var bufferList = AudioBufferList()
bufferList.mNumberBuffers = numberBuffers
bufferList.mBuffers.mData = UnsafeMutableRawPointer.allocate(byteCount: Int(bufferSize), alignment: MemoryLayout<UInt8>.alignment)
bufferList.mBuffers.mDataByteSize = bufferSize
bufferList.mBuffers.mNumberChannels = 2
// processing
bufferList.mBuffers.mData?.deallocate()
kotlarmilos commented 3 months ago

This is a proposal on structs and enum projections from the design document: https://github.com/dotnet/designs/blob/main/proposed/swift-interop.md#structsvalue-types

I conducted an experiment to highlight the differences between outlined structs and to gain a deeper understanding of their semantics. The experiment below evaluated three types of struct: POD/Trivial, bitwise-takable, non-trivial non-movable. It focused on their behavior when passed between functions in the library evolution mode, to determine if they contain "pass-by-value" semantics despite being passed by reference. The "pass-by-value" semantics here refers to creating a local copy on the stack.

In the experiment, structs are passed in doProxy function:

public func doProxy(_ myStruct: Struct) {
    proxy(myStruct)
}

POD/Trivial structs

A trivial struct with primitive types. It follows "pass-by-value" semantics, with a local copy stored on the stack when passed to a function.

public struct Point {
    var x: Double
    var y: Double
}

Assembly code for doProxy with a trivial struct:

_$s6sample7doProxyyyAA5PointVF:
00000000000039b4    sub sp, sp, #0x30
00000000000039b8    stp x29, x30, [sp, #0x20]
00000000000039bc    add x29, sp, #0x20
00000000000039c0    str xzr, [sp, #0x10] ; init struct layout
00000000000039c4    str xzr, [sp, #0x18]
00000000000039c8    ldr d1, [x0] ; load struct properties
00000000000039cc    ldr d0, [x0, #0x8]
00000000000039d0    str d1, [sp, #0x10] ; local copy of struct on stack
00000000000039d4    str d0, [sp, #0x18]
00000000000039d8    mov x0, sp ; pass struct by reference as stack allocated value type
00000000000039dc    str d1, [sp]
00000000000039e0    str d0, [sp, #0x8]
00000000000039e4    bl  _$s6sample11proxyyyAA5PointVF
00000000000039e8    ldp x29, x30, [sp, #0x20]
00000000000039ec    add sp, sp, #0x30
00000000000039f0    ret

Bitwise-takable structs

The struct includes a reference type. It follows "pass-by-value" semantics, with a local copy stored on the stack when passed to a function.

class ReferenceType {
    var value: Int
    init(value: Int) {
        self.value = value
    }
}

public struct BitwiseMovableNonTrivial {
    var reference: ReferenceType
    var value: Int = 10
}

Assembly code for doProxy with a bitwise-takable struct:

_$s6sample7doProxyyyAA24BitwiseMovableNonTrivialVF:
00000000000036e0    sub sp, sp, #0x30
00000000000036e4    stp x29, x30, [sp, #0x20]
00000000000036e8    add x29, sp, #0x20
00000000000036ec    str xzr, [sp, #0x10] ; init struct layout
00000000000036f0    str xzr, [sp, #0x18]
00000000000036f4    ldr x9, [x0] ; load struct properties
00000000000036f8    ldr x8, [x0, #0x8]
00000000000036fc    str x9, [sp, #0x10] ; local copy of struct on stack
0000000000003700    str x8, [sp, #0x18]
0000000000003704    mov x0, sp  ; pass struct by reference as stack allocated value type
0000000000003708    str x9, [sp]
000000000000370c    str x8, [sp, #0x8]
0000000000003710    bl  _$s6sample11proxyyyAA24BitwiseMovableNonTrivialVF
0000000000003714    ldp x29, x30, [sp, #0x20]
0000000000003718    add sp, sp, #0x30
000000000000371c    ret

Non-trivial non-movable structs

The struct includes a weak reference and doesn't follow "pass-by-value" semantics. As such, it is not copied onto the stack, and its reference is copied onto the stack only.

class AnotherClass {
    var value: Int
    init(value: Int) {
        self.value = value
    }
}

public struct NonTrivialNonMovable {
    weak var weakReference: AnotherClass?
    var value: Int = 10

    init(reference: AnotherClass) {
        self.weakReference = reference
    }
}

Assembly code for doProxy with a non-trivial non-movable struct:

_$s6sample7doProxy8myStructyAA010NonTrivialF7MovableV_tF:
0000000000003734    sub sp, sp, #0x20
0000000000003738    stp x29, x30, [sp, #0x10]
000000000000373c    add x29, sp, #0x10
0000000000003740    str xzr, [sp, #0x8]
0000000000003744    mov x8, x0
0000000000003748    str x8, [sp, #0x8] ; local copy of reference on stack
000000000000374c    bl  _$s6sample11finishProxyyyAA010NonTrivialD7MovableVF
0000000000003750    ldp x29, x30, [sp, #0x10]
0000000000003754    add sp, sp, #0x20
0000000000003758    ret

There is a public API in Swift for POD and bitwise-takable types:

https://github.com/apple/swift/blob/4a2b178944959f854d13d6e0dfbb7ffd21c10a64/stdlib/public/core/Builtin.swift#L747-L753

/// Returns `true` if type is a POD type. A POD type is a type that does not
/// require any special handling on copying or destruction.
@_transparent
public // @testable
func _isPOD<T>(_ type: T.Type) -> Bool {
  return Bool(Builtin.ispod(type))
}

https://github.com/apple/swift/blob/4a2b178944959f854d13d6e0dfbb7ffd21c10a64/stdlib/public/core/Builtin.swift#L768-L774

/// Returns `true` if type is a bitwise takable. A bitwise takable type can
/// just be moved to a different address in memory.
@_transparent
public // @testable
func _isBitwiseTakable<T>(_ type: T.Type) -> Bool {
  return Bool(Builtin.isbitwisetakable(type))
}
kotlarmilos commented 3 months ago

Here is a summary of the public API and projection implementation.

TL;DR: Align with the proposed approach from the design document.

Public API

There are several options for projecting enums and structs into C#. Below are the options.

Projecting as C# structs for sequences less than or equal to 4 machine words, and projecting as C# classes for sequences greater than 4 machine words.

This approach implies moving low-level logic into the projection tooling and may lead to flipping between C# structs and classes across different versions, resulting in behavior changes that may not be visible to end-users.

Map frozen Swift structs to C# structs and non-frozen Swift structs to C# classes.

This approach provides a clear distinction between projections and does not impose low-level implementation details. When using the library evolution mode, all non-frozen structs are passed by reference.

POD/Trivial, bitwise-takable, and non-trivial non-movable structs

This approach is based on memory representation and maps POD/Trivial and bitwise-takeable structs to C# structs. The non-trivial non-movable must ensure the explicit memory handling (see design document) and are mapped to C# classes.

After reviewing different options, the proposal is to combine struct memory representation with frozen attribute (as an implementation feature):

Projection implementation

For frozen structs that are POD/Trivial or bitwise-takeable, the proposal is to map these frozen structs with each field treated as a separate local variable into C# structs. In library evolution mode, non-frozen structs are treated as "opaque" value types. This means the tooling will treat all non-frozen structs as potentially non-bitwise-movable and will consult the value witness table during runtime for allocating layout and size. This means that all non-frozen structs are mapped to C# classes in library evolution mode.

According to the proposed approach, no additional support is required within the runtime at this point.


Please share any comments or concerns regarding the proposed approach. Implementation details will be discussed in subsequent PRs.

jakobbotsch commented 3 months ago

Thanks for sharing those examples.

According to the proposed approach, no additional support is required within the runtime at this point.

I think we need both SwiftSelf<T> and SwiftIndirectResult to support these.

kotlarmilos commented 3 months ago

SwiftSelf is needed when calling non-mutating instance methods on frozen structs. From what I can see, we should require that such a parameter is the first in the signature. The runtime needs to lower it and pass it in the swift self register if it has more than 4 primitives, and otherwise pass it as if it was a normal struct (in multiple registers)

This is a good idea, but it will require an API review. Before proceeding with the review, I want to ensure the proposed direction is appropriate by implementing an MVP for CrytoKit and other libraries.

SwiftIndirectResult is needed to allow the tooling to call functions that return non-frozen structs

Could you provide an example? Initially, I had the same thought, but I confirmed that the indirect result register is used in assignment expressions and that this should be compatible with arm64/x64 calling conventions, not Swift-specific.

jakobbotsch commented 3 months ago

Could you provide an example? Initially, I had the same thought, but I confirmed that the indirect result register is used in assignment expressions and that this should be compatible with arm64/x64 calling conventions, not Swift-specific.

On x64 Swift passes the return buffer in rax, which is not the same as the underlying calling convention. But furthermore there is no way for the projection tooling to generate a call to a function that returns a non-frozen struct. The tooling needs to allocate a dynamically sized buffer and pass a pointer to it as the return buffer, but there is no way to represent that without SwiftIndirectResult.

kotlarmilos commented 3 months ago

You are correct. The arm64 callconv loads the indirect return result register for value types only. Since we want to project non-frozen structs as C# classes, the tooling needs direct access to the return buffer registers. We will create an API proposal for SwiftSelf<T> and SwiftIndirectResult and proceed with implementing the tooling support based on the confirmed public API and its implementation.

Thank you all for providing valuable feedback!